diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml new file mode 100644 index 0000000..7258c78 --- /dev/null +++ b/.github/workflows/build.yml @@ -0,0 +1,114 @@ +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' + 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 + + - 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..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 @@ -107,70 +109,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 +155,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 cf7aea1..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 @@ -77,3 +85,46 @@ jobs: bash -n scripts/release.sh bash -n packaging/build-appimage.sh bash -n packaging/build-dmg.sh + + # The same job again for the same reason. The Windows backends are faked at + # the one function that loads user32 and kernel32, so every line of them is + # already read on the Linux above; what only this job can catch is the half + # that reads the real system. %APPDATA% and %LOCALAPPDATA% have to be the + # directories Windows actually hands out, a path spelled with a backslash has + # to be one the tests can still read, and the config-permission test has to + # skip rather than fail on a file system that decides by ACL. + windows: + runs-on: windows-latest + strategy: + fail-fast: false + matrix: + python: ["3.11", "3.13"] + + steps: + - uses: actions/checkout@v4 + + - uses: actions/setup-python@v5 + with: + python-version: ${{ matrix.python }} + + # No apt step: PyQt6's wheel carries the Qt DLLs it needs on Windows. + - name: Install PyQt6 + run: python -m pip install --quiet PyQt6 + + - name: Run the tests + run: python -m unittest discover --verbose + + # What the Mac does for its installer, in the language this one is in. + # Parsing only: install.ps1 writes into the Start Menu and the user PATH, + # 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: | + 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 c23508a..c77685a 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` +Four systems are supported: Wayland, X11, macOS and Windows. Each one is a named +entry in a table, and one chooser picks between them, so a fifth adds an entry +and a line rather than a branch inside every function. The tables are +`paste.Desktop` (clipboard and key press), `audio.Sound` (capture, the device +lists, and whether the far side of a meeting can be recorded at all), +`paths.directories()` (where the settings and the data live) and `hotkey.backend()`, +which names the one mechanism a session has for holding a key. Keep `sys.platform` inside the chooser and read it there every time: a constant settled at import is one no test can stand somewhere else. +Where a platform cannot do something, say so in its table entry rather than in +the code that asks. `audio.Sound.meetings` is the shape of it: Windows offers no +capture device for what the speakers are playing, and a caller reading a False +there can tell that apart from an empty device list, which only means the tool +that lists them is not installed. + The tests are split along the same line, and almost none of them are skipped. -892 of the 935 run on any machine, including every line of the Wayland, X11 and -macOS backends: the programs are faked at `shutil.which`, the frameworks at the -one function that loads them. A test class says which system it is standing on -rather than avoiding the question: +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 +standing on rather than avoiding the question: ```python class MacOS(ClipboardContract, DikteTest): @@ -73,14 +82,19 @@ class MacOS(ClipboardContract, DikteTest): here = paste.MACOS ``` -so the Linux half is checked on a Mac and the macOS half on Linux, and a change -to a chooser cannot quietly break the platform nobody is sitting at. What the -systems owe in common is written once as a contract class and subclassed by each -of them. +so the Linux half is checked on a Mac, the macOS half on Linux and the Windows +half on both, and a change to a chooser cannot quietly break the platform nobody +is sitting at. What the systems owe in common is written once as a contract +class and subclassed by each of them. The 43 that do carry `@linux_only` are the ones that would need the real thing: -the `/dev/input` listener, KDE's shortcut file, GNOME's gsettings. Mark a test -that way only when faking it would leave nothing to test. A test that quietly +the `/dev/input` listener, KDE's shortcut file, GNOME's gsettings. The 20 with +`@posix_only` are 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. ## What a pull request should carry diff --git a/README.md b/README.md index 5498f52..fe36166 100644 --- a/README.md +++ b/README.md @@ -5,10 +5,10 @@ machine by default, a model cleans it up (dropping the *uh*s, the restarts, the missing punctuation), and the result lands in your clipboard and is pasted into whatever window you were typing in. -Built for KDE Plasma 6 on Wayland, and runs on GNOME X11, macOS and any other -Linux desktop that will let it read the keyboard. No -dependencies beyond system packages: just the Python standard library, 3.11 or -newer, and PyQt6. +Built for KDE Plasma 6 on Wayland, and runs on GNOME X11, macOS, +[Windows](README.windows.md) and any other Linux desktop that will let it read +the keyboard. No dependencies beyond system packages: just the Python standard +library, 3.11 or newer, and PyQt6. *[Türkçe README](README.tr.md)* @@ -24,15 +24,17 @@ 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 @@ -93,11 +95,22 @@ Dikte talks to. Build it (`cmake -B build -DWHISPER_BUILD_SERVER=ON transcribe in the cloud. A meeting needs BlackHole or Loopback (`brew install blackhole-2ch`); dictation does not. +Windows works the same way, holding the keys through the system's own hotkey +service while Dikte runs. 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 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: @@ -120,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` | @@ -215,8 +229,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 @@ -229,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 25329f0..5e88c69 100644 --- a/README.tr.md +++ b/README.tr.md @@ -4,10 +4,10 @@ çevrilir, bir model transkripti temizler (ıı'lar, tekrarlar, eksik noktalama), sonuç panoya kopyalanır ve o an yazdığın pencereye yapıştırılır. -KDE Plasma 6 / Wayland için yazıldı; GNOME X11'de, macOS'ta ve klavyeyi -okumasına izin veren diğer Linux masaüstlerinde de çalışır. Sistem -paketleri dışında bağımlılığı yok: sadece Python standart kütüphanesi (3.11 veya -üstü) ve PyQt6. +KDE Plasma 6 / Wayland için yazıldı; GNOME X11'de, macOS'ta, +[Windows](README.windows.md)'ta ve klavyeyi okumasına izin veren diğer Linux +masaüstlerinde de çalışır. Sistem paketleri dışında bağımlılığı yok: sadece +Python standart kütüphanesi (3.11 veya üstü) ve PyQt6. *[English README](README.md)* @@ -23,15 +23,16 @@ paketleri dışında bağımlılığı yok: sadece Python standart kütüphanesi ## 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 @@ -92,11 +93,20 @@ 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. 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 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 @@ -119,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` | @@ -211,9 +222,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 @@ -226,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/README.windows.md b/README.windows.md new file mode 100644 index 0000000..7fdb819 --- /dev/null +++ b/README.windows.md @@ -0,0 +1,90 @@ +# 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 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 + +`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. 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: + +```powershell +powershell -ExecutionPolicy Bypass -File install.ps1 +``` + +This adds a **Dikte** entry to the Start Menu and a **`dikte`** command to the +terminal. Add `-Autostart` to also start it at sign-in; `-Uninstall` removes +all of it and leaves the repository and your settings alone. + +To try it without installing anything: + +```sh +python -m dikte +``` + +## First run + +1. The tray icon appears and the Settings window opens. +2. Under **API and models**, download a local whisper model (the whisper.cpp + Windows build is fetched automatically) or enter an OpenAI, Groq or + OpenRouter key. +3. The shortcut defaults to `Ctrl+Space` and is changed under Shortcuts. + While Dikte runs, Windows' own hotkey service (RegisterHotKey) listens for + it: nothing to install and no permission to grant. + +## What is different from Linux and macOS + +- **Meeting recording (microphone + speakers) is not supported yet.** Windows + does not offer what the speakers are playing as a capture device, so there + is nothing to record the far side from. Everything else works, including + transcribing audio and video files. +- **The shortcut is swallowed**: while Dikte holds `Ctrl+Space`, the focused + application does not see it. This is how macOS behaves too, and unlike the + Linux listener, which shares the key. +- No external tools for the clipboard or the key press: both go straight + through the Windows API (the clipboard, SendInput). +- Settings live under `%APPDATA%\Dikte`, models and recordings under + `%LOCALAPPDATA%\Dikte`. + +## Performance + +- The local install fetches whisper.cpp's **OpenBLAS build**, which + transcribes about twice as fast as the stock one on a plain CPU. There is + no GPU build to fetch for machines without an NVIDIA card, and none for + Windows on ARM either: whisper.cpp publishes x64 only, so a Snapdragon + machine runs it under emulation and the cloud is the faster option there. +- Setting Settings → API and models → **Threads** near your physical core + count helps noticeably; the server's own default is 4. +- If speed matters more than accuracy, `ggml-small` and `ggml-base` are much + faster; `ggml-large-v3-turbo-q5_0` transcribes best. + +## Troubleshooting + +- **Recording does not start:** does `dikte doctor` find ffmpeg, and does + `dikte devices` list your microphone? `devices` also takes a fresh listing, + which is what to run after plugging one in. +- **Nothing is pasted:** a normal-privilege process cannot type into an + elevated (administrator) window; run Dikte elevated too, or paste by hand. + The text lands on the clipboard either way. +- **The shortcut does nothing:** another application already holds the + combination. Dikte says so in a tray notification when it asks for the key; + pick a different one under Settings → Shortcuts. diff --git a/dikte/__init__.py b/dikte/__init__.py index b487fc9..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.0" +__version__ = "1.0.2" diff --git a/dikte/api.py b/dikte/api.py index fa89af1..7a0e78f 100644 --- a/dikte/api.py +++ b/dikte/api.py @@ -18,6 +18,7 @@ import mimetypes import os import secrets import socket +import sys import threading import urllib.error import urllib.request @@ -57,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): @@ -148,6 +159,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() @@ -211,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): @@ -227,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 @@ -258,7 +278,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() @@ -306,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/app.py b/dikte/app.py index 51385d6..abede82 100644 --- a/dikte/app.py +++ b/dikte/app.py @@ -15,6 +15,7 @@ import json import os import signal import socket +import subprocess import sys import threading import time @@ -33,8 +34,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,12 +46,14 @@ 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 mac_window # 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 @@ -78,6 +82,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): @@ -99,6 +134,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. @@ -163,6 +201,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() @@ -215,6 +264,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) @@ -231,6 +285,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): @@ -999,12 +1054,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_() @@ -1068,8 +1169,29 @@ class Dikte: if self.settings_window is not None: self.settings_window.close() self.shutdown() + # Stop answering before the replacement is started, not just afterwards. + # execv leaves nothing behind to answer, but a Windows restart is two + # processes for a moment, and removeServer does nothing about a name + # another process is holding. The new one then either opens a second + # server on a name the old one is still answering on, so that a command + # arriving in that moment reaches the process that is going away, or + # fails to open one at all and says so to a console nobody is watching. + # Closing first leaves neither. + if self.server is not None: + self.server.close() QLocalServer.removeServer(SERVER_NAME) args = ipc.launcher() + ["--gui"] + if sys.platform == "win32": + # execv on Windows mangles arguments with spaces and leaves the two + # processes sharing a console; a detached start does neither. + subprocess.Popen( + args, + creationflags=(subprocess.DETACHED_PROCESS + | subprocess.CREATE_NEW_PROCESS_GROUP), + close_fds=True, + ) + QApplication.instance().quit() + return os.execv(args[0], args) def shutdown(self): @@ -1140,7 +1262,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) @@ -1230,6 +1356,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() diff --git a/dikte/assistant.py b/dikte/assistant.py index 89c2ec1..b57fd06 100644 --- a/dikte/assistant.py +++ b/dikte/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/dikte/audio.py b/dikte/audio.py index 7221625..ef24461 100644 --- a/dikte/audio.py +++ b/dikte/audio.py @@ -33,6 +33,10 @@ from PyQt6.QtCore import QObject, pyqtSignal from .i18n import t +# Console programs started from a windowless process would otherwise each open +# a console window of their own on Windows. +NO_WINDOW = getattr(subprocess, "CREATE_NO_WINDOW", 0) if sys.platform == "win32" else 0 + RATE = 16000 CHANNELS = 1 SAMPLE_WIDTH = 2 # s16 @@ -57,6 +61,19 @@ QUIET_MIC_SECONDS = 10 QUIET_MIC_SHARE = 0.5 +def _interrupt(proc): + """Ask a recorder process to end. + + SIGINT is the polite way everywhere it exists; Windows has no equivalent a + child can be sent, so the process is terminated outright. The captured + audio is not lost either way: it has already been read from the pipe. + """ + if sys.platform == "win32": + proc.terminate() + else: + proc.send_signal(signal.SIGINT) + + class Recorder(QObject): """Runs the available sound-server recorder and reads raw PCM from stdout.""" @@ -111,7 +128,8 @@ class Recorder(QObject): try: self._proc = subprocess.Popen( - cmd, stdout=subprocess.PIPE, stderr=subprocess.PIPE, bufsize=0 + cmd, stdout=subprocess.PIPE, stderr=subprocess.PIPE, bufsize=0, + creationflags=NO_WINDOW, ) except OSError as exc: self.failed.emit(t("Could not start recording: {error}", error=exc)) @@ -172,7 +190,7 @@ class Recorder(QObject): proc = self._proc if proc and proc.poll() is None: try: - proc.send_signal(signal.SIGINT) + _interrupt(proc) proc.wait(timeout=1.5) except (subprocess.TimeoutExpired, OSError): try: @@ -284,6 +302,14 @@ class MeetingRecorder(QObject): def start(self, path, mic_target="", system_target="", max_seconds=14400): if self.active: return + # Before ffmpeg is looked for, because installing it would not help: a + # system with no way to capture what the speakers are playing has none + # whatever else is on the machine. + if not sound().meetings: + self.failed.emit(t("This system offers nothing that records what " + "the speakers are playing, so a meeting cannot " + "be recorded on it.")) + return if not shutil.which("ffmpeg"): self.failed.emit(t("ffmpeg not found. Install it to record a meeting.")) return @@ -312,7 +338,8 @@ class MeetingRecorder(QObject): self._procs = [] for command, log in zip(commands, self._logs): self._procs.append(subprocess.Popen( - command, stdout=subprocess.PIPE, stderr=log, bufsize=0 + command, stdout=subprocess.PIPE, stderr=log, bufsize=0, + creationflags=NO_WINDOW, )) except (OSError, wave.Error) as exc: # One of two capture processes may already be running, and a Mac @@ -411,7 +438,7 @@ class MeetingRecorder(QObject): running = [proc for proc in self._procs if proc.poll() is None] for proc in running: try: - proc.send_signal(signal.SIGINT) + _interrupt(proc) except OSError: pass for proc in running: @@ -838,13 +865,131 @@ def _avfoundation_default_output(): return "" +# Windows records through DirectShow, the one capture API ffmpeg's Windows +# builds all ship with. What the speakers are playing is not offered as a +# device at all, so a meeting has nothing to record the far side from yet. + + +# A device entry and the line under it, in the two shapes ffmpeg has printed +# this listing in. Newer builds mark each device `(audio)` or `(video)`; older +# ones print no marker and group the devices under a heading instead. Both are +# anchored at each end, so that the error lines the command ends with, which +# quote the device name that was not found, are not read as devices. The +# bracketed prefix is not pinned to a spelling: ffmpeg 8 writes `[in#0 @ ...]` +# where the versions before it wrote `[dshow @ ...]`. +_DSHOW_ENTRY = re.compile( + r'^(?:\[[^\]]*\]\s*)?"([^"]+)"\s*(?:\(([^)]*)\))?\s*$') +_DSHOW_ALTERNATIVE = re.compile( + r'^(?:\[[^\]]*\]\s*)?Alternative name\s+"([^"]+)"\s*$') +_DSHOW_HEADING = re.compile(r'DirectShow (audio|video) devices') + +# The last listing taken, so that a dictation does not pay for one of its own. +_DSHOW_SEEN = [] + + +def _parse_dshow_listing(text): + """[(id, name)] for the audio devices in one ffmpeg device listing. + + Two friendly names on one machine are routinely identical: a laptop with a + headset plugged in shows two microphones called the same thing, and + `audio=` would reach only the first of them either way. The + alternative name ffmpeg prints under each device is unique and is what the + recorder is given back, while the friendly name is what a user picks from. + """ + devices = [] + heading = "" + for line in text.splitlines(): + found = _DSHOW_HEADING.search(line) + if found: + heading = found.group(1) + continue + found = _DSHOW_ALTERNATIVE.match(line.strip()) + if found: + if devices: + devices[-1][0] = found.group(1) + continue + found = _DSHOW_ENTRY.match(line.strip()) + if found: + kind = (found.group(2) or heading).lower() + devices.append([found.group(1), found.group(1), kind]) + return [(identifier, name) for identifier, name, kind in devices + if "audio" in kind] + + +def _dshow_devices(): + """[(id, name)] for every DirectShow audio capture device, freshly asked. + + The list comes out on stderr of a command that then fails, the same + documented trick AVFoundation uses above. + """ + if not shutil.which("ffmpeg"): + return [] + try: + result = subprocess.run( + ["ffmpeg", "-hide_banner", "-list_devices", "true", + "-f", "dshow", "-i", "dummy"], + capture_output=True, timeout=8, check=False, creationflags=NO_WINDOW, + ) + except (subprocess.SubprocessError, OSError): + return [] + + devices = _parse_dshow_listing(result.stderr.decode("utf-8", "replace")) + _DSHOW_SEEN[:] = devices + return devices + + +def _dshow_first_device(): + """The device an unset target stands for, without a listing per dictation. + + dshow has no "default" for an empty target to mean, so it has to be turned + into a name, and asking ffmpeg for one costs a process every time the key + is pressed. The last listing is used when there is one: opening Settings or + running `dikte devices` takes a fresh one, which is what somebody who has + just plugged a microphone in does anyway. + """ + devices = _DSHOW_SEEN or _dshow_devices() + return devices[0][0] if devices else "" + + +def _dshow_record(target): + if not shutil.which("ffmpeg"): + return [] + device = target or _dshow_first_device() + if not device: + return [] + return [ + "ffmpeg", "-hide_banner", "-nostdin", "-loglevel", "error", + # dshow holds half a second of audio before handing anything over; + # asked for the chunk the level meter is measured in instead. + "-f", "dshow", "-audio_buffer_size", str(CHUNK_LATENCY_MS), + "-i", f"audio={device}", + "-ac", str(CHANNELS), "-ar", str(RATE), "-f", "s16le", "-", + ] + + +def _dshow_meeting(mic_target, system_target): + return [] # no monitor devices to record the far side from + + +def _dshow_no_outputs(): + return [] + + +def _dshow_no_default_output(): + return "" + + Sound = collections.namedtuple( "Sound", # How to capture one source and how to capture two at once, that one as the # list of processes it takes, the two device lists, which device a meeting - # records the far side from, and what to say when the programs for any of - # it are not installed. - "record meeting inputs outputs default_output missing", + # records the far side from, whether this system can record one at all, and + # what to say when the programs for any of it are not installed. + # + # `meetings` is the sound system's own answer, not this machine's: an empty + # output list means the tool that lists them is missing, which is a thing a + # user can go and fix, while False here is a thing they cannot. + "record meeting inputs outputs default_output meetings missing", ) PULSE = Sound( @@ -853,6 +998,7 @@ PULSE = Sound( inputs=_pulse_inputs, outputs=_pulse_outputs, default_output=_pulse_default_output, + meetings=True, missing="No audio recorder found. Install pulseaudio-utils or pipewire-audio.", ) @@ -865,13 +1011,33 @@ COREAUDIO = Sound( # empty list would leave nothing to pick. outputs=_avfoundation_named_inputs, default_output=_avfoundation_default_output, + # With a loopback driver installed, which is what the Settings note is for. + meetings=True, missing="ffmpeg not found. Install it with: brew install ffmpeg", ) +DSHOW = Sound( + record=_dshow_record, + meeting=_dshow_meeting, + inputs=_dshow_devices, + outputs=_dshow_no_outputs, + default_output=_dshow_no_default_output, + # Windows offers no capture device for what the speakers are playing, and + # there is no driver to install that would add one. + meetings=False, + missing="ffmpeg or a microphone was not found. Install ffmpeg with: " + "winget install Gyan.FFmpeg", +) + + def sound(): """The programs this machine records through.""" - return COREAUDIO if sys.platform == "darwin" else PULSE + if sys.platform == "darwin": + return COREAUDIO + if sys.platform == "win32": + return DSHOW + return PULSE def list_sources(): diff --git a/dikte/cleanup.py b/dikte/cleanup.py index 22d31ec..96eab1d 100644 --- a/dikte/cleanup.py +++ b/dikte/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/dikte/cli.py b/dikte/cli.py index a7da1cd..3109348 100644 --- a/dikte/cli.py +++ b/dikte/cli.py @@ -17,8 +17,10 @@ import json import os import shutil import signal +import subprocess import sys import time +import webbrowser from PyQt6.QtCore import QCoreApplication, QTimer @@ -29,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 @@ -136,6 +140,16 @@ def launch_gui(verb=""): if verb: args.append(verb) args.append("--gui") + if sys.platform == "win32": + # execv on Windows mangles arguments with spaces and would leave the + # application tied to this console; start it detached instead. + subprocess.Popen( + args, + creationflags=(subprocess.DETACHED_PROCESS + | subprocess.CREATE_NEW_PROCESS_GROUP), + close_fds=True, + ) + sys.exit(0) os.execv(args[0], args) @@ -299,6 +313,9 @@ def cmd_transcribe(opts): return fail(opts, f"no such file: {path}") conf = cfg.Config() + # This runs here rather than in the instance, so the local servers have to + # be handed their settings here too; the GUI does this at startup. + conf.apply_local() timestamps = opts.srt or _pick(opts.timestamps, conf["file_timestamps"]) worker = filetranscribe.FileTranscriber(conf) @@ -643,7 +660,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" @@ -760,11 +780,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. @@ -777,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: @@ -802,9 +852,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) @@ -1060,12 +1121,18 @@ 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) # --- 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/filetranscribe.py b/dikte/filetranscribe.py index 5ac97e3..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) @@ -283,6 +338,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. @@ -308,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/ggml.py b/dikte/ggml.py index 6805337..9256f0f 100644 --- a/dikte/ggml.py +++ b/dikte/ggml.py @@ -43,6 +43,7 @@ import threading import time import urllib.error import urllib.request +import zipfile from . import hub from . import paths @@ -154,11 +155,14 @@ def download(item, target, on_progress=None, should_stop=None, require_hash=True try: with urllib.request.urlopen(request, timeout=60) as response: total = int(response.headers.get("Content-Length") or item.size or 0) + # Windows refuses to delete a file that is open, so nothing is + # unlinked until the handle is closed again. + stopped = overlong = False with open(part, "wb") as out: while True: if should_stop is not None and should_stop(): - part.unlink(missing_ok=True) - return False + stopped = True + break block = response.read(DOWNLOAD_CHUNK) if not block: break @@ -168,11 +172,17 @@ def download(item, target, on_progress=None, should_stop=None, require_hash=True # More than was announced: a body that does not end is the # one way this loop could run until the disk is full. if total and done > total: - 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: @@ -218,9 +228,11 @@ def _has_vulkan(): llama.cpp publishes no CUDA build for Linux, so Vulkan is what a graphics card gets here. The build without it is smaller and runs on the CPU, and fetching the Vulkan one for a machine that cannot load it would only make - the download bigger. + the download bigger. Windows spells the loader vulkan-1.dll. """ - return bool(ctypes.util.find_library("vulkan")) + return bool(ctypes.util.find_library("vulkan") + or (sys.platform == "win32" + and ctypes.util.find_library("vulkan-1"))) def _wanted_assets(program): @@ -233,6 +245,20 @@ def _wanted_assets(program): arch = _arch() if sys.platform == "darwin": return () if program is WHISPER else (f"bin-macos-{arch}.tar.gz",) + if sys.platform == "win32": + if program is WHISPER: + # The BLAS build first: on a plain CPU it transcribes about twice + # as fast as the stock one, and it carries everything it needs. + # Full names, because "bin-x64.zip" alone would also match the + # CUDA archives, whichever the release happened to list first. + # + # x64 whatever this machine is, because whisper.cpp publishes no + # arm64 build for Windows: a Snapdragon runs this one emulated, + # which is slow but is the only local option there is. + return ("whisper-blas-bin-x64.zip", "whisper-bin-x64.zip") + if _has_vulkan() and arch == "x64": + return ("bin-win-vulkan-x64.zip", f"bin-win-cpu-{arch}.zip") + return (f"bin-win-cpu-{arch}.zip",) if program is LLAMA and _has_vulkan(): return (f"bin-ubuntu-vulkan-{arch}.tar.gz", f"bin-ubuntu-{arch}.tar.gz") return (f"bin-ubuntu-{arch}.tar.gz",) @@ -278,6 +304,11 @@ def system_program(program): return bool(shutil.which(program.binary)) +def _binary_file(program): + """What the program's file is called on disk here.""" + return f"{program.binary}.exe" if sys.platform == "win32" else program.binary + + def _find_binary(root, name): for path in sorted(pathlib.Path(root).rglob(name)): if path.is_file(): @@ -286,19 +317,24 @@ def _find_binary(root, name): def _extract(archive, into): - """Unpack a release tarball, refusing anything that reaches outside `into`. + """Unpack a release archive, refusing anything that reaches outside `into`. The archives lay their libraries next to their binaries and are linked with an $ORIGIN runpath, so a whole directory is what has to survive the trip and - the binary cannot be lifted out of it. + the binary cannot be lifted out of it. Linux and macOS releases come as + tarballs, Windows ones as zips; zipfile never writes outside its target. """ try: + if str(archive).endswith(".zip"): + with zipfile.ZipFile(archive) as bundle: + bundle.extractall(into) + return with tarfile.open(archive, "r:gz") as tar: try: tar.extractall(into, filter="data") except TypeError: # Python without the extraction filters tar.extractall(into) - except (tarfile.TarError, OSError) as exc: + except (tarfile.TarError, zipfile.BadZipFile, OSError) as exc: raise LocalError(t("Could not unpack {name}: {error}", name=os.path.basename(str(archive)), error=exc)) from exc @@ -344,7 +380,7 @@ def install_program(program, tag="", on_progress=None, should_stop=None, if not download(item, archive, on_progress, should_stop): return "" _extract(archive, into) - binary = _find_binary(into, program.binary) + binary = _find_binary(into, _binary_file(program)) if binary is None: raise LocalError(t("{name} was not in the download.", name=program.binary)) @@ -501,6 +537,26 @@ def _tail(path, lines=3): return " | ".join(found[-lines:]) +def _win_image_name(pid): + """The lower-cased file name of the process's executable, or ''.""" + import ctypes + kernel32 = ctypes.WinDLL("kernel32", use_last_error=True) + kernel32.OpenProcess.restype = ctypes.c_void_p + kernel32.OpenProcess.argtypes = [ctypes.c_uint32, ctypes.c_int, ctypes.c_uint32] + kernel32.CloseHandle.argtypes = [ctypes.c_void_p] + handle = kernel32.OpenProcess(0x1000, False, pid) # QUERY_LIMITED_INFORMATION + if not handle: + return "" + try: + buffer = ctypes.create_unicode_buffer(260) + size = ctypes.c_uint32(len(buffer)) + ok = kernel32.QueryFullProcessImageNameW( + ctypes.c_void_p(handle), 0, buffer, ctypes.byref(size)) + return os.path.basename(buffer.value).lower() if ok else "" + finally: + kernel32.CloseHandle(handle) + + class Server: """One process, started when something needs it and stopped when nothing does. @@ -602,6 +658,8 @@ class Server: args + ["--host", HOST, "--port", str(port)], stdout=sink, stderr=subprocess.STDOUT, stdin=subprocess.DEVNULL, + # No console window of its own on Windows. + creationflags=getattr(subprocess, "CREATE_NO_WINDOW", 0), ) except OSError as exc: raise LocalError(t("Could not start {name}: {error}", @@ -699,8 +757,11 @@ class Server: number could belong to something else entirely, and killing it would be a good deal worse than the leak being cleaned up. The program name alone could be somebody else's copy; the name together with Dikte's own data - directory on the command line could not. + directory on the command line could not. Windows offers no command line + to read, so the executable's name is the whole of the answer there. """ + if sys.platform == "win32": + return _win_image_name(pid) == _binary_file(self.program).lower() try: blob = pathlib.Path(f"/proc/{pid}/cmdline").read_bytes() except OSError: diff --git a/dikte/hotkey.py b/dikte/hotkey.py index 16c9a1e..649ef36 100644 --- a/dikte/hotkey.py +++ b/dikte/hotkey.py @@ -440,15 +440,175 @@ def _carbon(): return carbon +# --- Windows: RegisterHotKey ------------------------------------------------ + +# Windows virtual-key codes: where a key sits, not what a layout prints on it. +WIN_KEYS = { + "space": 0x20, "tab": 0x09, "enter": 0x0D, "return": 0x0D, + "esc": 0x1B, "escape": 0x1B, "backspace": 0x08, "insert": 0x2D, + "delete": 0x2E, "home": 0x24, "end": 0x23, "pgup": 0x21, "pgdown": 0x22, + "up": 0x26, "down": 0x28, "left": 0x25, "right": 0x27, + **{str(digit): 0x30 + digit for digit in range(10)}, + **{chr(ord("a") + i): 0x41 + i for i in range(26)}, + **{f"f{n}": 0x6F + n for n in range(1, 13)}, +} +WIN_MODS = { + "alt": 0x0001, "ctrl": 0x0002, "control": 0x0002, "shift": 0x0004, + "meta": 0x0008, "super": 0x0008, "win": 0x0008, +} +WIN_MOD_NOREPEAT = 0x4000 # holding the combination fires it once +WM_HOTKEY = 0x0312 +WM_QUIT = 0x0012 + + +def _win_input(): + """user32 and kernel32, which is all the listener talks to. + + Loaded on the first start rather than at import: this module is read on + every system, and these two libraries exist on one of them. + """ + return ctypes.windll.user32, ctypes.windll.kernel32 + + +def parse_windows_shortcut(text): + """'Ctrl+Space' -> (2, 32), or (None, None) when unusable.""" + parts = [part.strip().lower() for part in str(text).split("+") if part.strip()] + modifiers, key = 0, None + for part in parts: + if part in WIN_MODS: + modifiers |= WIN_MODS[part] + elif key is None and part in WIN_KEYS: + key = WIN_KEYS[part] + else: + return None, None + if key is None: + return None, None + return modifiers, key + + +class WinHotkey(QObject): + """Catches global shortcuts through Windows' own hotkey service. + + RegisterHotKey asks for one combination rather than reading the keyboard, + so it needs no permission at all. Like Carbon's and unlike the evdev + listener it swallows the key: while Dikte holds a combination, nothing + else on the machine receives it. + + RegisterHotKey only fires on the thread that called it, so registration + and the message loop live together on one worker thread; start() hands the + bindings over and waits for it to report what Windows actually gave us. + """ + + triggered = pyqtSignal(str) # the name the binding was registered under + failed = pyqtSignal(str) + + def __init__(self, parent=None): + super().__init__(parent) + self._user32 = None + self._kernel32 = None + self._thread = None + self._thread_id = None + self._count = 0 + + @property + def running(self): + return self._count > 0 and self._thread is not None and self._thread.is_alive() + + def start(self, bindings): + """`bindings` is {name: 'Ctrl+Space'}; an empty combination is skipped.""" + self.stop() + try: + self._user32, self._kernel32 = _win_input() + except (AttributeError, OSError) as exc: + self.failed.emit(t("Could not reach the Windows shortcut service: " + "{error}", error=exc)) + return False + wanted = [] + for identifier, (name, shortcut) in enumerate(bindings.items(), 1): + if not shortcut: + continue + modifiers, key = parse_windows_shortcut(shortcut) + if key is None: + self.failed.emit( + t("Could not parse the shortcut: {shortcut}", shortcut=shortcut) + ) + continue + wanted.append((identifier, name, shortcut, modifiers, key)) + if not wanted: + return False + + ready = threading.Event() + outcome = {"count": 0, "thread_id": None} + self._thread = threading.Thread( + target=self._loop, args=(wanted, ready, outcome), daemon=True + ) + self._thread.start() + ready.wait(timeout=5) + self._thread_id = outcome["thread_id"] + self._count = outcome["count"] + if not self._count: + self._thread = None + return self._count > 0 + + def stop(self): + if self._thread and self._thread_id and self._user32: + self._user32.PostThreadMessageW(self._thread_id, WM_QUIT, 0, 0) + self._thread.join(timeout=1.5) + self._thread = None + self._thread_id = None + self._count = 0 + _REGISTERED.clear() + + def _loop(self, wanted, ready, outcome): + import ctypes.wintypes + user32, kernel32 = self._user32, self._kernel32 + outcome["thread_id"] = kernel32.GetCurrentThreadId() + + # The message queue a PostThreadMessage needs only exists once the + # thread has asked for messages; peek once before reporting ready. + message = ctypes.wintypes.MSG() + user32.PeekMessageW(ctypes.byref(message), None, WM_QUIT, WM_QUIT, 0) + + names = {} + for identifier, name, shortcut, modifiers, key in wanted: + if user32.RegisterHotKey(None, identifier, + modifiers | WIN_MOD_NOREPEAT, key): + names[identifier] = name + spec = SHORTCUTS.get(name) + if spec: + _REGISTERED[spec.desktop_id] = shortcut + else: + # This is the conflict warning on Windows: there is no list to + # read beforehand, the answer comes from asking for the key. + self.failed.emit(t( + "Windows would not give Dikte {shortcut}; another " + "application already holds it.", shortcut=shortcut)) + outcome["count"] = len(names) + ready.set() + if not names: + return + + try: + while user32.GetMessageW(ctypes.byref(message), None, 0, 0) > 0: + if message.message == WM_HOTKEY: + name = names.get(int(message.wParam)) + if name: + self.triggered.emit(name) + finally: + for identifier in names: + user32.UnregisterHotKey(None, identifier) + + # --- the desktop's own shortcut ------------------------------------------- -# The four ways a combination can reach Dikte. Everything below asks backend() +# The five ways a combination can reach Dikte. Everything below asks backend() # rather than looking at the session itself, so the name shown, the status read # back, what Install writes and what the installer promises cannot disagree # about which one this session got. KDE = "kde" GNOME = "gnome" MACOS = "macos" +WINDOWS = "windows" LISTENER = "listener" @@ -456,6 +616,10 @@ def _macos(): return sys.platform == "darwin" +def _windows(): + return sys.platform == "win32" + + def backend(): """Which shortcut mechanism this session has. @@ -467,6 +631,8 @@ def backend(): """ if _macos(): return MACOS + if _windows(): + return WINDOWS names = os.environ.get("XDG_CURRENT_DESKTOP", "").lower().split(":") names = [name.strip() for name in names if name.strip()] if any("gnome" in name for name in names) and shutil.which("gsettings"): @@ -596,7 +762,11 @@ def gnome_shortcut_status(desktop_id=DESKTOP_ID): def listener(parent=None): """The thing that hears the key, for whichever system this is.""" - return CarbonHotkey(parent) if _macos() else EvdevHotkey(parent) + if _macos(): + return CarbonHotkey(parent) + if _windows(): + return WinHotkey(parent) + return EvdevHotkey(parent) def default_combo(which): @@ -613,16 +783,20 @@ def default_combo(which): def valid_shortcut(text): """Whether this machine can bind the combination as it was typed.""" - parse = parse_macos_shortcut if _macos() else parse_shortcut - return parse(text)[1] is not None + if _macos(): + return parse_macos_shortcut(text)[1] is not None + if _windows(): + return parse_windows_shortcut(text)[1] is not None + return parse_shortcut(text)[1] is not None def installs_shortcuts(): """Whether this system keeps a shortcut registry to write into. KDE and GNOME do, and something outside Dikte reads it, so the combination - survives Dikte being closed. macOS and the plain listener do not: there is - nothing to install, nothing to remove, and Settings should not offer either. + survives Dikte being closed. macOS, Windows and the plain listener do not: + there is nothing to install, nothing to remove, and Settings should not + offer either. """ return backend() in (KDE, GNOME) @@ -631,7 +805,7 @@ def shortcut_needs_restart(): """Whether an installed shortcut waits for the next login before it works. KWin reads kglobalshortcutsrc once, when it starts. GNOME picks a binding - up as it is written, and the other two never had one to write. + up as it is written, and the others never had one to write. """ return backend() == KDE @@ -644,7 +818,7 @@ def install_shortcut(shortcut, exec_command, name="Dikte: start/stop recording", if which == KDE: return install_kde_shortcut(shortcut, exec_command, name, desktop_id) _REGISTERED[desktop_id] = shortcut - if which == MACOS: + if which in (MACOS, WINDOWS): return True, t( "Shortcut saved: {shortcut}\nDikte holds this one itself while it " "is running, so it works as soon as the settings are saved.", @@ -686,6 +860,8 @@ def desktop_name(): which = backend() if which == MACOS: return "macOS" + if which == WINDOWS: + return "Windows" if which == GNOME: return "GNOME" if which == KDE: @@ -776,11 +952,11 @@ def kde_shortcut_status(desktop_id=DESKTOP_ID): def conflicting_shortcuts(shortcut, desktop_id=DESKTOP_ID): """Names of other KDE entries bound to the same combination.""" if backend() != KDE: - # Nowhere else has a list to read. macOS answers the question by - # refusing the registration, which CarbonHotkey reports when it asks - # for the key; the other two would only be reading a file their session - # never looks at, and a leftover one from a Plasma install the user has - # since left would refuse perfectly good combinations. + # Nowhere else has a list to read. macOS and Windows answer the question + # by refusing the registration, which their listeners report when they + # ask for the key; the other two would only be reading a file their + # session never looks at, and a leftover one from a Plasma install the + # user has since left would refuse perfectly good combinations. return [] try: text = SHORTCUTS_FILE.read_text(encoding="utf-8") 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 6a29fec..f7f4910 100644 --- a/dikte/i18n.py +++ b/dikte/i18n.py @@ -109,6 +109,10 @@ TR = { "Ses kayıt aracı bulunamadı. pulseaudio-utils ya da pipewire-audio kur.", "ffmpeg not found. Install it with: brew install ffmpeg": "ffmpeg bulunamadı. Şununla kur: brew install ffmpeg", + "ffmpeg or a microphone was not found. Install ffmpeg with: " + "winget install Gyan.FFmpeg": + "ffmpeg ya da bir mikrofon bulunamadı. ffmpeg'i şununla kur: " + "winget install Gyan.FFmpeg", "Audio recorder stopped before receiving sound: {error}": "Ses kayıt aracı veri alamadan kapandı: {error}", "Could not copy to clipboard: {error}": "Panoya kopyalanamadı: {error}", @@ -188,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", @@ -293,6 +316,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}", @@ -353,6 +380,11 @@ TR = { "meantime.": "Dikte bu kombinasyonları çalışırken macOS'tan kendisi ister. Hiçbir şey " "kurulmaz ve o sırada başka hiçbir uygulama bu tuşları almaz.", + "Dikte asks Windows for these combinations itself, while it is running. " + "Nothing is installed, and no other application receives them in the " + "meantime.": + "Dikte bu kombinasyonları çalışırken Windows'tan kendisi ister. Hiçbir şey " + "kurulmaz ve o sırada başka hiçbir uygulama bu tuşları almaz.", "{desktop} keeps no shortcut registry, so Dikte listens for these " "combinations itself while it is running. Your user has to be able to read " "/dev/input for that, and the focused application receives the keys as " @@ -392,6 +424,12 @@ TR = { "macOS would not give Dikte {shortcut}; another application already holds it.": "macOS {shortcut} kombinasyonunu Dikte'ye vermedi; başka bir uygulama " "onu şimdiden tutuyor.", + "Could not reach the Windows shortcut service: {error}": + "Windows kısayol servisine ulaşılamadı: {error}", + "Windows would not give Dikte {shortcut}; another application already " + "holds it.": + "Windows {shortcut} kombinasyonunu Dikte'ye vermedi; başka bir uygulama " + "onu şimdiden tutuyor.", "Cannot read /dev/input. Your user needs to be in the 'input' group:\n" " sudo usermod -aG input $USER (then log out and back in)": "/dev/input okunamıyor. Kullanıcının 'input' grubunda olması gerekir:\n" @@ -643,6 +681,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/dikte/integrate.py b/dikte/integrate.py index 0af3744..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. @@ -34,6 +40,15 @@ 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" +# 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(): @@ -55,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 @@ -151,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": @@ -209,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) @@ -216,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() @@ -398,6 +425,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 +490,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 +511,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 @@ -481,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 8d9e367..02f668a 100644 --- a/dikte/ipc.py +++ b/dikte/ipc.py @@ -15,7 +15,11 @@ import sys from PyQt6.QtNetwork import QLocalSocket -SERVER_NAME = "dikte-" + str(os.getuid()) +from . import integrate + +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. @@ -41,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/overlay.py b/dikte/overlay.py index a99d785..37a2761 100644 --- a/dikte/overlay.py +++ b/dikte/overlay.py @@ -70,9 +70,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/dikte/paste.py b/dikte/paste.py index d340d61..8588025 100644 --- a/dikte/paste.py +++ b/dikte/paste.py @@ -354,6 +354,166 @@ def _macos_press(shortcut, delay, focus=None): core.CFRelease(up) +def _win_keys(shortcut): + """'Ctrl+V' -> [0x11, 0x56]: Windows virtual-key codes, modifiers first.""" + codes = [] + for key in _keys(shortcut): + if key not in WIN_KEYCODES: + raise PasteError(t("Unknown key: {key}", key=key)) + codes.append(WIN_KEYCODES[key]) + return codes + + +# Windows virtual-key codes (winuser.h). Like Apple's, they say where the key +# sits rather than what a layout prints on it. +WIN_KEYCODES = { + "ctrl": 0x11, "control": 0x11, "shift": 0x10, "alt": 0x12, + "super": 0x5B, "meta": 0x5B, + "v": 0x56, "insert": 0x2D, "enter": 0x0D, "return": 0x0D, +} +_WIN_KEYUP = 0x0002 # KEYEVENTF_KEYUP +_WIN_CF_UNICODETEXT = 13 # what the clipboard calls UTF-16 text +_WIN_GMEM_MOVEABLE = 0x0002 + + +@functools.lru_cache(maxsize=1) +def _win_api(): + """user32 and kernel32 with their prototypes spelled out. + + The default return type is a 32-bit int, which silently truncates the + 64-bit handles and pointers every one of these calls trades in. + """ + user32 = ctypes.WinDLL("user32", use_last_error=True) + kernel32 = ctypes.WinDLL("kernel32", use_last_error=True) + user32.OpenClipboard.argtypes = [ctypes.c_void_p] + user32.GetClipboardData.restype = ctypes.c_void_p + user32.GetClipboardData.argtypes = [ctypes.c_uint] + user32.SetClipboardData.restype = ctypes.c_void_p + user32.SetClipboardData.argtypes = [ctypes.c_uint, ctypes.c_void_p] + kernel32.GlobalAlloc.restype = ctypes.c_void_p + kernel32.GlobalAlloc.argtypes = [ctypes.c_uint, ctypes.c_size_t] + kernel32.GlobalLock.restype = ctypes.c_void_p + kernel32.GlobalLock.argtypes = [ctypes.c_void_p] + kernel32.GlobalUnlock.argtypes = [ctypes.c_void_p] + kernel32.GlobalFree.argtypes = [ctypes.c_void_p] + return user32, kernel32 + + +def _win_error(): + """GetLastError where it exists, so the failure paths run under any test.""" + return getattr(ctypes, "get_last_error", lambda: 0)() + + +def _win_open_clipboard(user32): + """The clipboard is a lock another program may hold for a moment.""" + for _ in range(10): + if user32.OpenClipboard(None): + return True + time.sleep(0.01) + return False + + +def _win_read_text(): + """The clipboard's text, '' when it holds none, None when it cannot be read.""" + user32, kernel32 = _win_api() + if not _win_open_clipboard(user32): + return None + try: + handle = user32.GetClipboardData(_WIN_CF_UNICODETEXT) + if not handle: + return "" + pointer = kernel32.GlobalLock(handle) + if not pointer: + return None + try: + return ctypes.wstring_at(pointer) + finally: + kernel32.GlobalUnlock(handle) + finally: + user32.CloseClipboard() + + +def _win_write_text(text): + user32, kernel32 = _win_api() + payload = str(text).encode("utf-16-le") + b"\x00\x00" + # Filled before the clipboard is opened at all. EmptyClipboard is what + # throws away whatever was there, and a failure after it and before the + # SetClipboardData would leave the clipboard holding nothing: the one way + # this function could lose what it was called to put back. + handle = kernel32.GlobalAlloc(_WIN_GMEM_MOVEABLE, len(payload)) + pointer = kernel32.GlobalLock(handle) if handle else None + if not pointer: + if handle: + kernel32.GlobalFree(handle) + raise PasteError(t("Could not copy to clipboard: {error}", + error="out of memory")) + ctypes.memmove(pointer, payload, len(payload)) + kernel32.GlobalUnlock(handle) + + if not _win_open_clipboard(user32): + kernel32.GlobalFree(handle) + raise PasteError(t("Could not copy to clipboard: {error}", + error="the clipboard is held by another program")) + try: + user32.EmptyClipboard() + if not user32.SetClipboardData(_WIN_CF_UNICODETEXT, handle): + raise PasteError(t("Could not copy to clipboard: {error}", + error=f"error {_win_error()}")) + handle = None # the clipboard owns it now + finally: + if handle: + kernel32.GlobalFree(handle) + user32.CloseClipboard() + + +class _WinKeybdInput(ctypes.Structure): + _fields_ = [("wVk", ctypes.c_ushort), ("wScan", ctypes.c_ushort), + ("dwFlags", ctypes.c_ulong), ("time", ctypes.c_ulong), + ("dwExtraInfo", ctypes.c_size_t)] + + +class _WinMouseInput(ctypes.Structure): + _fields_ = [("dx", ctypes.c_long), ("dy", ctypes.c_long), + ("mouseData", ctypes.c_ulong), ("dwFlags", ctypes.c_ulong), + ("time", ctypes.c_ulong), ("dwExtraInfo", ctypes.c_size_t)] + + +class _WinInputUnion(ctypes.Union): + _fields_ = [("mi", _WinMouseInput), ("ki", _WinKeybdInput)] + + +class _WinInput(ctypes.Structure): + # The union carries the mouse shape too: SendInput sizes its argument by + # the biggest member whether or not it is the one being sent. + _fields_ = [("type", ctypes.c_ulong), ("union", _WinInputUnion)] + + +def _win_press(shortcut, delay): + """Post the presses and releases straight into the input queue. + + No permission stands in front of SendInput the way Accessibility does on + macOS: whatever window has focus receives the combination. + """ + codes = _win_keys(shortcut) + user32, _ = _win_api() + time.sleep(delay) # let the selection settle and focus come back + + events = ([(code, 0) for code in codes] + + [(code, _WIN_KEYUP) for code in reversed(codes)]) + inputs = (_WinInput * len(events))() + for entry, (code, flags) in zip(inputs, events): + entry.type = 1 # INPUT_KEYBOARD + entry.union.ki = _WinKeybdInput(code, 0, flags, 0, 0) + sent = user32.SendInput(len(inputs), inputs, ctypes.sizeof(_WinInput)) + if sent != len(inputs): + raise PasteError(t("Could not run {tool}: {error}", tool="SendInput", + error=f"error {_win_error()}")) + + +def _win_ready(): + return True + + # --- which of them is here ------------------------------------------------- Desktop = collections.namedtuple( @@ -386,6 +546,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 @@ -407,6 +578,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"): @@ -451,6 +624,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: @@ -478,6 +654,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}.", @@ -497,7 +676,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/dikte/paths.py b/dikte/paths.py index 8493746..7670c8b 100644 --- a/dikte/paths.py +++ b/dikte/paths.py @@ -16,7 +16,8 @@ import pathlib import sys -def _xdg(var, default): +def _env(var, default): + """The directory a variable names, or the one it stands in for.""" return pathlib.Path(os.environ.get(var) or os.path.expanduser(default)) @@ -24,13 +25,20 @@ def directories(platform=None): """(settings, data), in the two places this system keeps them. macOS keeps both in the one directory a Mac user's backup already knows - about. Everywhere else they are separate and follow the XDG variables. + about. Windows keeps them apart on purpose: settings roam with the account, + and several gigabytes of models are exactly what a roaming profile must not + carry. Everywhere else they are separate and follow the XDG variables. """ - if (platform or sys.platform) == "darwin": + here = platform or sys.platform + if here == "darwin": support = pathlib.Path.home() / "Library/Application Support/Dikte" return support, support - return (_xdg("XDG_CONFIG_HOME", "~/.config") / "dikte", - _xdg("XDG_DATA_HOME", "~/.local/share") / "dikte") + if here == "win32": + roaming = _env("APPDATA", "~/AppData/Roaming") + local = _env("LOCALAPPDATA", "~/AppData/Local") + return roaming / "Dikte", local / "Dikte" + return (_env("XDG_CONFIG_HOME", "~/.config") / "dikte", + _env("XDG_DATA_HOME", "~/.local/share") / "dikte") CONFIG_DIR, DATA_DIR = directories() diff --git a/dikte/settings_ui.py b/dikte/settings_ui.py index 1a73968..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): @@ -1100,6 +1129,18 @@ class SettingsWindow(QDialog): )) mac_note.setWordWrap(True) sources_form.addRow(mac_note) + elif not audio.sound().meetings: + # Windows is the system this is written for: it offers nothing that + # captures what the speakers are playing, and there is no driver to + # install that would put an entry in the list above. Left unsaid, + # the box is simply empty and the Record button fails at the press. + nothing_note = QLabel(t( + "This system offers nothing that records what the speakers are " + "playing, so a meeting cannot be recorded on it. Dictation and " + "transcribing a file are unaffected." + )) + nothing_note.setWordWrap(True) + sources_form.addRow(nothing_note) note = QLabel(t( "Wear headphones if you can. Through speakers your microphone hears " @@ -1369,6 +1410,12 @@ class SettingsWindow(QDialog): "running. Nothing is installed, and no other application receives " "them in the meantime." ) + elif hotkey.backend() == hotkey.WINDOWS: + explanation = t( + "Dikte asks Windows for these combinations itself, while it is " + "running. Nothing is installed, and no other application receives " + "them in the meantime." + ) else: # The desktops nobody writes a backend for. Saying "installed" here # would be the old bug in words: there is no registry, the listener @@ -1550,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]) @@ -1643,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: @@ -1873,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/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/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/install.ps1 b/install.ps1 new file mode 100644 index 0000000..c847131 --- /dev/null +++ b/install.ps1 @@ -0,0 +1,82 @@ +# Installs Dikte for this Windows user: a Start Menu entry, an optional +# autostart entry, and a `dikte` command that works from any terminal. +# +# powershell -ExecutionPolicy Bypass -File install.ps1 # install +# powershell -ExecutionPolicy Bypass -File install.ps1 -Autostart # + start at sign-in +# powershell -ExecutionPolicy Bypass -File install.ps1 -Uninstall # remove +param( + [switch]$Autostart, + [switch]$Uninstall +) + +$ErrorActionPreference = "Stop" +$repo = $PSScriptRoot +# The one file that starts the application, whoever is asking: the Start Menu +# entry, the autostart entry and the dikte command all name it. +$entry = Join-Path $repo "dikte\__main__.py" +$startMenu = [Environment]::GetFolderPath("Programs") +$startup = [Environment]::GetFolderPath("Startup") +$shortcut = Join-Path $startMenu "Dikte.lnk" +$autostartLink = Join-Path $startup "Dikte.lnk" +# WindowsApps is already on the user PATH, so a dikte.cmd left there runs from +# any terminal without a PATH edit and without an administrator. +$cmdShim = Join-Path $env:LOCALAPPDATA "Microsoft\WindowsApps\dikte.cmd" + +if ($Uninstall) { + foreach ($path in @($shortcut, $autostartLink, $cmdShim)) { + if (Test-Path $path) { Remove-Item $path -Force; Write-Host "removed: $path" } + } + Write-Host "Dikte's shortcuts are gone. The repository and your settings are not." + exit 0 +} + +# --- what it needs ---------------------------------------------------------- +$python = Get-Command python -ErrorAction SilentlyContinue +if (-not $python) { + Write-Error "No python found. Install it with: winget install Python.Python.3.12" +} +$version = & python -c "import sys; print('%d.%d' % sys.version_info[:2])" +if ([version]$version -lt [version]"3.11") { + Write-Error "Python 3.11 or newer is needed, and this one is $version." +} +& python -c "import PyQt6.QtWidgets" 2>$null +if ($LASTEXITCODE -ne 0) { + Write-Host "Installing PyQt6..." + & python -m pip install PyQt6 + if ($LASTEXITCODE -ne 0) { Write-Error "PyQt6 would not install." } +} +if (-not (Get-Command ffmpeg -ErrorAction SilentlyContinue)) { + Write-Warning "No ffmpeg found. Recording needs it: winget install Gyan.FFmpeg" +} + +# pythonw.exe runs the same program without a console window behind it. +$pythonw = Join-Path (Split-Path $python.Source) "pythonw.exe" +if (-not (Test-Path $pythonw)) { $pythonw = $python.Source } + +# --- the Start Menu entry --------------------------------------------------- +$shell = New-Object -ComObject WScript.Shell +foreach ($path in @($shortcut) + $(if ($Autostart) { @($autostartLink) } else { @() })) { + $link = $shell.CreateShortcut($path) + $link.TargetPath = $pythonw + $link.Arguments = "`"$entry`" --gui" + $link.WorkingDirectory = $repo + $link.Description = "Dikte: dictation" + $link.Save() + Write-Host "shortcut: $path" +} + +# --- the dikte command ------------------------------------------------------ +# The interpreter by its full path rather than by name: the one checked above is +# the one the command line should run, whatever a later PATH change puts first. +$shimDir = Split-Path $cmdShim +if (Test-Path $shimDir) { + "@echo off`r`n`"$($python.Source)`" `"$entry`" %*" | + Out-File $cmdShim -Encoding ascii + Write-Host "command: dikte ($cmdShim)" +} else { + Write-Warning "No $shimDir on this machine, so there is no dikte command. Run it as: python `"$entry`"" +} + +Write-Host "" +Write-Host "Installed. Start it from the Start Menu as 'Dikte', or type 'dikte' in a terminal." +Write-Host "The Settings window opens on the first run: download a model there and pick the shortcut (Ctrl+Space by default)." diff --git a/packaging/build-windows.ps1 b/packaging/build-windows.ps1 new file mode 100644 index 0000000..6170606 --- /dev/null +++ b/packaging/build-windows.ps1 @@ -0,0 +1,109 @@ +#!/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-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 +# 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..d16d784 --- /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 dikte-cli.exe, 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-cli.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..6cfd651 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,46 @@ 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. +# +# 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-cli", + 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/support.py b/tests/support.py index 18c0a06..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 @@ -40,6 +41,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( @@ -74,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_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_audio.py b/tests/test_audio.py index 3f7777d..a7ea353 100644 --- a/tests/test_audio.py +++ b/tests/test_audio.py @@ -887,5 +887,201 @@ class MacRecordingCommand(OnMacOS, DikteTest): self.assertFalse(recorder.active) +class NoFarSideToRecord(DikteTest): + """Two different answers, and the table is what tells them apart. + + A sound system that records the far side has a device this machine could + not pick out, and Settings is where to choose one. A sound system that does + not had nothing to offer there in the first place, and "pick one" would + send somebody to an empty box and an installation that cannot help. + """ + + def failure(self, meetings): + recorder = audio.MeetingRecorder() + failures = [] + recorder.failed.connect(failures.append) + with only_these_tools("ffmpeg"), \ + mock.patch.object(audio, "default_monitor", return_value=""), \ + mock.patch.object(audio, "sound", + return_value=audio.PULSE._replace( + meetings=meetings)): + recorder.start(str(self.path("meeting.wav"))) + self.assertFalse(recorder.active) + return failures[0] + + def test_a_system_that_records_the_far_side_sends_you_to_settings(self): + self.assertIn("Settings", self.failure(True)) + + def test_a_system_that_does_not_says_that_instead(self): + message = self.failure(False) + self.assertIn("nothing that records what the speakers", message) + self.assertNotIn("Settings", message) + + def test_the_three_sound_systems_each_answer_the_question(self): + self.assertTrue(audio.PULSE.meetings) + self.assertTrue(audio.COREAUDIO.meetings) + self.assertFalse(audio.DSHOW.meetings) + + +class OnWindows: + """A test that runs as if the machine ran Windows.""" + + def setUp(self): + super().setUp() + self.enterContext(mock.patch.object(sys, "platform", "win32")) + + +class WindowsDevices(OnWindows, DikteTest): + """The one ffmpeg listing the device questions are answered from. + + dshow names devices rather than numbering them, and the names carry + whatever alphabet the machine speaks, so the listing here does too. + """ + + MIC = "@device_cm_{33D9A762}\\wave_{B1C2}" + LISTING = ( + '[dshow @ 0000020c] "Integrated Camera" (video)\n' + '[dshow @ 0000020c] Alternative name "@device_pnp_\\...."\n' + '[dshow @ 0000020c] "Mikrofon Dizisi (Intel Smart Sound)" (audio)\n' + f'[dshow @ 0000020c] Alternative name "{MIC}"\n' + '[dshow @ 0000020c] "Kulaklık (Soundcore Life Q30)" (audio)\n' + '[dshow @ 0000020c] Could not find audio only device with name ' + '"dummy" among source devices of type audio.\n' + "dummy: Immediate exit requested\n" + ).encode("utf-8") + + def setUp(self): + super().setUp() + # The listing is remembered between calls, so that a dictation does not + # run ffmpeg of its own. It cannot be remembered between tests. + audio._DSHOW_SEEN.clear() + self.addCleanup(audio._DSHOW_SEEN.clear) + + @contextlib.contextmanager + def listing(self, stderr=None, tools=("ffmpeg",)): + completed = FakeCompleted( + returncode=1, stderr=self.LISTING if stderr is None else stderr) + with only_these_tools(*tools), \ + mock.patch.object(subprocess, "run", + return_value=completed) as run: + yield run + + def test_windows_records_through_dshow(self): + self.assertIs(audio.sound(), audio.DSHOW) + + def test_the_audio_devices_are_the_only_ones_read(self): + with self.listing(): + self.assertEqual(audio.list_sources(), [ + (self.MIC, "Mikrofon Dizisi (Intel Smart Sound)"), + ("Kulaklık (Soundcore Life Q30)", + "Kulaklık (Soundcore Life Q30)"), + ]) + + def test_the_device_ffmpeg_could_not_open_is_not_one_of_them(self): + """The command ends by quoting the name it was sent to look for.""" + with self.listing(): + self.assertNotIn("dummy", [name for _, name in audio.list_sources()]) + + def test_a_listing_from_ffmpeg_8_which_renamed_the_prefix(self): + """ffmpeg 8 writes `[in#0 @ ...]` where older builds wrote `[dshow @ ...]`.""" + listing = ( + '[in#0 @ 00000238c3300ac0] "Integrated Camera" (video)\n' + '[in#0 @ 00000238c3300ac0] Alternative name "@device_pnp_\\..."\n' + '[in#0 @ 00000238c3300ac0] "OBS Virtual Camera" (none)\n' + '[in#0 @ 00000238c3300ac0] Alternative name "@device_sw_{860B}"\n' + '[in#0 @ 00000238c3300ac0] "Mikrofon Dizisi (Intel® Smart Sound)" (audio)\n' + '[in#0 @ 00000238c3300ac0] Alternative name "@device_cm_{33D9}"\n' + "Error opening input file dummy.\n" + ).encode("utf-8") + with self.listing(stderr=listing): + self.assertEqual(audio.list_sources(), + [("@device_cm_{33D9}", + "Mikrofon Dizisi (Intel® Smart Sound)")]) + + def test_a_listing_from_an_ffmpeg_that_marks_nothing(self): + """Older builds print a heading instead of an (audio) on every line.""" + listing = ( + '[dshow @ 0] DirectShow video devices\n' + '[dshow @ 0] "Integrated Camera"\n' + '[dshow @ 0] Alternative name "@device_pnp_\\..."\n' + '[dshow @ 0] DirectShow audio devices\n' + '[dshow @ 0] "Microphone (Realtek Audio)"\n' + '[dshow @ 0] Alternative name "@device_cm_{ABCD}"\n' + ).encode("utf-8") + with self.listing(stderr=listing): + self.assertEqual(audio.list_sources(), + [("@device_cm_{ABCD}", "Microphone (Realtek Audio)")]) + + def test_two_devices_called_the_same_thing_stay_apart(self): + """The normal state of a laptop with a headset plugged into it.""" + listing = ( + '[dshow @ 0] "Microphone" (audio)\n' + '[dshow @ 0] Alternative name "@device_cm_{ONE}"\n' + '[dshow @ 0] "Microphone" (audio)\n' + '[dshow @ 0] Alternative name "@device_cm_{TWO}"\n' + ).encode("utf-8") + with self.listing(stderr=listing): + sources = audio.list_sources() + self.assertEqual([identifier for identifier, _ in sources], + ["@device_cm_{ONE}", "@device_cm_{TWO}"]) + self.assertEqual({name for _, name in sources}, {"Microphone"}) + + def test_no_ffmpeg_installed(self): + with only_these_tools(): + self.assertEqual(audio.list_sources(), []) + self.assertEqual(audio.recording_command(), []) + + def test_the_identifier_is_what_the_recorder_is_given_back(self): + with self.listing(): + cmd = audio.recording_command(self.MIC) + self.assertEqual(cmd[cmd.index("-f") + 1], "dshow") + self.assertIn(f"audio={self.MIC}", cmd) + + def test_no_microphone_named_means_the_first_one_listed(self): + """dshow has no default device for an empty target to mean.""" + with self.listing(): + self.assertIn(f"audio={self.MIC}", audio.recording_command()) + + def test_a_dictation_does_not_run_a_listing_of_its_own(self): + """Two hundred milliseconds of ffmpeg in front of every key press.""" + with self.listing() as run: + audio.list_sources() + audio.recording_command() + audio.recording_command() + self.assertEqual(run.call_count, 1) + + def test_opening_the_device_list_asks_again(self): + """Which is what somebody who has just plugged one in does.""" + with self.listing() as run: + audio.list_sources() + audio.list_sources() + self.assertEqual(run.call_count, 2) + + def test_a_machine_with_no_microphone_at_all(self): + with self.listing(stderr=b'[dshow @ 0] "Integrated Camera" (video)\n'): + self.assertEqual(audio.recording_command(), []) + + def test_an_ffmpeg_that_will_not_run(self): + with only_these_tools("ffmpeg"), \ + mock.patch.object(subprocess, "run", side_effect=OSError("nope")): + self.assertEqual(audio.list_sources(), []) + + def test_nothing_offers_the_far_side_of_a_meeting(self): + """What the speakers play is not a capture device Windows hands out.""" + with self.listing(): + self.assertEqual(audio.list_monitors(), []) + self.assertEqual(audio.default_monitor(), "") + self.assertEqual(audio.meeting_commands("mic", "sys"), []) + + def test_a_meeting_says_what_is_wrong_rather_than_where_to_look(self): + recorder = audio.MeetingRecorder() + failures = [] + recorder.failed.connect(failures.append) + with self.listing(): + recorder.start(str(self.path("meeting.wav"))) + self.assertIn("nothing that records what the speakers", failures[0]) + self.assertFalse(recorder.active) + + if __name__ == "__main__": unittest.main() diff --git a/tests/test_cli.py b/tests/test_cli.py index ef8f6f5..585bad5 100644 --- a/tests/test_cli.py +++ b/tests/test_cli.py @@ -10,13 +10,20 @@ import contextlib import io import json import unittest +import webbrowser +from typing import ClassVar from unittest import mock +from dikte import audio from dikte import cli from dikte import config as cfg +from dikte import ggml from dikte import hotkey +from dikte import hub from dikte import ipc -from tests.support import DikteTest, fake_urlopen +from dikte import paste +from dikte import update +from tests.support import DikteTest, fake_urlopen, only_these_tools, url_error class Options: @@ -417,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.""" @@ -434,6 +503,30 @@ class Doctor(DikteTest): self.assertIn("OpenRouter key, cleaning up on some/model", self.run_doctor(as_json=False, cleanup_model="some/model")) + def test_it_asks_after_the_programs_this_desktop_actually_uses(self): + """A missing ydotool on a Mac is a red mark with nothing behind it.""" + with mock.patch.object(cli.paste, "desktop", return_value=paste.MACOS): + mac = self.run_doctor()["programs"] + with mock.patch.object(cli.paste, "desktop", return_value=paste.WAYLAND): + wayland = self.run_doctor()["programs"] + self.assertIn("pbcopy", mac) + self.assertNotIn("ydotool", mac) + self.assertIn("ydotool", wayland) + self.assertIn("ffmpeg", mac) # the one every system records through + + def test_a_system_that_shells_out_for_neither_half_is_asked_for_neither(self): + # shutil.which is faked as well as the platform: the real one reads + # sys.platform too, and reaches for a Windows API this machine has not + # got the moment it is told it is on Windows. + with mock.patch.object(cli.paste, "desktop", return_value=paste.WINDOWS), \ + only_these_tools("ffmpeg"), \ + mock.patch.object(cli.sys, "platform", "win32"): + programs = self.run_doctor()["programs"] + self.assertNotIn("", programs) + self.assertEqual([name for name in ("wl-copy", "ydotool", "pactl", + "pw-record", "kwriteconfig6") + if name in programs], []) + def test_cleanup_on_a_cli_is_a_question_about_the_program(self): reply = self.run_doctor(cleanup_provider="codex", cleanup_codex_model="gpt-5.4") @@ -445,6 +538,25 @@ class Doctor(DikteTest): cleanup_codex_model="gpt-5.4")) +class Devices(DikteTest): + def test_a_machine_with_nothing_names_its_own_missing_program(self): + """The Windows README sends people here, and pactl is not on it.""" + for here, expected in ((audio.DSHOW, "ffmpeg"), + (audio.PULSE, "pulseaudio-utils")): + with self.subTest(sound=expected): + with mock.patch.object(cli.audio, "sound", return_value=here), \ + mock.patch.object(cli.audio, "list_sources", + return_value=[]), \ + mock.patch.object(cli.audio, "list_monitors", + return_value=[]), \ + mock.patch.object(cli.audio, "default_monitor", + return_value=""), \ + captured() as (out, _err): + code = cli.cmd_devices(Options(json=True)) + self.assertEqual(code, 1) + self.assertIn(expected, json.loads(out.getvalue())["error"]) + + class Finding(DikteTest): def test_no_history_at_all(self): self.assertIsNone(cli._find_history("last")) @@ -623,5 +735,27 @@ class Replies(DikteTest): self.assertFalse(launched.called) +class TranscribeRunsHere(DikteTest): + """`dikte transcribe` runs in this process, not in the instance.""" + + def test_the_local_servers_are_handed_the_settings_first(self): + # The GUI does this at startup; a CLI run has no GUI to have done it, + # and without it the whisper server holds an empty model name. + wav = self.path("clip.wav") + wav.write_bytes(b"RIFF not really audio") + self.write_config({"local_model": "ggml-base.bin"}) + self.addCleanup(ggml.whisper.configure, + model="", threads=0, gpu=True, binary="") + + opts = cli.build_parser().parse_args(["transcribe", str(wav)]) + with mock.patch.object(cli.filetranscribe, "FileTranscriber"), \ + mock.patch.object(cli, "_headless", + return_value={"error": "stopped"}), \ + captured(): + cli.cmd_transcribe(opts) + + self.assertEqual(ggml.whisper.settings()["model"], "ggml-base.bin") + + if __name__ == "__main__": unittest.main() diff --git a/tests/test_config.py b/tests/test_config.py index aa15c91..9d8e372 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() @@ -491,6 +494,9 @@ class ReadyToRun(DikteTest): def setUp(self): super().setUp() self.patch_attr(ggml, "MODELS_DIR", self.path("models")) + # A machine Dikte is actually installed on would otherwise answer for + # the "missing program" below through the real install record. + self.patch_attr(ggml, "BIN_DIR", self.path("bin")) def install(self, name): path = ggml.whisper_model_path(name) 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) diff --git a/tests/test_ggml.py b/tests/test_ggml.py index 951654b..14a8ddf 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 from dikte import ggml @@ -77,6 +78,15 @@ def tarball(entries): return buf.getvalue() +def zipball(entries): + """A .zip laid out the way the Windows releases are.""" + buf = io.BytesIO() + with zipfile.ZipFile(buf, "w") as bundle: + for name, content in entries.items(): + bundle.writestr(name, content) + return buf.getvalue() + + class Local(DikteTest): """A test with its own bin, models and cache directories.""" @@ -691,3 +701,87 @@ class Sizes(DikteTest): self.assertEqual(ggml.human_size(512), "512 B") self.assertEqual(ggml.human_size(574041195), "547.4 MB") self.assertEqual(ggml.human_size(3_095_033_483), "2.9 GB") + + +# --- Windows ---------------------------------------------------------------- + + +class WindowsAssets(Local): + """Which archive a Windows machine is handed.""" + + def setUp(self): + super().setUp() + self.patch_attr(sys, "platform", "win32") + self.patch_attr(ggml, "_arch", lambda: "x64") + + def test_whisper_prefers_the_blas_build(self): + # On a plain CPU it transcribes about twice as fast as the stock one. + self.assertEqual(ggml._wanted_assets(ggml.WHISPER), + ("whisper-blas-bin-x64.zip", "whisper-bin-x64.zip")) + + def test_llama_takes_the_vulkan_build_when_there_is_a_loader(self): + self.patch_attr(ggml, "_has_vulkan", lambda: True) + self.assertEqual(ggml._wanted_assets(ggml.LLAMA), + ("bin-win-vulkan-x64.zip", "bin-win-cpu-x64.zip")) + + def test_llama_falls_back_to_the_cpu_build_without_one(self): + self.patch_attr(ggml, "_has_vulkan", lambda: False) + self.assertEqual(ggml._wanted_assets(ggml.LLAMA), + ("bin-win-cpu-x64.zip",)) + + def test_an_arm_machine_is_not_handed_the_x64_build(self): + self.patch_attr(ggml, "_arch", lambda: "arm64") + self.patch_attr(ggml, "_has_vulkan", lambda: True) + self.assertEqual(ggml._wanted_assets(ggml.LLAMA), + ("bin-win-cpu-arm64.zip",)) + + def test_an_arm_machine_is_handed_the_x64_whisper_anyway(self): + """whisper.cpp publishes no arm64 build for Windows: the release has + Win32 and x64 and nothing else, so emulated is the only local option + a Snapdragon has. Pinned here so that a release which does start + publishing one is noticed rather than quietly ignored.""" + self.patch_attr(ggml, "_arch", lambda: "arm64") + self.assertEqual(ggml._wanted_assets(ggml.WHISPER), + ("whisper-blas-bin-x64.zip", "whisper-bin-x64.zip")) + + +class InstallOnWindows(Local): + """The Windows releases are zips, and the binary carries .exe.""" + + def setUp(self): + super().setUp() + self.patch_attr(sys, "platform", "win32") + self.patch_attr(ggml, "_arch", lambda: "x64") + self.archive = zipball({ + "Release/whisper-server.exe": b"MZ not really a program", + "Release/whisper.dll": b"not really a library", + }) + + def release(self, *names): + digest = hashlib.sha256(self.archive) + return {"tag_name": "v1.9.1", "assets": [ + {"name": name, "browser_download_url": f"https://example.invalid/{name}", + "size": 10, "digest": "sha256:" + digest.hexdigest()} + for name in names]} + + def test_the_zip_lands_and_the_exe_inside_it_is_found(self): + with serving(self.release("whisper-blas-bin-x64.zip"), self.archive): + path = ggml.install_program(ggml.WHISPER) + self.assertTrue(path.endswith("whisper-server.exe")) + self.assertTrue(os.path.isfile(path)) + self.assertTrue(os.path.isfile(os.path.join(os.path.dirname(path), + "whisper.dll"))) + self.assertEqual(ggml.installed_program(ggml.WHISPER), path) + + def test_the_blas_build_is_the_one_fetched_when_both_are_offered(self): + listing = self.release("whisper-bin-x64.zip", "whisper-blas-bin-x64.zip") + with serving(listing, self.archive) as calls: + ggml.install_program(ggml.WHISPER) + urls = [call.args[0].full_url for call in calls.call_args_list] + self.assertTrue(urls[1].endswith("whisper-blas-bin-x64.zip")) + + def test_a_release_with_nothing_for_windows_says_so(self): + with fake_urlopen(json_body(self.release("whisper-bin-ubuntu-x64.tar.gz"))): + with self.assertRaises(ggml.LocalError) as caught: + ggml.install_program(ggml.WHISPER) + self.assertIn("this machine", str(caught.exception)) diff --git a/tests/test_hotkey.py b/tests/test_hotkey.py index 3ac7b52..55006fa 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 + from dikte import config as cfg from dikte import hotkey from tests.support import DikteTest, FakeCompleted, linux_only @@ -753,5 +757,204 @@ class MacChooser(DikteTest): self.assertFalse(hotkey.valid_shortcut("Cmd+Space")) +# --- Windows ---------------------------------------------------------------- + +class ParseWindowsShortcut(unittest.TestCase): + def test_the_default(self): + self.assertEqual(hotkey.parse_windows_shortcut("Ctrl+Space"), + (hotkey.WIN_MODS["ctrl"], 0x20)) + + def test_case_and_spacing_do_not_matter(self): + self.assertEqual(hotkey.parse_windows_shortcut(" ctrl + SPACE "), + hotkey.parse_windows_shortcut("Ctrl+Space")) + + def test_several_modifiers_are_one_number(self): + modifiers, key = hotkey.parse_windows_shortcut("Ctrl+Shift+M") + self.assertEqual(modifiers, + hotkey.WIN_MODS["ctrl"] | hotkey.WIN_MODS["shift"]) + self.assertEqual(key, hotkey.WIN_KEYS["m"]) + + def test_the_synonyms_land_on_one_number(self): + for name in ("meta", "super", "win"): + with self.subTest(name=name): + self.assertEqual(hotkey.parse_windows_shortcut(f"{name}+space"), + (hotkey.WIN_MODS["win"], 0x20)) + self.assertEqual(hotkey.parse_windows_shortcut("Control+Space"), + hotkey.parse_windows_shortcut("Ctrl+Space")) + + def test_a_key_on_its_own(self): + self.assertEqual(hotkey.parse_windows_shortcut("F9"), + (0, hotkey.WIN_KEYS["f9"])) + + def test_modifiers_with_no_key(self): + self.assertEqual(hotkey.parse_windows_shortcut("Ctrl+Alt"), (None, None)) + + def test_a_key_nobody_mapped(self): + self.assertEqual(hotkey.parse_windows_shortcut("Ctrl+F13"), (None, None)) + + def test_something_that_is_not_even_a_string(self): + self.assertEqual(hotkey.parse_windows_shortcut(None), (None, None)) + + +class FakeWinHotkeys: + """user32 and kernel32, as much of both as the listener calls. + + The message queue is a real queue: GetMessageW blocks on it the way the + real one blocks on the thread's, so the listener runs its actual loop and + a test presses the key by posting the message a press would. + """ + + def __init__(self): + self.registered = {} # identifier -> (modifiers, key) + self.refused = set() # (modifiers, key) another program holds + self.unregistered = [] + self.queue = queue.Queue() + + # --- user32 + def RegisterHotKey(self, hwnd, identifier, modifiers, key): + if (modifiers & ~hotkey.WIN_MOD_NOREPEAT, key) in self.refused: + return 0 + self.registered[identifier] = (modifiers, key) + return 1 + + def UnregisterHotKey(self, hwnd, identifier): + self.unregistered.append(identifier) + self.registered.pop(identifier, None) + return 1 + + def PeekMessageW(self, reference, hwnd, low, high, remove): + return 0 + + def GetMessageW(self, reference, hwnd, low, high): + kind, wparam = self.queue.get() + if kind == hotkey.WM_QUIT: + return 0 + message = reference._obj + message.message = kind + message.wParam = wparam + return 1 + + def PostThreadMessageW(self, thread_id, message, wparam, lparam): + self.queue.put((message, wparam)) + return 1 + + # --- kernel32 + def GetCurrentThreadId(self): + return 1 + + # --- the keyboard + def press(self, identifier): + self.queue.put((hotkey.WM_HOTKEY, identifier)) + + +class WinListener(DikteTest): + """What the listener asks Windows for, without a Windows to ask.""" + + def setUp(self): + super().setUp() + self.api = FakeWinHotkeys() + self.patch_attr(hotkey, "_win_input", lambda: (self.api, self.api)) + self.addCleanup(hotkey._REGISTERED.clear) + self.listener = hotkey.WinHotkey() + self.addCleanup(self.listener.stop) + self.failures = [] + # Direct, because the emits come from the listener's own thread and + # there is no event loop here to carry a queued one across. + self.listener.failed.connect(self.failures.append, + Qt.ConnectionType.DirectConnection) + + @staticmethod + def settles(seen, count=1): + """The signals arrive from the listener's own thread, not this one.""" + deadline = time.monotonic() + 2 + while len(seen) < count and time.monotonic() < deadline: + time.sleep(0.01) + return seen + + def test_every_binding_is_registered_with_its_modifiers(self): + self.assertTrue(self.listener.start({"toggle": "Ctrl+Space", + "cancel": "Ctrl+Shift+Space"})) + norepeat = hotkey.WIN_MOD_NOREPEAT + self.assertEqual(self.api.registered, { + 1: (hotkey.WIN_MODS["ctrl"] | norepeat, 0x20), + 2: (hotkey.WIN_MODS["ctrl"] | hotkey.WIN_MODS["shift"] | norepeat, 0x20), + }) + + def test_what_landed_is_what_the_status_line_shows(self): + self.listener.start({"toggle": "Ctrl+Space"}) + self.assertEqual(hotkey._REGISTERED, + {hotkey.DESKTOP_ID: "Ctrl+Space"}) + + def test_a_press_arrives_under_the_name_it_was_registered_as(self): + seen = [] + self.listener.triggered.connect(seen.append, + Qt.ConnectionType.DirectConnection) + self.listener.start({"toggle": "Ctrl+Space", "cancel": "Ctrl+Shift+Space"}) + self.api.press(2) + self.assertEqual(self.settles(seen), ["cancel"]) + + def test_a_held_combination_is_reported_and_the_rest_still_land(self): + self.api.refused = {(hotkey.WIN_MODS["ctrl"], 0x20)} + started = self.listener.start({"toggle": "Ctrl+Space", + "cancel": "Ctrl+Shift+Space"}) + self.assertTrue(started) + self.assertIn("Ctrl+Space", self.settles(self.failures)[0]) + self.assertEqual(list(self.api.registered), [2]) + + def test_an_unparsable_binding_is_reported(self): + self.assertFalse(self.listener.start({"toggle": "Ctrl+F13"})) + self.assertIn("Ctrl+F13", self.failures[0]) + + def test_nothing_but_empty_bindings_does_not_start(self): + self.assertFalse(self.listener.start({"toggle": "", "cancel": ""})) + self.assertFalse(self.listener.running) + + def test_stop_lets_go_of_everything(self): + self.listener.start({"toggle": "Ctrl+Space", "cancel": "Ctrl+Shift+Space"}) + self.listener.stop() + self.assertEqual(self.api.registered, {}) + self.assertEqual(hotkey._REGISTERED, {}) + self.assertFalse(self.listener.running) + + def test_a_second_start_is_a_clean_slate(self): + self.listener.start({"toggle": "Ctrl+Space"}) + self.assertTrue(self.listener.start({"toggle": "Ctrl+Shift+Space"})) + self.assertEqual(self.api.registered, + {1: (hotkey.WIN_MODS["ctrl"] | hotkey.WIN_MODS["shift"] + | hotkey.WIN_MOD_NOREPEAT, 0x20)}) + + +class WindowsChooser(DikteTest): + def setUp(self): + super().setUp() + self.enterContext(mock.patch.object(hotkey.sys, "platform", "win32")) + self.addCleanup(hotkey._REGISTERED.clear) + + def test_the_listener_is_the_windows_hotkey_service(self): + self.assertIsInstance(hotkey.listener(), hotkey.WinHotkey) + + def test_a_combination_is_checked_against_the_windows_table(self): + self.assertTrue(hotkey.valid_shortcut("Ctrl+Space")) + self.assertFalse(hotkey.valid_shortcut("Ctrl+F13")) + + def test_no_registry_to_write_into_and_no_restart_to_wait_for(self): + self.assertFalse(hotkey.installs_shortcuts()) + self.assertFalse(hotkey.shortcut_needs_restart()) + self.assertEqual(hotkey.desktop_name(), "Windows") + + def test_installing_records_it_rather_than_writing_anything(self): + with mock.patch.object(hotkey.subprocess, "run") as run: + ok, message = hotkey.install_shortcut("Ctrl+Space", "dikte toggle") + run.assert_not_called() + self.assertTrue(ok) + self.assertEqual(hotkey.shortcut_status(), "Ctrl+Space") + hotkey.remove_shortcut() + self.assertIsNone(hotkey.shortcut_status()) + + def test_no_list_of_conflicts_to_read(self): + """Not even KDE's file, which a dual-boot home directory could hold.""" + self.assertEqual(hotkey.conflicting_shortcuts("Ctrl+Space"), []) + + if __name__ == "__main__": unittest.main() diff --git a/tests/test_integrate.py b/tests/test_integrate.py index 6bc43b6..f5a5f97 100644 --- a/tests/test_integrate.py +++ b/tests/test_integrate.py @@ -9,12 +9,14 @@ 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 from unittest import mock from dikte import integrate +from tests.support import posix_only class Frozen: @@ -75,6 +77,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 +86,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 +99,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 +218,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 +354,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" @@ -358,6 +365,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 +432,123 @@ 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()) + + +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-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): + 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(), []) + + +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() diff --git a/tests/test_ipc.py b/tests/test_ipc.py index ff44d27..502574c 100644 --- a/tests/test_ipc.py +++ b/tests/test_ipc.py @@ -7,6 +7,8 @@ answers by saying nothing at all. import json import os +import pathlib +import shlex import sys import unittest from unittest import mock @@ -57,13 +59,17 @@ class FakeSocket: class Paths(unittest.TestCase): def test_script_path_points_at_dikte(self): - self.assertTrue(ipc.script_path().endswith("dikte/__main__.py")) + # By its parts rather than as a string: the separator is a backslash on + # Windows, and the path is what a shortcut there runs too. + path = pathlib.Path(ipc.script_path()) + self.assertEqual(path.parts[-2:], ("dikte", "__main__.py")) self.assertTrue(os.path.exists(ipc.script_path())) def test_the_shortcut_command_runs_it_with_this_interpreter(self): - command = ipc.command_for("toggle") - self.assertTrue(command.startswith(sys.executable)) - self.assertTrue(command.endswith(" toggle")) + # Read back through the same quoting it went out with: a Windows path + # is spelled with backslashes and comes out of the join quoted. + self.assertEqual(shlex.split(ipc.command_for("toggle")), + [sys.executable, ipc.script_path(), "toggle"]) def test_a_packaged_build_names_itself_and_no_interpreter(self): """There is no __main__.py on disk in one, and sys.executable is the diff --git a/tests/test_paste.py b/tests/test_paste.py index 5ca27aa..0f37995 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) @@ -504,5 +508,149 @@ class MacClipboardSnapshot(DikteTest): self.assertFalse(os.path.exists(directory)) +class FakeWin32: + """user32 and kernel32, as much of both as paste.py calls. + + The clipboard is a string held here. A read materialises it as this + machine's own wide characters, which is what wstring_at reads wherever the + test runs; a write arrives as the UTF-16 the real clipboard is handed, so + what the code sent is exactly what is checked. + """ + + def __init__(self): + self.text = None + self.buffers = {} + self.next_handle = 1 + self.pressed = [] # (virtual key, flags), in the order sent + self.send_result = None # None: report every event as delivered + self.held = False # another program has the clipboard open + self.out_of_memory = False + + def _keep(self, buffer): + handle = self.next_handle + self.next_handle += 1 + self.buffers[handle] = buffer + return handle + + # --- user32 + def OpenClipboard(self, owner): + return 0 if self.held else 1 + + def CloseClipboard(self): + return 1 + + def EmptyClipboard(self): + self.text = None + return 1 + + def GetClipboardData(self, fmt): + if self.text is None: + return 0 + return self._keep(ctypes.create_unicode_buffer(self.text)) + + def SetClipboardData(self, fmt, handle): + raw = self.buffers[handle].raw + self.text = raw.decode("utf-16-le").split("\x00", 1)[0] + return handle + + def SendInput(self, count, inputs, size): + self.pressed.extend((entry.union.ki.wVk, entry.union.ki.dwFlags) + for entry in inputs) + return count if self.send_result is None else self.send_result + + # --- kernel32 + def GlobalAlloc(self, flags, size): + if self.out_of_memory: + return 0 + return self._keep(ctypes.create_string_buffer(size)) + + def GlobalLock(self, handle): + buffer = self.buffers.get(handle) + return ctypes.addressof(buffer) if buffer else 0 + + def GlobalUnlock(self, handle): + return 1 + + def GlobalFree(self, handle): + self.buffers.pop(handle, None) + return 1 + + +class Windows(Standing, DikteTest): + """Windows shells out to nothing: both halves are calls into the system.""" + + platform = "win32" + here = paste.WINDOWS + + def setUp(self): + super().setUp() + self.api = FakeWin32() + self.patch_attr(paste, "_win_api", lambda: (self.api, self.api)) + self.patch_attr(paste.time, "sleep", lambda seconds: None) + + def test_what_is_copied_is_what_reads_back(self): + paste.copy("ığüşöç İ") + self.assertEqual(paste.read_clipboard(), "ığüşöç İ".encode("utf-8")) + + def test_an_empty_clipboard_reads_as_empty_text(self): + self.assertEqual(paste.read_clipboard(), b"") + + def test_what_was_saved_goes_back_after_the_paste(self): + paste.copy("mine") + saved = paste.read_clipboard() + paste.copy("the dictation") + paste.copy_bytes(saved) + self.assertEqual(self.api.text, "mine") + + def test_a_copy_that_fails_leaves_what_was_there(self): + """EmptyClipboard is the point of no return, so nothing runs after it.""" + paste.copy("mine") + for failure in ("out_of_memory", "held"): + with self.subTest(failure=failure): + setattr(self.api, failure, True) + with self.assertRaises(paste.PasteError): + paste.copy("the dictation") + self.assertEqual(self.api.text, "mine") + setattr(self.api, failure, False) + + def test_the_handle_is_not_leaked_when_the_copy_fails(self): + self.api.held = True + with self.assertRaises(paste.PasteError): + paste.copy("the dictation") + self.assertEqual(self.api.buffers, {}) + + def test_readiness_asks_for_no_program_and_no_permission(self): + with only_these_tools(): + self.assertTrue(paste.paste_ready()) + + def test_the_keys_go_down_in_order_and_up_in_reverse(self): + paste.press("ctrl+v") + keyup = 0x0002 + self.assertEqual(self.api.pressed, + [(0x11, 0), (0x56, 0), (0x56, keyup), (0x11, keyup)]) + + def test_three_keys(self): + paste.press("ctrl+shift+v") + self.assertEqual([code for code, _ in self.api.pressed], + [0x11, 0x10, 0x56, 0x56, 0x10, 0x11]) + + def test_the_other_spellings_land_on_the_same_keys(self): + paste.press("super+enter") + first, self.api.pressed = self.api.pressed, [] + paste.press("meta+return") + self.assertEqual(self.api.pressed, first) + + def test_a_key_nobody_mapped_is_refused_before_anything_is_sent(self): + with self.assertRaises(paste.PasteError): + paste.press("ctrl+f13") + self.assertEqual(self.api.pressed, []) + + def test_a_press_the_system_did_not_take_says_so(self): + self.api.send_result = 0 + with self.assertRaises(paste.PasteError) as caught: + paste.press("ctrl+v") + self.assertIn("SendInput", str(caught.exception)) + + if __name__ == "__main__": unittest.main() diff --git a/tests/test_paths.py b/tests/test_paths.py index 81517d1..8c076f4 100644 --- a/tests/test_paths.py +++ b/tests/test_paths.py @@ -15,29 +15,50 @@ from dikte import paths class Directories(unittest.TestCase): + """Spelled with forward slashes throughout. + + A backslash separates on Windows only, and every one of these runs on all + three systems: `as_posix()` is the one spelling they can all be read in. + """ + def test_linux_keeps_them_apart_and_follows_xdg(self): with mock.patch.dict(os.environ, {"XDG_CONFIG_HOME": "/c", "XDG_DATA_HOME": "/d"}): config_dir, data_dir = paths.directories("linux") - self.assertEqual(str(config_dir), "/c/dikte") - self.assertEqual(str(data_dir), "/d/dikte") + self.assertEqual(config_dir.as_posix(), "/c/dikte") + self.assertEqual(data_dir.as_posix(), "/d/dikte") def test_linux_without_the_variables_set(self): with mock.patch.dict(os.environ, {}, clear=True): config_dir, data_dir = paths.directories("linux") - self.assertTrue(str(config_dir).endswith("/.config/dikte")) - self.assertTrue(str(data_dir).endswith("/.local/share/dikte")) + self.assertTrue(config_dir.as_posix().endswith("/.config/dikte")) + self.assertTrue(data_dir.as_posix().endswith("/.local/share/dikte")) def test_a_mac_keeps_both_in_application_support(self): config_dir, data_dir = paths.directories("darwin") self.assertEqual(config_dir, data_dir) - self.assertTrue(str(config_dir).endswith("/Library/Application Support/Dikte")) + self.assertTrue(config_dir.as_posix() + .endswith("/Library/Application Support/Dikte")) def test_a_mac_does_not_read_the_xdg_variables(self): """A Mac with them set from some other tool still stores in one place.""" with mock.patch.dict(os.environ, {"XDG_CONFIG_HOME": "/c"}): config_dir, _ = paths.directories("darwin") - self.assertNotIn("/c", str(config_dir)) + self.assertNotIn("/c", config_dir.as_posix()) + + def test_windows_keeps_the_models_out_of_the_roaming_profile(self): + """Settings roam with the account; several gigabytes must not.""" + with mock.patch.dict(os.environ, {"APPDATA": "C:/roam", + "LOCALAPPDATA": "C:/local"}): + config_dir, data_dir = paths.directories("win32") + self.assertEqual(config_dir.as_posix(), "C:/roam/Dikte") + self.assertEqual(data_dir.as_posix(), "C:/local/Dikte") + + def test_windows_without_the_variables_set(self): + with mock.patch.dict(os.environ, {}, clear=True): + config_dir, data_dir = paths.directories("win32") + self.assertTrue(config_dir.as_posix().endswith("/AppData/Roaming/Dikte")) + self.assertTrue(data_dir.as_posix().endswith("/AppData/Local/Dikte")) class OnePlace(unittest.TestCase): 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("