From 3436e6b426ad58a123b1ffdfa268a59b7225e733 Mon Sep 17 00:00:00 2001 From: huseyin-emre-tigci Date: Fri, 14 Aug 2026 16:53:21 +0300 Subject: [PATCH 01/27] Add Windows support Windows joins the three systems as its own entry in each table: DirectShow through ffmpeg for capture, the Win32 clipboard and SendInput for the paste, RegisterHotKey for the global shortcut, and the whisper.cpp and llama.cpp Windows zips (the OpenBLAS whisper build, which transcribes about twice as fast on a plain CPU). Settings go to APPDATA, data to LOCALAPPDATA, and install.ps1 adds the Start Menu entry, the dikte command and an optional autostart. Meetings are not supported yet: Windows offers nothing to record the far side from. Porting surfaced three fixes that were not Windows specific: - A stopped or overlong download tried to delete its .part file while still holding it open, which Windows refuses. The unlinks now wait for the handle. - The CLI transcribed files without handing the local servers their settings first, so a local provider failed with "no model downloaded" wherever the GUI had not run in the same process. - The audio content types are pinned instead of asked of the registry, which answers differently machine to machine. One fix is Windows specific but sits in shared code: shutdown() does not end a blocked recv there, so stopping a request also closes the socket handle. Co-Authored-By: Claude Fable 5 --- README.md | 12 ++- README.windows.md | 72 +++++++++++++++ api.py | 14 ++- assistant.py | 1 + audio.py | 109 +++++++++++++++++++++-- cleanup.py | 1 + cli.py | 14 +++ config.py | 6 ++ dikte.py | 18 +++- filetranscribe.py | 1 + ggml.py | 92 +++++++++++++++++--- hotkey.py | 200 +++++++++++++++++++++++++++++++++++++++--- i18n.py | 10 +++ install.ps1 | 74 ++++++++++++++++ ipc.py | 4 +- overlay.py | 5 +- paste.py | 182 +++++++++++++++++++++++++++++++++++++- tests/test_audio.py | 78 +++++++++++++++++ tests/test_cli.py | 23 +++++ tests/test_config.py | 26 ++++-- tests/test_ggml.py | 85 ++++++++++++++++++ tests/test_hotkey.py | 203 +++++++++++++++++++++++++++++++++++++++++++ tests/test_paste.py | 127 +++++++++++++++++++++++++++ tests/test_ui.py | 8 ++ 24 files changed, 1317 insertions(+), 48 deletions(-) create mode 100644 README.windows.md create mode 100644 install.ps1 diff --git a/README.md b/README.md index 5517d69..b2cc0f9 100644 --- a/README.md +++ b/README.md @@ -5,9 +5,9 @@ machine by default, a model cleans it up (dropping the *uh*s, the restarts, the missing punctuation), and the result lands in your clipboard and is pasted into whatever window you were typing in. -Built for KDE Plasma 6 on Wayland, and runs on GNOME X11 and macOS too. 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 and +[Windows](README.windows.md) too. No dependencies beyond system packages: just +the Python standard library, 3.11 or newer, and PyQt6. *[Türkçe README](README.tr.md)* @@ -61,6 +61,12 @@ that catches the keys is the mechanism there and there is nothing to run: needs BlackHole or Loopback, because nothing else offers what the speakers are playing. +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 dikte.py`, 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. `./update.sh` pulls and puts all of that back, keeping the keys you chose; `./uninstall.sh` takes it away diff --git a/README.windows.md b/README.windows.md new file mode 100644 index 0000000..2a9f0c3 --- /dev/null +++ b/README.windows.md @@ -0,0 +1,72 @@ +# 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 + +```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 dikte.py +``` + +## 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. +- 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 `ffmpeg -version` run? Does + `dikte devices` list your microphone? +- **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. diff --git a/api.py b/api.py index 775fe31..ee0222e 100644 --- a/api.py +++ b/api.py @@ -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() diff --git a/assistant.py b/assistant.py index f708ab3..0dcf180 100644 --- a/assistant.py +++ b/assistant.py @@ -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}", diff --git a/audio.py b/audio.py index 55beb50..5108043 100644 --- a/audio.py +++ b/audio.py @@ -30,6 +30,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 @@ -39,6 +43,19 @@ CHUNK_LATENCY_MS = round(CHUNK_FRAMES / RATE * 1000) MIN_FRAMES = int(RATE * 0.25) +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.""" @@ -70,7 +87,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)) @@ -125,7 +143,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: @@ -246,7 +264,8 @@ class MeetingRecorder(QObject): # nobody drains would eventually block it, so it writes to a file. self._log = tempfile.TemporaryFile() self._proc = subprocess.Popen( - cmd, stdout=subprocess.PIPE, stderr=self._log, bufsize=0 + cmd, stdout=subprocess.PIPE, stderr=self._log, bufsize=0, + creationflags=NO_WINDOW, ) except (OSError, wave.Error) as exc: self._close_file() @@ -298,7 +317,7 @@ class MeetingRecorder(QObject): proc = self._proc if proc and proc.poll() is None: try: - proc.send_signal(signal.SIGINT) + _interrupt(proc) proc.wait(timeout=2) except (subprocess.TimeoutExpired, OSError): try: @@ -603,6 +622,71 @@ 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. + + +def _dshow_devices(): + """[(name, name)] for every DirectShow audio capture device. + + The list comes out on stderr of a command that then fails, the same + documented trick AVFoundation uses above. Names are the only stable handle + dshow offers a user; they are what the recorder is given back. + """ + 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 = [] + for line in result.stderr.decode("utf-8", "replace").splitlines(): + if "(audio)" not in line: + continue + match = re.search(r'"([^"]+)"\s*\([^)]*audio[^)]*\)', line) + if match: + devices.append((match.group(1), match.group(1))) + return devices + + +def _dshow_record(target): + if not shutil.which("ffmpeg"): + return [] + # dshow has no "default" device: an unset target means the first one listed. + device = target + if not device: + inputs = _dshow_devices() + if not inputs: + return [] + device = inputs[0][0] + 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 two at once, the two device lists, which @@ -633,9 +717,24 @@ COREAUDIO = Sound( ) +DSHOW = Sound( + record=_dshow_record, + meeting=_dshow_meeting, + inputs=_dshow_devices, + outputs=_dshow_no_outputs, + default_output=_dshow_no_default_output, + 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(): diff --git a/cleanup.py b/cleanup.py index b6485b3..d59eb36 100644 --- a/cleanup.py +++ b/cleanup.py @@ -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.", diff --git a/cli.py b/cli.py index 7992bd0..d282b2b 100644 --- a/cli.py +++ b/cli.py @@ -17,6 +17,7 @@ import json import os import shutil import signal +import subprocess import sys import time @@ -134,6 +135,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(sys.executable, args) @@ -297,6 +308,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) diff --git a/config.py b/config.py index c80bbfb..529c3c1 100644 --- a/config.py +++ b/config.py @@ -27,6 +27,12 @@ def _directories(platform=None): if (platform or sys.platform) == "darwin": support = pathlib.Path.home() / "Library/Application Support/Dikte" return support, support + if (platform or sys.platform) == "win32": + roaming = pathlib.Path( + os.environ.get("APPDATA") or pathlib.Path.home() / "AppData/Roaming") + local = pathlib.Path( + os.environ.get("LOCALAPPDATA") or pathlib.Path.home() / "AppData/Local") + return roaming / "Dikte", local / "Dikte" return (_xdg("XDG_CONFIG_HOME", "~/.config") / "dikte", _xdg("XDG_DATA_HOME", "~/.local/share") / "dikte") diff --git a/dikte.py b/dikte.py index cda7f54..d76a82a 100755 --- a/dikte.py +++ b/dikte.py @@ -12,6 +12,7 @@ import json import os import signal import socket +import subprocess import sys import threading @@ -881,6 +882,17 @@ class Dikte: self.settings_window.close() self.shutdown() QLocalServer.removeServer(SERVER_NAME) + 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( + [sys.executable, ipc.script_path(), "--gui"], + creationflags=(subprocess.DETACHED_PROCESS + | subprocess.CREATE_NEW_PROCESS_GROUP), + close_fds=True, + ) + QApplication.instance().quit() + return os.execv(sys.executable, [sys.executable, ipc.script_path(), "--gui"]) def shutdown(self): @@ -951,7 +963,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) diff --git a/filetranscribe.py b/filetranscribe.py index 4c8b4b7..d6401b4 100644 --- a/filetranscribe.py +++ b/filetranscribe.py @@ -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. diff --git a/ggml.py b/ggml.py index 5ba85a7..890b641 100644 --- a/ggml.py +++ b/ggml.py @@ -43,6 +43,7 @@ import threading import time import urllib.error import urllib.request +import zipfile import hub from i18n import t @@ -51,8 +52,15 @@ HOST = "127.0.0.1" # The path api.py asks for, so its URL and the server's line up. INFERENCE_PATH = "/v1/audio/transcriptions" -DATA_DIR = (pathlib.Path(os.environ.get("XDG_DATA_HOME") - or os.path.expanduser("~/.local/share")) / "dikte") +def _data_dir(): + if sys.platform == "win32": + return pathlib.Path(os.environ.get("LOCALAPPDATA") + or os.path.expanduser("~/AppData/Local")) / "Dikte" + return pathlib.Path(os.environ.get("XDG_DATA_HOME") + or os.path.expanduser("~/.local/share")) / "dikte" + + +DATA_DIR = _data_dir() BIN_DIR = DATA_DIR / "bin" MODELS_DIR = DATA_DIR / "models" @@ -151,11 +159,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 @@ -165,11 +176,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: - part.unlink(missing_ok=True) - raise LocalError(t("{name} is longer than it said it " - "would be.", name=item.name)) + 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)) # 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: @@ -215,9 +232,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): @@ -230,6 +249,16 @@ 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. + 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",) @@ -275,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(): @@ -283,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 @@ -334,7 +373,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)) @@ -491,6 +530,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. @@ -592,6 +651,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}", @@ -689,8 +750,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: diff --git a/hotkey.py b/hotkey.py index e7e10ea..89a6898 100644 --- a/hotkey.py +++ b/hotkey.py @@ -418,12 +418,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 ------------------------------------------- def _macos(): return sys.platform == "darwin" +def _windows(): + return sys.platform == "win32" + + def _gnome(): desktop = os.environ.get("XDG_CURRENT_DESKTOP", "").lower() return "gnome" in desktop and shutil.which("gsettings") is not None @@ -548,37 +711,44 @@ 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 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 does not: there is nothing to install, - nothing to remove, and Settings should not offer either. + survives Dikte being closed. macOS and Windows do not: there is nothing to + install, nothing to remove, and Settings should not offer either. """ - return not _macos() + return not _macos() and not _windows() def shortcut_needs_restart(): """Whether an installed shortcut waits for the next login before it works. KWin reads kglobalshortcutsrc once, when it starts. GNOME picks a binding - up as it is written, and macOS never had one to write. + up as it is written, and macOS and Windows never had one to write. """ - return not _macos() and not _gnome() + return not _macos() and not _windows() and not _gnome() def install_shortcut(shortcut, exec_command, name="Dikte: start/stop recording", desktop_id=DESKTOP_ID): - if _macos(): + if _macos() or _windows(): _REGISTERED[desktop_id] = shortcut return True, t( "Shortcut saved: {shortcut}\nDikte holds this one itself while it " @@ -591,7 +761,7 @@ def install_shortcut(shortcut, exec_command, name="Dikte: start/stop recording", def remove_shortcut(desktop_id=DESKTOP_ID): - if _macos(): + if _macos() or _windows(): _REGISTERED.pop(desktop_id, None) elif _gnome(): remove_gnome_shortcut(desktop_id) @@ -600,7 +770,7 @@ def remove_shortcut(desktop_id=DESKTOP_ID): def shortcut_status(desktop_id=DESKTOP_ID): - if _macos(): + if _macos() or _windows(): return _REGISTERED.get(desktop_id) return (gnome_shortcut_status(desktop_id) if _gnome() else kde_shortcut_status(desktop_id)) @@ -609,6 +779,8 @@ def shortcut_status(desktop_id=DESKTOP_ID): def desktop_name(): if _macos(): return "macOS" + if _windows(): + return "Windows" return "GNOME" if _gnome() else "KDE" @@ -693,9 +865,9 @@ def kde_shortcut_status(desktop_id=DESKTOP_ID): def conflicting_shortcuts(shortcut, desktop_id=DESKTOP_ID): """Names of other KDE entries bound to the same combination.""" - if _macos(): - # There is no list to read: macOS answers the question by refusing the - # registration, which CarbonHotkey reports when it asks for the key. + if _macos() or _windows(): + # There is no list to read: both answer the question by refusing the + # registration, which their listeners report when they ask for the key. return [] try: text = SHORTCUTS_FILE.read_text(encoding="utf-8") diff --git a/i18n.py b/i18n.py index 436f917..761416d 100644 --- a/i18n.py +++ b/i18n.py @@ -106,6 +106,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}", @@ -355,6 +359,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" diff --git a/install.ps1 b/install.ps1 new file mode 100644 index 0000000..cbd86de --- /dev/null +++ b/install.ps1 @@ -0,0 +1,74 @@ +# Dikte'yi bu Windows kullanicisi icin kurar: Baslat Menusu kisayolu, istege +# bagli otomatik baslangic ve her yerden calisan bir `dikte` komutu. +# +# powershell -ExecutionPolicy Bypass -File install.ps1 # kur +# powershell -ExecutionPolicy Bypass -File install.ps1 -Autostart # + oturum acilisinda baslat +# powershell -ExecutionPolicy Bypass -File install.ps1 -Uninstall # kaldir +param( + [switch]$Autostart, + [switch]$Uninstall +) + +$ErrorActionPreference = "Stop" +$repo = $PSScriptRoot +$startMenu = [Environment]::GetFolderPath("Programs") +$startup = [Environment]::GetFolderPath("Startup") +$shortcut = Join-Path $startMenu "Dikte.lnk" +$autostartLink = Join-Path $startup "Dikte.lnk" +# WindowsApps kullanici PATH'inde hazir durur; oraya birakilan dikte.cmd her +# terminalden calisir. +$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 "silindi: $path" } + } + Write-Host "Dikte kisayollari kaldirildi. Depo klasoru ve ayarlar duruyor." + exit 0 +} + +# --- gereksinimler ---------------------------------------------------------- +$python = Get-Command python -ErrorAction SilentlyContinue +if (-not $python) { + Write-Error "Python bulunamadi. Kurun: 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+ gerekli, bulunan: $version" +} +& python -c "import PyQt6.QtWidgets" 2>$null +if ($LASTEXITCODE -ne 0) { + Write-Host "PyQt6 kuruluyor..." + & python -m pip install PyQt6 + if ($LASTEXITCODE -ne 0) { Write-Error "PyQt6 kurulamadi." } +} +if (-not (Get-Command ffmpeg -ErrorAction SilentlyContinue)) { + Write-Warning "ffmpeg bulunamadi. Ses kaydi icin gerekli: winget install Gyan.FFmpeg" +} + +# pythonw.exe konsol penceresi acmadan calistirir. +$pythonw = Join-Path (Split-Path $python.Source) "pythonw.exe" +if (-not (Test-Path $pythonw)) { $pythonw = $python.Source } + +# --- Baslat Menusu kisayolu ------------------------------------------------- +$shell = New-Object -ComObject WScript.Shell +foreach ($path in @($shortcut) + $(if ($Autostart) { @($autostartLink) } else { @() })) { + $link = $shell.CreateShortcut($path) + $link.TargetPath = $pythonw + $link.Arguments = "`"$repo\dikte.py`" --gui" + $link.WorkingDirectory = $repo + $link.Description = "Dikte: sesli dikte" + $link.Save() + Write-Host "kisayol: $path" +} + +# --- dikte komutu ----------------------------------------------------------- +$shimDir = Split-Path $cmdShim +if (Test-Path $shimDir) { + "@echo off`r`npython `"$repo\dikte.py`" %*" | Out-File $cmdShim -Encoding ascii + Write-Host "komut: dikte ($cmdShim)" +} + +Write-Host "" +Write-Host "Kurulum tamam. Baslat Menusu'nden 'Dikte' ile ya da terminalden 'dikte' yazarak baslatin." +Write-Host "Ilk acilista Ayarlar penceresi acilir: oradan model indirin ve kisayolu secin (varsayilan Ctrl+Space)." diff --git a/ipc.py b/ipc.py index 541e067..659811c 100644 --- a/ipc.py +++ b/ipc.py @@ -14,7 +14,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. diff --git a/overlay.py b/overlay.py index f843306..b0cc39e 100644 --- a/overlay.py +++ b/overlay.py @@ -61,9 +61,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 diff --git a/paste.py b/paste.py index 030f203..1ca172b 100644 --- a/paste.py +++ b/paste.py @@ -278,6 +278,159 @@ 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" + if not _win_open_clipboard(user32): + raise PasteError(t("Could not copy to clipboard: {error}", + error="the clipboard is held by another program")) + handle = None + try: + user32.EmptyClipboard() + handle = kernel32.GlobalAlloc(_WIN_GMEM_MOVEABLE, len(payload)) + pointer = kernel32.GlobalLock(handle) if handle else None + if not pointer: + raise PasteError(t("Could not copy to clipboard: {error}", + error="out of memory")) + ctypes.memmove(pointer, payload, len(payload)) + kernel32.GlobalUnlock(handle) + 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( @@ -310,6 +463,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 @@ -331,6 +495,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"): @@ -375,6 +541,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: @@ -402,6 +571,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}.", @@ -421,7 +593,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) diff --git a/tests/test_audio.py b/tests/test_audio.py index 22d4f2a..0f9582f 100644 --- a/tests/test_audio.py +++ b/tests/test_audio.py @@ -606,5 +606,83 @@ class MacRecordingCommand(OnMacOS, DikteTest): self.assertFalse(recorder.active) +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. + """ + + LISTING = ( + '[dshow @ 0000020c] "Integrated Camera" (video)\n' + '[dshow @ 0000020c] Alternative name "@device_pnp_\\...."\n' + '[dshow @ 0000020c] "Mikrofon Dizisi (Intel Smart Sound)" (audio)\n' + '[dshow @ 0000020c] Alternative name "@device_cm_{33D9A762}...."\n' + '[dshow @ 0000020c] "Kulaklık (Soundcore Life Q30)" (audio)\n' + "dummy: Immediate exit requested\n" + ).encode("utf-8") + + @contextlib.contextmanager + def listing(self, stderr=None, tools=("ffmpeg",)): + completed = FakeCompleted( + returncode=1, stderr=self.LISTING if stderr is None else stderr) + with only_these_tools(*tools), \ + mock.patch.object(subprocess, "run", return_value=completed): + yield + + def test_windows_records_through_dshow(self): + self.assertIs(audio.sound(), audio.DSHOW) + + def test_the_audio_lines_are_the_only_ones_read(self): + with self.listing(): + self.assertEqual(audio.list_sources(), [ + ("Mikrofon Dizisi (Intel Smart Sound)", + "Mikrofon Dizisi (Intel Smart Sound)"), + ("Kulaklık (Soundcore Life Q30)", + "Kulaklık (Soundcore Life Q30)"), + ]) + + def test_no_ffmpeg_installed(self): + with only_these_tools(): + self.assertEqual(audio.list_sources(), []) + self.assertEqual(audio.recording_command(), []) + + def test_the_name_is_what_the_recorder_is_given_back(self): + with self.listing(): + cmd = audio.recording_command("Kulaklık (Soundcore Life Q30)") + self.assertEqual(cmd[cmd.index("-f") + 1], "dshow") + self.assertIn("audio=Kulaklık (Soundcore Life Q30)", 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("audio=Mikrofon Dizisi (Intel Smart Sound)", + audio.recording_command()) + + 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_command("mic", "sys"), []) + + if __name__ == "__main__": unittest.main() diff --git a/tests/test_cli.py b/tests/test_cli.py index 9547df2..af42b2e 100644 --- a/tests/test_cli.py +++ b/tests/test_cli.py @@ -14,6 +14,7 @@ from unittest import mock import cli import config as cfg +import ggml import hotkey import ipc from tests.support import DikteTest, fake_urlopen @@ -564,5 +565,27 @@ class Replies(DikteTest): self.assertEqual(cli.run(["cancel"]), 0) +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() diff --git a/tests/test_config.py b/tests/test_config.py index 31e153c..fec1417 100644 --- a/tests/test_config.py +++ b/tests/test_config.py @@ -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() @@ -467,25 +470,33 @@ class Directories(unittest.TestCase): with mock.patch.dict(os.environ, {"XDG_CONFIG_HOME": "/c", "XDG_DATA_HOME": "/d"}): config_dir, data_dir = cfg._directories("linux") - self.assertEqual(str(config_dir), "/c/dikte") - self.assertEqual(str(data_dir), "/d/dikte") + 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 = cfg._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 = cfg._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, _ = cfg._directories("darwin") - self.assertNotIn("/c", str(config_dir)) + self.assertNotIn("/c", config_dir.as_posix()) + + def test_windows_keeps_settings_and_data_apart(self): + with mock.patch.dict(os.environ, {"APPDATA": r"C:\roam", + "LOCALAPPDATA": r"C:\local"}): + config_dir, data_dir = cfg._directories("win32") + self.assertEqual(config_dir.as_posix(), "C:/roam/Dikte") + self.assertEqual(data_dir.as_posix(), "C:/local/Dikte") if __name__ == "__main__": @@ -519,6 +530,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) diff --git a/tests/test_ggml.py b/tests/test_ggml.py index 161e688..2e3075f 100644 --- a/tests/test_ggml.py +++ b/tests/test_ggml.py @@ -15,6 +15,7 @@ import tarfile import textwrap import threading import time +import zipfile from unittest import mock 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.""" @@ -681,3 +691,78 @@ 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",)) + + +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)) diff --git a/tests/test_hotkey.py b/tests/test_hotkey.py index c6ec50f..d466b89 100644 --- a/tests/test_hotkey.py +++ b/tests/test_hotkey.py @@ -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 + import config as cfg import hotkey from tests.support import DikteTest, FakeCompleted, linux_only @@ -681,5 +685,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() diff --git a/tests/test_paste.py b/tests/test_paste.py index 4bfe99f..7e80c0e 100644 --- a/tests/test_paste.py +++ b/tests/test_paste.py @@ -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) @@ -429,5 +433,128 @@ 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 + + def _keep(self, buffer): + handle = self.next_handle + self.next_handle += 1 + self.buffers[handle] = buffer + return handle + + # --- user32 + def OpenClipboard(self, owner): + return 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): + 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_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() diff --git a/tests/test_ui.py b/tests/test_ui.py index 518166f..790390c 100644 --- a/tests/test_ui.py +++ b/tests/test_ui.py @@ -15,6 +15,7 @@ from PyQt6.QtWidgets import QApplication, QMessageBox import cleanup import config as cfg +import ggml import hotkey import overlay as overlay_module import paste @@ -449,6 +450,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) From 6b86696648695ac7ee2415f3693eb4986ff88a04 Mon Sep 17 00:00:00 2001 From: huseyin-emre-tigci Date: Fri, 14 Aug 2026 18:09:24 +0300 Subject: [PATCH 02/27] Spell the Windows paths with forward slashes in the directories test A backslash only separates on Windows, and this test also runs on the Linux that checks the Windows half. Co-Authored-By: Claude Fable 5 --- tests/test_config.py | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/tests/test_config.py b/tests/test_config.py index fec1417..7d0a031 100644 --- a/tests/test_config.py +++ b/tests/test_config.py @@ -492,8 +492,10 @@ class Directories(unittest.TestCase): self.assertNotIn("/c", config_dir.as_posix()) def test_windows_keeps_settings_and_data_apart(self): - with mock.patch.dict(os.environ, {"APPDATA": r"C:\roam", - "LOCALAPPDATA": r"C:\local"}): + # Forward slashes, because a backslash only separates on Windows and + # this test also runs on the Linux that checks the Windows half. + with mock.patch.dict(os.environ, {"APPDATA": "C:/roam", + "LOCALAPPDATA": "C:/local"}): config_dir, data_dir = cfg._directories("win32") self.assertEqual(config_dir.as_posix(), "C:/roam/Dikte") self.assertEqual(data_dir.as_posix(), "C:/local/Dikte") From a6fab18a225e393c4a6ee4b9dddbc8f190ff4d8c Mon Sep 17 00:00:00 2001 From: yusufipk Date: Sun, 16 Aug 2026 09:50:43 +0300 Subject: [PATCH 03/27] Keep the meeting a quiet microphone gave us, and read both captures at once A recording is never deleted for being disappointing. A microphone that handed over nothing still leaves the right channel, which is everyone else, and an hour of them is worth more than the empty channel costs; the one thing the user cannot get back is the half that was there. So the exact-zero check stays and stops throwing the file away: it says what the microphone did, in a tray warning next to the recording being written up, and the minutes are produced from what there is. Reading the two capture pipes in turn from one thread put the failure it was meant to fix back in a worse place. A microphone that stops delivering leaves that read waiting forever, and the far side is not read either until its pipe fills and its ffmpeg stops writing into it: the meeting freezes, the levels sit still, and nothing is said for as long as nobody looks. Each stream now has a reader of its own and a queue, so neither can hold the other up, and a side that has said nothing for STALL_SECONDS ends the recording the way a dead ffmpeg already did, out loud and keeping what was captured. Which system needs how many processes belongs in the table with everything else that differs, so meeting() returns the list of commands it takes: one on PulseAudio, one per device on a Mac. meeting_commands() is the chooser again rather than a function with a Mac inside it, and the empty entry in COREAUDIO is gone. The two AVFoundation targets are resolved against a single device listing, which costs one ffmpeg run instead of two and cannot see the indexes renumber between the microphone and the far side. --- audio.py | 180 +++++++++++++++++++++++++++++--------------- dikte.py | 6 ++ i18n.py | 10 ++- tests/test_audio.py | 112 ++++++++++++++++++++++----- 4 files changed, 225 insertions(+), 83 deletions(-) diff --git a/audio.py b/audio.py index bf15ff6..511bdf1 100644 --- a/audio.py +++ b/audio.py @@ -2,15 +2,16 @@ Dictation records one source. A meeting records two of them at once, the microphone and what comes out of the speakers, and for that it goes through -ffmpeg: one process reading both devices and merging them into the two channels -of a single stream, which is the only way the two stay aligned with each other -over an hour. +ffmpeg. PulseAudio hands both devices to a single process, which merges them +into the two channels of one stream and keeps them aligned itself. AVFoundation +cannot be asked the same: two of its sessions inside one process starve each +other, so a Mac captures each device on its own and the two mono streams are +interleaved here as they arrive. Which programs do the capturing is a property of the machine, not of the code above: PulseAudio or PipeWire on Linux, AVFoundation through ffmpeg on macOS. They are gathered into one group each near the bottom of this file, and a -chooser picks between them. macOS uses one ffmpeg process per AVFoundation -device: two AVFoundation sessions in one process silently starve one another. +chooser picks between them. """ import array @@ -18,6 +19,7 @@ import collections import json import math import os +import queue import re import shutil import signal @@ -39,6 +41,21 @@ CHUNK_BYTES = CHUNK_FRAMES * SAMPLE_WIDTH * CHANNELS CHUNK_LATENCY_MS = round(CHUNK_FRAMES / RATE * 1000) MIN_FRAMES = int(RATE * 0.25) +# A capture process hands over a block every CHUNK_LATENCY_MS. One that has said +# nothing for this long has stopped rather than fallen behind, and the meeting +# ends and says so instead of sitting on a read that will never return. +STALL_SECONDS = 5.0 +# Room for a whole stall of the other stream, so the side still delivering is +# never the one left waiting. +QUEUE_BLOCKS = int(STALL_SECONDS * RATE / CHUNK_FRAMES) + 8 + +# Exact zeroes are not quiet, they are nothing: a microphone that is really in +# the room has a noise floor. This much of a recording that long means it handed +# nothing over, which is worth saying once the meeting is over and nothing can +# be done about it any more. +QUIET_MIC_SECONDS = 10 +QUIET_MIC_SHARE = 0.5 + class Recorder(QObject): """Runs the available sound-server recorder and reads raw PCM from stdout.""" @@ -193,17 +210,12 @@ def recording_command(target=""): def meeting_commands(mic_target, system_target): """The capture processes that produce one stereo meeting stream. - PulseAudio can keep both inputs in one ffmpeg process. AVFoundation cannot: - on a real Mac its two sessions silently starve the microphone, so each Mac - device is captured and clock-corrected by its own process. MeetingRecorder - interleaves those two mono streams after that. + One of them on PulseAudio, which merges both inputs itself; one per device + on a Mac, because two AVFoundation sessions in a process starve each other. + Which of the two it is stays in the table with everything else the sound + system decides, and MeetingRecorder reads the count rather than the machine. """ - if sound() is COREAUDIO: - return [ - _avfoundation_meeting_capture(mic_target), - _avfoundation_meeting_capture(system_target), - ] - return [sound().meeting(mic_target, system_target)] + return sound().meeting(mic_target, system_target) class AudioDeviceError(RuntimeError): @@ -223,11 +235,11 @@ class MeetingRecorder(QObject): levels = pyqtSignal(float, float) # mine, theirs stopped = pyqtSignal(str, float) # wav path, duration (s) died = pyqtSignal() # ffmpeg quit on its own + warned = pyqtSignal(str) # recorded, but something was wrong failed = pyqtSignal(str) def __init__(self, parent=None): super().__init__(parent) - self._proc = None self._procs = [] self._thread = None self._wav = None @@ -277,10 +289,10 @@ class MeetingRecorder(QObject): self._procs.append(subprocess.Popen( command, stdout=subprocess.PIPE, stderr=log, bufsize=0 )) - self._proc = self._procs[0] except (OSError, wave.Error) as exc: + # One of two capture processes may already be running, and a Mac + # left holding an open AVFoundation session records nothing else. self._terminate_processes() - self._proc = None self._procs = [] self._close_file() self._drop_log() @@ -326,13 +338,21 @@ class MeetingRecorder(QObject): pass def _pump_split(self): - left = self._procs[0].stdout - right = self._procs[1].stdout + # A reader thread per process. Taking turns on the two pipes from one + # thread would let a starved microphone hold up the far side too: its + # blocks would sit unread until the pipe filled and its ffmpeg stopped + # writing, and an hour of meeting would freeze with nothing said. Each + # stream is read as fast as it arrives, and a side that goes quiet for + # STALL_SECONDS ends the recording rather than hanging it. block = CHUNK_FRAMES * SAMPLE_WIDTH + streams = [queue.Queue(maxsize=QUEUE_BLOCKS) for _ in self._procs] + for proc, blocks in zip(self._procs, streams): + threading.Thread(target=_read_blocks, daemon=True, + args=(proc.stdout, blocks, block)).start() try: while True: - mine = _read_exact(left, block) - theirs = _read_exact(right, block) + mine = _next_block(streams[0]) + theirs = _next_block(streams[1]) if not mine or not theirs: break frames = min(len(mine), len(theirs)) // SAMPLE_WIDTH @@ -407,7 +427,6 @@ class MeetingRecorder(QObject): self._thread = None codes = [proc.poll() for proc in self._procs] code = next((value for value in codes if value), 0) - self._proc = None self._procs = [] self._close_file() return code @@ -422,7 +441,7 @@ class MeetingRecorder(QObject): pass def stop(self): - if not self._proc: + if not self._procs: return # The count is read after the join: the pump thread is still appending # the last blocks up to the moment it ends. @@ -446,21 +465,19 @@ class MeetingRecorder(QObject): if tail or code else t("Recording too short, speak for at least 0.3 s") ) return - if (self._split_inputs and frames >= RATE * 10 - and self._mic_zero_frames / frames > 0.5): - empty = round(self._mic_zero_frames / frames * 100) - self._drop_log() - try: - os.unlink(self._path) - except OSError: - pass - self.failed.emit(t( - "The macOS microphone stopped delivering audio ({percent}% was " - "empty). The unusable recording was discarded; reconnect the " - "device and try again.", percent=empty, - )) - return self._drop_log() + # A microphone that handed nothing over costs the left channel, and the + # recording is kept anyway: the right one is everyone else, and an hour + # of them is worth more than an empty channel costs. Only the split + # capture can starve a device this way; one ffmpeg reading both cannot. + if (self._split_inputs and frames >= RATE * QUIET_MIC_SECONDS + and self._mic_zero_frames / frames > QUIET_MIC_SHARE): + self.warned.emit(t( + "The microphone handed over almost nothing ({percent}% of the " + "recording was empty), so your own side of the meeting will be " + "mostly missing. Check the device before the next one.", + percent=round(self._mic_zero_frames / frames * 100), + )) self.stopped.emit(self._path, frames / RATE) def _drop_log(self): @@ -496,20 +513,40 @@ def stereo_levels(chunk): def interleave_mono(left, right): - """Two equally long mono-s16 buffers into one stereo-s16 buffer.""" - left_samples = array.array("h") - right_samples = array.array("h") - left_samples.frombytes(left[:len(left) - len(left) % SAMPLE_WIDTH]) - right_samples.frombytes(right[:len(right) - len(right) % SAMPLE_WIDTH]) + """Two mono-s16 buffers into one stereo-s16 buffer, the shorter one setting + the length.""" + left_samples, right_samples = _samples(left), _samples(right) frames = min(len(left_samples), len(right_samples)) - stereo_samples = array.array("h") - stereo_samples.extend( - sample for pair in zip(left_samples[:frames], right_samples[:frames]) - for sample in pair - ) + stereo_samples = array.array("h", bytes(frames * 2 * SAMPLE_WIDTH)) + stereo_samples[0::2] = left_samples[:frames] + stereo_samples[1::2] = right_samples[:frames] return stereo_samples.tobytes() +def _read_blocks(stream, blocks, size): + """One stream's blocks onto its queue, ending with the empty one. + + A queue that stays full is the pump having given up on this recording, and + then there is nobody left to hand anything to. + """ + try: + while True: + block = _read_exact(stream, size) + blocks.put(block, timeout=STALL_SECONDS) + if not block: + return + except (OSError, ValueError, queue.Full): + pass + + +def _next_block(blocks): + """The next block of a stream, empty once it ends or falls silent.""" + try: + return blocks.get(timeout=STALL_SECONDS) + except queue.Empty: + return b"" + + def _read_exact(stream, size): """Read one meter-sized block, tolerating short unbuffered pipe reads.""" out = bytearray() @@ -522,9 +559,13 @@ def _read_exact(stream, size): def _zero_samples(chunk): + return _samples(chunk).count(0) + + +def _samples(chunk): samples = array.array("h") samples.frombytes(chunk[:len(chunk) - len(chunk) % SAMPLE_WIDTH]) - return sum(sample == 0 for sample in samples) + return samples def _peak(samples): @@ -535,8 +576,9 @@ def _peak(samples): # --- the sound system, one group per machine ------------------------------- -# Both meeting commands merge the same way: each input down to mono at our own -# rate, then the two of them into the left and right of one stream. +# How the one PulseAudio process merges: each input down to mono at our own +# rate, then the two of them into the left and right of one stream. A Mac does +# the first half per process and the second half itself, in interleave_mono(). MERGE_FILTER = ( f"[0:a]aresample={RATE}:async=1,aformat=sample_fmts=s16:channel_layouts=mono[m];" f"[1:a]aresample={RATE}:async=1,aformat=sample_fmts=s16:channel_layouts=mono[s];" @@ -600,13 +642,14 @@ def _pw_record_raw_option(): def _pulse_meeting(mic_target, system_target): - return [ + """One process for both devices: PulseAudio keeps them aligned itself.""" + return [[ "ffmpeg", "-hide_banner", "-nostdin", "-loglevel", "error", "-f", "pulse", "-thread_queue_size", "4096", "-i", mic_target or "default", "-f", "pulse", "-thread_queue_size", "4096", "-i", system_target, "-filter_complex", MERGE_FILTER, "-map", "[out]", "-f", "s16le", "-ar", str(RATE), "-", - ] + ]] def _pactl_sources(): @@ -674,8 +717,20 @@ def _avfoundation_record(target): ] -def _avfoundation_meeting_capture(target): - target = _resolve_avfoundation_target(target) +def _avfoundation_meeting(mic_target, system_target): + """A process per device, both names read off the same device listing. + + Asking ffmpeg what is plugged in costs a process of its own, and a listing + taken twice could renumber in between: the two targets have to be resolved + against the same one to name the same machine the user picked from. + """ + inputs = _avfoundation_inputs() + return [_avfoundation_meeting_capture(mic_target, inputs), + _avfoundation_meeting_capture(system_target, inputs)] + + +def _avfoundation_meeting_capture(target, inputs=None): + target = _resolve_avfoundation_target(target, inputs) return [ "ffmpeg", "-hide_banner", "-nostdin", "-loglevel", "error", "-thread_queue_size", "4096", @@ -725,7 +780,7 @@ def _avfoundation_named_inputs(): for _index, description in _avfoundation_inputs()] -def _resolve_avfoundation_target(target): +def _resolve_avfoundation_target(target, inputs=None): """Resolve a stored device name to its current, positional ffmpeg index.""" if not target or target == "default": return "default" @@ -734,7 +789,9 @@ def _resolve_avfoundation_target(target): "The saved macOS audio device uses an old numeric index. Open " "Settings and select the device again before recording." )) - matches = [index for index, description in _avfoundation_inputs() + if inputs is None: + inputs = _avfoundation_inputs() + matches = [index for index, description in inputs if description == target] if not matches: raise AudioDeviceError(t( @@ -758,9 +815,10 @@ def _avfoundation_default_output(): Sound = collections.namedtuple( "Sound", - # How to capture one source and two at once, the two device lists, which - # device a meeting records the far side from, and what to say when the - # programs for any of it are not installed. + # 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", ) @@ -775,7 +833,7 @@ PULSE = Sound( COREAUDIO = Sound( record=_avfoundation_record, - meeting=None, # two separate capture processes; see meeting_commands() + meeting=_avfoundation_meeting, inputs=_avfoundation_named_inputs, # Every macOS capture device is offered as the far side of a meeting, the # loopback driver among them: there is no way to tell them apart, and an diff --git a/dikte.py b/dikte.py index cda7f54..3d72a09 100755 --- a/dikte.py +++ b/dikte.py @@ -123,6 +123,7 @@ class Dikte: self.meeting_recorder.levels.connect(self._on_meeting_levels) self.meeting_recorder.stopped.connect(self._on_meeting_recorded) self.meeting_recorder.died.connect(self._on_meeting_died) + self.meeting_recorder.warned.connect(self._on_meeting_warning) self.meeting_recorder.failed.connect(self._on_meeting_error) self.meetings.progress.connect(self._on_meeting_progress) self.meetings.finished.connect(self._on_meeting_finished) @@ -721,6 +722,11 @@ class Dikte: self._settle(MEETING, {"ok": False, "error": message}) self._on_error(message) + def _on_meeting_warning(self, message): + """It was recorded and it is being written up, but read this first.""" + self.tray.showMessage("Dikte", message, + QSystemTrayIcon.MessageIcon.Warning, 12000) + def _on_meeting_died(self): if self.meeting_state != M_RECORDING: return diff --git a/i18n.py b/i18n.py index 52ab91c..c1495e3 100644 --- a/i18n.py +++ b/i18n.py @@ -566,10 +566,12 @@ TR = { "duplicate or choose a different device.": "Birden fazla macOS ses aygıtının adı {device}. Aynı adlı aygıtlardan " "birini çıkar ya da başka bir aygıt seç.", - "The macOS microphone stopped delivering audio ({percent}% was empty). The " - "unusable recording was discarded; reconnect the device and try again.": - "macOS mikrofonu ses iletmeyi durdurdu (kaydın %{percent} kadarı boştu). " - "Kullanılamaz kayıt silindi; aygıtı yeniden bağlayıp tekrar dene.", + "The microphone handed over almost nothing ({percent}% of the recording was " + "empty), so your own side of the meeting will be mostly missing. Check the " + "device before the next one.": + "Mikrofon neredeyse hiçbir şey iletmedi (kaydın %{percent} kadarı boştu), " + "toplantının senin tarafın büyük ölçüde eksik olacak. Bir sonrakinden " + "önce aygıtı kontrol et.", "Transcribing {side}: {index}/{count}…": "{side} yazıya çevriliyor: {index}/{count}…", "you": "sen", diff --git a/tests/test_audio.py b/tests/test_audio.py index 7a6a09e..5bda0ab 100644 --- a/tests/test_audio.py +++ b/tests/test_audio.py @@ -16,6 +16,7 @@ import json import os import subprocess import sys +import threading import unittest import wave from unittest import mock @@ -251,6 +252,34 @@ class FakeProcess: self._alive = False +class StalledProcess(FakeProcess): + """A capture that hands over a buffer and then stops answering at all. + + Not the same thing as one that ends: the device is still there and the pipe + is still open, and a read of it never comes back. + """ + + def __init__(self, data): + super().__init__(data) + self.stdout = _StalledStream(data) + + +class _StalledStream: + def __init__(self, data): + self._data = io.BytesIO(data) + self._released = threading.Event() + + def read(self, size): + chunk = self._data.read(size) + if chunk: + return chunk + self._released.wait() + return b"" + + def release(self): + self._released.set() + + class RecordingCommand(OnLinux, DikteTest): """Which program captures the microphone, and how it is asked to.""" @@ -486,8 +515,10 @@ class MeetingCommands(unittest.TestCase): def commands(self, platform, mic="", system="them"): with mock.patch.object(sys, "platform", platform), \ - mock.patch.object(audio, "_resolve_avfoundation_target", - side_effect=lambda target: target or "default"): + mock.patch.object(audio, "_avfoundation_inputs", return_value=[]), \ + mock.patch.object( + audio, "_resolve_avfoundation_target", + side_effect=lambda target, inputs=None: target or "default"): return audio.meeting_commands(mic, system) def test_linux_reads_both_through_pulse(self): @@ -529,23 +560,41 @@ class MeetingCommands(unittest.TestCase): for command in self.commands(platform): self.assertIn("-nostdin", command) + def test_both_mac_devices_are_read_off_one_listing(self): + """Asking twice costs an ffmpeg run, and the second answer could have + renumbered between the two.""" + with mock.patch.object(sys, "platform", "darwin"), \ + mock.patch.object(audio, "_avfoundation_inputs", + return_value=[("0", "mine"), + ("1", "them")]) as inputs: + audio.meeting_commands("mine", "them") + inputs.assert_called_once_with() + class MacMeetingRecorder(OnMacOS, DikteTest): + # Whichever way the machine has them ordered, a name is what is saved and + # the index it happens to hold now is what ffmpeg is given. + DEVICES = [("0", "External Headset"), ("1", "BlackHole 2ch"), + ("2", "MacBook Pro Microphone")] + + def devices(self): + return mock.patch.object(audio, "_avfoundation_inputs", + return_value=self.DEVICES) + def record(self, mine, theirs): path = str(self.path("meeting.wav")) recorder = audio.MeetingRecorder() - stopped, failed = [], [] + stopped, failed, warnings = [], [], [] recorder.stopped.connect(lambda *args: stopped.append(args)) recorder.failed.connect(failed.append) + recorder.warned.connect(warnings.append) processes = [FakeProcess(mine), FakeProcess(theirs)] - with only_these_tools("ffmpeg"), \ - mock.patch.object(audio, "_resolve_avfoundation_target", - side_effect=("2", "1")), \ + with only_these_tools("ffmpeg"), self.devices(), \ mock.patch.object(subprocess, "Popen", side_effect=processes) as popen: recorder.start(path, "MacBook Pro Microphone", "BlackHole 2ch") recorder._thread.join(timeout=5) recorder.stop() - return path, recorder, stopped, failed, processes, popen + return path, warnings, stopped, failed, processes, popen def test_the_two_capture_processes_become_one_stereo_file(self): path, _, stopped, failed, _, _ = self.record( @@ -567,14 +616,42 @@ class MacMeetingRecorder(OnMacOS, DikteTest): self.assertIn(":2", commands[0]) self.assertIn(":1", commands[1]) - def test_an_unusable_mostly_empty_microphone_is_not_transcribed(self): - path, _, stopped, failed, _, _ = self.record( + def test_a_mostly_empty_microphone_is_said_out_loud_and_still_kept(self): + """Half the file is everyone else, and an hour of them is worth more + than the empty channel costs.""" + path, warnings, stopped, failed, _, _ = self.record( silence(11.0), tone(11.0) ) - self.assertEqual(stopped, []) - self.assertEqual(len(failed), 1) - self.assertIn("empty", failed[0]) - self.assertFalse(os.path.exists(path)) + self.assertEqual(failed, []) + self.assertEqual(len(stopped), 1) + self.assertTrue(os.path.exists(path)) + self.assertIn("empty", warnings[0]) + + def test_a_microphone_that_was_merely_quiet_is_not_complained_about(self): + _, warnings, stopped, _, _, _ = self.record(tone(11.0), tone(11.0)) + self.assertEqual(warnings, []) + self.assertEqual(len(stopped), 1) + + def test_a_capture_that_falls_silent_ends_the_meeting_rather_than_hanging(self): + """One thread taking turns on both pipes would sit on the dead read + until somebody noticed, an hour later.""" + path = str(self.path("meeting.wav")) + recorder = audio.MeetingRecorder() + stopped = [] + recorder.stopped.connect(lambda *args: stopped.append(args)) + mine, theirs = StalledProcess(tone(0.512)), FakeProcess(tone(30.0)) + with only_these_tools("ffmpeg"), self.devices(), \ + mock.patch.object(audio, "STALL_SECONDS", 0.2), \ + mock.patch.object(subprocess, "Popen", side_effect=(mine, theirs)): + try: + recorder.start(path, "MacBook Pro Microphone", "BlackHole 2ch") + recorder._thread.join(timeout=2) + self.assertFalse(recorder.active) + recorder.stop() + finally: + mine.stdout.release() + self.assertAlmostEqual(stopped[0][1], 0.512, places=3) + self.assertTrue(os.path.exists(path)) def test_stopping_ends_both_capture_processes(self): _, _, _, _, processes, _ = self.record(tone(0.5), tone(0.5)) @@ -584,22 +661,21 @@ class MacMeetingRecorder(OnMacOS, DikteTest): recorder = audio.MeetingRecorder() failed = [] recorder.failed.connect(failed.append) - with only_these_tools("ffmpeg"), \ - mock.patch.object(audio, "_avfoundation_inputs", return_value=[]), \ + with only_these_tools("ffmpeg"), self.devices(), \ mock.patch.object(subprocess, "Popen") as popen: recorder.start(str(self.path("meeting.wav")), "2", "1") popen.assert_not_called() self.assertIn("old numeric index", failed[0]) def test_a_second_capture_process_that_cannot_start_cleans_up_the_first(self): + """A Mac left holding an open AVFoundation session records nothing + else until it is let go.""" path = str(self.path("meeting.wav")) recorder = audio.MeetingRecorder() failed = [] recorder.failed.connect(failed.append) first = FakeProcess(tone(1.0)) - with only_these_tools("ffmpeg"), \ - mock.patch.object(audio, "_resolve_avfoundation_target", - side_effect=("2", "1")), \ + with only_these_tools("ffmpeg"), self.devices(), \ mock.patch.object(subprocess, "Popen", side_effect=(first, OSError("refused"))): recorder.start(path, "MacBook Pro Microphone", "BlackHole 2ch") From 8c62795b8b8be72aa77cd9989acd9a8b6c0d1a4b Mon Sep 17 00:00:00 2001 From: yusufipk Date: Sun, 16 Aug 2026 10:28:43 +0300 Subject: [PATCH 04/27] Give Windows devices an identifier, and ask ffmpeg for them once Three things about the dshow backend, all of them found by reading rather than by running, so all three want checking on a real Windows machine. The device listing is parsed in both of the shapes ffmpeg has printed it in: newer builds mark every device `(audio)` or `(video)`, older ones print a heading and no marks, and only the first was read. Each pattern is anchored at both ends now, so the error lines the command ends with, which quote the device name it was told to look for, are no longer read as a device of that name. What is stored for a device is the alternative name under it rather than the friendly one. A laptop with a headset plugged in has two microphones called the same thing, and `audio=Microphone` reaches the first of them whichever one was picked; the alternative name is unique. The friendly name stays what is shown, which is what the (id, description) pair in these lists has always been for. An unset microphone meant "the first one listed", and the listing costs an ffmpeg of its own, so every press of the key paid for a process before the recording started. The last listing is remembered instead, and opening Settings or running `dikte devices` takes a fresh one. And a fourth thing, which is about what the interface says rather than what it does: whether the far side of a meeting can be captured at all is now an entry in `audio.Sound` instead of being read off an empty device list. The two are not the same answer. An empty list on Linux means pactl is not installed, which a user can go and fix; False on Windows means there is no such device and no driver that would add one. The Meeting tab says so under the empty box, and starting a meeting says it instead of sending somebody to Settings to pick from a list that will never have anything in it. --- audio.py | 103 +++++++++++++++++++++++++++++------- i18n.py | 10 ++++ settings_ui.py | 12 +++++ tests/test_audio.py | 124 ++++++++++++++++++++++++++++++++++++++++---- tests/test_ui.py | 31 +++++++++++ 5 files changed, 250 insertions(+), 30 deletions(-) diff --git a/audio.py b/audio.py index 292b533..46e88f3 100644 --- a/audio.py +++ b/audio.py @@ -277,6 +277,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 @@ -837,12 +845,55 @@ def _avfoundation_default_output(): # 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. +_DSHOW_ENTRY = re.compile( + r'^(?:\[dshow @ [^\]]*\]\s*)?"([^"]+)"\s*(?:\(([^)]*)\))?\s*$') +_DSHOW_ALTERNATIVE = re.compile( + r'^(?:\[dshow @ [^\]]*\]\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=` 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(): - """[(name, name)] for every DirectShow audio capture device. + """[(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. Names are the only stable handle - dshow offers a user; they are what the recorder is given back. + documented trick AVFoundation uses above. """ if not shutil.which("ffmpeg"): return [] @@ -855,26 +906,30 @@ def _dshow_devices(): except (subprocess.SubprocessError, OSError): return [] - devices = [] - for line in result.stderr.decode("utf-8", "replace").splitlines(): - if "(audio)" not in line: - continue - match = re.search(r'"([^"]+)"\s*\([^)]*audio[^)]*\)', line) - if match: - devices.append((match.group(1), match.group(1))) + 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 [] - # dshow has no "default" device: an unset target means the first one listed. - device = target + device = target or _dshow_first_device() if not device: - inputs = _dshow_devices() - if not inputs: - return [] - device = inputs[0][0] + return [] return [ "ffmpeg", "-hide_banner", "-nostdin", "-loglevel", "error", # dshow holds half a second of audio before handing anything over; @@ -901,9 +956,13 @@ 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( @@ -912,6 +971,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.", ) @@ -924,6 +984,8 @@ 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", ) @@ -934,6 +996,9 @@ DSHOW = Sound( 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", ) diff --git a/i18n.py b/i18n.py index 15c2133..ccbbf48 100644 --- a/i18n.py +++ b/i18n.py @@ -623,6 +623,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.": diff --git a/settings_ui.py b/settings_ui.py index b671931..3cb6225 100644 --- a/settings_ui.py +++ b/settings_ui.py @@ -1015,6 +1015,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 " diff --git a/tests/test_audio.py b/tests/test_audio.py index 6525b0d..027a060 100644 --- a/tests/test_audio.py +++ b/tests/test_audio.py @@ -822,6 +822,42 @@ 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.""" @@ -837,51 +873,108 @@ class WindowsDevices(OnWindows, DikteTest): 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' - '[dshow @ 0000020c] Alternative name "@device_cm_{33D9A762}...."\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): - yield + 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_lines_are_the_only_ones_read(self): + def test_the_audio_devices_are_the_only_ones_read(self): with self.listing(): self.assertEqual(audio.list_sources(), [ - ("Mikrofon Dizisi (Intel Smart Sound)", - "Mikrofon Dizisi (Intel Smart Sound)"), + (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_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_name_is_what_the_recorder_is_given_back(self): + def test_the_identifier_is_what_the_recorder_is_given_back(self): with self.listing(): - cmd = audio.recording_command("Kulaklık (Soundcore Life Q30)") + cmd = audio.recording_command(self.MIC) self.assertEqual(cmd[cmd.index("-f") + 1], "dshow") - self.assertIn("audio=Kulaklık (Soundcore Life Q30)", cmd) + 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("audio=Mikrofon Dizisi (Intel Smart Sound)", - audio.recording_command()) + 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'): @@ -899,6 +992,15 @@ class WindowsDevices(OnWindows, DikteTest): 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() diff --git a/tests/test_ui.py b/tests/test_ui.py index 0cc5969..567c97f 100644 --- a/tests/test_ui.py +++ b/tests/test_ui.py @@ -13,6 +13,7 @@ from unittest import mock from PyQt6.QtWidgets import QApplication, QMessageBox +import audio import cleanup import config as cfg import ggml @@ -449,6 +450,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() From ca559e39613cc5f00e77444cb3a4fb7d210884d5 Mon Sep 17 00:00:00 2001 From: yusufipk Date: Sun, 16 Aug 2026 10:28:52 +0300 Subject: [PATCH 05/27] Fill the clipboard buffer before emptying the clipboard EmptyClipboard is the point of no return: after it, whatever was there is gone, and the allocation that failed on the next line left the clipboard holding nothing. That is the one path where restoring what a dictation borrowed could lose it instead. The buffer is filled first, and the clipboard is opened only once there is something to put in it. --- paste.py | 23 +++++++++++++++-------- tests/test_paste.py | 23 ++++++++++++++++++++++- 2 files changed, 37 insertions(+), 9 deletions(-) diff --git a/paste.py b/paste.py index 772e8c4..d15c9dc 100644 --- a/paste.py +++ b/paste.py @@ -412,19 +412,26 @@ def _win_read_text(): 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")) - handle = None try: user32.EmptyClipboard() - handle = kernel32.GlobalAlloc(_WIN_GMEM_MOVEABLE, len(payload)) - pointer = kernel32.GlobalLock(handle) if handle else None - if not pointer: - raise PasteError(t("Could not copy to clipboard: {error}", - error="out of memory")) - ctypes.memmove(pointer, payload, len(payload)) - kernel32.GlobalUnlock(handle) if not user32.SetClipboardData(_WIN_CF_UNICODETEXT, handle): raise PasteError(t("Could not copy to clipboard: {error}", error=f"error {_win_error()}")) diff --git a/tests/test_paste.py b/tests/test_paste.py index db098e7..5315c42 100644 --- a/tests/test_paste.py +++ b/tests/test_paste.py @@ -484,6 +484,8 @@ class FakeWin32: 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 @@ -493,7 +495,7 @@ class FakeWin32: # --- user32 def OpenClipboard(self, owner): - return 1 + return 0 if self.held else 1 def CloseClipboard(self): return 1 @@ -519,6 +521,8 @@ class FakeWin32: # --- 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): @@ -559,6 +563,23 @@ class Windows(Standing, DikteTest): 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()) From bbb9bccda4d053f5605a6e5330468886cd1e4d9f Mon Sep 17 00:00:00 2001 From: yusufipk Date: Sun, 16 Aug 2026 10:28:52 +0300 Subject: [PATCH 06/27] Stop answering before the replacement starts, on a restart execv leaves nothing behind to answer, so this never came up on Linux or a Mac. A Windows restart is two processes for a moment: the new one looks for an instance to hand its command to, and if it finds the old one still listening it takes itself for the second copy and exits, leaving nothing running at all. The server is closed before the replacement is started rather than after. Not reproduced, because it is a race and it lost: found by reading the order of the two calls, and worth a restart or twenty on a real machine. --- dikte.py | 11 +++++++++++ 1 file changed, 11 insertions(+) diff --git a/dikte.py b/dikte.py index 7ce82a2..d777206 100755 --- a/dikte.py +++ b/dikte.py @@ -88,6 +88,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. @@ -901,6 +904,13 @@ 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 a new one that reached a server still + # listening would take itself for the second copy, hand its command over + # and exit, leaving nothing running at all. + if self.server is not None: + self.server.close() QLocalServer.removeServer(SERVER_NAME) if sys.platform == "win32": # execv on Windows mangles arguments with spaces and leaves the two @@ -1066,6 +1076,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() From 743dcc9b8aa2727f0a5fb7d7de62aa7dd725ab78 Mon Sep 17 00:00:00 2001 From: yusufipk Date: Sun, 16 Aug 2026 10:29:07 +0300 Subject: [PATCH 07/27] Ask doctor and devices about the programs this system actually uses `doctor` had the Wayland pair spelled into it, so an X11 machine was never asked about the two it really pastes with, a Mac was told ydotool and kwriteconfig6 were missing, and Windows, which shells out for neither half of the clipboard, got five red marks for programs it was never going to have. The two come out of `paste.Desktop` now, and the Linux-only three are added on Linux. A row saying a program is missing on a machine that would never have run it is not a diagnosis, it is a mark to explain away. `devices` had the same shape of answer: "pactl found nothing; is PipeWire running?" on a Windows machine with no microphone. It names whatever this sound system is missing instead, which is the string the table already carries for it. The Windows README's troubleshooting sends people to both, so it says so. --- README.windows.md | 5 +++-- cli.py | 22 ++++++++++++++++++---- tests/test_cli.py | 47 ++++++++++++++++++++++++++++++++++++++++++++++- 3 files changed, 67 insertions(+), 7 deletions(-) diff --git a/README.windows.md b/README.windows.md index 2a9f0c3..8b4a5e6 100644 --- a/README.windows.md +++ b/README.windows.md @@ -62,8 +62,9 @@ python dikte.py ## Troubleshooting -- **Recording does not start:** does `ffmpeg -version` run? Does - `dikte devices` list your microphone? +- **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. diff --git a/cli.py b/cli.py index e6b4450..6a76cc9 100644 --- a/cli.py +++ b/cli.py @@ -655,7 +655,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" @@ -791,9 +794,20 @@ 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", - assistant.executable(assistant.provider(conf)) or "claude", - cleanup.executable(cleanup.provider(conf))] + # 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} target = conf.transcribe_target() cleaner = cleanup.provider(conf) diff --git a/tests/test_cli.py b/tests/test_cli.py index 5fe4c36..1948916 100644 --- a/tests/test_cli.py +++ b/tests/test_cli.py @@ -12,12 +12,14 @@ import json import unittest from unittest import mock +import audio import cli import config as cfg import ggml import hotkey import ipc -from tests.support import DikteTest, fake_urlopen +import paste +from tests.support import DikteTest, fake_urlopen, only_these_tools class Options: @@ -435,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") @@ -446,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")) From 476265465f51368fd53989dfe7d86facd0c98780 Mon Sep 17 00:00:00 2001 From: yusufipk Date: Sun, 16 Aug 2026 10:29:07 +0300 Subject: [PATCH 08/27] Write the Windows installer in the language the rest of it is written in install.sh and install-mac.sh are in English, and README.tr.md is where Turkish lives. install.ps1 arrived in Turkish, and in a Turkish with the diacritics stripped out of it, which is neither one language nor the other. Two things while it was open: the `dikte` command ran whichever `python` the PATH answered with rather than the one checked a few lines above it, and a machine with no WindowsApps directory got no command and no word about why. --- install.ps1 | 53 +++++++++++++++++++++++++++++------------------------ 1 file changed, 29 insertions(+), 24 deletions(-) diff --git a/install.ps1 b/install.ps1 index cbd86de..0d331bb 100644 --- a/install.ps1 +++ b/install.ps1 @@ -1,9 +1,9 @@ -# Dikte'yi bu Windows kullanicisi icin kurar: Baslat Menusu kisayolu, istege -# bagli otomatik baslangic ve her yerden calisan bir `dikte` komutu. +# 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 # kur -# powershell -ExecutionPolicy Bypass -File install.ps1 -Autostart # + oturum acilisinda baslat -# powershell -ExecutionPolicy Bypass -File install.ps1 -Uninstall # kaldir +# 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 @@ -15,60 +15,65 @@ $startMenu = [Environment]::GetFolderPath("Programs") $startup = [Environment]::GetFolderPath("Startup") $shortcut = Join-Path $startMenu "Dikte.lnk" $autostartLink = Join-Path $startup "Dikte.lnk" -# WindowsApps kullanici PATH'inde hazir durur; oraya birakilan dikte.cmd her -# terminalden calisir. +# 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 "silindi: $path" } + if (Test-Path $path) { Remove-Item $path -Force; Write-Host "removed: $path" } } - Write-Host "Dikte kisayollari kaldirildi. Depo klasoru ve ayarlar duruyor." + Write-Host "Dikte's shortcuts are gone. The repository and your settings are not." exit 0 } -# --- gereksinimler ---------------------------------------------------------- +# --- what it needs ---------------------------------------------------------- $python = Get-Command python -ErrorAction SilentlyContinue if (-not $python) { - Write-Error "Python bulunamadi. Kurun: winget install Python.Python.3.12" + 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+ gerekli, bulunan: $version" + 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 "PyQt6 kuruluyor..." + Write-Host "Installing PyQt6..." & python -m pip install PyQt6 - if ($LASTEXITCODE -ne 0) { Write-Error "PyQt6 kurulamadi." } + if ($LASTEXITCODE -ne 0) { Write-Error "PyQt6 would not install." } } if (-not (Get-Command ffmpeg -ErrorAction SilentlyContinue)) { - Write-Warning "ffmpeg bulunamadi. Ses kaydi icin gerekli: winget install Gyan.FFmpeg" + Write-Warning "No ffmpeg found. Recording needs it: winget install Gyan.FFmpeg" } -# pythonw.exe konsol penceresi acmadan calistirir. +# 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 } -# --- Baslat Menusu kisayolu ------------------------------------------------- +# --- 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 = "`"$repo\dikte.py`" --gui" $link.WorkingDirectory = $repo - $link.Description = "Dikte: sesli dikte" + $link.Description = "Dikte: dictation" $link.Save() - Write-Host "kisayol: $path" + Write-Host "shortcut: $path" } -# --- dikte komutu ----------------------------------------------------------- +# --- 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`npython `"$repo\dikte.py`" %*" | Out-File $cmdShim -Encoding ascii - Write-Host "komut: dikte ($cmdShim)" + "@echo off`r`n`"$($python.Source)`" `"$repo\dikte.py`" %*" | + 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 `"$repo\dikte.py`"" } Write-Host "" -Write-Host "Kurulum tamam. Baslat Menusu'nden 'Dikte' ile ya da terminalden 'dikte' yazarak baslatin." -Write-Host "Ilk acilista Ayarlar penceresi acilir: oradan model indirin ve kisayolu secin (varsayilan Ctrl+Space)." +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)." From ec4d7da09d0c5ee3fcbeecfddd6ea3105cd4fb04 Mon Sep 17 00:00:00 2001 From: yusufipk Date: Sun, 16 Aug 2026 10:29:07 +0300 Subject: [PATCH 09/27] Say there is a fourth system in the two documents that count them README.tr.md still offered three, so a Turkish reader could not tell Windows was supported at all. CONTRIBUTING opens the section a port is written from, and it described three tables and three systems; there are four of each now, plus paths.py, which master separated out while this branch was adding a Windows case to the two copies it replaced. --- CONTRIBUTING.md | 39 ++++++++++++++++++++++++--------------- README.tr.md | 12 +++++++++--- 2 files changed, 33 insertions(+), 18 deletions(-) diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index c23508a..1eff08c 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -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` -inside the chooser and read it there every time: a constant settled at import is -one no test can stand somewhere else. +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 the +`_macos()`/`_windows()`/`_gnome()` set in `hotkey.py`. Keep `sys.platform` inside +the chooser and read it there every time: a constant settled at import is one no +test can stand somewhere else. + +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: +1009 of the 1052 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,10 +82,10 @@ 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 diff --git a/README.tr.md b/README.tr.md index db1f23c..099fe1c 100644 --- a/README.tr.md +++ b/README.tr.md @@ -4,9 +4,9 @@ çevrilir, bir model transkripti temizler (ıı'lar, tekrarlar, eksik noktalama), sonuç panoya kopyalanır ve o an yazdığın pencereye yapıştırılır. -KDE Plasma 6 / Wayland için yazıldı, GNOME X11 ve macOS'ta da çalışır. Sistem -paketleri dışında bağımlılığı yok: sadece Python standart kütüphanesi (3.11 veya -üstü) ve PyQt6. +KDE Plasma 6 / Wayland için yazıldı, GNOME X11, macOS ve +[Windows](README.windows.md)'ta da çalışır. Sistem paketleri dışında bağımlılığı +yok: sadece Python standart kütüphanesi (3.11 veya üstü) ve PyQt6. *[English README](README.md)* @@ -81,6 +81,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 dikte.py`; 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ı da iki argümanı. `./update.sh` son sürümü çeker ve bunları senin seçtiğin tuşlarla yerine koyar; From 0419c88ac298c72aa54240f17b9aab404586ffdc Mon Sep 17 00:00:00 2001 From: yusufipk Date: Sun, 16 Aug 2026 10:58:09 +0300 Subject: [PATCH 10/27] Run the tests on Windows too, which is where they have never run Sixty of them are about the Windows backends, and every one of them has only ever run on Linux and on a Mac: user32 and kernel32 are faked at the one function that loads them, which is the whole point and is also the whole limit. The half that reads the real system has been taken on trust. The Mac has had a job of its own for exactly this reason since it was ported, and the comment on it says what it is for; this is the same job with the same reason. What it should catch that nothing else does: whether %APPDATA% and %LOCALAPPDATA% are the directories Windows actually hands out, whether a path spelled with a backslash is still one the tests can read, whether the config-permission test skips rather than fails where the mode bits mean nothing, and whether install.ps1 parses. Running install.ps1 is not on the table: it writes into the Start Menu and the user PATH. --- .github/workflows/tests.yml | 38 +++++++++++++++++++++++++++++++++++++ 1 file changed, 38 insertions(+) diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml index 86eaa88..2366639 100644 --- a/.github/workflows/tests.yml +++ b/.github/workflows/tests.yml @@ -74,3 +74,41 @@ jobs: bash -n install-mac.sh bash -n update.sh bash -n uninstall.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 } From 93f3db889d337afc45db31f2605aec053c0411c5 Mon Sep 17 00:00:00 2001 From: yusufipk Date: Sun, 16 Aug 2026 11:12:36 +0300 Subject: [PATCH 11/27] Say out loud that Windows on ARM gets an emulated whisper Checked against the release listings rather than guessed: whisper.cpp publishes Win32 and x64 for Windows and nothing else, while llama.cpp does publish bin-win-cpu-arm64.zip. So a Snapdragon machine gets a native cleanup model and an emulated transcriber, which is slow enough that the cloud is the better answer there, and neither the code nor the README said so. The test pins it, so that a whisper.cpp release which does start publishing an arm64 build turns the choice red rather than being quietly ignored. --- README.windows.md | 4 +++- ggml.py | 4 ++++ tests/test_ggml.py | 9 +++++++++ 3 files changed, 16 insertions(+), 1 deletion(-) diff --git a/README.windows.md b/README.windows.md index 8b4a5e6..fd0789d 100644 --- a/README.windows.md +++ b/README.windows.md @@ -54,7 +54,9 @@ python dikte.py - 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. + 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 diff --git a/ggml.py b/ggml.py index 39a6e21..87a2c4c 100644 --- a/ggml.py +++ b/ggml.py @@ -251,6 +251,10 @@ def _wanted_assets(program): # 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") diff --git a/tests/test_ggml.py b/tests/test_ggml.py index dab95a4..1f01372 100644 --- a/tests/test_ggml.py +++ b/tests/test_ggml.py @@ -725,6 +725,15 @@ class WindowsAssets(Local): 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.""" From 5025f8293cb0721672a6f37d6633263df68f4659 Mon Sep 17 00:00:00 2001 From: yusufipk Date: Sun, 16 Aug 2026 11:47:40 +0300 Subject: [PATCH 12/27] Describe the restart race as what it is, not as what it is not The comment claimed the new process would take itself for the second copy and hand its command over. That is the command line's path, through cli.run and ipc.send; run_app with --gui never asks whether anything is already running, it calls listen() and prints if that fails. The change is the same one either way: two processes asking for one name is either two servers answering on it or a listen that fails into a console nobody reads, and closing first leaves neither. --- dikte.py | 9 ++++++--- 1 file changed, 6 insertions(+), 3 deletions(-) diff --git a/dikte.py b/dikte.py index d777206..cc1eb47 100755 --- a/dikte.py +++ b/dikte.py @@ -906,9 +906,12 @@ class Dikte: 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 a new one that reached a server still - # listening would take itself for the second copy, hand its command over - # and exit, leaving nothing running at all. + # 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) From bac988e0e651f48b6f2d12e398a7553312014df0 Mon Sep 17 00:00:00 2001 From: yusufipk Date: Sun, 16 Aug 2026 14:13:12 +0300 Subject: [PATCH 13/27] Ask for the entry point by its parts, not by a slash The assertion spelled the path with a forward slash, which is not the separator Windows joins with. The Windows job this branch adds is the first to run it there. --- tests/test_ipc.py | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/tests/test_ipc.py b/tests/test_ipc.py index a95cb8b..97d0373 100644 --- a/tests/test_ipc.py +++ b/tests/test_ipc.py @@ -7,6 +7,7 @@ answers by saying nothing at all. import json import os +import pathlib import sys import unittest from unittest import mock @@ -57,7 +58,10 @@ 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): From 180ddac37e91fc72925a5699e3d5d1c45c96386c Mon Sep 17 00:00:00 2001 From: yusufipk Date: Sun, 16 Aug 2026 15:54:44 +0300 Subject: [PATCH 14/27] Let the Windows job read the merged suite The merge put a Windows runner under tests master wrote for two systems, and 21 of them fell over on it. Two were the quoting: `command_for` goes through shlex now, so a Windows path comes back in quotes, and the two tests that read the command as a string were reading it as a Linux one. They ask through the same join instead. The other nineteen are `integrate.py`, which writes the menu entry and the login item a downloaded build installs for itself. There are two downloads, an AppImage and a disk image, so the module has a Linux half and a macOS half and nothing a Windows host would run: its tests hand the fake home over in $HOME, which Windows does not read, and compare paths that start at the root. They carry `@posix_only`, which comes off again the day there is a Windows build to integrate. --- CONTRIBUTING.md | 9 ++++++--- tests/support.py | 12 ++++++++++++ tests/test_integrate.py | 6 ++++++ tests/test_ipc.py | 8 +++++--- tests/test_ui.py | 7 ++++--- 5 files changed, 33 insertions(+), 9 deletions(-) diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index a3f85b2..c364c89 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -70,7 +70,7 @@ 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. -1104 of the 1147 run on any machine, including every line of the Wayland, X11, +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 @@ -88,8 +88,11 @@ 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 diff --git a/tests/support.py b/tests/support.py index 18c0a06..f6ec508 100644 --- a/tests/support.py +++ b/tests/support.py @@ -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( diff --git a/tests/test_integrate.py b/tests/test_integrate.py index 6bc43b6..9721dd4 100644 --- a/tests/test_integrate.py +++ b/tests/test_integrate.py @@ -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" diff --git a/tests/test_ipc.py b/tests/test_ipc.py index e58c06f..502574c 100644 --- a/tests/test_ipc.py +++ b/tests/test_ipc.py @@ -8,6 +8,7 @@ answers by saying nothing at all. import json import os import pathlib +import shlex import sys import unittest from unittest import mock @@ -65,9 +66,10 @@ class Paths(unittest.TestCase): 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 diff --git a/tests/test_ui.py b/tests/test_ui.py index b3a748d..6cd402c 100644 --- a/tests/test_ui.py +++ b/tests/test_ui.py @@ -21,6 +21,7 @@ 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 @@ -283,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 @@ -460,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()) @@ -484,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()) From aef33585c133e80c5bad4c3bf1ff3223c5714412 Mon Sep 17 00:00:00 2001 From: yusufipk Date: Sun, 16 Aug 2026 15:55:52 +0300 Subject: [PATCH 15/27] Say where the Windows install comes from now that there are downloads --- README.windows.md | 3 +++ 1 file changed, 3 insertions(+) diff --git a/README.windows.md b/README.windows.md index cc13b57..05adcf6 100644 --- a/README.windows.md +++ b/README.windows.md @@ -12,6 +12,9 @@ up and pasted where your cursor is. ## 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 ``` From 45b18722bef39e64fc0585c054748ab9bcad762b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?G=C3=B6khan?= Date: Sun, 16 Aug 2026 17:20:32 +0300 Subject: [PATCH 16/27] Keep the front where the dictation started on macOS Recording goes through ffmpeg's avfoundation input, and opening a capture session there brings the process that did it to the front. ffmpeg is a child of Dikte with no bundle of its own, so macOS credits the move to Dikte: the window the user was dictating into goes inactive, its caret stops, its title bar greys out, and the Cmd+V at the end of the run lands somewhere other than the document it was meant for. Measured with a TextEdit document in front: press the shortcut front = TextEdit recorder.start returns front = TextEdit 89 ms later front = Dikte Nothing about the capture session can be asked not to do this. Starting ffmpeg in its own session, and clearing __CFBundleIdentifier from its environment, were both tried and both measured to make no difference, so it is undone instead: the application in front is noted before the indicator goes up, and a short watch puts it back the moment Dikte takes the front. Measured at 99 ms from the moment it is taken, against the title bar staying grey for the whole dictation before. All three ways in do it, dictation, agent and meeting, since all three open the same capture. The indicator had a share of the same problem and needed AppKit for it too, so mac_window.py carries both. An NSPanel is hidden by the system the moment its application stops being the active one, which for a dictation indicator is immediately, and ordering one to the front brings its application with it unless it carries the nonactivating style bit. Neither is reachable through Qt. The runtime is loaded in _appkit() rather than at import, the way paste.py loads its frameworks in _macos_api(), so the module imports on a machine with no AppKit and the tests below run there as well: 52 new tests, none of them skipped anywhere. Co-Authored-By: Claude Opus 5 (1M context) --- dikte/app.py | 94 ++++++++++++++- dikte/mac_window.py | 203 +++++++++++++++++++++++++++++++ dikte/overlay.py | 7 ++ dikte/paste.py | 38 +++++- dikte/worker.py | 15 ++- tests/test_paste.py | 39 ++++++ tests/test_ui.py | 282 +++++++++++++++++++++++++++++++++++++++++++ tests/test_worker.py | 16 ++- 8 files changed, 679 insertions(+), 15 deletions(-) create mode 100644 dikte/mac_window.py diff --git a/dikte/app.py b/dikte/app.py index a098199..b53969a 100644 --- a/dikte/app.py +++ b/dikte/app.py @@ -17,6 +17,7 @@ import signal import socket import sys import threading +import time # A Wayland client cannot place a window in a screen corner, so the indicator # is drawn through XWayland. @@ -46,6 +47,7 @@ from . import hotkey # noqa: E402 from . import i18n # noqa: E402 from . import integrate # noqa: E402 from . import ipc # noqa: E402 +from . import mac_window # noqa: E402 from . import meeting # noqa: E402 from . import trayicon # noqa: E402 from .i18n import t # noqa: E402 @@ -107,6 +109,12 @@ class Dikte: # Which recording is the current one, so a timer set for the run that # started it cannot stop the one that came after. self._run_id = 0 + # The application that was in front when the recording started, which + # is where the transcript is meant to go, and the timer watching for + # the moment it has to be put back there. macOS only; see + # _give_the_front_back. + self.front_before = None + self._front_watch = None self.overlay = Overlay(self.conf["overlay_corner"]) # The agent's indicator sits on top of the dictation one when both are @@ -550,9 +558,19 @@ class Dikte: self.last_toggle.restart() return False + def _the_front(self): + """The application a recording is about to start from, or None. + + Asked before the indicator goes up rather than alongside the + microphone: putting a window on screen can take the front as well, and + once it has, the only answer left to the question is Dikte. + """ + return mac_window.frontmost_pid() if sys.platform == "darwin" else None + def start(self): if self.state != IDLE or self.recording: return + self.front_before = self._the_front() self.overlay.show_recording() self._begin_recording(DICTATION) self._set_state(RECORDING) @@ -560,6 +578,7 @@ class Dikte: def start_ask(self): if self.ask_state != IDLE or self.recording: return + self.front_before = self._the_front() self.ask_overlay.show_recording(asking=True) self._begin_recording(ASK) self._set_ask_state(RECORDING) @@ -572,6 +591,68 @@ class Dikte: self.elapsed.restart() self.ticker.start() self.recorder.start(self.conf["mic_target"], self.conf["max_seconds"]) + self._give_the_front_back(self.front_before) + + def _give_the_front_back(self, was_in_front): + """Hand the front back to whoever had it when the recording started. + + On macOS a recording goes through ffmpeg's avfoundation input, and + opening a capture session there brings the process that did it to the + front. ffmpeg is a child of Dikte with no bundle of its own, so the + system credits the move to Dikte: the window the user was typing in + loses the front, its caret stops, its title bar greys out, and the + Cmd+V at the end of the dictation has nowhere to land. Measured with a + TextEdit document in front: + + press the shortcut front = TextEdit + recorder.start returns front = TextEdit + 89 ms later front = Dikte + + Nothing about the capture session can be asked not to do this. It is + not a window of ours and no flag reaches it. Starting ffmpeg in its own + session, and clearing __CFBundleIdentifier from its environment, were + both tried and both measured to make no difference. So it is undone + instead. The move lands a moment after the process starts rather than + during the call, hence the short watch rather than one attempt: it + gives up as soon as it has put the front back, and in any case after a + second and a half, which is longer than the microphone has ever taken + to open. + + Silent off macOS, and silent when the recording started from Dikte + itself: there is nothing to give back. + """ + # One watch at a time. A second recording started before the first + # watch had finished would otherwise leave two of them running, and the + # older one would put the front back where the older recording + # started, which by then is the wrong window. + if self._front_watch is not None: + self._front_watch.stop() + self._front_watch = None + if not was_in_front or was_in_front == os.getpid(): + return + deadline = time.monotonic() + 1.5 + watch = QTimer(self.app) + # Ten milliseconds because the front is already gone by the time this + # notices, and every tick it waits is a tick of the user's window drawn + # inactive: at forty the title bar visibly blinks, at ten it does not. + # Two messages to AppKit per tick, for at most a second and a half. + watch.setInterval(10) + + def look(): + if mac_window.is_frontmost(): + mac_window.activate(was_in_front) + self._stop_watching_the_front() + elif time.monotonic() > deadline: + self._stop_watching_the_front() + + watch.timeout.connect(look) + self._front_watch = watch + watch.start() + + def _stop_watching_the_front(self): + if self._front_watch is not None: + self._front_watch.stop() + self._front_watch = None def stop(self): if self.state != RECORDING: @@ -687,6 +768,12 @@ class Dikte: return base = meeting.new_base() _, wav_path = cfg.meeting_paths(base) + # A meeting opens the same capture as a dictation does, and takes the + # front the same way: whoever is being recorded is in a call, and + # having their window go inactive mid-sentence is worse here than + # anywhere else. Kept as a local rather than on self: a dictation may + # already be waiting on its own note for where to paste. + was_in_front = self._the_front() self.meeting_recorder.start( str(wav_path), self.conf["meeting_mic_target"] or self.conf["mic_target"], @@ -695,6 +782,7 @@ class Dikte: ) if not self.meeting_recorder.active: return # start() has already said what went wrong + self._give_the_front_back(was_in_front) self.meeting_base = base self.meeting_elapsed.restart() self.meeting_ticker.start() @@ -821,11 +909,13 @@ class Dikte: def _on_recorded(self, wav_path, duration, rms_values): owner, self.recorder_owner = self.recorder_owner, None wants_paste = self.paste_override.pop(owner, None) + focus, self.front_before = self.front_before, None if owner == ASK: self.ask_pipeline.run(wav_path, duration, rms_values, ask=True, - paste=wants_paste) + paste=wants_paste, focus=focus) else: - self.pipeline.run(wav_path, duration, rms_values, paste=wants_paste) + self.pipeline.run(wav_path, duration, rms_values, + paste=wants_paste, focus=focus) def _on_finished(self, _raw, text, warning): if warning: diff --git a/dikte/mac_window.py b/dikte/mac_window.py new file mode 100644 index 0000000..538c6cd --- /dev/null +++ b/dikte/mac_window.py @@ -0,0 +1,203 @@ +"""The parts of AppKit a dictation needs on macOS and Qt does not reach. + +Two jobs, both about staying out of the user's way. + +The first is keeping the indicator on screen. Qt draws it as a tool window, +which on macOS is an NSPanel, and an NSPanel is hidden by the system the moment +its application stops being the active one. For a dictation indicator that is +exactly backwards: you press the shortcut inside some other program, so Dikte is +never the active application, and the one window that has something to say +disappears as soon as you look away from it. Three settings AppKit has and Qt +does not expose: + + hidesOnDeactivate = NO stay put when another application comes forward + collectionBehavior show on whichever desktop is in front, including + over a full screen window, and stay out of Cmd+Tab + nonactivating panel come to the front without bringing Dikte with it + +The second is putting the front back. Opening the microphone activates Dikte +whatever the indicator does, so app.py watches for that and calls activate() +here. See _give_the_front_back() there for the measurement. + +Done through the Objective-C runtime rather than a binding, because Dikte has no +third party Python packages and this is a handful of messages to three objects. +The runtime is loaded in _appkit() rather than at import, the way paste.py loads +its frameworks in _macos_api(): that is the one function a test fakes, and it is +what lets the tests below run on a machine that has no AppKit at all. +""" + +import ctypes +import ctypes.util +import os + +from PyQt6.QtGui import QGuiApplication + +# NSWindowCollectionBehavior, as of the macOS these names come from: +CAN_JOIN_ALL_SPACES = 1 << 0 +IGNORES_CYCLE = 1 << 6 # not a window Cmd+Tab should ever land on +FULL_SCREEN_AUXILIARY = 1 << 8 +BEHAVIOUR = CAN_JOIN_ALL_SPACES | IGNORES_CYCLE | FULL_SCREEN_AUXILIARY + +# NSWindowStyleMaskNonactivatingPanel. Without it, ordering the indicator to +# the front brings Dikte to the front with it, and the application the user was +# typing in loses focus the moment they start dictating: the Cmd+V at the end +# then lands on the indicator instead of their document. Qt has no flag for +# this; WA_ShowWithoutActivating governs the show, not what the panel does to +# the application afterwards. +NONACTIVATING_PANEL = 1 << 7 + +_appkit_runtime = None + + +class _AppKit: + """objc_msgSend under the signatures this file sends it through. + + It has no fixed signature of its own, and calling it through the wrong + argument or return types is how a Mac crashes rather than raises, so each + one is spelled out once here and used by name below. + """ + + def __init__(self, objc): + self.objc = objc + objc.sel_registerName.restype = ctypes.c_void_p + objc.sel_registerName.argtypes = [ctypes.c_char_p] + objc.objc_getClass.restype = ctypes.c_void_p + objc.objc_getClass.argtypes = [ctypes.c_char_p] + self.ask = self._as(ctypes.c_void_p) + self.ask_bool = self._as(ctypes.c_bool) + self.ask_pid = self._as(ctypes.c_int) # pid_t is an int32 + self.ask_unsigned = self._as(ctypes.c_ulong) # NSUInteger + self.tell_bool = self._as(None, ctypes.c_bool) + self.tell_unsigned = self._as(None, ctypes.c_ulong) + self.ask_of_class = self._as(ctypes.c_bool, ctypes.c_void_p) + self.ask_of_pid = self._as(ctypes.c_void_p, ctypes.c_int) + self.ask_with_options = self._as(ctypes.c_bool, ctypes.c_ulong) + + def _as(self, returns, *arguments): + return ctypes.cast(self.objc.objc_msgSend, ctypes.CFUNCTYPE( + returns, ctypes.c_void_p, ctypes.c_void_p, *arguments)) + + def selector(self, name): + return self.objc.sel_registerName(name) + + def shared(self, class_name, selector): + """A class's singleton, e.g. +[NSWorkspace sharedWorkspace].""" + return ctypes.c_void_p(self.ask( + ctypes.c_void_p(self.objc.objc_getClass(class_name)), + self.selector(selector))) + + +def _appkit(): + """The Objective-C runtime, loaded the first time something needs it. + + Loaded here rather than at import so that this module can be imported on a + machine that has no AppKit: the tests stand on macOS from a Linux machine + and back, and this is the one function they replace to do it. + """ + global _appkit_runtime + if _appkit_runtime is None: + _appkit_runtime = _AppKit( + ctypes.cdll.LoadLibrary(ctypes.util.find_library("objc"))) + return _appkit_runtime + + +def frontmost_pid(): + """Which application is in front, by process id, or None when unasked. + + A process id rather than the object itself: the object would have to be + retained to survive the trip, and a number needs nothing looking after it. + """ + try: + api = _appkit() + workspace = api.shared(b"NSWorkspace", b"sharedWorkspace") + running = ctypes.c_void_p(api.ask( + workspace, api.selector(b"frontmostApplication"))) + if not running: + return None + return int(api.ask_pid(running, api.selector(b"processIdentifier"))) + except Exception: + return None + + +def activate(pid): + """Put the application with that process id back in front. + + False when it has gone away in the meantime, or when the message could not + be sent at all: a dictation is not worth failing over the window behind it. + """ + if not pid: + return False + try: + api = _appkit() + running = ctypes.c_void_p(api.ask_of_pid( + ctypes.c_void_p(api.objc.objc_getClass(b"NSRunningApplication")), + api.selector(b"runningApplicationWithProcessIdentifier:"), + int(pid))) + if not running: + return False + # activateWithOptions: rather than the deprecated activate, and with no + # options: bringing every one of its windows forward is not asked for, + # only the application it was before Dikte took the front from it. + return bool(api.ask_with_options( + running, api.selector(b"activateWithOptions:"), 0)) + except Exception: + return False + + +def is_frontmost(): + """Whether Dikte itself is the application in front. + + By process id rather than -[NSRunningApplication isActive] on our own + process, which stays true once the application has ever been activated: + measured True with another application plainly in front. + """ + pid = frontmost_pid() + return pid is not None and pid == os.getpid() + + +def _is_panel(api, window): + """Whether this window is an NSPanel, which is the only kind the + nonactivating bit is legal on: setting it on a plain NSWindow raises an + Objective-C exception, and an exception through ctypes takes the process + down with it.""" + panel = api.objc.objc_getClass(b"NSPanel") + if not panel: + return False + return bool(api.ask_of_class(window, api.selector(b"isKindOfClass:"), + ctypes.c_void_p(panel))) + + +def keep_on_screen(widget): + """Ask the window behind `widget` to stay while other programs are used. + + Silent when anything is not as expected: an indicator that cannot be made + to linger is still an indicator, and a dictation should not fail over the + window it is drawn in. + """ + # Only the Cocoa backend hands out a real NSView. Under the offscreen + # platform the tests run on, winId() is a number that means something else + # entirely, and sending an Objective-C message to it is how a test run + # turns into a crash. + if QGuiApplication.platformName() != "cocoa": + return False + try: + api = _appkit() + view = ctypes.c_void_p(int(widget.winId())) + window = api.ask(view, api.selector(b"window")) + if not window: + return False + window = ctypes.c_void_p(window) + api.tell_bool(window, api.selector(b"setHidesOnDeactivate:"), False) + api.tell_unsigned(window, api.selector(b"setCollectionBehavior:"), + BEHAVIOUR) + # Only a panel may carry the nonactivating bit, and only a panel is + # asked to: on anything else the message raises, and an Objective-C + # exception through ctypes takes the process with it. + if _is_panel(api, window): + mask = api.ask_unsigned(window, api.selector(b"styleMask")) + if not mask & NONACTIVATING_PANEL: + api.tell_unsigned(window, api.selector(b"setStyleMask:"), + mask | NONACTIVATING_PANEL) + return True + except (AttributeError, OSError, RuntimeError, ValueError): + return False diff --git a/dikte/overlay.py b/dikte/overlay.py index a46cb6d..a99d785 100644 --- a/dikte/overlay.py +++ b/dikte/overlay.py @@ -7,6 +7,8 @@ from PyQt6.QtCore import Qt, QTimer, QRectF, QPointF from PyQt6.QtGui import QColor, QCursor, QFont, QPainter, QPainterPath, QPen, QFontMetrics from PyQt6.QtWidgets import QWidget, QApplication +from . import mac_window + BARS = 22 HEIGHT = 56 MIN_WIDTH = 210 @@ -201,6 +203,11 @@ class Overlay(QWidget): self._reposition() if not self.isVisible(): self.show() + if sys.platform == "darwin": + # After show(), because the window it works on does not exist until + # then, and every time, because a window Qt rebuilt has the setting + # again at its default. + mac_window.keep_on_screen(self) if self._concealed: self.raise_() self._concealed = False diff --git a/dikte/paste.py b/dikte/paste.py index fb1a9e4..d340d61 100644 --- a/dikte/paste.py +++ b/dikte/paste.py @@ -147,7 +147,9 @@ def _program_keyboard(program, command, hint=""): def ready(): return shutil.which(program) is not None - def press(shortcut, delay): + def press(shortcut, delay, _focus=None): + # Nothing here takes the front from the window being dictated into, so + # there is nothing to hand back: the process id is a macOS concern. if not ready(): raise PasteError(t("{tool} not found, cannot paste automatically.", tool=program)) @@ -296,11 +298,27 @@ def _ask_for_permission(): pass -def _macos_press(shortcut, delay): +def _macos_press(shortcut, delay, focus=None): """Post the key down and up straight into the window system. Nothing is typed anywhere until macOS has been told to trust Dikte, and it only asks once, when the paste it was granted for is first tried. + + `focus` is the application that was in front when the recording began. The + keys land wherever the window system is pointing, so a Dikte that has ended + up in front would swallow its own transcript; when that has happened the + front is handed back before pressing. Nothing is taken from anyone else: an + application the user went to while the transcription ran is where they want + the text now. + + This runs on the transcription's own thread rather than the main one, and + the two calls it makes are the kind AppKit documents as answering + atomically wherever they are asked from: NSRunningApplication is thread + safe by its own header, and the workspace lookup behind it returns a + reference rather than anything that has to be held. Stressed with four + threads and 32000 lookups against a running main loop without a fault; if + one ever does happen, mac_window answers None and the press goes ahead + where it would have gone anyway. """ keycode, flags = _macos_keys(shortcut) services, core = _macos_api() @@ -310,6 +328,12 @@ def _macos_press(shortcut, delay): "macOS has not been told to let Dikte press keys. Turn Dikte on " "under System Settings → Privacy & Security → Accessibility." )) + if focus: + # Imported here rather than at the top: it reaches for QtGui, and a + # terminal that only wants the clipboard should not pay for that. + from . import mac_window + if mac_window.is_frontmost(): + mac_window.activate(focus) time.sleep(delay) # let the selection settle and focus come back down = services.CGEventCreateKeyboardEvent(None, keycode, True) @@ -488,7 +512,11 @@ def paste_ready(): return desktop().ready() -def press(shortcut="", delay=0.12): - """Press a paste combination, e.g. 'ctrl+v', or this desktop's own.""" +def press(shortcut="", delay=0.12, focus=None): + """Press a paste combination, e.g. 'ctrl+v', or this desktop's own. + + `focus` is the process the keys are meant for, remembered when the + recording started: see the macOS press for what is done with it. + """ here = desktop() - here.press(shortcut or here.shortcuts[0], delay) + here.press(shortcut or here.shortcuts[0], delay, focus) diff --git a/dikte/worker.py b/dikte/worker.py index 16cdbad..b576d56 100644 --- a/dikte/worker.py +++ b/dikte/worker.py @@ -50,16 +50,20 @@ class Pipeline(QObject): def busy(self): return self._thread is not None and self._thread.is_alive() - def run(self, wav_path, duration, rms_values=(), ask=False, paste=None): + def run(self, wav_path, duration, rms_values=(), ask=False, paste=None, + focus=None): """`paste` overrides the setting for this one run, which is what a dictation asked for from a terminal wants: the text comes back down the - socket, and pasting it into whatever had focus is nobody's intention.""" + socket, and pasting it into whatever had focus is nobody's intention. + + `focus` is the application that was in front when the recording began, + as a process id, and is where the paste is meant to land.""" if self.busy: return self._stop.clear() self._thread = threading.Thread( target=self._work, - args=(wav_path, duration, list(rms_values), ask, paste), + args=(wav_path, duration, list(rms_values), ask, paste, focus), daemon=True, ) self._thread.start() @@ -73,7 +77,8 @@ class Pipeline(QObject): """ self._stop.set() - def _work(self, wav_path, duration, rms_values, ask, paste_override=None): + def _work(self, wav_path, duration, rms_values, ask, paste_override=None, + focus=None): conf = self.conf started = time.monotonic() raw = "" @@ -144,7 +149,7 @@ class Pipeline(QObject): paste.copy(text) if wants_paste: self.stage.emit(t("Pasting…")) - paste.press(conf["paste_shortcut"]) + paste.press(conf["paste_shortcut"], focus=focus) finally: if previous is not None: # Let the focused application consume the temporary diff --git a/tests/test_paste.py b/tests/test_paste.py index 8da2c81..5ca27aa 100644 --- a/tests/test_paste.py +++ b/tests/test_paste.py @@ -440,6 +440,45 @@ class MacOS(ClipboardContract, DikteTest): self.assertFalse(paste.paste_ready()) +class MacPasteGoesWhereTheDictationStarted(MacOS): + """The keys land in the frontmost window, so the front is what decides + where a transcript ends up.""" + + def setUp(self): + super().setUp() + from dikte import mac_window + self.mac_window = mac_window + self.activated = [] + self.patch_attr(mac_window, "activate", self.activated.append) + + def frontmost(self, dikte_is): + self.patch_attr(self.mac_window, "is_frontmost", lambda: dikte_is) + + def test_a_dikte_that_took_the_front_hands_it_back_before_pressing(self): + self.frontmost(True) + paste.press("cmd+v", focus=4242) + self.assertEqual(self.activated, [4242]) + self.assertEqual([event for _, event in self.api.posted], [1001, 1002]) + + def test_another_application_in_front_is_where_the_user_went_and_is_left(self): + self.frontmost(False) + paste.press("cmd+v", focus=4242) + self.assertEqual(self.activated, []) + + def test_a_run_that_remembered_nobody_asks_nothing(self): + self.frontmost(True) + paste.press("cmd+v") + self.assertEqual(self.activated, []) + + def test_the_front_is_handed_back_only_once_macos_trusts_dikte(self): + """Pulling the user out of their window and then failing to type would + be the worst of both.""" + self.frontmost(True) + self.api.trusted = False + with self.assertRaises(paste.PasteError): + paste.press("cmd+v", focus=4242) + self.assertEqual(self.activated, []) + class MacClipboardSnapshot(DikteTest): def test_every_native_type_is_restored_and_the_files_are_removed(self): directory = tempfile.mkdtemp(prefix="dikte-test-clipboard-") diff --git a/tests/test_ui.py b/tests/test_ui.py index 192aae7..5818cbf 100644 --- a/tests/test_ui.py +++ b/tests/test_ui.py @@ -488,6 +488,288 @@ class KdeSettings(Settings): self.assertFalse(window.evdev_enabled.isHidden()) + + +class FakeAppKit: + """The Objective-C runtime, answering rather than being asked. + + Stands in for what mac_window._appkit() loads, so what the indicator's + window would have been sent can be read off `told` on any machine. The + selectors come back as the names they were registered under, which is what + lets the tests below name the message they mean. + """ + + def __init__(self, window=4242, panel=True, mask=0xe): + self.objc = self + self.window, self.panel, self.mask = window, panel, mask + self.told = {} + + def objc_getClass(self, name): + return 1 if name == b"NSPanel" else 0 + + def selector(self, name): + return name.decode() + + def shared(self, _class_name, _selector): + return 1 + + def ask(self, _to, selector): + return self.window if selector == "window" else 0 + + def ask_unsigned(self, _to, _selector): + return self.mask + + def ask_of_class(self, _to, _selector, _klass): + return self.panel + + def tell_bool(self, _to, selector, value): + self.told[selector] = value + + tell_unsigned = tell_bool + + +class MacIndicatorWindow(DikteTest): + """Which of the three AppKit settings the indicator's window is sent. + + The nonactivating bit is the one worth a test of its own: it is legal on an + NSPanel and nowhere else, and sending it to a plain NSWindow raises an + Objective-C exception, which through ctypes is not something Python can + catch. It takes the whole process down. `_is_panel` is the only thing + standing between the two, so what it answers has to decide what is sent. + """ + + def setUp(self): + super().setUp() + from dikte import mac_window + self.mac_window = mac_window + self.patch_attr(mac_window.QGuiApplication, "platformName", + staticmethod(lambda: "cocoa")) + + def told(self, **kwargs): + """What keep_on_screen sends an indicator, against this AppKit.""" + appkit = FakeAppKit(**kwargs) + self.patch_attr(self.mac_window, "_appkit", lambda: appkit) + widget = overlay_module.Overlay() + self.addCleanup(widget.deleteLater) + self.addCleanup(widget.close) + self.answered = self.mac_window.keep_on_screen(widget) + return appkit.told + + def test_a_window_that_is_not_a_panel_is_never_sent_the_style_mask(self): + """The message that would kill the process. The other two still go.""" + told = self.told(panel=False) + self.assertTrue(self.answered) + self.assertNotIn("setStyleMask:", told) + self.assertIs(told["setHidesOnDeactivate:"], False) + self.assertEqual(told["setCollectionBehavior:"], + self.mac_window.BEHAVIOUR) + + def test_a_panel_without_the_bit_is_sent_the_mask_with_it_added(self): + told = self.told(panel=True, mask=0xe) + self.assertEqual(told["setStyleMask:"], + 0xe | self.mac_window.NONACTIVATING_PANEL) + + def test_a_panel_that_already_has_the_bit_is_left_alone(self): + """Every dictation runs this again, and a mask Cocoa did not need is a + window it rebuilds underneath the indicator.""" + told = self.told(panel=True, + mask=0xe | self.mac_window.NONACTIVATING_PANEL) + self.assertNotIn("setStyleMask:", told) + + def test_a_window_the_view_does_not_have_yet_is_not_messaged(self): + self.assertEqual(self.told(window=0), {}) + self.assertFalse(self.answered) + + def test_nothing_is_sent_anywhere_but_cocoa(self): + """Every other platform hands out a winId that means something else + entirely, and messaging it crashes the run.""" + self.patch_attr(self.mac_window.QGuiApplication, "platformName", + staticmethod(lambda: "offscreen")) + self.assertEqual(self.told(), {}) + self.assertFalse(self.answered) + + +class GivingTheFrontBack(DikteTest): + """Opening the microphone brings Dikte to the front, and the window the + user was dictating into goes inactive with the caret in it. Nothing can be + asked of the capture session, so the front is put back afterwards. + + The watch is driven by hand here: what matters is what it decides, not how + long Qt takes to tick. + """ + + def setUp(self): + super().setUp() + from dikte import app as dikte_module + from dikte import mac_window + self.dikte = dikte_module + self.activated = [] + self.patch_attr(mac_window, "activate", self.activated.append) + self.ticks = [] + outer = self + + class FakeTimer: + """Records what it was asked to do and hands over the tick.""" + + def __init__(self, _parent): + self.interval = None + self.running = False + outer.ticks.append(self) + + def setInterval(self, milliseconds): + self.interval = milliseconds + + def start(self): + self.running = True + + def stop(self): + self.running = False + + @property + def timeout(self): + return self + + def connect(self, slot): + self.tick = slot + + self.patch_attr(dikte_module, "QTimer", FakeTimer) + + class BareDikte: + """As much of the application as this one method touches.""" + + app = None + _front_watch = None + _the_front = dikte_module.Dikte._the_front + _give_the_front_back = dikte_module.Dikte._give_the_front_back + _stop_watching_the_front = dikte_module.Dikte._stop_watching_the_front + + self.bare = BareDikte + + def watching(self, was_in_front, dikte_in_front, on=None): + from dikte import mac_window + self.patch_attr(mac_window, "is_frontmost", lambda: dikte_in_front) + dikte = on if on is not None else self.bare() + dikte._give_the_front_back(was_in_front) + return self.ticks[-1] if self.ticks else None + + def test_the_front_goes_back_to_whoever_had_it(self): + watch = self.watching(4242, dikte_in_front=True) + watch.tick() + self.assertEqual(self.activated, [4242]) + self.assertFalse(watch.running) # one is enough; it stops watching + + def test_a_front_that_was_never_taken_is_left_where_it_is(self): + """The microphone does not always take it, and pulling an application + forward that is already there is one flicker for nothing.""" + watch = self.watching(4242, dikte_in_front=False) + watch.tick() + self.assertEqual(self.activated, []) + self.assertTrue(watch.running) # still waiting for the moment + + def test_it_gives_up_rather_than_watching_for_ever(self): + watch = self.watching(4242, dikte_in_front=False) + with mock.patch.object(self.dikte.time, "monotonic", + return_value=self.dikte.time.monotonic() + 60): + watch.tick() + self.assertFalse(watch.running) + self.assertEqual(self.activated, []) + + def test_a_dictation_started_in_dikte_itself_watches_nothing(self): + """Settings is a window of ours, and the front is already where it + belongs.""" + self.assertIsNone(self.watching(os.getpid(), dikte_in_front=True)) + + def test_a_second_recording_calls_off_the_watch_the_first_one_left(self): + """The older watch remembers where the older recording started, and by + now that is the wrong window to be pulling forward.""" + dikte = self.bare() + first = self.watching(4242, dikte_in_front=False, on=dikte) + second = self.watching(1111, dikte_in_front=False, on=dikte) + self.assertFalse(first.running) + self.assertTrue(second.running) + second.tick() # and the survivor is the new one + self.patch_attr(self.mac_window_module(), "is_frontmost", lambda: True) + second.tick() + self.assertEqual(self.activated, [1111]) + + def test_a_recording_nobody_needs_watching_for_still_calls_off_the_old_one(self): + """Starting the next one from Dikte's own window is not a reason to + leave the last one's watch running.""" + dikte = self.bare() + first = self.watching(4242, dikte_in_front=False, on=dikte) + self.watching(os.getpid(), dikte_in_front=False, on=dikte) + self.assertFalse(first.running) + self.assertIsNone(dikte._front_watch) + + def test_nobody_in_front_is_nobody_to_go_back_to(self): + self.assertIsNone(self.watching(None, dikte_in_front=True)) + + def mac_window_module(self): + from dikte import mac_window + return mac_window + + +class EveryRecordingProtectsTheFront(DikteTest): + """Three ways in, dictation, agent and meeting, and all three open the same + avfoundation capture, so all three take the front the same way. What is + checked here is the order: the front has to be noted before the microphone + is opened, and the watch armed after, or there is nothing to go back to. + """ + + def setUp(self): + super().setUp() + from dikte import app as dikte_module + self.dikte = dikte_module + self.order = [] + + def app(self, **attributes): + """A Dikte that records the order it does things in, and nothing else. + + The methods under test are called unbound against it, so the stand-ins + go on the object rather than on the class. + """ + dikte = mock.Mock(**attributes) + dikte._run_id = 0 + dikte.conf = {"mic_target": "", "max_seconds": 60, + "meeting_mic_target": "", "meeting_system_target": "", + "meeting_max_seconds": 60} + dikte._the_front.side_effect = lambda: self.order.append("noted") or 4242 + dikte._give_the_front_back.side_effect = ( + lambda pid: self.order.append(f"watching {pid}")) + dikte._begin_recording = ( + lambda owner: self.dikte.Dikte._begin_recording(dikte, owner)) + dikte.recorder.start.side_effect = ( + lambda *_a: self.order.append("microphone")) + dikte.meeting_recorder.start.side_effect = ( + lambda *_a: self.order.append("microphone")) + return dikte + + def test_a_dictation_notes_the_front_before_the_indicator_is_even_shown(self): + dikte = self.app(state=self.dikte.IDLE, recording=False) + dikte.overlay.show_recording.side_effect = ( + lambda *_a: self.order.append("indicator")) + self.dikte.Dikte.start(dikte) + self.assertEqual(self.order, + ["noted", "indicator", "microphone", "watching 4242"]) + + def test_the_agent_does_the_same(self): + dikte = self.app(ask_state=self.dikte.IDLE, recording=False) + self.dikte.Dikte.start_ask(dikte) + self.assertEqual(self.order, ["noted", "microphone", "watching 4242"]) + + def test_a_meeting_does_the_same(self): + """The one most worth protecting: the user is in a call.""" + dikte = self.app(meeting_state=self.dikte.M_IDLE) + dikte.meeting_recorder.active = True + self.dikte.Dikte.start_meeting(dikte) + self.assertEqual(self.order, ["noted", "microphone", "watching 4242"]) + + def test_a_meeting_whose_microphone_never_opened_watches_nothing(self): + dikte = self.app(meeting_state=self.dikte.M_IDLE) + dikte.meeting_recorder.active = False + self.dikte.Dikte.start_meeting(dikte) + self.assertEqual(self.order, ["noted", "microphone"]) + class Overlay(DikteTest): def overlay(self, **kwargs): widget = overlay_module.Overlay(**kwargs) diff --git a/tests/test_worker.py b/tests/test_worker.py index 1ea30c3..44102fb 100644 --- a/tests/test_worker.py +++ b/tests/test_worker.py @@ -32,7 +32,8 @@ class Chain(DikteTest): transcript="uh, book it for Thursday", cleaned="Book it for Thursday.", cleanup_error=None, answer=("Booked.", ""), rms=None, - clipboard=b"what was there before", paste_error=None): + clipboard=b"what was there before", paste_error=None, + focus=None): pipeline = worker.Pipeline(self.conf) done, failures, stages, cancels = [], [], [], [] pipeline.finished.connect(lambda *args: done.append(args)) @@ -60,7 +61,8 @@ class Chain(DikteTest): "copy": copy, "copy_bytes": copy_bytes, "press": press, "read_clipboard": read_clipboard} pipeline._work(self.wav, duration, - self.rms if rms is None else rms, ask, paste_override) + self.rms if rms is None else rms, ask, paste_override, + focus) return {"done": done, "failures": failures, "stages": stages, "cancelled": cancels, **calls} @@ -72,7 +74,15 @@ class Chain(DikteTest): self.assertEqual(run["done"][0], ("uh, book it for Thursday", "Book it for Thursday.", "")) run["copy"].assert_called_once_with("Book it for Thursday.") - run["press"].assert_called_once_with(self.conf["paste_shortcut"]) + run["press"].assert_called_once_with(self.conf["paste_shortcut"], + focus=None) + + def test_the_paste_is_told_where_the_dictation_started(self): + """Whoever was in front when the recording began is where the keys are + meant to go, and the press is the only part that can act on it.""" + run = self.run_chain(focus=4242) + run["press"].assert_called_once_with(self.conf["paste_shortcut"], + focus=4242) def test_the_stages_are_named_as_they_happen(self): run = self.run_chain() From 45e7af474a0419c2c17548bc7c666ad80e927fad Mon Sep 17 00:00:00 2001 From: firat Date: Sun, 16 Aug 2026 17:18:46 +0200 Subject: [PATCH 17/27] Remove downloaded Mac command during integration cleanup --- dikte/integrate.py | 27 +++++++++++++++++++++++---- tests/test_integrate.py | 25 +++++++++++++++++++++++++ 2 files changed, 48 insertions(+), 4 deletions(-) diff --git a/dikte/integrate.py b/dikte/integrate.py index 0af3744..e5a2477 100644 --- a/dikte/integrate.py +++ b/dikte/integrate.py @@ -34,6 +34,7 @@ import sys AGENT_ID = "io.github.yusufipk.dikte" ICON_NAME = "dikte" DESKTOP_FILE = "dikte.desktop" +MACOS_COMMAND_MARKER = "# Written by Dikte itself. Delete it to be rid of it.\n" def packaged(): @@ -398,6 +399,20 @@ def _agent_path(): return pathlib.Path.home() / "Library" / "LaunchAgents" / f"{AGENT_ID}.plist" +def _macos_command_path(): + return pathlib.Path.home() / ".local" / "bin" / "dikte" + + +def _macos_command_is_ours(command): + """Whether this is the wrapper a downloaded Mac build wrote itself.""" + try: + return command.is_file() and MACOS_COMMAND_MARKER in command.read_text( + encoding="utf-8" + ) + except (OSError, UnicodeDecodeError): + return False + + def _agent_plist(app): """Through `open` rather than the executable inside the bundle, so that the process is one LaunchServices started: that is what gives it the bundle's @@ -449,13 +464,13 @@ def _macos_install(app, force=False): # The command, as a wrapper rather than a symlink: the executable has to be # run from inside the bundle for macOS to file its permissions under Dikte, # and a symlink somewhere else is a different process to macOS. - command = pathlib.Path.home() / ".local" / "bin" / "dikte" + command = _macos_command_path() binary = app / "Contents" / "MacOS" / "Dikte" - marker = "# Written by Dikte itself. Delete it to be rid of it.\n" - script = f'#!/bin/sh\n{marker}exec {shlex.quote(str(binary))} "$@"\n' + script = (f'#!/bin/sh\n{MACOS_COMMAND_MARKER}' + f'exec {shlex.quote(str(binary))} "$@"\n') # install-mac.sh writes its own wrapper here, naming the checkout's Python. # Ours only replaces a wrapper it wrote before, or nothing at all. - ours = command.exists() and marker in command.read_text(encoding="utf-8") + ours = _macos_command_is_ours(command) if (not command.exists() or ours or force) and _write(command, script): command.chmod(0o755) written.append(command) @@ -470,6 +485,10 @@ def _macos_remove(): capture_output=True, check=False) agent.unlink() gone.append(agent) + command = _macos_command_path() + if _macos_command_is_ours(command): + command.unlink() + gone.append(command) return gone diff --git a/tests/test_integrate.py b/tests/test_integrate.py index 6bc43b6..cd2626d 100644 --- a/tests/test_integrate.py +++ b/tests/test_integrate.py @@ -358,6 +358,12 @@ class MacOS(Home): mock.patch.object(integrate, "_launchctl_reload"): return integrate.install(force=force) + def remove(self): + with Frozen("/Applications/Dikte.app/Contents/MacOS/Dikte", + home=self.home, platform="darwin"), \ + mock.patch("subprocess.run"): + return integrate.remove() + def test_it_writes_a_login_item_and_the_command(self): app = self.home / "Applications" / "Dikte.app" (app / "Contents/MacOS").mkdir(parents=True) @@ -419,6 +425,25 @@ class MacOS(Home): self.install(app) self.assertIn("install-mac.sh", command.read_text()) + def test_removing_takes_back_the_login_item_and_command_it_wrote(self): + app = self.home / "Applications" / "Dikte.app" + (app / "Contents/MacOS").mkdir(parents=True) + self.install(app) + command = self.home / ".local/bin/dikte" + + self.assertEqual(self.remove(), [self.agent(), command]) + self.assertFalse(self.agent().exists()) + self.assertFalse(command.exists()) + + def test_removing_leaves_another_installers_command_alone(self): + command = self.home / ".local/bin/dikte" + command.parent.mkdir(parents=True) + command.write_text("#!/bin/sh\n# Written by install-mac.sh.\n" + "exec /usr/local/bin/python3 /src/__main__.py \"$@\"\n") + + self.assertEqual(self.remove(), []) + self.assertIn("install-mac.sh", command.read_text()) + if __name__ == "__main__": unittest.main() From 11a4058e162f24b9f0f74e89ee065fcbfa12a39d Mon Sep 17 00:00:00 2001 From: huseyin-emre-tigci Date: Mon, 17 Aug 2026 01:02:42 +0300 Subject: [PATCH 18/27] Let the device listing match ffmpeg 8's bracket prefix The dshow parser pinned the bracketed prefix to the `[dshow @ ...]` the older builds print, and ffmpeg 8 writes `[in#0 @ ...]` there instead: on a current install every line fell through the anchors, the device list came back empty, and a dictation with no microphone picked could not start at all. The prefix is now any bracketed tag, which the anchors and the quoted name still keep apart from the error lines. Pinned with a listing taken verbatim from ffmpeg 8.1.2 on Windows 11, third kind marker `(none)` included. Co-Authored-By: Claude Fable 5 --- dikte/audio.py | 8 +++++--- tests/test_audio.py | 16 ++++++++++++++++ 2 files changed, 21 insertions(+), 3 deletions(-) diff --git a/dikte/audio.py b/dikte/audio.py index edcd5dc..ef24461 100644 --- a/dikte/audio.py +++ b/dikte/audio.py @@ -874,11 +874,13 @@ def _avfoundation_default_output(): # 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. +# 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'^(?:\[dshow @ [^\]]*\]\s*)?"([^"]+)"\s*(?:\(([^)]*)\))?\s*$') + r'^(?:\[[^\]]*\]\s*)?"([^"]+)"\s*(?:\(([^)]*)\))?\s*$') _DSHOW_ALTERNATIVE = re.compile( - r'^(?:\[dshow @ [^\]]*\]\s*)?Alternative name\s+"([^"]+)"\s*$') + 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. diff --git a/tests/test_audio.py b/tests/test_audio.py index 7435443..a7ea353 100644 --- a/tests/test_audio.py +++ b/tests/test_audio.py @@ -982,6 +982,22 @@ class WindowsDevices(OnWindows, DikteTest): 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 = ( From 23d56acfb57f73733eaa338a9eeab7c87295219f Mon Sep 17 00:00:00 2001 From: yusufipk Date: Tue, 18 Aug 2026 16:08:47 +0300 Subject: [PATCH 19/27] Publish a Windows setup beside the AppImage and the disk image The releases page had nothing for Windows, so the only way in was a checkout, a Python and a pip install. What goes out now is one setup program per release: PyInstaller's directory, the pinned ffmpeg the disk image already uses, and Inno Setup around both. It installs for the account alone, so no administrator is asked for. Two executables over the one program there, because a windowed one on Windows has no standard output at all: Dikte.exe for the Start Menu and dikte.exe for the terminal, sharing everything they carry. The icon is drawn by Dikte itself into an .ico, the way the Mac's .icns and Linux's PNGs already are, so there is still no image file in the repository. Starting at sign-in is a registry value rather than a Startup shortcut, which is what lets the setup program, the uninstaller and `dikte integrate` all mean the same thing: the wizard asks once, and typing the command changes the answer later. The three builds move into build.yml, which release.yml now calls instead of holding its own copy, and which a pull request touching the packaging runs on its own. A broken build is then a red pull request rather than a failed release. --- .github/workflows/build.yml | 112 ++++++++++++++++++++++++++++++++ .github/workflows/release.yml | 76 ++++------------------ .github/workflows/tests.yml | 17 +++-- CONTRIBUTING.md | 10 +-- README.md | 35 +++++----- README.tr.md | 34 +++++----- README.windows.md | 16 ++++- dikte/cli.py | 10 ++- dikte/integrate.py | 106 ++++++++++++++++++++++++++++-- dikte/ipc.py | 12 ++++ dikte/trayicon.py | 53 +++++++++++++-- packaging/build-windows.ps1 | 89 ++++++++++++++++++++++++++ packaging/dikte.iss | 117 ++++++++++++++++++++++++++++++++++ packaging/dikte.spec | 45 ++++++++++--- packaging/entry.py | 5 +- tests/test_integrate.py | 69 ++++++++++++++++++++ tests/test_trayicon.py | 22 +++++++ 17 files changed, 696 insertions(+), 132 deletions(-) create mode 100644 .github/workflows/build.yml create mode 100644 packaging/build-windows.ps1 create mode 100644 packaging/dikte.iss diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml new file mode 100644 index 0000000..1416b82 --- /dev/null +++ b/.github/workflows/build.yml @@ -0,0 +1,112 @@ +name: build + +# The three downloads, in one place. release.yml calls this one rather than +# holding a copy of it, and a pull request that touches the packaging runs it +# on its own, because the alternative is finding out that a build is broken +# from the release that was supposed to publish it. + +on: + workflow_call: + inputs: + ref: + description: what to check out; the caller's own ref when empty + type: string + required: false + default: "" + version: + description: write this version into the tree before building + type: string + required: false + default: "" + pull_request: + paths: + - packaging/** + - .github/workflows/build.yml + - dikte/integrate.py + - dikte/trayicon.py + workflow_dispatch: + +permissions: + contents: read + +jobs: + build: + strategy: + fail-fast: false + matrix: + include: + # The oldest Ubuntu still offered, because the glibc a build links + # against is the oldest one it will run on, and 22.04's covers every + # distribution released since. Move it up only when it goes away. + - os: ubuntu-22.04 + kind: appimage + - os: macos-latest + kind: dmg + # Intel Macs. This runner is the last x86_64 image Actions will + # offer, and it goes away in August 2027. + - os: macos-15-intel + kind: dmg + # x64 only, which is what the PyQt6 wheel and whisper.cpp both + # publish for Windows; a machine on ARM runs the result emulated, + # the same way it runs everything else that was never built for it. + - os: windows-latest + kind: windows + + runs-on: ${{ matrix.os }} + steps: + - uses: actions/checkout@v4 + with: + ref: ${{ inputs.ref }} + + - uses: actions/setup-python@v5 + with: + python-version: "3.12" + + # PyQt6 ships Qt itself, but Qt still loads these from the system, and + # the build draws its own icon before it packages anything. + - name: Install the Qt runtime libraries + if: runner.os == 'Linux' + run: | + sudo apt-get update + sudo apt-get install --no-install-recommends -y \ + libegl1 libgl1 libxkbcommon0 libdbus-1-3 libglib2.0-0 \ + libfontconfig1 libfreetype6 libgssapi-krb5-2 + + - name: Install PyQt6 and PyInstaller + run: python -m pip install --quiet PyQt6 pyinstaller + + # Only for the builds off master: a tagged build already says the number + # it was tagged with, and rewriting it would be rewriting the tag. + - name: Write the version being built + if: inputs.version + # Named, because the Windows runner's own shell is PowerShell and this + # is a here-document. + shell: bash + env: + VERSION: ${{ inputs.version }} + run: | + python - <<'PY' + import os, pathlib, re + path = pathlib.Path("dikte/__init__.py") + path.write_text(re.sub(r'^__version__ = ".*"$', + f'__version__ = "{os.environ["VERSION"]}"', + path.read_text(), flags=re.M)) + PY + + - name: Build + if: runner.os != 'Windows' + run: ./packaging/build-${{ matrix.kind }}.sh + + # The same steps as the other two, in the language the platform already + # has: drawing the icon, PyInstaller, and wrapping the result in what + # people download. + - name: Build + if: runner.os == 'Windows' + shell: pwsh + run: ./packaging/build-${{ matrix.kind }}.ps1 + + - uses: actions/upload-artifact@v4 + with: + name: dikte-${{ matrix.os }} + path: dist/* + if-no-files-found: error diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 6ae61d0..d6689ed 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -107,70 +107,16 @@ jobs: esac echo "version=$version" >> "$GITHUB_OUTPUT" + # The builds themselves are build.yml, which a pull request touching the + # packaging also runs on its own. One definition, so the download somebody + # gets from a release and the one a pull request was checked against cannot + # come out of two different sets of steps. build: needs: version - strategy: - fail-fast: false - matrix: - include: - # The oldest Ubuntu still offered, because the glibc a build links - # against is the oldest one it will run on, and 22.04's covers every - # distribution released since. Move it up only when it goes away. - - os: ubuntu-22.04 - kind: appimage - - os: macos-latest - kind: dmg - # Intel Macs. This runner is the last x86_64 image Actions will - # offer, and it goes away in August 2027. - - os: macos-15-intel - kind: dmg - - runs-on: ${{ matrix.os }} - steps: - - uses: actions/checkout@v4 - with: - ref: ${{ needs.version.outputs.ref }} - - - uses: actions/setup-python@v5 - with: - python-version: "3.12" - - # PyQt6 ships Qt itself, but Qt still loads these from the system, and - # the build draws its own icon before it packages anything. - - name: Install the Qt runtime libraries - if: runner.os == 'Linux' - run: | - sudo apt-get update - sudo apt-get install --no-install-recommends -y \ - libegl1 libgl1 libxkbcommon0 libdbus-1-3 libglib2.0-0 \ - libfontconfig1 libfreetype6 libgssapi-krb5-2 - - - name: Install PyQt6 and PyInstaller - run: python -m pip install --quiet PyQt6 pyinstaller - - # Only for the builds off master: a tagged build already says the number - # it was tagged with, and rewriting it would be rewriting the tag. - - name: Write the version being built - if: needs.version.outputs.prerelease == 'true' - env: - VERSION: ${{ needs.version.outputs.version }} - run: | - python - <<'PY' - import os, pathlib, re - path = pathlib.Path("dikte/__init__.py") - path.write_text(re.sub(r'^__version__ = ".*"$', - f'__version__ = "{os.environ["VERSION"]}"', - path.read_text(), flags=re.M)) - PY - - - name: Build - run: ./packaging/build-${{ matrix.kind }}.sh - - - uses: actions/upload-artifact@v4 - with: - name: dikte-${{ matrix.os }} - path: dist/* - if-no-files-found: error + uses: ./.github/workflows/build.yml + with: + ref: ${{ needs.version.outputs.ref }} + version: ${{ needs.version.outputs.prerelease == 'true' && needs.version.outputs.version || '' }} publish: needs: [version, build] @@ -207,6 +153,12 @@ jobs: Accessibility permissions, which macOS asks for the first time each is used. It asks again after an update, because an application signed with no certificate is one macOS has never seen before. + + Windows: run the setup, which installs for your account alone and asks + for no administrator. It carries the ffmpeg recording needs and adds a + Start Menu entry, a `dikte` command and, unless you untick it, a start + at sign-in. It is not signed either, so SmartScreen offers only "Don't + run" until you press More info. Add/Remove Programs uninstalls it. EOF ) diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml index 5a5ff42..89ee656 100644 --- a/.github/workflows/tests.yml +++ b/.github/workflows/tests.yml @@ -107,11 +107,16 @@ jobs: 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 + # Parsing only: install.ps1 writes into the Start Menu and the user PATH, + # and build-windows.ps1 downloads an ffmpeg and runs PyInstaller. The + # setup program itself is compiled by build.yml, on the pull requests + # that touch it. + - name: Check the installer and the build script parse shell: pwsh run: | - $problems = $null - [System.Management.Automation.Language.Parser]::ParseFile( - "$PWD/install.ps1", [ref]$null, [ref]$problems) > $null - if ($problems) { $problems; exit 1 } + foreach ($script in "install.ps1", "packaging/build-windows.ps1") { + $problems = $null + [System.Management.Automation.Language.Parser]::ParseFile( + "$PWD/$script", [ref]$null, [ref]$problems) > $null + if ($problems) { $problems; exit 1 } + } diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index c364c89..b925daf 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -70,7 +70,7 @@ 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. -1084 of the 1147 run on any machine, including every line of the Wayland, X11, +1094 of the 1157 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 @@ -89,9 +89,11 @@ 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. 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 +`@posix_only` are the half of `integrate.py` that writes files, the menu entry +and the login item a downloaded build puts down for itself, which want a home +directory laid out the way those two systems lay one out. Its Windows half is +one registry value, since the setup program there did the rest, and the three +functions that read and write it are faked like anything else. 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. diff --git a/README.md b/README.md index 75e40de..c195318 100644 --- a/README.md +++ b/README.md @@ -24,15 +24,17 @@ library, 3.11 or newer, and PyQt6. ## Install -The [releases page](../../releases) has an AppImage and a disk image per Mac -architecture. Both write their own menu entry, login item and `dikte` command -the first time they run, and stand aside for an installation already on the -machine; `dikte integrate --remove` takes them back. The AppImage still wants -the system packages below, for the sound server, the clipboard and the -keyboard. The disk image is signed with no Apple certificate, so the first -launch is refused until you press **Open Anyway** under System Settings → -Privacy & Security, and macOS asks for the microphone and Accessibility again -after each update; installing from a checkout is what avoids that. +The [releases page](../../releases) has an AppImage, a disk image per Mac +architecture and a Windows setup. The first two write their own menu entry, +login item and `dikte` command the first time they run, and stand aside for an +installation already on the machine; `dikte integrate --remove` takes them +back. The AppImage still wants the system packages below, for the sound +server, the clipboard and the keyboard. The disk image is signed with no Apple +certificate, so the first launch is refused until you press **Open Anyway** +under System Settings → Privacy & Security, and macOS asks for the microphone +and Accessibility again after each update; installing from a checkout is what +avoids that. The Windows setup installs for your account alone and carries an +ffmpeg with it. ```sh sudo pacman -S --needed pipewire-audio wl-clipboard ydotool ffmpeg python-pyqt6 @@ -94,10 +96,12 @@ 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). +service while Dikte runs. The setup on the releases page carries the ffmpeg +recording needs and asks for no administrator; from a checkout it is `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 @@ -221,8 +225,9 @@ keys. Everything below is in the `dikte` package, which is what `python3 -m dikte` runs and what the `__main__.py` in it hands to every launcher and shortcut. `scripts/` holds install-mac.sh, update.sh, uninstall.sh and release.sh; -`packaging/` builds the AppImage and the disk image that release.sh's tag -publishes; install.sh stays at the top, and `tests/` has a file per module. +`packaging/` builds the AppImage, the disk image and the Windows setup that +release.sh's tag publishes; install.sh stays at the top, and `tests/` has a +file per module. ``` app.py entry point, tray icon, state machine diff --git a/README.tr.md b/README.tr.md index 642e37f..65a50d1 100644 --- a/README.tr.md +++ b/README.tr.md @@ -23,15 +23,16 @@ Python standart kütüphanesi (3.11 veya üstü) ve PyQt6. ## Kurulum -[Sürümler sayfasında](../../releases) bir AppImage, bir de her Mac mimarisi -için birer disk imajı var. İkisi de ilk çalıştıklarında kendi menü girdisini, -oturum açılışını ve `dikte` komutunu yazar, makinede zaten duran bir kuruluma -dokunmazlar; `dikte integrate --remove` yazdıklarını geri alır. AppImage yine -de aşağıdaki sistem paketlerini ister: ses sunucusu, pano ve klavye onlardan -gelir. Disk imajı bir Apple sertifikasıyla imzalı değil, bu yüzden ilk açılış -reddedilir, Sistem Ayarları → Gizlilik ve Güvenlik altından **Yine de Aç** -demek gerekir; macOS her güncellemeden sonra mikrofonu ve Erişilebilirliği -yeniden sorar, checkout'tan kurmak bundan kurtarır. +[Sürümler sayfasında](../../releases) bir AppImage, her Mac mimarisi için +birer disk imajı, bir de Windows kurulumu var. İlk ikisi ilk çalıştıklarında +kendi menü girdisini, oturum açılışını ve `dikte` komutunu yazar, makinede +zaten duran bir kuruluma dokunmazlar; `dikte integrate --remove` yazdıklarını +geri alır. AppImage yine de aşağıdaki sistem paketlerini ister: ses sunucusu, +pano ve klavye onlardan gelir. Disk imajı bir Apple sertifikasıyla imzalı +değil, bu yüzden ilk açılış reddedilir, Sistem Ayarları → Gizlilik ve Güvenlik +altından **Yine de Aç** demek gerekir; macOS her güncellemeden sonra mikrofonu +ve Erişilebilirliği yeniden sorar, checkout'tan kurmak bundan kurtarır. +Windows kurulumu yalnızca kendi hesabına kurar ve yanında bir ffmpeg taşır. ```sh sudo pacman -S --needed pipewire-audio wl-clipboard ydotool ffmpeg python-pyqt6 @@ -93,10 +94,11 @@ 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. +kısayol servisi üzerinden tutuyor. Sürümler sayfasındaki kurulum kaydın +istediği ffmpeg'i de taşıyor ve yönetici istemiyor; checkout'tan ise `winget +install Gyan.FFmpeg`, `pip install PyQt6`, sonra `python -m dikte`, Başlat +Menüsü girdisi ve `dikte` komutu için de 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 @@ -217,9 +219,9 @@ Kısayollar sekmesi bağlanacak komutu gösterir. Aşağıdakilerin hepsi `dikte` paketinin içinde: `python3 -m dikte` bunu çalıştırır, içindeki `__main__.py` de her başlatıcının ve kısayolun adlandırdığı dosyadır. `scripts/` altında install-mac.sh, update.sh, uninstall.sh ve release.sh var; -`packaging/` release.sh'ın attığı etiketin yayımladığı AppImage ile disk -imajını derler; install.sh en üstte kalır, `tests/` içinde de her modülün bir -dosyası. +`packaging/` release.sh'ın attığı etiketin yayımladığı AppImage'i, disk imajını +ve Windows kurulumunu derler; install.sh en üstte kalır, `tests/` içinde de her +modülün bir dosyası. ``` app.py giriş noktası, tepsi simgesi, durum makinesi diff --git a/README.windows.md b/README.windows.md index 05adcf6..33bb63d 100644 --- a/README.windows.md +++ b/README.windows.md @@ -5,15 +5,25 @@ up and pasted where your cursor is. ## Requirements -- **Windows 10/11** +Windows 10 or 11. The setup on the [releases page](../../releases) carries +everything else with it, and is x64, which an ARM machine runs emulated the way +it runs whisper.cpp. A checkout wants: + - **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. +`Dikte--x64-setup.exe` from the releases page installs for your +account alone, so no administrator is asked for, and puts down a Start Menu +entry, a `dikte` command and, unless you untick it, a start at sign-in. It is +signed with no certificate, so SmartScreen offers only **Don't run** until you +press **More info**. Add/Remove Programs uninstalls it, and `dikte integrate` +and `dikte integrate --remove` are the sign-in entry on its own, for changing +your mind about that later. + +From a checkout instead: ```powershell powershell -ExecutionPolicy Bypass -File install.ps1 diff --git a/dikte/cli.py b/dikte/cli.py index 78efeef..8bfcf56 100644 --- a/dikte/cli.py +++ b/dikte/cli.py @@ -777,11 +777,14 @@ def cmd_integrate(opts): Run for you on every start, so this is for the two cases that start does not cover: undoing it, and repairing it from a terminal after the AppImage - was moved while Dikte was not running. + was moved while Dikte was not running. On Windows the setup program wrote + the rest, and what is left for this is the switch it could only offer while + it was on the screen: typing it starts Dikte at sign-in, --remove stops it. """ if not integrate.packaged(): + installer = "install.ps1" if sys.platform == "win32" else "./install.sh" return fail(opts, "this is a checkout, not a downloaded build; " - "./install.sh writes those files here", 2) + f"{installer} writes those files here", 2) try: # force, because typing this is asking for it outright, where the same # call on every start stands aside for an installation already there. @@ -1088,7 +1091,8 @@ def build_parser(): remove.set_defaults(func=cmd_shortcut) integrated = leaf(subs, "integrate", - "menu entry, login item and command, for a downloaded build") + "menu entry, start at sign-in and command, " + "for a downloaded build") integrated.add_argument("--remove", action="store_true", help="take them away again") integrated.set_defaults(func=cmd_integrate) diff --git a/dikte/integrate.py b/dikte/integrate.py index e5a2477..a55fcf3 100644 --- a/dikte/integrate.py +++ b/dikte/integrate.py @@ -6,6 +6,12 @@ downloaded an AppImage or dragged Dikte.app out of a disk image ran no installer at all, so the application writes those files itself, on its first run and again whenever the file it was started from has moved. +Windows is the one platform where the download is an installer, and it wrote +the Start Menu entry, the `dikte` command and the uninstaller as it ran. What +is left here is the one thing it can only ask about once: whether Dikte starts +when you sign in. `dikte integrate` turns that on later and `--remove` turns it +off, and a plain start only repairs an entry that is already there. + Nothing here runs from a checkout. install.sh has already written the same files there, pointing at the interpreter that checkout was installed against, and overwriting them with a guess would be a downgrade. @@ -35,6 +41,14 @@ AGENT_ID = "io.github.yusufipk.dikte" ICON_NAME = "dikte" DESKTOP_FILE = "dikte.desktop" MACOS_COMMAND_MARKER = "# Written by Dikte itself. Delete it to be rid of it.\n" +# The windowed executable the Windows setup installs, beside the console one +# the `dikte` command runs. +WINDOWS_APP = "Dikte.exe" +# Where Windows keeps what to start when somebody signs in, and the name the +# setup program files Dikte's entry under. Both halves have to agree: the +# uninstaller deletes this value, and so does `dikte integrate --remove`. +RUN_KEY = "Software\\Microsoft\\Windows\\CurrentVersion\\Run" +RUN_VALUE = "Dikte" def packaged(): @@ -56,6 +70,13 @@ def target(): for parent in executable.parents: if parent.suffix == ".app": return parent + if sys.platform == "win32": + # The windowed executable, whichever of the two is running: the console + # one is what the `dikte` command names, and a sign-in that started + # that one would open a console window nobody asked for. + windowed = executable.with_name(WINDOWS_APP) + if windowed.is_file(): + return windowed return executable @@ -152,11 +173,12 @@ def use_system_certificates(): def bundled_bin(): """Where a build keeps the helper programs it carries, if it carries any. - The disk image ships an ffmpeg because macOS records through one and has - nothing like it preinstalled, so a Mac that downloaded Dikte and nothing - else would otherwise not be able to record at all. The AppImage carries - none: Linux records through parec or pw-record, which come with the sound - server, and the distributions all package ffmpeg for the rest. + The disk image and the Windows setup both ship an ffmpeg, because both + systems record through one and neither has anything like it preinstalled, + so a machine that downloaded Dikte and nothing else would otherwise not be + able to record at all. The AppImage carries none: Linux records through + parec or pw-record, which come with the sound server, and the distributions + all package ffmpeg for the rest. """ binary = pathlib.Path(sys.executable).parent if sys.platform == "darwin" and binary.name == "MacOS": @@ -210,6 +232,8 @@ def install(force=False): """ if sys.platform == "darwin": return _macos_install(target(), force) + if sys.platform == "win32": + return _windows_install(target(), force) return _linux_install(target(), force) @@ -217,6 +241,8 @@ def remove(): """Take them away again. The paths that were there to delete.""" if sys.platform == "darwin": return _macos_remove() + if sys.platform == "win32": + return _windows_remove() return _linux_remove() @@ -500,3 +526,73 @@ def _launchctl_reload(agent): capture_output=True, check=False) subprocess.run(["launchctl", "bootstrap", f"gui/{os.getuid()}", str(agent)], capture_output=True, check=False) + + +# --- Windows -------------------------------------------------------------- +# +# The setup program did the installing here, which leaves one question a +# wizard can only ask while it is on the screen: whether Dikte starts when you +# sign in. That answer is a registry value, so it is one both sides can write: +# the setup program sets it from the tick box, the uninstaller deletes it +# however it got there, and the two functions below are the same switch from a +# terminal, long after the wizard is gone. + + +def _run_entry(): + """What the autostart entry names, or "" when there is none.""" + import winreg + try: + with winreg.OpenKey(winreg.HKEY_CURRENT_USER, RUN_KEY) as key: + value, kind = winreg.QueryValueEx(key, RUN_VALUE) + except OSError: + return "" + return value if kind == winreg.REG_SZ and isinstance(value, str) else "" + + +def _write_run_entry(command): + import winreg + with winreg.CreateKey(winreg.HKEY_CURRENT_USER, RUN_KEY) as key: + winreg.SetValueEx(key, RUN_VALUE, 0, winreg.REG_SZ, command) + + +def _delete_run_entry(): + """Whether there was one to delete.""" + import winreg + try: + with winreg.OpenKey(winreg.HKEY_CURRENT_USER, RUN_KEY, 0, + winreg.KEY_SET_VALUE) as key: + winreg.DeleteValue(key, RUN_VALUE) + except OSError: + return False + return True + + +def _run_entry_name(): + """What to call the value in a listing, since it is not a file.""" + return f"HKCU\\{RUN_KEY}\\{RUN_VALUE}" + + +def _windows_install(app, force=False): + """Point the autostart entry at this build. What changed. + + Only `force`, which is what typing `dikte integrate` means, creates one. + The call on every start repairs an entry that is already there and names an + executable somewhere else, which is what an installation moved to another + drive or reinstalled into another directory leaves behind; somebody who + unticked the box in the wizard, or turned it off since, is not asked again + by every start. + """ + command = f'"{app}"' + current = _run_entry() + if not current and not force: + return [] + if current == command: + return [] + _write_run_entry(command) + return [_run_entry_name()] + + +def _windows_remove(): + """Stop starting at sign-in. The Start Menu entry, the command and the + files are the uninstaller's, and Add/Remove Programs is where they go.""" + return [_run_entry_name()] if _delete_run_entry() else [] diff --git a/dikte/ipc.py b/dikte/ipc.py index 823799b..02f668a 100644 --- a/dikte/ipc.py +++ b/dikte/ipc.py @@ -15,6 +15,8 @@ import sys from PyQt6.QtNetwork import QLocalSocket +from . import integrate + SERVER_NAME = "dikte-" + ( str(os.getuid()) if hasattr(os, "getuid") else os.environ.get("USERNAME", "user")) @@ -43,9 +45,19 @@ def launcher(): under a fresh /tmp path every run, so what a shortcut written today has to say is the .AppImage file the user keeps, not the binary inside this run's mount. APPIMAGE is what the runtime puts that path in. + + The Windows build is two executables over one program, and the one to start + again is always the windowed one: `dikte toggle` typed at a terminal runs + the console one, and the application it leaves running should no more be + tied to that terminal than the one the Start Menu starts. """ if not getattr(sys, "frozen", False): return [sys.executable, script_path()] + if sys.platform == "win32": + windowed = os.path.join(os.path.dirname(sys.executable), + integrate.WINDOWS_APP) + if os.path.isfile(windowed): + return [windowed] return [os.environ.get("APPIMAGE") or sys.executable] diff --git a/dikte/trayicon.py b/dikte/trayicon.py index cce525b..87cc628 100644 --- a/dikte/trayicon.py +++ b/dikte/trayicon.py @@ -23,9 +23,10 @@ outwards, which stands out on a dark bar and stays readable on a light one. """ import pathlib +import struct import sys -from PyQt6.QtCore import QPointF, QRectF, Qt +from PyQt6.QtCore import QBuffer, QPointF, QRectF, Qt from PyQt6.QtGui import (QColor, QIcon, QLinearGradient, QPainter, QPainterPath, QPen, QPixmap) @@ -210,6 +211,9 @@ APP_ICON_SIZES = (16, 32, 128, 256, 512) # What an XDG icon theme is asked for: a menu wants 48, a task bar 22 or 24, a # file dialog 16, and something scaling for a HiDPI panel wants the big ones. HICOLOR_SIZES = (16, 22, 24, 32, 48, 64, 128, 256) +# What goes into the .ico: the Windows shell picks the nearest of these itself, +# and 256 is the one the large view in Explorer and the setup program read. +ICO_SIZES = (16, 24, 32, 48, 64, 128, 256) def app_pixmap(size): @@ -292,26 +296,63 @@ def write_hicolor(directory, name="dikte"): return written +def write_ico(path): + """Write the Windows icon, every size in the one file. The path it wrote. + + An .ico is a directory of images and a run of image data after it, and + since Vista each image may be a PNG rather than the bitmap-and-mask pair + the format started with. PNGs are what Qt can already produce, so the + twenty bytes of header per size are the whole of the work, and it saves + both a build dependency and an icon file in the repository. + """ + path = pathlib.Path(path) + images = [] + for size in ICO_SIZES: + buffer = QBuffer() + buffer.open(QBuffer.OpenModeFlag.WriteOnly) + app_pixmap(size).save(buffer, "PNG") + images.append((size, bytes(buffer.data()))) + buffer.close() + + # 0, then 1 for an icon rather than a cursor, then the count. + header = struct.pack(".iconset` for install-mac.sh, `--hicolor ` for - install.sh. + install.sh, `--ico ` for the Windows build. A QGuiApplication has to exist before a QPixmap can, and offscreen because this runs from a shell script with no window to open. """ - hicolor = len(argv) == 3 and argv[1] == "--hicolor" - if not hicolor and len(argv) != 2: + flag = argv[1] if len(argv) == 3 else "" + if flag not in ("--hicolor", "--ico") and len(argv) != 2: print("usage: trayicon.py .iconset\n" - " trayicon.py --hicolor ", file=sys.stderr) + " trayicon.py --hicolor \n" + " trayicon.py --ico .ico", file=sys.stderr) return 2 from PyQt6.QtGui import QGuiApplication QGuiApplication.setAttribute( Qt.ApplicationAttribute.AA_UseSoftwareOpenGL, True) app = QGuiApplication(["dikte-icon", "-platform", "offscreen"]) try: - if hicolor: + if flag == "--hicolor": for path in write_hicolor(argv[2]): print(path) + elif flag == "--ico": + print(write_ico(argv[2])) else: print(write_iconset(argv[1])) finally: diff --git a/packaging/build-windows.ps1 b/packaging/build-windows.ps1 new file mode 100644 index 0000000..9b64b96 --- /dev/null +++ b/packaging/build-windows.ps1 @@ -0,0 +1,89 @@ +#!/usr/bin/env pwsh +# The Windows download: one setup program, carrying everything Dikte needs to +# record and to be started again after a sign-in. +# +# Run from anywhere; it works in build\ at the top of the checkout and leaves +# the finished .exe in dist\. x64 only, because that is what the PyQt6 wheel and +# whisper.cpp both publish for Windows; a Windows on ARM machine runs it under +# the emulation it runs everything else under. +# +# powershell -ExecutionPolicy Bypass -File packaging\build-windows.ps1 +$ErrorActionPreference = "Stop" + +$root = Split-Path -Parent $PSScriptRoot +$build = Join-Path $root "build" +$out = Join-Path $root "dist" +$dist = Join-Path $build "dist\dikte" + +$env:PYTHONPATH = $root +$version = & python -c "import dikte; print(dikte.__version__)" +if ($LASTEXITCODE -ne 0) { throw "could not read the version out of dikte/__init__.py" } + +# A pinned tag and a checksum rather than "whatever is newest": this binary goes +# out inside something people run, so what it is has to be decided here and not +# by whoever pushes to that repository next. The same release the disk image +# takes its ffmpeg from, which is gyan.dev's essentials build repackaged, and +# dshow is in it, which is the one part of ffmpeg recording here goes through. +$ffmpegTag = "b6.1.1" +$ffmpegAsset = "ffmpeg-win32-x64.gz" +$ffmpegSha = "8883A3DFFBD0A16CF4EF95206EA05283F78908DBFB118F73C83F4951DCC06D77" + +if (Test-Path $build) { Remove-Item $build -Recurse -Force } +if (Test-Path $out) { Remove-Item $out -Recurse -Force } +New-Item -ItemType Directory -Path $build, $out | Out-Null + +# 1. The icon --------------------------------------------------------------- +# Drawn by Dikte itself, offscreen, which is why there is no image file in the +# repository. Before the application, because PyInstaller writes it into the +# executable rather than beside it, and the setup program uses the same file. +$icon = Join-Path $build "Dikte.ico" +$env:QT_QPA_PLATFORM = "offscreen" +& python -m dikte.trayicon --ico $icon +if ($LASTEXITCODE -ne 0) { throw "the icon would not draw" } +Remove-Item Env:\QT_QPA_PLATFORM +$env:DIKTE_ICO = $icon + +# 2. The application -------------------------------------------------------- +# Two executables in the one directory: Dikte.exe, which is windowed and is +# what a shortcut starts, and dikte.exe, which has a console and is what the +# `dikte` command runs. +& python -m PyInstaller (Join-Path $root "packaging\dikte.spec") ` + --distpath (Join-Path $build "dist") --workpath (Join-Path $build "work") ` + --noconfirm --clean +if ($LASTEXITCODE -ne 0) { throw "PyInstaller failed" } + +# 3. ffmpeg ----------------------------------------------------------------- +# Recording on Windows goes through ffmpeg's DirectShow input, and Windows +# ships nothing like it, so without this the download would be an application +# that cannot record until the person who downloaded it installs one. bin\ +# beside the executables, because integrate.py puts that directory in front of +# PATH at startup and everything reaching for ffmpeg goes through shutil.which. +$archive = Join-Path $build $ffmpegAsset +Invoke-WebRequest -UseBasicParsing -OutFile $archive ` + "https://github.com/eugeneware/ffmpeg-static/releases/download/$ffmpegTag/$ffmpegAsset" +$got = (Get-FileHash $archive -Algorithm SHA256).Hash +if ($got -ne $ffmpegSha) { throw "ffmpeg checksum: expected $ffmpegSha, got $got" } + +$bin = Join-Path $dist "bin" +New-Item -ItemType Directory -Path $bin | Out-Null +$compressed = [System.IO.File]::OpenRead($archive) +$stream = New-Object System.IO.Compression.GzipStream( + $compressed, [System.IO.Compression.CompressionMode]::Decompress) +$binary = [System.IO.File]::Create((Join-Path $bin "ffmpeg.exe")) +try { $stream.CopyTo($binary) } finally { $binary.Dispose(); $stream.Dispose(); $compressed.Dispose() } + +# 4. The setup program ------------------------------------------------------ +# Inno Setup comes with the GitHub runner. On a machine that has not got it: +# winget install JRSoftware.InnoSetup +$iscc = (Get-Command iscc -ErrorAction SilentlyContinue).Source +if (-not $iscc) { + $iscc = Join-Path ${env:ProgramFiles(x86)} "Inno Setup 6\ISCC.exe" +} +if (-not (Test-Path $iscc)) { + throw "no Inno Setup found. Install it with: winget install JRSoftware.InnoSetup" +} +& $iscc "/DVersion=$version" "/DSource=$dist" "/DIcon=$icon" ` + (Join-Path $root "packaging\dikte.iss") +if ($LASTEXITCODE -ne 0) { throw "Inno Setup failed" } + +Write-Host "dist\Dikte-$version-x64-setup.exe" diff --git a/packaging/dikte.iss b/packaging/dikte.iss new file mode 100644 index 0000000..a727efa --- /dev/null +++ b/packaging/dikte.iss @@ -0,0 +1,117 @@ +; What the Windows download is: the directory PyInstaller built, wrapped in the +; setup program Windows expects. Run it through build-windows.ps1, which draws +; the icon, builds that directory, puts an ffmpeg in it and passes the version +; in; ISCC on its own has none of that. +; +; Per user rather than per machine. It keeps the whole thing out of the way of +; the administrator prompt, which for something a person is trying out is the +; difference between a download and a phone call to whoever owns the laptop, +; and nothing here writes outside the account anyway. + +#ifndef Version + #define Version "0.0.0" +#endif +#ifndef Source + #define Source "..\build\dist\dikte" +#endif +#ifndef Icon + #define Icon "..\build\Dikte.ico" +#endif + +[Setup] +; The identifier Add/Remove Programs files this under, and what an update +; recognises the older installation by. The same one the Mac's login item and +; the bundle use, and like those it never changes. +AppId=io.github.yusufipk.dikte +AppName=Dikte +AppVersion={#Version} +AppPublisher=Yusuf Ipek +AppSupportURL=https://github.com/yusufipk/dikte +DefaultDirName={localappdata}\Programs\Dikte +DefaultGroupName=Dikte +DisableProgramGroupPage=yes +PrivilegesRequired=lowest +ArchitecturesAllowed=x64compatible +ArchitecturesInstallIn64BitMode=x64compatible +OutputDir=..\dist +OutputBaseFilename=Dikte-{#Version}-x64-setup +SetupIconFile={#Icon} +UninstallDisplayIcon={app}\Dikte.exe +WizardStyle=modern +; Most of the download is Qt and ffmpeg, both of which compress well, and the +; slower setting is a minute of a build machine's time against a smaller file +; for everybody who downloads it. +Compression=lzma2/max +SolidCompression=yes +; An update over a running Dikte would otherwise fail on the executable it +; cannot replace. Restart Manager closes it and starts it again afterwards. +CloseApplications=yes +RestartApplications=yes + +[Languages] +Name: "english"; MessagesFile: "compiler:Default.isl" + +[Tasks] +; On by default: Dikte is a tray application holding a global shortcut, and one +; that is not running when you press the key is one that does nothing. +Name: "autostart"; Description: "Start Dikte when I sign in" + +[Files] +Source: "{#Source}\*"; DestDir: "{app}"; Flags: recursesubdirs ignoreversion + +[Icons] +Name: "{autoprograms}\Dikte"; Filename: "{app}\Dikte.exe" + +[Registry] +; Starting at sign-in, as a registry value rather than a shortcut in the +; Startup folder: it is the one place the setup program, the uninstaller and +; `dikte integrate` can all read and write without a COM library between them. +Root: HKCU; Subkey: "Software\Microsoft\Windows\CurrentVersion\Run"; \ + ValueType: string; ValueName: "Dikte"; ValueData: """{app}\Dikte.exe"""; \ + Flags: uninsdeletevalue; Tasks: autostart +; And taking it away again, for an update where the box was unticked. Both +; lines delete on uninstall, so an entry `dikte integrate` wrote later goes +; too, whichever way it got there. +Root: HKCU; Subkey: "Software\Microsoft\Windows\CurrentVersion\Run"; \ + ValueType: none; ValueName: "Dikte"; \ + Flags: deletevalue uninsdeletevalue; Tasks: not autostart + +[Run] +Filename: "{app}\Dikte.exe"; Description: "Start Dikte"; \ + Flags: nowait postinstall skipifsilent + +[Code] +{ The `dikte` command. WindowsApps is already on the user's PATH, so a .cmd + left there runs from any terminal without touching the PATH and without an + administrator; the alternative is an environment variable edit that every + open terminal misses. It names the console executable, which is the one that + can print to the terminal it was typed in. } + +function ShimDir(): String; +begin + Result := ExpandConstant('{localappdata}\Microsoft\WindowsApps'); +end; + +function ShimPath(): String; +begin + Result := ShimDir() + '\dikte.cmd'; +end; + +procedure CurStepChanged(CurStep: TSetupStep); +var + Shim: String; +begin + if CurStep = ssPostInstall then begin + if DirExists(ShimDir()) then begin + Shim := '@echo off' + #13#10 + + '"' + ExpandConstant('{app}\dikte.exe') + '" %*' + #13#10; + SaveStringToFile(ShimPath(), Shim, False); + end; + end; +end; + +procedure CurUninstallStepChanged(CurUninstallStep: TUninstallStep); +begin + if CurUninstallStep = usUninstall then + DeleteFile(ShimPath()); +end; diff --git a/packaging/dikte.spec b/packaging/dikte.spec index 68d4f15..6df6534 100644 --- a/packaging/dikte.spec +++ b/packaging/dikte.spec @@ -1,12 +1,14 @@ -# PyInstaller's description of the build, shared by the AppImage and the disk -# image. Run it through build-appimage.sh or build-dmg.sh rather than by hand: -# each of those has a few steps of its own on either side of this. +# PyInstaller's description of the build, shared by the AppImage, the disk +# image and the Windows setup. Run it through build-appimage.sh, build-dmg.sh +# or build-windows.ps1 rather than by hand: each of those has a few steps of +# its own on either side of this. # -# A directory rather than a single file, on both platforms. Onefile unpacks -# itself into /tmp on every start, which for something a global shortcut is -# meant to bring up is a second of nothing happening, and for the AppImage it -# would be an unpacking inside an unpacking. The single file people download is -# the AppImage and the .dmg; this only has to be tidy inside them. +# A directory rather than a single file, on all three. Onefile unpacks itself +# into a temporary directory on every start, which for something a global +# shortcut is meant to bring up is a second of nothing happening, and for the +# AppImage it would be an unpacking inside an unpacking. The single file people +# download is the AppImage, the .dmg and the setup program; this only has to be +# tidy inside them. import os import pathlib @@ -24,6 +26,7 @@ __version__ = re.search(r'^__version__ = "(.*)"$', re.M).group(1) MACOS = sys.platform == "darwin" +WINDOWS = sys.platform == "win32" BUNDLE_ID = "io.github.yusufipk.dikte" # PyQt6's wheel is most of the build, and most of the wheel is modules nothing @@ -60,19 +63,41 @@ executable = EXE( # noqa: F821 analysis.scripts, [], exclude_binaries=True, - name="Dikte" if MACOS else "dikte", + name="Dikte" if MACOS or WINDOWS else "dikte", console=False, - # Both platforms use whatever the machine is, because neither build is + # Every platform uses whatever the machine is, because no build here is # cross-compiled: the workflow runs one job per architecture. target_arch=None, # Ad-hoc, and only on a Mac, where an arm64 binary that carries no # signature at all is refused by the kernel rather than merely warned # about. build-dmg.sh signs the finished bundle over the top of this. codesign_identity="-" if MACOS else None, + # Windows keeps the icon inside the executable, and build-windows.ps1 draws + # it from the same shapes the tray uses. A Mac reads the one BUNDLE names + # below, and the AppImage installs PNGs into the icon theme instead. + icon=os.environ.get("DIKTE_ICO") or None, ) +# The same program a second time, as a console application, and only on +# Windows. A windowed executable there is one the loader gives no console and +# no standard output at all, so `dikte doctor` started from a terminal would +# print nothing to it and answer nothing to a script. Everywhere else the one +# executable does both jobs: a terminal that started it keeps its output, and +# nothing opens a window nobody asked for. +console_executable = EXE( # noqa: F821 + archive, + analysis.scripts, + [], + exclude_binaries=True, + name="dikte", + console=True, + target_arch=None, + icon=os.environ.get("DIKTE_ICO") or None, +) if WINDOWS else None + collection = COLLECT( # noqa: F821 executable, + *([console_executable] if WINDOWS else []), analysis.binaries, analysis.datas, name="dikte", diff --git a/packaging/entry.py b/packaging/entry.py index 94c2a04..a20c60d 100644 --- a/packaging/entry.py +++ b/packaging/entry.py @@ -1,11 +1,12 @@ -"""What the AppImage and the disk image start. +"""What the AppImage, the disk image and the Windows setup start. dikte/__main__.py is written for a checkout: it puts the directory above the package on the import path, which a build has neither the need for nor a directory to point at. What is left over is one thing a checkout never sees. The Finder hands a double-clicked application a -psn_0_… argument naming the process serial number, which argparse reads as a flag it has never heard of and -exits over, and no one clicking an icon would ever find out why. +exits over, and no one clicking an icon would ever find out why. Nothing else +here is one platform's: the same file is both Windows executables as well. The three environment lines have to run before anything starts a process, opens a connection or reaches for ffmpeg, and before is easier to be sure of diff --git a/tests/test_integrate.py b/tests/test_integrate.py index 46636a9..999f115 100644 --- a/tests/test_integrate.py +++ b/tests/test_integrate.py @@ -451,5 +451,74 @@ class MacOS(Home): self.assertIn("install-mac.sh", command.read_text()) +class Windows(unittest.TestCase): + """The half of the Windows install the setup program cannot do. + + It writes the Start Menu entry, the command and the uninstaller as it runs, + and asks once whether Dikte should start at sign-in. Changing that answer + afterwards is what is left here, and the value is faked rather than the + registry, so that all of it is read on every platform the tests run on. + """ + + def setUp(self): + self.value = "" + for name, function in (("_run_entry", lambda: self.value), + ("_write_run_entry", self._write), + ("_delete_run_entry", self._delete)): + patch = mock.patch.object(integrate, name, function) + patch.start() + self.addCleanup(patch.stop) + + self.tmp = tempfile.TemporaryDirectory() + self.addCleanup(self.tmp.cleanup) + self.installed = pathlib.Path(self.tmp.name).resolve() + self.app = self.installed / "Dikte.exe" + self.app.write_text("") + + def _write(self, command): + self.value = command + + def _delete(self): + there, self.value = bool(self.value), "" + return there + + def install(self, force=False): + with Frozen(str(self.app), platform="win32"): + return integrate.install(force=force) + + def remove(self): + with Frozen(str(self.app), platform="win32"): + return integrate.remove() + + def test_the_windowed_executable_is_what_starts_at_sign_in(self): + """The console one is what the `dikte` command runs, and a sign-in that + started that would open a console window nobody asked for.""" + with Frozen(str(self.installed / "dikte.exe"), platform="win32"): + self.assertEqual(integrate.target(), self.app) + + def test_a_start_does_not_turn_it_on_for_somebody_who_said_no(self): + self.assertEqual(self.install(), []) + self.assertEqual(self.value, "") + + def test_typing_it_turns_starting_at_sign_in_on(self): + self.assertEqual(len(self.install(force=True)), 1) + self.assertEqual(self.value, f'"{self.app}"') + + def test_an_installation_that_moved_is_pointed_at_where_it_is_now(self): + self.value = '"D:\\Dikte\\Dikte.exe"' + self.assertEqual(len(self.install()), 1) + self.assertEqual(self.value, f'"{self.app}"') + + def test_running_it_again_changes_nothing(self): + self.install(force=True) + self.assertEqual(self.install(), []) + + def test_removing_stops_it_starting_and_says_so_once(self): + self.install(force=True) + self.assertEqual(len(self.remove()), 1) + self.assertEqual(self.value, "") + self.assertEqual(self.remove(), []) + + if __name__ == "__main__": unittest.main() diff --git a/tests/test_trayicon.py b/tests/test_trayicon.py index a257781..bfe3cc5 100644 --- a/tests/test_trayicon.py +++ b/tests/test_trayicon.py @@ -7,6 +7,8 @@ test blends each icon onto a bar of its own and asks whether anything of it survives, once over black and once over white. """ +import pathlib +import struct import sys import tempfile import unittest @@ -150,6 +152,26 @@ class ApplicationIcon(DikteTest): self.assertFalse(icon.isNull()) self.assertIn(48, [size.width() for size in icon.availableSizes()]) + def test_the_windows_icon_is_one_file_holding_every_size(self): + """Written by hand, so the header is what a test can be wrong about: + Windows reads the sizes out of the directory at the front rather than + out of the images, and a 256 is written there as a zero.""" + with tempfile.TemporaryDirectory() as root: + path = trayicon.write_ico(pathlib.Path(root) / "Dikte.ico") + data = path.read_bytes() + reserved, kind, count = struct.unpack_from(" Date: Tue, 18 Aug 2026 18:26:15 +0200 Subject: [PATCH 20/27] Wait for macOS focus restoration to land --- dikte/app.py | 18 +++++++++++++++--- tests/test_ui.py | 37 +++++++++++++++++++++++++++++++++++-- 2 files changed, 50 insertions(+), 5 deletions(-) diff --git a/dikte/app.py b/dikte/app.py index b53969a..51385d6 100644 --- a/dikte/app.py +++ b/dikte/app.py @@ -637,12 +637,24 @@ class Dikte: # inactive: at forty the title bar visibly blinks, at ten it does not. # Two messages to AppKit per tick, for at most a second and a half. watch.setInterval(10) + # activateWithOptions: answers whether macOS accepted the request, not + # whether the other application is already back in front. Keep the + # watch alive until that asynchronous handoff is observable; on Intel + # Macs it can take hundreds of milliseconds after the call returned. + restore_requested = False def look(): - if mac_window.is_frontmost(): - mac_window.activate(was_in_front) + nonlocal restore_requested + if time.monotonic() > deadline: self._stop_watching_the_front() - elif time.monotonic() > deadline: + return + if mac_window.is_frontmost(): + if not restore_requested: + restore_requested = mac_window.activate(was_in_front) + elif restore_requested: + # The request has landed. Stop only now, rather than as soon + # as AppKit accepted it, so a delayed or failed handoff stays + # under observation until the deadline guard above. self._stop_watching_the_front() watch.timeout.connect(look) diff --git a/tests/test_ui.py b/tests/test_ui.py index 5818cbf..7e68203 100644 --- a/tests/test_ui.py +++ b/tests/test_ui.py @@ -604,7 +604,7 @@ class GivingTheFrontBack(DikteTest): from dikte import mac_window self.dikte = dikte_module self.activated = [] - self.patch_attr(mac_window, "activate", self.activated.append) + self.patch_attr(mac_window, "activate", self.activate) self.ticks = [] outer = self @@ -645,6 +645,10 @@ class GivingTheFrontBack(DikteTest): self.bare = BareDikte + def activate(self, pid): + self.activated.append(pid) + return True + def watching(self, was_in_front, dikte_in_front, on=None): from dikte import mac_window self.patch_attr(mac_window, "is_frontmost", lambda: dikte_in_front) @@ -656,7 +660,36 @@ class GivingTheFrontBack(DikteTest): watch = self.watching(4242, dikte_in_front=True) watch.tick() self.assertEqual(self.activated, [4242]) - self.assertFalse(watch.running) # one is enough; it stops watching + self.assertTrue(watch.running) # accepted is not the same as landed + self.patch_attr(self.mac_window_module(), "is_frontmost", lambda: False) + watch.tick() + self.assertFalse(watch.running) + + def test_an_accepted_restore_is_not_sent_again_while_it_is_landing(self): + watch = self.watching(4242, dikte_in_front=True) + watch.tick() + watch.tick() + self.assertEqual(self.activated, [4242]) + self.assertTrue(watch.running) + + def test_an_accepted_restore_that_never_lands_still_times_out(self): + watch = self.watching(4242, dikte_in_front=True) + watch.tick() + with mock.patch.object(self.dikte.time, "monotonic", + return_value=self.dikte.time.monotonic() + 60): + watch.tick() + self.assertFalse(watch.running) + self.assertEqual(self.activated, [4242]) + + def test_a_restore_the_system_refused_is_retried(self): + from dikte import mac_window + self.patch_attr(mac_window, "activate", + lambda pid: self.activated.append(pid) or False) + watch = self.watching(4242, dikte_in_front=True) + watch.tick() + watch.tick() + self.assertEqual(self.activated, [4242, 4242]) + self.assertTrue(watch.running) def test_a_front_that_was_never_taken_is_left_where_it_is(self): """The microphone does not always take it, and pulling an application From 29643f26b39fbf81ddb38ff2f8dd8cc1199149e1 Mon Sep 17 00:00:00 2001 From: yusufipk Date: Wed, 19 Aug 2026 11:11:30 +0300 Subject: [PATCH 21/27] Keep the two Windows executables from being one file Windows matches a filename without regard to its case, so Dikte.exe and dikte.exe were one file in the installation directory: PyInstaller wrote the console build second and that is the only one that landed. Every launcher then opened a console window behind the tray, and closing it killed the application. The console one is dikte-cli.exe now, which the setup's shim names. The build reads the PE subsystem of both afterwards, since a filesystem that keeps the two names apart is exactly what the check would run on otherwise. --- CONTRIBUTING.md | 2 +- README.windows.md | 4 +++- packaging/build-windows.ps1 | 24 ++++++++++++++++++++++-- packaging/dikte.iss | 6 +++--- packaging/dikte.spec | 7 ++++++- tests/test_integrate.py | 32 +++++++++++++++++++++++++++++++- 6 files changed, 66 insertions(+), 9 deletions(-) diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index b925daf..c77685a 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -70,7 +70,7 @@ 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. -1094 of the 1157 run on any machine, including every line of the Wayland, X11, +1096 of the 1159 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 diff --git a/README.windows.md b/README.windows.md index 33bb63d..7fdb819 100644 --- a/README.windows.md +++ b/README.windows.md @@ -21,7 +21,9 @@ entry, a `dikte` command and, unless you untick it, a start at sign-in. It is signed with no certificate, so SmartScreen offers only **Don't run** until you press **More info**. Add/Remove Programs uninstalls it, and `dikte integrate` and `dikte integrate --remove` are the sign-in entry on its own, for changing -your mind about that later. +your mind about that later. The `dikte` command is the same file install.ps1 +writes, so over a checkout the setup takes it over and uninstalling takes it +away; run install.ps1 again to get the checkout's back. From a checkout instead: diff --git a/packaging/build-windows.ps1 b/packaging/build-windows.ps1 index 9b64b96..6170606 100644 --- a/packaging/build-windows.ps1 +++ b/packaging/build-windows.ps1 @@ -45,13 +45,33 @@ $env:DIKTE_ICO = $icon # 2. The application -------------------------------------------------------- # Two executables in the one directory: Dikte.exe, which is windowed and is -# what a shortcut starts, and dikte.exe, which has a console and is what the -# `dikte` command runs. +# what a shortcut starts, and dikte-cli.exe, which has a console and is what +# the `dikte` command runs. Their names differ by more than case on purpose; +# Windows would otherwise keep one file for both. & python -m PyInstaller (Join-Path $root "packaging\dikte.spec") ` --distpath (Join-Path $build "dist") --workpath (Join-Path $build "work") ` --noconfirm --clean if ($LASTEXITCODE -ne 0) { throw "PyInstaller failed" } +# And the check that the two are really two. Windows matches a filename +# without regard to its case, so a rename that leaves them a case apart puts +# one file in the directory and whichever was written second is what both +# names find. It cost a windowed build once, silently: the machines the +# packaging is otherwise checked on all have case-sensitive filesystems, so +# this only ever shows up here. Subsystem 2 is windowed, 3 is a console, and +# it is the field the loader reads to decide which to give. +function Get-PeSubsystem($path) { + $bytes = [System.IO.File]::ReadAllBytes($path) + $header = [BitConverter]::ToInt32($bytes, 0x3C) + return [BitConverter]::ToUInt16($bytes, $header + 0x5C) +} +foreach ($pair in @(@("Dikte.exe", 2), @("dikte-cli.exe", 3))) { + $subsystem = Get-PeSubsystem (Join-Path $dist $pair[0]) + if ($subsystem -ne $pair[1]) { + throw "$($pair[0]) is subsystem $subsystem, expected $($pair[1]): the two executables collided" + } +} + # 3. ffmpeg ----------------------------------------------------------------- # Recording on Windows goes through ffmpeg's DirectShow input, and Windows # ships nothing like it, so without this the download would be an application diff --git a/packaging/dikte.iss b/packaging/dikte.iss index a727efa..d16d784 100644 --- a/packaging/dikte.iss +++ b/packaging/dikte.iss @@ -84,8 +84,8 @@ Filename: "{app}\Dikte.exe"; Description: "Start Dikte"; \ { The `dikte` command. WindowsApps is already on the user's PATH, so a .cmd left there runs from any terminal without touching the PATH and without an administrator; the alternative is an environment variable edit that every - open terminal misses. It names the console executable, which is the one that - can print to the terminal it was typed in. } + open terminal misses. It names dikte-cli.exe, the console executable, which + is the one that can print to the terminal it was typed in. } function ShimDir(): String; begin @@ -104,7 +104,7 @@ begin if CurStep = ssPostInstall then begin if DirExists(ShimDir()) then begin Shim := '@echo off' + #13#10 - + '"' + ExpandConstant('{app}\dikte.exe') + '" %*' + #13#10; + + '"' + ExpandConstant('{app}\dikte-cli.exe') + '" %*' + #13#10; SaveStringToFile(ShimPath(), Shim, False); end; end; diff --git a/packaging/dikte.spec b/packaging/dikte.spec index 6df6534..6cfd651 100644 --- a/packaging/dikte.spec +++ b/packaging/dikte.spec @@ -84,12 +84,17 @@ executable = EXE( # noqa: F821 # print nothing to it and answer nothing to a script. Everywhere else the one # executable does both jobs: a terminal that started it keeps its output, and # nothing opens a window nobody asked for. +# +# Named apart from the windowed one rather than `dikte` beside `Dikte`. +# Windows matches a filename without regard to its case, so those two are one +# file in one directory: whichever PyInstaller writes second is the only one +# installed, and a build made on a case-sensitive filesystem never sees it. console_executable = EXE( # noqa: F821 archive, analysis.scripts, [], exclude_binaries=True, - name="dikte", + name="dikte-cli", console=True, target_arch=None, icon=os.environ.get("DIKTE_ICO") or None, diff --git a/tests/test_integrate.py b/tests/test_integrate.py index 999f115..f5a5f97 100644 --- a/tests/test_integrate.py +++ b/tests/test_integrate.py @@ -9,6 +9,7 @@ has to notice AppImageLauncher's entry, which is not under the name ours is. import os import pathlib import plistlib +import re import sys import tempfile import unittest @@ -493,7 +494,7 @@ class Windows(unittest.TestCase): def test_the_windowed_executable_is_what_starts_at_sign_in(self): """The console one is what the `dikte` command runs, and a sign-in that started that would open a console window nobody asked for.""" - with Frozen(str(self.installed / "dikte.exe"), platform="win32"): + with Frozen(str(self.installed / "dikte-cli.exe"), platform="win32"): self.assertEqual(integrate.target(), self.app) def test_a_start_does_not_turn_it_on_for_somebody_who_said_no(self): @@ -520,5 +521,34 @@ class Windows(unittest.TestCase): self.assertEqual(self.remove(), []) +class WindowsExecutableNames(unittest.TestCase): + """The two Windows executables, read out of the files that name them. + + Windows matches a filename without regard to its case, so Dikte.exe and + dikte.exe are one file in one directory and whichever was written second is + the only one installed. Nothing else here would catch that: these tests and + the builds that check the packaging both run on filesystems where the two + names are two files. + """ + + root = pathlib.Path(__file__).resolve().parent.parent + + def executables(self): + """What the spec calls each one, in the order it builds them.""" + spec = (self.root / "packaging" / "dikte.spec").read_text() + return [re.search(r'name="(.*?)"', block).group(1) + for block in spec.split("EXE(")[1:]] + + def test_the_two_are_more_than_a_case_apart(self): + windowed, console = self.executables() + self.assertNotEqual(windowed.lower(), console.lower()) + + def test_the_command_runs_the_console_one(self): + """The setup writes the shim, so it is the setup that has to be right.""" + console = self.executables()[1] + setup = (self.root / "packaging" / "dikte.iss").read_text() + self.assertIn(f"{{app}}\\{console}.exe", setup) + + if __name__ == "__main__": unittest.main() From d0c93e5a17a24fb6f069a71a4d30a319eb329565 Mon Sep 17 00:00:00 2001 From: yusufipk Date: Wed, 19 Aug 2026 12:36:16 +0300 Subject: [PATCH 22/27] Let a stalled apt mirror fail the job rather than hold it Both Linux test jobs on this branch sat an hour in apt-get, on runners where the same step takes seconds, and a job with nothing to time it out holds the pull request red-free and unfinished until the six hour limit. Three retries and a twenty second timeout give apt another mirror, and five minutes on the step gives the job an end. --- .github/workflows/build.yml | 6 ++++-- .github/workflows/release.yml | 8 +++++--- .github/workflows/tests.yml | 12 ++++++++++-- 3 files changed, 19 insertions(+), 7 deletions(-) diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index 1416b82..7258c78 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -66,9 +66,11 @@ jobs: # the build draws its own icon before it packages anything. - name: Install the Qt runtime libraries if: runner.os == 'Linux' + timeout-minutes: 5 run: | - sudo apt-get update - sudo apt-get install --no-install-recommends -y \ + apt="-o Acquire::Retries=3 -o Acquire::http::Timeout=20" + sudo apt-get $apt update + sudo apt-get $apt install --no-install-recommends -y \ libegl1 libgl1 libxkbcommon0 libdbus-1-3 libglib2.0-0 \ libfontconfig1 libfreetype6 libgssapi-krb5-2 diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index d6689ed..ae3a4cd 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -46,9 +46,11 @@ jobs: - uses: actions/setup-python@v5 with: python-version: "3.12" - - run: | - sudo apt-get update - sudo apt-get install --no-install-recommends -y \ + - timeout-minutes: 5 + run: | + apt="-o Acquire::Retries=3 -o Acquire::http::Timeout=20" + sudo apt-get $apt update + sudo apt-get $apt install --no-install-recommends -y \ libegl1 libgl1 libxkbcommon0 libdbus-1-3 libglib2.0-0 \ libfontconfig1 libfreetype6 libgssapi-krb5-2 - run: python -m pip install --quiet PyQt6 diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml index 89ee656..7efc485 100644 --- a/.github/workflows/tests.yml +++ b/.github/workflows/tests.yml @@ -24,10 +24,18 @@ jobs: # for the offscreen platform the tests run on. QtNetwork wants the Kerberos # library, and the widgets want fontconfig, whether or not anything is # ever drawn. + # + # A mirror that stops answering is what the retries and the timeout are + # for: apt waits on it by default, and a job waiting on apt sits there + # until the six hour limit rather than going red in a minute. The same + # two lines are in build.yml and release.yml, which install the same + # eight libraries. - name: Install the Qt runtime libraries + timeout-minutes: 5 run: | - sudo apt-get update - sudo apt-get install --no-install-recommends -y \ + apt="-o Acquire::Retries=3 -o Acquire::http::Timeout=20" + sudo apt-get $apt update + sudo apt-get $apt install --no-install-recommends -y \ libegl1 libgl1 libxkbcommon0 libdbus-1-3 libglib2.0-0 \ libfontconfig1 libfreetype6 libgssapi-krb5-2 From f55e2017857448a442f9f54b4ae6e714a81eddd5 Mon Sep 17 00:00:00 2001 From: yusufipk Date: Thu, 20 Aug 2026 12:27:42 +0300 Subject: [PATCH 23/27] Cut a long file into chunks a hosted request can outlive An hour and a half of speech came back as "OpenRouter: HTTP 502: The operation was aborted due to timeout", every time. The upload limit was the only thing deciding where the file was cut, and mp3 at 48 kbps reaches 24 MB after an hour, so a 90 minute file became two chunks and the first one was 63 minutes of audio in a single request. Nothing between here and the model stays on the line that long. A chunk is capped at fifteen minutes now, whatever it weighs, and the call is finally handed a timeout of its own: it was going out on the 300 second default sized for a dictation, which the same chunk would have hit first anyway. The other half is not throwing the run away when one request fails. ApiError carries whether a second try can fix it, which is true of the statuses a gateway raises itself and of a dropped connection, and false of a rejected key. A chunk is asked for three times, waiting five then ten seconds, with the Stop button still able to get through. If it does fail in the end, what was already heard goes to the output box rather than the bin, with the status line saying where it stops. --- dikte/api.py | 21 +++++- dikte/filetranscribe.py | 140 +++++++++++++++++++++++++---------- dikte/i18n.py | 4 + tests/test_api.py | 27 +++++++ tests/test_filetranscribe.py | 72 +++++++++++++++++- 5 files changed, 217 insertions(+), 47 deletions(-) diff --git a/dikte/api.py b/dikte/api.py index 0c76b80..7a0e78f 100644 --- a/dikte/api.py +++ b/dikte/api.py @@ -58,10 +58,20 @@ def timestamp_model(provider, selected=""): return "openai/whisper-1" if provider == "openrouter" else "whisper-1" +# What a gateway in front of the model answers of its own accord: the request +# never reached the model, or the model was still working when the connection +# was given up on. Trying again is the only thing that fixes any of them, and +# with a long file it is worth the second try rather than losing the run. +RETRY_STATUS = frozenset({408, 429, 500, 502, 503, 504}) + + class ApiError(Exception): - def __init__(self, message, status=None): + def __init__(self, message, status=None, retryable=None): super().__init__(message) self.status = status + # Anything not on that list is the request itself being wrong, and it + # will be just as wrong the second time. + self.retryable = status in RETRY_STATUS if retryable is None else retryable class Aborted(Exception): @@ -218,7 +228,7 @@ def explain(exc, service): if exc.status == 429: return ApiError(t("{service} is rate limiting you (HTTP 429). Try again in " "a moment.", service=service), exc.status) - return ApiError(f"{service}: {exc}", exc.status) + return ApiError(f"{service}: {exc}", exc.status, retryable=exc.retryable) def _request(url, data, headers, timeout=120, aborter=None): @@ -234,8 +244,11 @@ def _request(url, data, headers, timeout=120, aborter=None): # not the network failing. URLError is an OSError, so both land here. if aborter is not None and aborter.aborted: raise Aborted from None + # A connection that dropped or timed out is the same bad minute as a + # 502, so it is worth the same second try. raise ApiError(t("Could not connect: {reason}", - reason=getattr(exc, "reason", exc))) from exc + reason=getattr(exc, "reason", exc)), + retryable=True) from exc except json.JSONDecodeError as exc: raise ApiError(t("Could not parse the response: {error}", error=exc)) from exc @@ -318,7 +331,7 @@ def local_failure(service, server, exc): """ detail = server.error() return ApiError(f"{service}: {exc}" + (f" ({detail})" if detail else ""), - exc.status) + exc.status, retryable=exc.retryable) def _transcribe_request(target, audio_path, language, prompt, response_format, diff --git a/dikte/filetranscribe.py b/dikte/filetranscribe.py index 8415752..084bb8f 100644 --- a/dikte/filetranscribe.py +++ b/dikte/filetranscribe.py @@ -1,15 +1,19 @@ """Transcribe an existing audio/video file with the same models. ffmpeg converts whatever comes in to 16 kHz mono WAV, and for a hosted API to -mp3 on top of that. The upload limit is the only reason a file is ever cut up, -and uncompressed audio reaches it after ten minutes where mp3 takes an hour. +mp3 on top of that. Two things decide where a file is cut up: the upload limit, +which uncompressed audio reaches after ten minutes where mp3 takes an hour, and +the clock. An hour of audio in one request is minutes of work at the other end, +and the gateway in front of the model hangs up long before the answer comes +back, which arrives here as a 502 with the whole chunk lost. So a chunk is also +capped at MAX_CHUNK_SECONDS however small it is on disk. -That is worth the encoder, because a cut is not free. Whisper hears in thirty -second windows and decides for itself where one cue ends and the next begins; a -chunk that starts in the middle of a sentence can come back as one cue per -window, twenty seconds of text at a time, for the whole rest of the chunk. So -the file is cut as rarely as the limit allows, what is cut overlaps, and -stitch() drops the half that was heard twice. +A cut is not free, which is what the encoder buys and why nothing is cut more +finely than that. Whisper hears in thirty second windows and decides for itself +where one cue ends and the next begins; a chunk that starts in the middle of a +sentence can come back as one cue per window, twenty seconds of text at a time, +for the whole rest of the chunk. So what is cut overlaps, and stitch() drops +the half that was heard twice. """ import contextlib @@ -19,6 +23,7 @@ import shutil import subprocess import tempfile import threading +import time import wave from PyQt6.QtCore import QObject, pyqtSignal @@ -29,10 +34,14 @@ from . import ggml from .i18n import t UPLOAD_LIMIT = 24 * 1024 * 1024 # the APIs take 25 MB; leave the form its room +MAX_CHUNK_SECONDS = 900 # as much audio as a hosted request can outlive MP3_BITRATE = "48k" # mono speech at 16 kHz: whisper hears nothing less OVERLAP_SECONDS = 30 # a whisper window: how far back a chunk starts WAV_CHUNK_SECONDS = 600 # 19 MB, for the caller that uploads the WAV itself CLEANUP_CHUNK_CHARS = 12000 # keep each cleanup call comfortably small +HOSTED_TIMEOUT = 600 # a quarter hour of audio, with room for the upload +RETRIES = 3 # how many times one chunk is asked for in all +RETRY_WAIT = 5 # seconds before the second try, doubled after that RATE = 16000 MIN_SUBTITLE_SECONDS = 1.5 # how long a cue with no end time of its own stays up @@ -86,9 +95,40 @@ class FileTranscriber(QObject): def _check(self): self._abort.check() + def _wait(self, seconds): + """Sleep on it, with the Stop button still able to get through.""" + deadline = time.monotonic() + seconds + while time.monotonic() < deadline: + self._check() + time.sleep(0.25) + self._check() + + def _attempt(self, call, stage): + """`call`, asked again when what failed was the network rather than us. + + One chunk is a quarter hour of audio that took a minute to encode and a + minute to upload, so a gateway having a bad moment is worth waiting out + rather than throwing the run away over. `stage` is what the status line + said before the failure, put back once the wait is over. + """ + for attempt in range(1, RETRIES + 1): + self._check() + try: + return call() + except api.ApiError as exc: + if attempt == RETRIES or not exc.retryable: + raise + self.progress.emit(t( + "{error} Trying again ({attempt}/{total})…", + error=exc, attempt=attempt + 1, total=RETRIES)) + self._wait(RETRY_WAIT * 2 ** (attempt - 1)) + self.progress.emit(stage) + def _work(self, path, timestamps, do_cleanup): conf = self.conf workdir = None + pieces = [] + segments = [] try: if not shutil.which("ffmpeg"): raise api.ApiError(t("ffmpeg not found. Install it to transcribe files.")) @@ -104,39 +144,36 @@ class FileTranscriber(QObject): if len(chunks) > 1: self.progress.emit(t("Splitting into {count} chunks…", count=len(chunks))) - pieces = [] - segments = [] for index, (chunk_path, offset) in enumerate(chunks, start=1): self._check() - self.progress.emit( - t("Transcribing chunk {index}/{count}…", - index=index, count=len(chunks)) - if len(chunks) > 1 else t("Transcribing…") - ) + stage = (t("Transcribing chunk {index}/{count}…", + index=index, count=len(chunks)) + if len(chunks) > 1 else t("Transcribing…")) + self.progress.emit(stage) if timestamps: - segments = stitch(segments, [ - (start + offset, end + offset, line) - for start, end, line in api.transcribe_segments( - target, - chunk_path, - language=conf["language"], - prompt=conf["transcribe_prompt"], - aborter=self._abort, - ) - ]) - else: - pieces.append(api.transcribe( + heard = self._attempt(lambda: api.transcribe_segments( target, chunk_path, language=conf["language"], prompt=conf["transcribe_prompt"], + timeout=HOSTED_TIMEOUT, aborter=self._abort, - )) + ), stage) + segments = stitch(segments, [ + (start + offset, end + offset, line) + for start, end, line in heard + ]) + else: + pieces.append(self._attempt(lambda: api.transcribe( + target, + chunk_path, + language=conf["language"], + prompt=conf["transcribe_prompt"], + timeout=HOSTED_TIMEOUT, + aborter=self._abort, + ), stage)) - if timestamps: - pieces = [f"[{format_timestamp(start)}] {line}" - for start, _, line in segments] - text = "\n".join(pieces) if timestamps else " ".join(pieces) + text = _joined(pieces, segments, timestamps) if do_cleanup and text: self._check() @@ -148,7 +185,16 @@ class FileTranscriber(QObject): except Cancelled: self.progress.emit(t("Stopped.")) except (api.ApiError, OSError, subprocess.SubprocessError, wave.Error) as exc: - self.failed.emit(str(exc)) + # An hour of a long file already heard is not worth throwing away + # because the chunk after it failed, or because cleanup did. Hand + # over what there is, and say in the same breath where it stops. + partial = _joined(pieces, segments, timestamps) + if partial: + self.finished.emit(partial, segments) + self.failed.emit(t("{error} The transcript up to there is below.", + error=exc)) + else: + self.failed.emit(str(exc)) finally: self._local = None if workdir: @@ -181,12 +227,21 @@ class FileTranscriber(QObject): self._local = ggml.llm if cleanup.provider(conf) == "local" else None prompt = conf.cleanup_prompt(with_timestamps=timestamps, subtitles=True) out = [] + stage = t("Cleaning up…") for block in split_text(text, timestamps): self._check() - out.append(cleanup.run(block, conf, prompt, aborter=self._abort)) + out.append(self._attempt( + lambda: cleanup.run(block, conf, prompt, aborter=self._abort), stage)) return ("\n" if timestamps else "\n\n").join(out) +def _joined(pieces, segments, timestamps): + """The transcript as one string, out of whichever of the two is holding it.""" + if timestamps: + pieces = [f"[{format_timestamp(start)}] {line}" for start, _, line in segments] + return "\n".join(pieces) if timestamps else " ".join(pieces) + + def format_timestamp(seconds): seconds = int(seconds) hours, rest = divmod(seconds, 3600) @@ -309,13 +364,20 @@ def wav_seconds(wav_path): def chunk_seconds(path, duration): """How many seconds of this audio fit in one request, or 0 when all of it does. - Measured rather than worked out: what an encoder makes of an hour of speech - depends on the speech, and the file on disk is the only honest answer. + Whichever of the two limits bites first. How much fits under the upload + limit is measured rather than worked out: what an encoder makes of an hour + of speech depends on the speech, and the file on disk is the only honest + answer. The other limit is MAX_CHUNK_SECONDS, and it is the one that catches + a long file at this bitrate: an hour and a half of mp3 is two chunks by size + and one of them is an hour of audio in a single request, which no hosted + gateway stays on the line for. """ - size = os.path.getsize(path) - if size <= UPLOAD_LIMIT or duration <= 0: + if duration <= 0: return 0.0 - return max(60.0, duration * UPLOAD_LIMIT / size * 0.95) + size = os.path.getsize(path) + fits = duration * UPLOAD_LIMIT / size * 0.95 if size > UPLOAD_LIMIT else duration + seconds = max(60.0, min(fits, MAX_CHUNK_SECONDS)) + return 0.0 if seconds >= duration else seconds def split_wav(wav_path, workdir, seconds=WAV_CHUNK_SECONDS, overlap=OVERLAP_SECONDS): diff --git a/dikte/i18n.py b/dikte/i18n.py index 9b4826c..398f740 100644 --- a/dikte/i18n.py +++ b/dikte/i18n.py @@ -297,6 +297,10 @@ TR = { "Converting audio…": "Ses dönüştürülüyor…", "Splitting into {count} chunks…": "{count} parçaya bölünüyor…", "Transcribing chunk {index}/{count}…": "{index}/{count} parça yazıya çevriliyor…", + "{error} Trying again ({attempt}/{total})…": + "{error} Yeniden deneniyor ({attempt}/{total})…", + "{error} The transcript up to there is below.": + "{error} Oraya kadar çevrilen metin aşağıda.", "Done: {chars} characters.": "Bitti: {chars} karakter.", "Stopped.": "Durduruldu.", "Failed: {error}": "Başarısız: {error}", diff --git a/tests/test_api.py b/tests/test_api.py index 58ab960..6bb28f8 100644 --- a/tests/test_api.py +++ b/tests/test_api.py @@ -79,6 +79,33 @@ class Explain(DikteTest): def test_the_status_is_carried_through(self): self.assertEqual(self.error(429).status, 429) + def test_so_is_whether_it_is_worth_asking_again(self): + self.assertTrue(self.error(502).retryable) + self.assertFalse(self.error(401).retryable) + + +class Retryable(unittest.TestCase): + """Which failures a second try can fix, and which will fail the same way.""" + + def test_a_gateway_that_gave_up_waiting(self): + for status in (408, 429, 500, 502, 503, 504): + with self.subTest(status=status): + self.assertTrue(api.ApiError("x", status).retryable) + + def test_a_request_that_was_wrong(self): + for status in (400, 401, 402, 403, 404, 413, 422): + with self.subTest(status=status): + self.assertFalse(api.ApiError("x", status).retryable) + + def test_an_error_of_our_own_is_not_the_network(self): + self.assertFalse(api.ApiError("Transcript came back empty.").retryable) + + def test_a_connection_that_dropped_is_worth_a_second_try(self): + with fake_urlopen(url_error("connection reset")): + with self.assertRaises(api.ApiError) as caught: + api._request("https://example.test", b"{}", {}) + self.assertTrue(caught.exception.retryable) + class ExtractError(unittest.TestCase): def test_the_usual_shape(self): diff --git a/tests/test_filetranscribe.py b/tests/test_filetranscribe.py index 2adc5ee..5bb2f75 100644 --- a/tests/test_filetranscribe.py +++ b/tests/test_filetranscribe.py @@ -214,10 +214,22 @@ class ChunkSeconds(DikteTest): self.assertEqual(ft.chunk_seconds(self.file(1024), 600), 0.0) def test_a_file_over_the_limit_is_cut_by_what_it_measured(self): - # Twice the limit over an hour, so a little under half an hour fits. - seconds = ft.chunk_seconds(self.file(ft.UPLOAD_LIMIT * 2), 3600) - self.assertGreater(seconds, 1500) - self.assertLess(seconds, 1800) + # Twice the limit over twenty minutes, so a little under ten fits. + seconds = ft.chunk_seconds(self.file(ft.UPLOAD_LIMIT * 2), 1200) + self.assertGreater(seconds, 500) + self.assertLess(seconds, 600) + + def test_a_chunk_is_never_more_audio_than_a_request_can_outlive(self): + """An hour in one request is a 502 from the gateway, whatever it weighs.""" + self.assertEqual(ft.chunk_seconds(self.file(ft.UPLOAD_LIMIT * 2), 3600), + ft.MAX_CHUNK_SECONDS) + + def test_a_small_file_that_is_still_hours_long_is_cut_on_the_clock(self): + self.assertEqual(ft.chunk_seconds(self.file(1024), 7200), + ft.MAX_CHUNK_SECONDS) + + def test_a_file_short_enough_on_both_counts_is_not_cut(self): + self.assertEqual(ft.chunk_seconds(self.file(1024), ft.MAX_CHUNK_SECONDS), 0.0) def test_a_file_with_no_length_is_left_whole(self): self.assertEqual(ft.chunk_seconds(self.file(ft.UPLOAD_LIMIT * 2), 0), 0.0) @@ -378,6 +390,58 @@ class Transcriber(DikteTest): worker.stop() self.assertTrue(worker._abort.aborted) + def test_a_chunk_is_given_longer_to_answer_than_a_dictation(self): + """A quarter hour of audio is not a sentence: the default would cut it off.""" + worker = ft.FileTranscriber(self.conf) + with mock.patch.object(ft, "_to_wav", side_effect=lambda *a: self.source), \ + mock.patch.object(ft, "_to_mp3", + side_effect=lambda path, *a, **k: path), \ + mock.patch.object(ft.shutil, "which", return_value="/usr/bin/ffmpeg"), \ + mock.patch.object(api, "transcribe", return_value="text") as call: + worker._work(self.source, False, False) + self.assertEqual(call.call_args.kwargs["timeout"], ft.HOSTED_TIMEOUT) + + def test_a_gateway_having_a_bad_moment_is_asked_again(self): + with mock.patch.object(ft.FileTranscriber, "_wait"): + done, failures, progress, _ = self.run_chain( + fail=[api.ApiError("HTTP 502: timeout", 502), "raw text"]) + self.assertEqual(failures, []) + self.assertEqual(done[0][0], "raw text") + self.assertTrue(any("Trying again" in message for message in progress)) + + def test_a_rejected_key_is_not_asked_again(self): + """Trying again with the same key is only a slower way to fail.""" + call = mock.Mock(side_effect=api.ApiError("rejected the API key", 401)) + with mock.patch.object(ft.FileTranscriber, "_wait"): + _, failures, _, _ = self.run_chain(fail=call) + self.assertEqual(call.call_count, 1) + self.assertIn("rejected", failures[0]) + + def test_a_chunk_is_given_up_on_after_the_last_try(self): + call = mock.Mock(side_effect=api.ApiError("HTTP 502: timeout", 502)) + with mock.patch.object(ft.FileTranscriber, "_wait"): + _, failures, _, _ = self.run_chain(fail=call) + self.assertEqual(call.call_count, ft.RETRIES) + self.assertIn("502", failures[0]) + + def test_what_was_heard_before_the_failure_is_still_handed_over(self): + """An hour already transcribed is not thrown away over the chunk after it.""" + boom = api.ApiError("HTTP 502: timeout", 502) + with mock.patch.object(ft.FileTranscriber, "_wait"), \ + mock.patch.object(ft.FileTranscriber, "_chunks", + side_effect=lambda wav, *a: [(wav, 0.0), (wav, 10.0)]): + done, failures, _, _ = self.run_chain( + fail=["first half"] + [boom] * ft.RETRIES) + self.assertEqual(done[0][0], "first half") + self.assertIn("502", failures[0]) + + def test_nothing_heard_at_all_is_a_plain_failure(self): + call = mock.Mock(side_effect=api.ApiError("rejected the API key", 401)) + with mock.patch.object(ft.FileTranscriber, "_wait"): + done, failures, _, _ = self.run_chain(fail=call) + self.assertEqual(done, []) + self.assertEqual(failures[0], "rejected the API key") + def test_a_second_start_while_one_is_running_is_ignored(self): worker = ft.FileTranscriber(self.conf) worker._thread = mock.Mock(is_alive=lambda: True) From 6bf5c8b3490530abda6f5f8447546a65437628af Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" <41898282+github-actions[bot]@users.noreply.github.com> Date: Sat, 22 Aug 2026 05:20:11 +0000 Subject: [PATCH 24/27] Dikte 1.0.1 --- dikte/__init__.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/dikte/__init__.py b/dikte/__init__.py index b487fc9..35d481b 100644 --- a/dikte/__init__.py +++ b/dikte/__init__.py @@ -10,4 +10,4 @@ business loading Qt to answer one question. # both the .dmg's Info.plist and the AppImage's file name are built from it. A # build off master rather than off a tag appends the commit to it, so that a # bug report from someone running "latest" names a commit. -__version__ = "1.0.0" +__version__ = "1.0.1" From 084c48151d9d16f155788fcc4b546b8e9d592942 Mon Sep 17 00:00:00 2001 From: yusufipk Date: Sat, 22 Aug 2026 10:34:59 +0300 Subject: [PATCH 25/27] Say when a newer release is out, and stop there A check on the releases page once a day: at start, on a timer while Dikte runs, from the General tab on demand, and from `dikte update` at a terminal. What it finds goes in the tray menu and in one notification per version, and opens the release page. Nothing is downloaded and nothing is installed. The four downloads are installed four different ways and three of those belong to the platform: a Mac bundle cannot rewrite itself while it is running, the Windows setup has an uninstall entry of its own, an AppImage is a file kept wherever its owner keeps it, and a checkout is updated with git. Being wrong about any one of them means an installation somebody has to repair by hand. Versions are compared by their numbers alone. A build off master carries the released number with its commit after it, and that build is ahead of the release it names rather than behind it; read as a version suffix, every nightly would be told to go back to a release it had already passed. The clock lives in its own file rather than in the settings, since a check runs while the settings window may be open and a background write into config.json is what would overwrite whatever it holds. --- README.md | 7 +- README.tr.md | 6 +- dikte/app.py | 101 ++++++++++++++++++++++++- dikte/cli.py | 35 +++++++++ dikte/config.py | 3 + dikte/hub.py | 16 ++++ dikte/i18n.py | 19 +++++ dikte/settings_ui.py | 71 ++++++++++++++++++ dikte/update.py | 158 ++++++++++++++++++++++++++++++++++++++ tests/support.py | 5 +- tests/test_cli.py | 68 ++++++++++++++++- tests/test_ui.py | 27 +++++++ tests/test_update.py | 175 +++++++++++++++++++++++++++++++++++++++++++ 13 files changed, 685 insertions(+), 6 deletions(-) create mode 100644 dikte/update.py create mode 100644 tests/test_update.py diff --git a/README.md b/README.md index c195318..fe36166 100644 --- a/README.md +++ b/README.md @@ -107,7 +107,10 @@ Meetings are not supported there yet; the details are in 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 that back; `./scripts/uninstall.sh` takes it away again and leaves your settings -and dictations alone unless you pass `--purge`. +and dictations alone unless you pass `--purge`. Dikte looks at the releases page +once a day and puts a line in the tray menu when a newer version is out, which +opens the page rather than installing anything; the General tab turns that off +or runs it on the spot. Speech to text and cleanup each pick a provider in the settings window, and both run here by default, on models of your own. The cloud is the other option: @@ -130,6 +133,7 @@ set next to it. | Speak a command to an agent | Tray menu → *Ask Claude*, or `dikte ask` | | Start / end a meeting | Tray menu → *Record a meeting*, or `dikte meeting` | | Settings | Tray menu → *Settings*, or `dikte settings` | +| Look for a newer version | General tab → *Check now*, or `dikte update` | | Reload after an update | Tray menu → *Restart*, or `dikte restart` | | Quit | Tray menu → *Quit*, or `dikte quit` | @@ -240,6 +244,7 @@ api.py transcription and cleanup requests (stdlib only) cleanup.py who rewrites the transcript: OpenRouter, here, Claude or Codex ggml.py whisper.cpp and llama.cpp here: fetch, verify, keep serving hub.py what GitHub and Hugging Face have on offer today +update.py whether a newer release is out, and the page it is on worker.py transcribe → clean up → clipboard → paste vad.py deciding whether a recording holds speech at all filetranscribe.py file transcription: ffmpeg, chunking, timestamps diff --git a/README.tr.md b/README.tr.md index 65a50d1..5e88c69 100644 --- a/README.tr.md +++ b/README.tr.md @@ -104,7 +104,9 @@ toplantı kaydı henüz yok, ayrıntılar [Windows README](README.windows.md)'si 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 bunları yerine koyar; `./scripts/uninstall.sh` hepsini geri alır, `--purge` -demedikçe ayarlarına ve diktelerine dokunmaz. +demedikçe ayarlarına ve diktelerine dokunmaz. Dikte sürüm sayfasına günde bir +kez bakar ve yeni sürüm çıkmışsa tepsi menüsüne bir satır koyar; o satır bir şey +kurmaz, sayfayı açar. Genel sekmesi bu denetimi kapatır ya da anında çalıştırır. Sesi yazıya çevirme ve temizleme, ayarlar penceresinde ayrı ayrı sağlayıcı seçer; ikisi de varsayılan olarak burada, kendi modellerinle çalışır. Bulutu @@ -127,6 +129,7 @@ yanındaki kutudan düşünme seviyesini de seçebilirsin. | Ajana sesle komut ver | Tepsi menüsü → *Claude'a sor*, ya da `dikte ask` | | Toplantıyı başlat / bitir | Tepsi menüsü → *Toplantı kaydet*, ya da `dikte meeting` | | Ayarlar | Tepsi menüsü → *Ayarlar*, ya da `dikte settings` | +| Yeni sürüm var mı bak | Genel sekmesi → *Şimdi bak*, ya da `dikte update` | | Güncelleme sonrası yeniden yükle | Tepsi menüsü → *Yeniden başlat*, ya da `dikte restart` | | Çık | Tepsi menüsü → *Çık*, ya da `dikte quit` | @@ -234,6 +237,7 @@ api.py transkript ve temizleme istekleri (yalnız stdlib) cleanup.py transkripti kim temizler: OpenRouter, burası, Claude ya da Codex ggml.py whisper.cpp ve llama.cpp'yi indirip burada çalıştırma hub.py GitHub ve Hugging Face'te bugün ne olduğu +update.py yeni sürüm çıkmış mı, çıkmışsa hangi sayfada worker.py transkript → temizleme → pano → yapıştırma vad.py kayıtta gerçekten konuşma var mı kararı filetranscribe.py dosyadan transkript: ffmpeg, parçalama, zaman damgaları diff --git a/dikte/app.py b/dikte/app.py index 842ee51..1da104e 100644 --- a/dikte/app.py +++ b/dikte/app.py @@ -33,8 +33,9 @@ if sys.platform == "darwin": os.environ.get("PATH", "")) if part ) -from PyQt6.QtCore import QTimer, QElapsedTimer, QSocketNotifier # noqa: E402 -from PyQt6.QtGui import QAction, QIcon # noqa: E402 +from PyQt6.QtCore import (QObject, QTimer, QElapsedTimer, QSocketNotifier, # noqa: E402 + QUrl, pyqtSignal) +from PyQt6.QtGui import QAction, QDesktopServices, QIcon # noqa: E402 from PyQt6.QtNetwork import QLocalServer, QLocalSocket # noqa: E402 from PyQt6.QtWidgets import QApplication, QMenu, QSystemTrayIcon # noqa: E402 @@ -44,11 +45,13 @@ from . import cli # noqa: E402 from . import config as cfg # noqa: E402 from . import ggml # noqa: E402 from . import hotkey # noqa: E402 +from . import hub # noqa: E402 from . import i18n # noqa: E402 from . import integrate # noqa: E402 from . import ipc # noqa: E402 from . import meeting # noqa: E402 from . import trayicon # noqa: E402 +from . import update # noqa: E402 from .i18n import t # noqa: E402 from .meeting import MeetingPipeline # noqa: E402 from .overlay import Overlay # noqa: E402 @@ -77,6 +80,37 @@ ECHO_MS = 2000 # short enough not to sit in the corner for the rest of the hour. PEEK_MS = 12000 +# When the releases page is looked at, and how often it is thought about after +# that. The delay is there so that a check never shares the first seconds of a +# start with the model being loaded and the desktop drawing the tray; the +# interval is not the interval between checks, which update.py holds at a day, +# but how often that clock is read, so that a machine left running for a week +# still asks once a day rather than once a boot. +UPDATE_DELAY_MS = 20000 +UPDATE_POLL_MS = 3 * 3600 * 1000 + + +class UpdateCheck(QObject): + """One look at the releases page, off the interface thread. + + An object of its own because the application is not one: a plain thread + cannot touch a widget, and a signal is the only way back onto the thread + that may. + """ + + # The newer release, or None when there is nothing to say, and the reason + # nothing could be found out instead. + done = pyqtSignal(object, str) + + def start(self): + def work(): + try: + self.done.emit(update.check(), "") + except hub.HubError as exc: + self.done.emit(None, str(exc)) + + threading.Thread(target=work, daemon=True).start() + class Dikte: def __init__(self, app): @@ -159,6 +193,17 @@ class Dikte: self.meeting_ticker.setInterval(500) self.meeting_ticker.timeout.connect(self._meeting_tick) + # What the last check found, read from disk rather than asked for, so + # that a tray built in the next line already knows to say so. + self.update_release = update.pending() + self.updates = UpdateCheck() + self.updates.done.connect(self._on_update_checked) + self.update_ticker = QTimer() + self.update_ticker.setInterval(UPDATE_POLL_MS) + self.update_ticker.timeout.connect(self._look_for_update) + self.update_ticker.start() + QTimer.singleShot(UPDATE_DELAY_MS, self._look_for_update) + self.tray = QSystemTrayIcon() self._apply_settings() self.tray.show() @@ -211,6 +256,11 @@ class Dikte: self.menu.addAction(self.meeting_cancel_action) self.menu.addSeparator() + # Named in _refresh_update, and hidden until a check has found one. + self.update_action = QAction("", self.menu) + self.update_action.triggered.connect(self.open_release_page) + self.menu.addAction(self.update_action) + self.settings_action = QAction(t("Settings…"), self.menu) self.settings_action.triggered.connect(self.open_settings) self.menu.addAction(self.settings_action) @@ -227,6 +277,7 @@ class Dikte: self.tray.setContextMenu(self.menu) self.tray.setToolTip(t("Dikte: ready")) self.tray.activated.connect(self._tray_clicked) + self._refresh_update() self._set_icon("audio-input-microphone") def _tray_clicked(self, reason): @@ -901,12 +952,58 @@ class Dikte: if len(message) > len(first_line): self.tray.showMessage("Dikte", message, QSystemTrayIcon.MessageIcon.Warning, 8000) + # ---- updates ---------------------------------------------------------- + + def _look_for_update(self): + """The timer. update.py decides whether this is a request or a memory.""" + if not self.conf["update_check"]: + return + self.updates.start() + + def _on_update_checked(self, release, error): + if error: + # Nobody asked for this, so nobody is waiting to be told it failed. + # A machine that is offline, or a GitHub that is rate-limiting the + # address, is not a thing to interrupt a dictation about. + print(f"dikte: update check: {error}", file=sys.stderr) + return + if release is None: + return + self._found_update(release) + # Once per version. A check that runs every day must not be a + # notification every day for an update somebody has decided to skip. + if update.announced() != release.version: + update.mark_announced(release.version) + self.tray.showMessage( + "Dikte", + t("Dikte {version} is out. The tray menu has the release page.", + version=release.version), + QSystemTrayIcon.MessageIcon.Information, 8000, + ) + + def _found_update(self, release): + self.update_release = release + self._refresh_update() + + def _refresh_update(self): + release = self.update_release + self.update_action.setVisible(release is not None) + if release is not None: + self.update_action.setText( + t("Dikte {version} is out…", version=release.version)) + + def open_release_page(self): + release = self.update_release + QDesktopServices.openUrl( + QUrl(release.url if release is not None else update.RELEASES_PAGE)) + # ---- settings --------------------------------------------------------- def open_settings(self): if self.settings_window is None: self.settings_window = SettingsWindow(self.conf, self.meetings) self.settings_window.applied.connect(self._apply_settings) + self.settings_window.update_found.connect(self._found_update) self.settings_window.finished.connect(self._settings_closed) self.settings_window.show() self.settings_window.raise_() diff --git a/dikte/cli.py b/dikte/cli.py index 8bfcf56..3109348 100644 --- a/dikte/cli.py +++ b/dikte/cli.py @@ -20,6 +20,7 @@ import signal import subprocess import sys import time +import webbrowser from PyQt6.QtCore import QCoreApplication, QTimer @@ -30,10 +31,12 @@ from . import cleanup from . import config as cfg from . import filetranscribe from . import hotkey +from . import hub from . import ipc from . import integrate from . import meeting from . import paste +from . import update from . import __version__ NOT_RUNNING = 3 @@ -797,6 +800,33 @@ def cmd_integrate(opts): f"{verb}:\n{listing}" if paths else "Nothing to change.") +def cmd_update(opts): + """Whether a newer Dikte has been released, and where it is. + + It looks and nothing more: what to do about the answer is a download page, + because the AppImage, the disk image, the Windows setup and a checkout are + four different installations and only their owner knows which one this is. + """ + try: + release = update.latest(refresh=True) + except hub.HubError as exc: + return fail(opts, exc) + # Written down even when there is nothing new, so that the application does + # not go and ask the same question an hour later. + update.remember(release) + waiting = update.newer(release.version) + payload = {"ok": True, "current": __version__, "latest": release.version, + "update": waiting, "url": release.url} + if not waiting: + return out(opts, payload, + f"Dikte {__version__} is the newest release.") + if opts.open: + webbrowser.open(release.url) + return out(opts, payload, + f"Dikte {release.version} is out; this is {__version__}.\n" + f"{release.url}") + + def cmd_status(opts): reply = ipc.send("status") if reply is None: @@ -1098,6 +1128,11 @@ def build_parser(): integrated.set_defaults(func=cmd_integrate) # --- the application -------------------------------------------------- + updates = leaf(subs, "update", "whether a newer Dikte has been released") + updates.add_argument("--open", action="store_true", + help="open the release page in a browser") + updates.set_defaults(func=cmd_update) + leaf(subs, "status", "what it is doing right now").set_defaults(func=cmd_status) for name, help_text in (("settings", "open the settings window"), ("restart", "reload the running instance"), diff --git a/dikte/config.py b/dikte/config.py index 7229553..fb7683a 100644 --- a/dikte/config.py +++ b/dikte/config.py @@ -457,6 +457,9 @@ DEFAULTS = { "overlay_corner": "bottom-left", "keep_audio": False, "history_limit": 200, + # A look at the releases page once a day, and nothing more than a look: + # what is found opens a browser, never an installer. + "update_check": True, "file_timestamps": False, "file_cleanup": True, "file_cleanup_prompt": "", # empty -> language-specific default diff --git a/dikte/hub.py b/dikte/hub.py index afd8133..9e6e518 100644 --- a/dikte/hub.py +++ b/dikte/hub.py @@ -136,6 +136,22 @@ def release(repo, tag="latest", refresh=False): return data.get("tag_name") or tag, assets +def newest_release(repo, refresh=False): + """(tag, page, published) for the newest release of a repository. + + release() above is for taking a file out of one and insists on there being + files to take; this is for the number, which a release with nothing + attached answers just as well. GitHub keeps prereleases out of "latest" on + its own, which is what leaves the nightly build off this answer. + """ + data = _fetch(f"gh-newest-{repo}", + f"{GITHUB_API}/repos/{repo}/releases/latest", refresh=refresh) + if not isinstance(data, dict) or not data.get("tag_name"): + raise HubError(t("{repo} has published no release.", repo=repo)) + return (data["tag_name"], data.get("html_url") or "", + data.get("published_at") or "") + + def files(repo, revision="main", refresh=False): """[Item] for every file in a Hugging Face repository. diff --git a/dikte/i18n.py b/dikte/i18n.py index 398f740..f7f4910 100644 --- a/dikte/i18n.py +++ b/dikte/i18n.py @@ -192,6 +192,25 @@ TR = { "Silence threshold": "Sessizlik eşiği", "Keep audio files ({path})": "Ses kayıtlarını sakla ({path})", + # --- updates -------------------------------------------------------- + "Updates": "Güncelleme", + "Look for a newer version once a day": "Günde bir kez yeni sürüm var mı diye bak", + "Dikte only looks. What it finds opens the release page in your browser; " + "it downloads and installs nothing by itself.": + "Dikte yalnızca bakar. Bulduğu şey tarayıcında sürüm sayfasını açar; " + "kendi başına hiçbir şey indirmez ve kurmaz.", + "Check now": "Şimdi bak", + "Looking…": "Bakılıyor…", + "Open the release page": "Sürüm sayfasını aç", + "This is Dikte {version}.": "Buradaki sürüm Dikte {version}.", + "Dikte {version} is the newest release.": "En yeni sürüm zaten bu: Dikte {version}.", + "Dikte {version} is out; this is {current}.": + "Dikte {version} çıkmış; buradaki sürüm {current}.", + "Dikte {version} is out…": "Dikte {version} çıkmış…", + "Dikte {version} is out. The tray menu has the release page.": + "Dikte {version} çıkmış. Sürüm sayfası tepsi menüsünde.", + "{repo} has published no release.": "{repo} için yayımlanmış sürüm yok.", + # --- settings: api -------------------------------------------------- "Keys": "Anahtarlar", "Speech to text": "Sesi yazıya çevirme", diff --git a/dikte/settings_ui.py b/dikte/settings_ui.py index 4563d1a..d3a97b7 100644 --- a/dikte/settings_ui.py +++ b/dikte/settings_ui.py @@ -13,6 +13,7 @@ from PyQt6.QtWidgets import ( QPushButton, QScrollArea, QSpinBox, QTabWidget, QVBoxLayout, QWidget, ) +from . import __version__ from . import api from . import assistant from . import audio @@ -21,9 +22,11 @@ from . import config as cfg from . import filetranscribe from . import ggml from . import hotkey +from . import hub from . import ipc from . import meeting from . import paste +from . import update from .filetranscribe import FileTranscriber from .i18n import t @@ -512,11 +515,16 @@ class LocalModelBox(QGroupBox): class SettingsWindow(QDialog): applied = pyqtSignal() + # A newer release this window's own check found, so that the tray icon + # hears about it from here rather than waiting for its own next check. + update_found = pyqtSignal(object) _models_loaded = pyqtSignal(list, str) _transcribe_models_loaded = pyqtSignal(list, str) # Which key was tested, whether it worked, and what to write under it. _test_done = pyqtSignal(str, bool, str) + # The release that was found, or None, and what went wrong instead. + _update_checked = pyqtSignal(object, str) def __init__(self, conf, meetings=None, parent=None): super().__init__(parent) @@ -533,6 +541,9 @@ class SettingsWindow(QDialog): self._key_fields = {} self._testers = {} self._shown_provider = "" + # Where "Open the release page" goes: the release itself once a check + # has named one, and the page that redirects to the newest until then. + self._release_url = update.RELEASES_PAGE self.transcriber = FileTranscriber(conf, self) self.setWindowTitle(t("Dikte Settings")) @@ -567,6 +578,7 @@ class SettingsWindow(QDialog): self._models_loaded.connect(self._on_models_loaded) self._transcribe_models_loaded.connect(self._on_transcribe_models_loaded) self._test_done.connect(self._on_test_done) + self._update_checked.connect(self._on_update_checked) self.transcriber.progress.connect(self._on_file_progress) self.transcriber.finished.connect(self._on_file_finished) self.transcriber.failed.connect(self._on_file_failed) @@ -698,6 +710,23 @@ class SettingsWindow(QDialog): t("Keep audio files ({path})", path=str(cfg.RECORDINGS_DIR)) ) form.addRow("", self.keep_audio) + + self.update_check = QCheckBox(t("Look for a newer version once a day")) + self.update_check.setToolTip( + t("Dikte only looks. What it finds opens the release page in your " + "browser; it downloads and installs nothing by itself.") + ) + form.addRow(t("Updates"), self.update_check) + + self.update_status = WrappedLabel("") + self.update_page = QPushButton(t("Open the release page")) + self.update_page.clicked.connect( + lambda: QDesktopServices.openUrl(QUrl(self._release_url)) + ) + self.update_now = QPushButton(t("Check now")) + self.update_now.clicked.connect(self._check_for_update) + form.addRow("", self._row(self.update_status, self.update_page, + self.update_now)) return page def _api_tab(self): @@ -1568,6 +1597,8 @@ class SettingsWindow(QDialog): self.silence_db.setValue(int(conf["silence_db"])) self.filter_hallucinations.setChecked(conf["filter_hallucinations"]) self.keep_audio.setChecked(conf["keep_audio"]) + self.update_check.setChecked(conf["update_check"]) + self._show_update(update.pending()) for name, who in cfg.TRANSCRIBERS.items(): self._key_fields[name].setText(conf[who.key]) @@ -1661,6 +1692,7 @@ class SettingsWindow(QDialog): conf["silence_db"] = float(self.silence_db.value()) conf["filter_hallucinations"] = self.filter_hallucinations.isChecked() conf["keep_audio"] = self.keep_audio.isChecked() + conf["update_check"] = self.update_check.isChecked() provider = self.transcribe_provider.currentData() or "local" if provider in self._models: @@ -1891,6 +1923,45 @@ class SettingsWindow(QDialog): button.setEnabled(True) answer.setText(("✓ " if ok else "✗ ") + message) + # ---- updates --------------------------------------------------------- + + def _check_for_update(self): + """The button, which asks GitHub whatever the daily clock says.""" + self.update_now.setEnabled(False) + self.update_status.setText(t("Looking…")) + + def work(): + try: + self._update_checked.emit(update.check(force=True), "") + except hub.HubError as exc: + self._update_checked.emit(None, str(exc)) + + threading.Thread(target=work, daemon=True).start() + + def _on_update_checked(self, release, error): + self.update_now.setEnabled(True) + if error: + self.update_status.setText(error) + return + self._show_update(release, asked=True) + if release is not None: + self.update_found.emit(release) + + def _show_update(self, release, asked=False): + """What the line under the checkbox says, and whether the page button + is on it. `release` is None when this build is the newest one, and + `asked` is what tells "nothing new" from "nobody has looked yet".""" + self.update_page.setVisible(release is not None) + if release is None: + self.update_status.setText( + t("Dikte {version} is the newest release.", version=__version__) + if asked else t("This is Dikte {version}.", version=__version__)) + return + self._release_url = release.url + self.update_status.setText( + t("Dikte {version} is out; this is {current}.", + version=release.version, current=__version__)) + # ---- audio file ------------------------------------------------------ def _choose_file(self): diff --git a/dikte/update.py b/dikte/update.py new file mode 100644 index 0000000..4375996 --- /dev/null +++ b/dikte/update.py @@ -0,0 +1,158 @@ +"""Whether a newer Dikte has been published, and where to get it. + +GitHub is asked for the newest release, its number is held against the one this +build carries, and that is where it stops. Nothing is downloaded and nothing is +replaced. The four downloads are installed in four different ways, and three of +those belong to the platform rather than to Dikte: a Mac bundle is dragged into +Applications and cannot rewrite itself while it is running, the Windows setup +is an installer with an uninstall entry of its own, an AppImage is a single +file kept wherever its owner keeps it, and a checkout is updated with git. A +program that guessed at all four would be wrong on at least one of them, and +being wrong there means an installation somebody has to repair by hand. So the +answer ends in a browser, on the release page, where the same download that was +installed the first time is waiting. + +The clock is kept in a file of its own rather than in the settings. A check +runs while the settings window may be open, and a background write into +config.json is exactly what would overwrite a setting somebody is in the middle +of changing. + +Nothing here imports Qt or the rest of the application: `dikte update` at a +terminal and the timer behind the tray icon ask the same three questions of the +same module. +""" + +import collections +import itertools +import json +import time + +from . import __version__ +from . import hub +from . import paths + +REPO = "yusufipk/dikte" +# Where somebody is sent. GitHub redirects this to whatever the newest release +# is, so it stays right without anybody writing a number into it. +RELEASES_PAGE = f"https://github.com/{REPO}/releases/latest" + +# Once a day. A release happens every few weeks at best, and a question nobody +# is waiting on is not one to ask GitHub on every start. +INTERVAL = 24 * 3600 + +# When the last check was, what it found, and which version has already been +# announced. In the data directory rather than the config one: it is not a +# setting, nobody edits it, and losing it costs one extra request. +STATE_FILE = paths.DATA_DIR / "update.json" + +Release = collections.namedtuple("Release", "version url published") + + +def _numbers(version): + """(1, 0, 2) for "v1.0.2", "1.0.2" and "1.0.2-dev.abc1234" alike. + + Empty for anything that does not start with a number, which is what a tag + naming something other than a version comes back as. + """ + number = str(version or "").strip().lstrip("vV").split("-")[0].split("+")[0] + parts = [] + for piece in number.split("."): + digits = "".join(itertools.takewhile(str.isdigit, piece)) + if not digits: + break + parts.append(int(digits)) + return tuple((parts + [0, 0, 0])[:3]) if parts else () + + +def newer(there, here=""): + """Whether the release numbered `there` is one this build has not got. + + Only the numbers are compared, and what follows them is dropped. A build + off master carries the released number with its commit after it + (1.0.1-dev.abc1234), and that build is ahead of 1.0.1 rather than behind + it; read as a version suffix it would be behind, and every nightly would be + told to update to the release it was already past. + """ + theirs = _numbers(there) + return bool(theirs) and theirs > _numbers(here or __version__) + + +def state(): + """What the last check wrote down; empty when there has never been one.""" + try: + stored = json.loads(STATE_FILE.read_text(encoding="utf-8")) + except (OSError, ValueError): + return {} + return stored if isinstance(stored, dict) else {} + + +def _store(**changes): + stored = state() + stored.update(changes) + try: + STATE_FILE.parent.mkdir(parents=True, exist_ok=True) + STATE_FILE.write_text(json.dumps(stored), encoding="utf-8") + except OSError: + pass # a check that cannot be written down still happened + return stored + + +def due(now=0): + """Whether a day has gone by since the last time anybody asked.""" + return (now or time.time()) - float(state().get("checked") or 0) >= INTERVAL + + +def latest(refresh=False): + """The newest published release, asked for outright. Raises HubError.""" + tag, url, published = hub.newest_release(REPO, refresh=refresh) + return Release(tag.lstrip("vV"), url or RELEASES_PAGE, published) + + +def remember(release): + """Write down that a check has just happened, and what it found.""" + _store(checked=time.time(), version=release.version, url=release.url, + published=release.published) + + +def pending(): + """The newer release the last check found, without asking anybody. + + What the tray icon is built from: the answer has to be there the moment it + appears, and a request on the way to the screen is a request nobody has + time for. + """ + stored = state() + version = stored.get("version") or "" + if not newer(version): + return None + return Release(version, stored.get("url") or RELEASES_PAGE, + stored.get("published") or "") + + +def check(force=False): + """A newer release, or None when there is nothing to say. + + The scheduled half: it asks only when a day has gone by, and answers from + what the last check found in between. `force` is the button in Settings and + the command line, which ask whatever the clock says. + + The clock here is the only throttle. Once it has decided to ask, it asks + for real rather than reading hub.py's few hours of cache, which is there to + keep a settings window from fetching the same model list twice in an + evening and would only ever answer this with something it already knew. + """ + if not force and not due(): + return pending() + release = latest(refresh=True) + remember(release) + return release if newer(release.version) else None + + +def announced(): + """The version somebody has already been shown a notification about.""" + return state().get("announced") or "" + + +def mark_announced(version): + """Said once. A daily check must not be a daily interruption.""" + _store(announced=version) diff --git a/tests/support.py b/tests/support.py index f6ec508..8a005f1 100644 --- a/tests/support.py +++ b/tests/support.py @@ -25,6 +25,7 @@ from unittest import mock from dikte import assistant from dikte import config as cfg from dikte import i18n +from dikte import update # What the application is, rather than what it does: PipeWire, wl-clipboard, # ydotool, KDE's shortcut file, /dev/input. A port to another desktop replaces @@ -86,8 +87,10 @@ class DikteTest(unittest.TestCase): MEETINGS_FILE=data_dir / "meetings.jsonl", ) # Resolved from cfg.DATA_DIR when assistant was imported, so it needs - # moving on its own. + # moving on its own. The same goes for where the update check writes + # down when it last ran. self.patch_attr(assistant, "SESSION_FILE", data_dir / "assistant.json") + self.patch_attr(update, "STATE_FILE", data_dir / "update.json") i18n.set_language("en") self.addCleanup(i18n.set_language, "en") diff --git a/tests/test_cli.py b/tests/test_cli.py index 3c2de6d..585bad5 100644 --- a/tests/test_cli.py +++ b/tests/test_cli.py @@ -10,6 +10,8 @@ import contextlib import io import json import unittest +import webbrowser +from typing import ClassVar from unittest import mock from dikte import audio @@ -17,9 +19,11 @@ from dikte import cli from dikte import config as cfg from dikte import ggml from dikte import hotkey +from dikte import hub from dikte import ipc from dikte import paste -from tests.support import DikteTest, fake_urlopen, only_these_tools +from dikte import update +from tests.support import DikteTest, fake_urlopen, only_these_tools, url_error class Options: @@ -420,6 +424,68 @@ class Providers(DikteTest): self.assertIn("Groq", out) +class Updates(DikteTest): + """`dikte update` looks, says what it found, and installs nothing.""" + + RELEASE: ClassVar[dict] = { + "tag_name": "v9.9.9", + "html_url": "https://github.com/yusufipk/dikte/releases/tag/v9.9.9", + } + + def setUp(self): + super().setUp() + self.patch_attr(hub, "CACHE_DIR", self.path("cache")) + # Nothing here may reach a browser, whatever the answer turns out to be. + self.opened = [] + self.patch_attr(webbrowser, "open", self.opened.append) + + def run_update(self, reply, **values): + with fake_urlopen(reply), captured() as (out, err): + code = cli.cmd_update(Options(open=False, **values)) + return code, out.getvalue(), err.getvalue() + + def test_a_newer_release_is_named_with_its_page(self): + code, out, _ = self.run_update(self.RELEASE) + self.assertEqual(code, 0) + self.assertIn("9.9.9", out) + self.assertIn(self.RELEASE["html_url"], out) + + def test_this_build_being_the_newest_is_not_a_failure(self): + code, out, _ = self.run_update({"tag_name": f"v{cli.__version__}"}) + self.assertEqual(code, 0) + self.assertIn("newest", out) + + def test_the_json_answer_says_both_numbers(self): + code, out, _ = self.run_update(self.RELEASE, json=True) + answer = json.loads(out) + self.assertTrue(answer["update"]) + self.assertEqual(answer["latest"], "9.9.9") + self.assertEqual(answer["current"], cli.__version__) + + def test_github_being_unreachable_is_a_failure_with_a_reason(self): + with fake_urlopen(url_error("no route to host")), captured() as (_, err): + code = cli.cmd_update(Options(open=False)) + self.assertEqual(code, 1) + self.assertIn("api.github.com", err.getvalue()) + + def test_the_browser_is_opened_only_when_asked_and_only_when_there_is_one(self): + self.run_update(self.RELEASE) + self.assertEqual(self.opened, []) + with fake_urlopen({"tag_name": f"v{cli.__version__}"}), captured(): + cli.cmd_update(Options(open=True)) + self.assertEqual(self.opened, []) + with fake_urlopen(self.RELEASE), captured(): + cli.cmd_update(Options(open=True)) + self.assertEqual(self.opened, [self.RELEASE["html_url"]]) + + def test_the_answer_is_written_down_for_the_application(self): + """A check at a terminal is a check; the tray must not go and ask the + same question an hour later.""" + self.run_update(self.RELEASE) + self.assertEqual(update.state()["version"], "9.9.9") + self.assertFalse(update.due()) + + class Doctor(DikteTest): """One pass over everything the settings window checks behind its buttons.""" diff --git a/tests/test_ui.py b/tests/test_ui.py index 6cd402c..0b04be9 100644 --- a/tests/test_ui.py +++ b/tests/test_ui.py @@ -25,6 +25,7 @@ from dikte import ipc from dikte import overlay as overlay_module from dikte import paste from dikte import settings_ui +from dikte import update from tests.support import DikteTest, only_these_tools # One application for the whole run; Qt allows no second one. @@ -103,6 +104,7 @@ CHANGED = { "pause_shortcut": "Meta+P", "evdev_hotkey": True, "history_limit": 50, + "update_check": False, } @@ -252,6 +254,31 @@ class Settings(DikteTest): self.assertEqual(shown, [provider]) self.assertFalse(box.isHidden()) + def test_the_update_line_names_the_version_that_is_running(self): + window = self.window(cfg.Config()) + self.assertIn(settings_ui.__version__, window.update_status.text()) + # Nothing to open until a check has found something to open. + self.assertTrue(window.update_page.isHidden()) + + def test_a_newer_release_puts_the_page_button_on_screen(self): + window = self.window(cfg.Config()) + told = [] + window.update_found.connect(told.append) + release = update.Release("9.9.9", "https://example.invalid/9.9.9", "") + window._on_update_checked(release, "") + self.assertIn("9.9.9", window.update_status.text()) + self.assertFalse(window.update_page.isHidden()) + self.assertEqual(window._release_url, release.url) + # And the tray hears about it from here rather than waiting a day. + self.assertEqual(told, [release]) + + def test_a_check_that_failed_says_why_and_hands_the_button_back(self): + window = self.window(cfg.Config()) + window.update_now.setEnabled(False) + window._on_update_checked(None, "api.github.com answered HTTP 403.") + self.assertIn("403", window.update_status.text()) + self.assertTrue(window.update_now.isEnabled()) + def test_the_settings_the_window_does_not_show_are_left_alone(self): """A tab nobody wrote must not reset what the command line set.""" self.write_config({"silence_db": -42.0, "speech_margin_db": 15.0, diff --git a/tests/test_update.py b/tests/test_update.py new file mode 100644 index 0000000..06aca80 --- /dev/null +++ b/tests/test_update.py @@ -0,0 +1,175 @@ +"""Whether a newer release is one worth telling somebody about. + +Two things carry the weight here. A version is compared by its numbers alone, +because a build off master carries the released number with its commit after +it and is ahead of that release rather than behind it. And the clock lives in a +file, so a day of asking nobody has to survive a restart. +""" + +import json +import time + +from dikte import hub +from dikte import update +from tests.support import DikteTest, fake_urlopen, url_error + +RELEASE = { + "tag_name": "v1.4.0", + "html_url": "https://github.com/yusufipk/dikte/releases/tag/v1.4.0", + "published_at": "2026-08-01T10:00:00Z", +} + + +class Numbers(DikteTest): + def test_a_tag_and_a_bare_number_read_the_same(self): + self.assertEqual(update._numbers("v1.4.0"), (1, 4, 0)) + self.assertEqual(update._numbers("1.4.0"), (1, 4, 0)) + + def test_a_short_number_is_filled_out(self): + self.assertEqual(update._numbers("2"), (2, 0, 0)) + self.assertEqual(update._numbers("2.1"), (2, 1, 0)) + + def test_what_follows_the_number_is_dropped(self): + self.assertEqual(update._numbers("1.0.1-dev.abc1234"), (1, 0, 1)) + self.assertEqual(update._numbers("1.0.1+build7"), (1, 0, 1)) + + def test_something_that_is_not_a_version_is_no_version(self): + self.assertEqual(update._numbers("latest"), ()) + self.assertEqual(update._numbers(""), ()) + self.assertEqual(update._numbers(None), ()) + + +class Newer(DikteTest): + def test_a_higher_number_is_newer(self): + self.assertTrue(update.newer("1.4.0", "1.3.9")) + self.assertTrue(update.newer("v2.0.0", "1.9.9")) + + def test_the_same_number_is_not(self): + self.assertFalse(update.newer("1.4.0", "1.4.0")) + self.assertFalse(update.newer("1.3.0", "1.4.0")) + + def test_a_build_off_master_is_ahead_of_the_release_it_names(self): + """1.0.1-dev.abc1234 was built after 1.0.1 went out, not before it. + Read as a version suffix it would be older, and every nightly would be + told to go back to the release it had already passed.""" + self.assertFalse(update.newer("1.0.1", "1.0.1-dev.abc1234")) + self.assertTrue(update.newer("1.0.2", "1.0.1-dev.abc1234")) + + def test_a_tag_that_is_not_a_version_is_never_newer(self): + self.assertFalse(update.newer("nightly", "1.0.0")) + + +class Asking(DikteTest): + def setUp(self): + super().setUp() + self.patch_attr(hub, "CACHE_DIR", self.path("cache")) + self.patch_attr(update, "__version__", "1.0.0") + + def test_the_newest_release_comes_back_with_its_page(self): + with fake_urlopen(RELEASE) as calls: + release = update.latest() + self.assertEqual(release.version, "1.4.0") + self.assertEqual(release.url, RELEASE["html_url"]) + self.assertEqual( + calls[0].full_url, + "https://api.github.com/repos/yusufipk/dikte/releases/latest") + + def test_a_release_with_no_page_falls_back_to_the_redirect(self): + with fake_urlopen({"tag_name": "v1.4.0"}): + release = update.latest() + self.assertEqual(release.url, update.RELEASES_PAGE) + + def test_a_repository_with_no_release_is_an_error(self): + with fake_urlopen({"message": "Not Found"}): + with self.assertRaises(hub.HubError): + update.latest() + + def test_a_check_answers_with_the_newer_release(self): + with fake_urlopen(RELEASE): + release = update.check() + self.assertEqual(release.version, "1.4.0") + + def test_a_check_that_finds_nothing_new_answers_with_nothing(self): + self.patch_attr(update, "__version__", "1.4.0") + with fake_urlopen(RELEASE): + self.assertIsNone(update.check()) + + def test_a_second_check_the_same_day_asks_nobody(self): + with fake_urlopen(RELEASE) as calls: + update.check() + release = update.check() + self.assertEqual(len(calls), 1) + # And still says what the first one found, since it is still true. + self.assertEqual(release.version, "1.4.0") + + def test_a_day_later_it_asks_again(self): + with fake_urlopen(RELEASE) as calls: + update.check() + update._store(checked=time.time() - update.INTERVAL - 60) + update.check() + self.assertEqual(len(calls), 2) + + def test_the_button_asks_whatever_the_clock_says(self): + with fake_urlopen(RELEASE) as calls: + update.check() + update.check(force=True) + self.assertEqual(len(calls), 2) + + def test_a_check_that_cannot_reach_github_says_so(self): + with fake_urlopen(url_error("no route to host")): + with self.assertRaises(hub.HubError): + update.check() + + +class Remembering(DikteTest): + def setUp(self): + super().setUp() + self.patch_attr(hub, "CACHE_DIR", self.path("cache")) + self.patch_attr(update, "__version__", "1.0.0") + + def test_what_the_last_check_found_survives_a_restart(self): + with fake_urlopen(RELEASE): + update.check() + release = update.pending() + self.assertEqual(release.version, "1.4.0") + self.assertEqual(release.url, RELEASE["html_url"]) + + def test_nothing_was_ever_checked(self): + self.assertIsNone(update.pending()) + self.assertEqual(update.state(), {}) + self.assertTrue(update.due()) + + def test_a_release_that_is_no_longer_newer_is_not_pending(self): + """The state file outlives the build that wrote it: an update that was + found and then installed must not still be waiting afterwards.""" + update._store(version="1.4.0") + self.patch_attr(update, "__version__", "1.4.0") + self.assertIsNone(update.pending()) + + def test_a_version_is_announced_once(self): + self.assertEqual(update.announced(), "") + update.mark_announced("1.4.0") + self.assertEqual(update.announced(), "1.4.0") + + def test_a_state_file_that_is_rubbish_is_no_state_at_all(self): + update.STATE_FILE.parent.mkdir(parents=True, exist_ok=True) + update.STATE_FILE.write_text("half a {", encoding="utf-8") + self.assertEqual(update.state(), {}) + self.assertIsNone(update.pending()) + + def test_a_state_file_that_cannot_be_written_is_not_a_failure(self): + self.patch_attr(update, "STATE_FILE", + self.path("nope") / "deeper" / "update.json") + self.path("nope").write_text("a file where a directory would go") + with fake_urlopen(RELEASE): + release = update.check() + self.assertEqual(release.version, "1.4.0") + + def test_the_clock_is_kept_out_of_the_settings(self): + """A background check writes while the settings window may be open, and + a write into config.json there would undo whatever it holds.""" + with fake_urlopen(RELEASE): + update.check() + stored = json.loads(update.STATE_FILE.read_text(encoding="utf-8")) + self.assertEqual(stored["version"], "1.4.0") + self.assertGreater(stored["checked"], 0) From 3663c7fd57569d1914424f4f9656a9941ae2d636 Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" <41898282+github-actions[bot]@users.noreply.github.com> Date: Sat, 22 Aug 2026 09:33:21 +0000 Subject: [PATCH 26/27] Dikte 1.0.2 --- dikte/__init__.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/dikte/__init__.py b/dikte/__init__.py index 35d481b..c95ff63 100644 --- a/dikte/__init__.py +++ b/dikte/__init__.py @@ -10,4 +10,4 @@ business loading Qt to answer one question. # both the .dmg's Info.plist and the AppImage's file name are built from it. A # build off master rather than off a tag appends the commit to it, so that a # bug report from someone running "latest" names a commit. -__version__ = "1.0.1" +__version__ = "1.0.2" From 0f30c852aba41064226c5eb801c88a774c170a25 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?G=C3=B6khan?= Date: Tue, 25 Aug 2026 00:29:26 +0300 Subject: [PATCH 27/27] Carry the remembered front past the Windows paste as well The macOS press learned a third argument, the application that was in front when the recording started, and every backend is handed it. SendInput takes no such thing: nothing on Windows moves the front while a recording runs, so the process id arrives and is left alone. Without this the Windows backend refuses the call the shared press makes, and the five key tests in tests.test_paste.Windows stop at a TypeError. Co-Authored-By: Claude Opus 5 (1M context) --- dikte/paste.py | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/dikte/paste.py b/dikte/paste.py index 8588025..93e7040 100644 --- a/dikte/paste.py +++ b/dikte/paste.py @@ -488,11 +488,14 @@ class _WinInput(ctypes.Structure): _fields_ = [("type", ctypes.c_ulong), ("union", _WinInputUnion)] -def _win_press(shortcut, delay): +def _win_press(shortcut, delay, _focus=None): """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. + + Nothing here takes the front from the window being dictated into, so the + remembered process id has nothing to hand back to: it is a macOS concern. """ codes = _win_keys(shortcut) user32, _ = _win_api()