mirror of
https://github.com/yusufipk/dikte.git
synced 2026-09-11 10:56:10 +00:00
Merge master into the live language switch branch
This commit is contained in:
@@ -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
|
||||
@@ -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
|
||||
uses: ./.github/workflows/build.yml
|
||||
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
|
||||
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
|
||||
)
|
||||
|
||||
|
||||
@@ -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 }
|
||||
}
|
||||
|
||||
+29
-15
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
+29
-17
@@ -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ı
|
||||
|
||||
@@ -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-<version>-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.
|
||||
+1
-1
@@ -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"
|
||||
|
||||
+30
-5
@@ -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,
|
||||
|
||||
+234
-5
@@ -15,8 +15,10 @@ import json
|
||||
import os
|
||||
import signal
|
||||
import socket
|
||||
import subprocess
|
||||
import sys
|
||||
import threading
|
||||
import time
|
||||
|
||||
# A Wayland client cannot place a window in a screen corner, so the indicator
|
||||
# is drawn through XWayland.
|
||||
@@ -32,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
|
||||
|
||||
@@ -43,11 +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
|
||||
@@ -76,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):
|
||||
@@ -97,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.
|
||||
@@ -107,6 +147,12 @@ class Dikte:
|
||||
# Which recording is the current one, so a timer set for the run that
|
||||
# started it cannot stop the one that came after.
|
||||
self._run_id = 0
|
||||
# The application that was in front when the recording started, which
|
||||
# is where the transcript is meant to go, and the timer watching for
|
||||
# the moment it has to be put back there. macOS only; see
|
||||
# _give_the_front_back.
|
||||
self.front_before = None
|
||||
self._front_watch = None
|
||||
|
||||
self.overlay = Overlay(self.conf["overlay_corner"])
|
||||
# The agent's indicator sits on top of the dictation one when both are
|
||||
@@ -155,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()
|
||||
@@ -207,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)
|
||||
@@ -223,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):
|
||||
@@ -550,9 +613,19 @@ class Dikte:
|
||||
self.last_toggle.restart()
|
||||
return False
|
||||
|
||||
def _the_front(self):
|
||||
"""The application a recording is about to start from, or None.
|
||||
|
||||
Asked before the indicator goes up rather than alongside the
|
||||
microphone: putting a window on screen can take the front as well, and
|
||||
once it has, the only answer left to the question is Dikte.
|
||||
"""
|
||||
return mac_window.frontmost_pid() if sys.platform == "darwin" else None
|
||||
|
||||
def start(self):
|
||||
if self.state != IDLE or self.recording:
|
||||
return
|
||||
self.front_before = self._the_front()
|
||||
self.overlay.show_recording()
|
||||
self._begin_recording(DICTATION)
|
||||
self._set_state(RECORDING)
|
||||
@@ -560,6 +633,7 @@ class Dikte:
|
||||
def start_ask(self):
|
||||
if self.ask_state != IDLE or self.recording:
|
||||
return
|
||||
self.front_before = self._the_front()
|
||||
self.ask_overlay.show_recording(asking=True)
|
||||
self._begin_recording(ASK)
|
||||
self._set_ask_state(RECORDING)
|
||||
@@ -572,6 +646,80 @@ class Dikte:
|
||||
self.elapsed.restart()
|
||||
self.ticker.start()
|
||||
self.recorder.start(self.conf["mic_target"], self.conf["max_seconds"])
|
||||
self._give_the_front_back(self.front_before)
|
||||
|
||||
def _give_the_front_back(self, was_in_front):
|
||||
"""Hand the front back to whoever had it when the recording started.
|
||||
|
||||
On macOS a recording goes through ffmpeg's avfoundation input, and
|
||||
opening a capture session there brings the process that did it to the
|
||||
front. ffmpeg is a child of Dikte with no bundle of its own, so the
|
||||
system credits the move to Dikte: the window the user was typing in
|
||||
loses the front, its caret stops, its title bar greys out, and the
|
||||
Cmd+V at the end of the dictation has nowhere to land. Measured with a
|
||||
TextEdit document in front:
|
||||
|
||||
press the shortcut front = TextEdit
|
||||
recorder.start returns front = TextEdit
|
||||
89 ms later front = Dikte
|
||||
|
||||
Nothing about the capture session can be asked not to do this. It is
|
||||
not a window of ours and no flag reaches it. Starting ffmpeg in its own
|
||||
session, and clearing __CFBundleIdentifier from its environment, were
|
||||
both tried and both measured to make no difference. So it is undone
|
||||
instead. The move lands a moment after the process starts rather than
|
||||
during the call, hence the short watch rather than one attempt: it
|
||||
gives up as soon as it has put the front back, and in any case after a
|
||||
second and a half, which is longer than the microphone has ever taken
|
||||
to open.
|
||||
|
||||
Silent off macOS, and silent when the recording started from Dikte
|
||||
itself: there is nothing to give back.
|
||||
"""
|
||||
# One watch at a time. A second recording started before the first
|
||||
# watch had finished would otherwise leave two of them running, and the
|
||||
# older one would put the front back where the older recording
|
||||
# started, which by then is the wrong window.
|
||||
if self._front_watch is not None:
|
||||
self._front_watch.stop()
|
||||
self._front_watch = None
|
||||
if not was_in_front or was_in_front == os.getpid():
|
||||
return
|
||||
deadline = time.monotonic() + 1.5
|
||||
watch = QTimer(self.app)
|
||||
# Ten milliseconds because the front is already gone by the time this
|
||||
# notices, and every tick it waits is a tick of the user's window drawn
|
||||
# inactive: at forty the title bar visibly blinks, at ten it does not.
|
||||
# Two messages to AppKit per tick, for at most a second and a half.
|
||||
watch.setInterval(10)
|
||||
# activateWithOptions: answers whether macOS accepted the request, not
|
||||
# whether the other application is already back in front. Keep the
|
||||
# watch alive until that asynchronous handoff is observable; on Intel
|
||||
# Macs it can take hundreds of milliseconds after the call returned.
|
||||
restore_requested = False
|
||||
|
||||
def look():
|
||||
nonlocal restore_requested
|
||||
if time.monotonic() > deadline:
|
||||
self._stop_watching_the_front()
|
||||
return
|
||||
if mac_window.is_frontmost():
|
||||
if not restore_requested:
|
||||
restore_requested = mac_window.activate(was_in_front)
|
||||
elif restore_requested:
|
||||
# The request has landed. Stop only now, rather than as soon
|
||||
# as AppKit accepted it, so a delayed or failed handoff stays
|
||||
# under observation until the deadline guard above.
|
||||
self._stop_watching_the_front()
|
||||
|
||||
watch.timeout.connect(look)
|
||||
self._front_watch = watch
|
||||
watch.start()
|
||||
|
||||
def _stop_watching_the_front(self):
|
||||
if self._front_watch is not None:
|
||||
self._front_watch.stop()
|
||||
self._front_watch = None
|
||||
|
||||
def stop(self):
|
||||
if self.state != RECORDING:
|
||||
@@ -687,6 +835,12 @@ class Dikte:
|
||||
return
|
||||
base = meeting.new_base()
|
||||
_, wav_path = cfg.meeting_paths(base)
|
||||
# A meeting opens the same capture as a dictation does, and takes the
|
||||
# front the same way: whoever is being recorded is in a call, and
|
||||
# having their window go inactive mid-sentence is worse here than
|
||||
# anywhere else. Kept as a local rather than on self: a dictation may
|
||||
# already be waiting on its own note for where to paste.
|
||||
was_in_front = self._the_front()
|
||||
self.meeting_recorder.start(
|
||||
str(wav_path),
|
||||
self.conf["meeting_mic_target"] or self.conf["mic_target"],
|
||||
@@ -695,6 +849,7 @@ class Dikte:
|
||||
)
|
||||
if not self.meeting_recorder.active:
|
||||
return # start() has already said what went wrong
|
||||
self._give_the_front_back(was_in_front)
|
||||
self.meeting_base = base
|
||||
self.meeting_elapsed.restart()
|
||||
self.meeting_ticker.start()
|
||||
@@ -821,11 +976,13 @@ class Dikte:
|
||||
def _on_recorded(self, wav_path, duration, rms_values):
|
||||
owner, self.recorder_owner = self.recorder_owner, None
|
||||
wants_paste = self.paste_override.pop(owner, None)
|
||||
focus, self.front_before = self.front_before, None
|
||||
if owner == ASK:
|
||||
self.ask_pipeline.run(wav_path, duration, rms_values, ask=True,
|
||||
paste=wants_paste)
|
||||
paste=wants_paste, focus=focus)
|
||||
else:
|
||||
self.pipeline.run(wav_path, duration, rms_values, paste=wants_paste)
|
||||
self.pipeline.run(wav_path, duration, rms_values,
|
||||
paste=wants_paste, focus=focus)
|
||||
|
||||
def _on_finished(self, _raw, text, warning):
|
||||
if warning:
|
||||
@@ -897,6 +1054,51 @@ 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):
|
||||
@@ -904,6 +1106,7 @@ class Dikte:
|
||||
self.settings_window = SettingsWindow(self.conf, self.meetings)
|
||||
self.settings_window.applied.connect(self._apply_settings)
|
||||
self.settings_window.language_changed.connect(self._reopen_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_()
|
||||
@@ -992,8 +1195,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):
|
||||
@@ -1064,7 +1288,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)
|
||||
@@ -1154,6 +1382,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()
|
||||
|
||||
@@ -378,6 +378,7 @@ def _stream(cmd, conf, on_event, should_stop):
|
||||
cmd, cwd=working_dir(conf), stdin=subprocess.DEVNULL,
|
||||
stdout=subprocess.PIPE, stderr=subprocess.PIPE,
|
||||
text=True, encoding="utf-8", errors="replace", bufsize=1,
|
||||
creationflags=getattr(subprocess, "CREATE_NO_WINDOW", 0),
|
||||
)
|
||||
except OSError as exc:
|
||||
raise AssistantError(t("Could not run {binary}: {error}",
|
||||
|
||||
+174
-8
@@ -33,6 +33,10 @@ from PyQt6.QtCore import QObject, pyqtSignal
|
||||
|
||||
from .i18n import t
|
||||
|
||||
# Console programs started from a windowless process would otherwise each open
|
||||
# a console window of their own on Windows.
|
||||
NO_WINDOW = getattr(subprocess, "CREATE_NO_WINDOW", 0) if sys.platform == "win32" else 0
|
||||
|
||||
RATE = 16000
|
||||
CHANNELS = 1
|
||||
SAMPLE_WIDTH = 2 # s16
|
||||
@@ -57,6 +61,19 @@ QUIET_MIC_SECONDS = 10
|
||||
QUIET_MIC_SHARE = 0.5
|
||||
|
||||
|
||||
def _interrupt(proc):
|
||||
"""Ask a recorder process to end.
|
||||
|
||||
SIGINT is the polite way everywhere it exists; Windows has no equivalent a
|
||||
child can be sent, so the process is terminated outright. The captured
|
||||
audio is not lost either way: it has already been read from the pipe.
|
||||
"""
|
||||
if sys.platform == "win32":
|
||||
proc.terminate()
|
||||
else:
|
||||
proc.send_signal(signal.SIGINT)
|
||||
|
||||
|
||||
class Recorder(QObject):
|
||||
"""Runs the available sound-server recorder and reads raw PCM from stdout."""
|
||||
|
||||
@@ -111,7 +128,8 @@ class Recorder(QObject):
|
||||
|
||||
try:
|
||||
self._proc = subprocess.Popen(
|
||||
cmd, stdout=subprocess.PIPE, stderr=subprocess.PIPE, bufsize=0
|
||||
cmd, stdout=subprocess.PIPE, stderr=subprocess.PIPE, bufsize=0,
|
||||
creationflags=NO_WINDOW,
|
||||
)
|
||||
except OSError as exc:
|
||||
self.failed.emit(t("Could not start recording: {error}", error=exc))
|
||||
@@ -172,7 +190,7 @@ class Recorder(QObject):
|
||||
proc = self._proc
|
||||
if proc and proc.poll() is None:
|
||||
try:
|
||||
proc.send_signal(signal.SIGINT)
|
||||
_interrupt(proc)
|
||||
proc.wait(timeout=1.5)
|
||||
except (subprocess.TimeoutExpired, OSError):
|
||||
try:
|
||||
@@ -284,6 +302,14 @@ class MeetingRecorder(QObject):
|
||||
def start(self, path, mic_target="", system_target="", max_seconds=14400):
|
||||
if self.active:
|
||||
return
|
||||
# Before ffmpeg is looked for, because installing it would not help: a
|
||||
# system with no way to capture what the speakers are playing has none
|
||||
# whatever else is on the machine.
|
||||
if not sound().meetings:
|
||||
self.failed.emit(t("This system offers nothing that records what "
|
||||
"the speakers are playing, so a meeting cannot "
|
||||
"be recorded on it."))
|
||||
return
|
||||
if not shutil.which("ffmpeg"):
|
||||
self.failed.emit(t("ffmpeg not found. Install it to record a meeting."))
|
||||
return
|
||||
@@ -312,7 +338,8 @@ class MeetingRecorder(QObject):
|
||||
self._procs = []
|
||||
for command, log in zip(commands, self._logs):
|
||||
self._procs.append(subprocess.Popen(
|
||||
command, stdout=subprocess.PIPE, stderr=log, bufsize=0
|
||||
command, stdout=subprocess.PIPE, stderr=log, bufsize=0,
|
||||
creationflags=NO_WINDOW,
|
||||
))
|
||||
except (OSError, wave.Error) as exc:
|
||||
# One of two capture processes may already be running, and a Mac
|
||||
@@ -411,7 +438,7 @@ class MeetingRecorder(QObject):
|
||||
running = [proc for proc in self._procs if proc.poll() is None]
|
||||
for proc in running:
|
||||
try:
|
||||
proc.send_signal(signal.SIGINT)
|
||||
_interrupt(proc)
|
||||
except OSError:
|
||||
pass
|
||||
for proc in running:
|
||||
@@ -838,13 +865,131 @@ def _avfoundation_default_output():
|
||||
return ""
|
||||
|
||||
|
||||
# Windows records through DirectShow, the one capture API ffmpeg's Windows
|
||||
# builds all ship with. What the speakers are playing is not offered as a
|
||||
# device at all, so a meeting has nothing to record the far side from yet.
|
||||
|
||||
|
||||
# A device entry and the line under it, in the two shapes ffmpeg has printed
|
||||
# this listing in. Newer builds mark each device `(audio)` or `(video)`; older
|
||||
# ones print no marker and group the devices under a heading instead. Both are
|
||||
# anchored at each end, so that the error lines the command ends with, which
|
||||
# quote the device name that was not found, are not read as devices. The
|
||||
# bracketed prefix is not pinned to a spelling: ffmpeg 8 writes `[in#0 @ ...]`
|
||||
# where the versions before it wrote `[dshow @ ...]`.
|
||||
_DSHOW_ENTRY = re.compile(
|
||||
r'^(?:\[[^\]]*\]\s*)?"([^"]+)"\s*(?:\(([^)]*)\))?\s*$')
|
||||
_DSHOW_ALTERNATIVE = re.compile(
|
||||
r'^(?:\[[^\]]*\]\s*)?Alternative name\s+"([^"]+)"\s*$')
|
||||
_DSHOW_HEADING = re.compile(r'DirectShow (audio|video) devices')
|
||||
|
||||
# The last listing taken, so that a dictation does not pay for one of its own.
|
||||
_DSHOW_SEEN = []
|
||||
|
||||
|
||||
def _parse_dshow_listing(text):
|
||||
"""[(id, name)] for the audio devices in one ffmpeg device listing.
|
||||
|
||||
Two friendly names on one machine are routinely identical: a laptop with a
|
||||
headset plugged in shows two microphones called the same thing, and
|
||||
`audio=<name>` would reach only the first of them either way. The
|
||||
alternative name ffmpeg prints under each device is unique and is what the
|
||||
recorder is given back, while the friendly name is what a user picks from.
|
||||
"""
|
||||
devices = []
|
||||
heading = ""
|
||||
for line in text.splitlines():
|
||||
found = _DSHOW_HEADING.search(line)
|
||||
if found:
|
||||
heading = found.group(1)
|
||||
continue
|
||||
found = _DSHOW_ALTERNATIVE.match(line.strip())
|
||||
if found:
|
||||
if devices:
|
||||
devices[-1][0] = found.group(1)
|
||||
continue
|
||||
found = _DSHOW_ENTRY.match(line.strip())
|
||||
if found:
|
||||
kind = (found.group(2) or heading).lower()
|
||||
devices.append([found.group(1), found.group(1), kind])
|
||||
return [(identifier, name) for identifier, name, kind in devices
|
||||
if "audio" in kind]
|
||||
|
||||
|
||||
def _dshow_devices():
|
||||
"""[(id, name)] for every DirectShow audio capture device, freshly asked.
|
||||
|
||||
The list comes out on stderr of a command that then fails, the same
|
||||
documented trick AVFoundation uses above.
|
||||
"""
|
||||
if not shutil.which("ffmpeg"):
|
||||
return []
|
||||
try:
|
||||
result = subprocess.run(
|
||||
["ffmpeg", "-hide_banner", "-list_devices", "true",
|
||||
"-f", "dshow", "-i", "dummy"],
|
||||
capture_output=True, timeout=8, check=False, creationflags=NO_WINDOW,
|
||||
)
|
||||
except (subprocess.SubprocessError, OSError):
|
||||
return []
|
||||
|
||||
devices = _parse_dshow_listing(result.stderr.decode("utf-8", "replace"))
|
||||
_DSHOW_SEEN[:] = devices
|
||||
return devices
|
||||
|
||||
|
||||
def _dshow_first_device():
|
||||
"""The device an unset target stands for, without a listing per dictation.
|
||||
|
||||
dshow has no "default" for an empty target to mean, so it has to be turned
|
||||
into a name, and asking ffmpeg for one costs a process every time the key
|
||||
is pressed. The last listing is used when there is one: opening Settings or
|
||||
running `dikte devices` takes a fresh one, which is what somebody who has
|
||||
just plugged a microphone in does anyway.
|
||||
"""
|
||||
devices = _DSHOW_SEEN or _dshow_devices()
|
||||
return devices[0][0] if devices else ""
|
||||
|
||||
|
||||
def _dshow_record(target):
|
||||
if not shutil.which("ffmpeg"):
|
||||
return []
|
||||
device = target or _dshow_first_device()
|
||||
if not device:
|
||||
return []
|
||||
return [
|
||||
"ffmpeg", "-hide_banner", "-nostdin", "-loglevel", "error",
|
||||
# dshow holds half a second of audio before handing anything over;
|
||||
# asked for the chunk the level meter is measured in instead.
|
||||
"-f", "dshow", "-audio_buffer_size", str(CHUNK_LATENCY_MS),
|
||||
"-i", f"audio={device}",
|
||||
"-ac", str(CHANNELS), "-ar", str(RATE), "-f", "s16le", "-",
|
||||
]
|
||||
|
||||
|
||||
def _dshow_meeting(mic_target, system_target):
|
||||
return [] # no monitor devices to record the far side from
|
||||
|
||||
|
||||
def _dshow_no_outputs():
|
||||
return []
|
||||
|
||||
|
||||
def _dshow_no_default_output():
|
||||
return ""
|
||||
|
||||
|
||||
Sound = collections.namedtuple(
|
||||
"Sound",
|
||||
# How to capture one source and how to capture two at once, that one as the
|
||||
# list of processes it takes, the two device lists, which device a meeting
|
||||
# records the far side from, and what to say when the programs for any of
|
||||
# it are not installed.
|
||||
"record meeting inputs outputs default_output missing",
|
||||
# records the far side from, whether this system can record one at all, and
|
||||
# what to say when the programs for any of it are not installed.
|
||||
#
|
||||
# `meetings` is the sound system's own answer, not this machine's: an empty
|
||||
# output list means the tool that lists them is missing, which is a thing a
|
||||
# user can go and fix, while False here is a thing they cannot.
|
||||
"record meeting inputs outputs default_output meetings missing",
|
||||
)
|
||||
|
||||
PULSE = Sound(
|
||||
@@ -853,6 +998,7 @@ PULSE = Sound(
|
||||
inputs=_pulse_inputs,
|
||||
outputs=_pulse_outputs,
|
||||
default_output=_pulse_default_output,
|
||||
meetings=True,
|
||||
missing="No audio recorder found. Install pulseaudio-utils or pipewire-audio.",
|
||||
)
|
||||
|
||||
@@ -865,13 +1011,33 @@ COREAUDIO = Sound(
|
||||
# empty list would leave nothing to pick.
|
||||
outputs=_avfoundation_named_inputs,
|
||||
default_output=_avfoundation_default_output,
|
||||
# With a loopback driver installed, which is what the Settings note is for.
|
||||
meetings=True,
|
||||
missing="ffmpeg not found. Install it with: brew install ffmpeg",
|
||||
)
|
||||
|
||||
|
||||
DSHOW = Sound(
|
||||
record=_dshow_record,
|
||||
meeting=_dshow_meeting,
|
||||
inputs=_dshow_devices,
|
||||
outputs=_dshow_no_outputs,
|
||||
default_output=_dshow_no_default_output,
|
||||
# Windows offers no capture device for what the speakers are playing, and
|
||||
# there is no driver to install that would add one.
|
||||
meetings=False,
|
||||
missing="ffmpeg or a microphone was not found. Install ffmpeg with: "
|
||||
"winget install Gyan.FFmpeg",
|
||||
)
|
||||
|
||||
|
||||
def sound():
|
||||
"""The programs this machine records through."""
|
||||
return COREAUDIO if sys.platform == "darwin" else PULSE
|
||||
if sys.platform == "darwin":
|
||||
return COREAUDIO
|
||||
if sys.platform == "win32":
|
||||
return DSHOW
|
||||
return PULSE
|
||||
|
||||
|
||||
def list_sources():
|
||||
|
||||
@@ -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.",
|
||||
|
||||
+72
-5
@@ -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,7 +852,18 @@ def cmd_status(opts):
|
||||
def cmd_doctor(opts):
|
||||
"""What the settings window checks behind its buttons, in one pass."""
|
||||
conf = cfg.Config()
|
||||
wanted = ["pw-record", "wl-copy", "ydotool", "ffmpeg", "pactl", "kwriteconfig6",
|
||||
# The two the clipboard and the key press go through come out of the table
|
||||
# rather than being spelled here, because they are not the same pair on all
|
||||
# four systems: X11 pastes with xclip where Wayland pastes with wl-copy, a
|
||||
# Mac shells out for one half and Windows for neither. A row saying ydotool
|
||||
# is missing on a machine that would never have run it is not a diagnosis,
|
||||
# it is a red mark to explain away.
|
||||
here = paste.desktop()
|
||||
wanted = [here.clipboard, here.keyboard]
|
||||
if sys.platform.startswith("linux"):
|
||||
# Recording, the device list, and KDE's shortcut registry.
|
||||
wanted += ["pw-record", "pactl", "kwriteconfig6"]
|
||||
wanted += ["ffmpeg",
|
||||
assistant.executable(assistant.provider(conf)) or "claude",
|
||||
cleanup.executable(cleanup.provider(conf))]
|
||||
programs = {name: shutil.which(name) or "" for name in wanted if name}
|
||||
@@ -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"),
|
||||
|
||||
@@ -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
|
||||
|
||||
+96
-33
@@ -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}…",
|
||||
stage = (t("Transcribing chunk {index}/{count}…",
|
||||
index=index, count=len(chunks))
|
||||
if len(chunks) > 1 else t("Transcribing…")
|
||||
)
|
||||
if len(chunks) > 1 else t("Transcribing…"))
|
||||
self.progress.emit(stage)
|
||||
if timestamps:
|
||||
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 api.transcribe_segments(
|
||||
target,
|
||||
chunk_path,
|
||||
language=conf["language"],
|
||||
prompt=conf["transcribe_prompt"],
|
||||
aborter=self._abort,
|
||||
)
|
||||
for start, end, line in heard
|
||||
])
|
||||
else:
|
||||
pieces.append(api.transcribe(
|
||||
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,6 +185,15 @@ class FileTranscriber(QObject):
|
||||
except Cancelled:
|
||||
self.progress.emit(t("Stopped."))
|
||||
except (api.ApiError, OSError, subprocess.SubprocessError, wave.Error) as 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
|
||||
@@ -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):
|
||||
|
||||
+72
-11
@@ -43,6 +43,7 @@ import threading
|
||||
import time
|
||||
import urllib.error
|
||||
import urllib.request
|
||||
import zipfile
|
||||
|
||||
from . import hub
|
||||
from . import paths
|
||||
@@ -154,11 +155,14 @@ def download(item, target, on_progress=None, should_stop=None, require_hash=True
|
||||
try:
|
||||
with urllib.request.urlopen(request, timeout=60) as response:
|
||||
total = int(response.headers.get("Content-Length") or item.size or 0)
|
||||
# Windows refuses to delete a file that is open, so nothing is
|
||||
# unlinked until the handle is closed again.
|
||||
stopped = overlong = False
|
||||
with open(part, "wb") as out:
|
||||
while True:
|
||||
if should_stop is not None and should_stop():
|
||||
part.unlink(missing_ok=True)
|
||||
return False
|
||||
stopped = True
|
||||
break
|
||||
block = response.read(DOWNLOAD_CHUNK)
|
||||
if not block:
|
||||
break
|
||||
@@ -168,11 +172,17 @@ def download(item, target, on_progress=None, should_stop=None, require_hash=True
|
||||
# More than was announced: a body that does not end is the
|
||||
# one way this loop could run until the disk is full.
|
||||
if total and done > total:
|
||||
overlong = True
|
||||
break
|
||||
if on_progress is not None:
|
||||
on_progress(done, total)
|
||||
if stopped:
|
||||
part.unlink(missing_ok=True)
|
||||
return False
|
||||
if overlong:
|
||||
part.unlink(missing_ok=True)
|
||||
raise LocalError(t("{name} is longer than it said it "
|
||||
"would be.", name=item.name))
|
||||
if on_progress is not None:
|
||||
on_progress(done, total)
|
||||
# A proxy notice or an error page that came back as 200 would otherwise
|
||||
# be renamed into place and only fail when something tries to read it.
|
||||
if total and done != total:
|
||||
@@ -218,9 +228,11 @@ def _has_vulkan():
|
||||
llama.cpp publishes no CUDA build for Linux, so Vulkan is what a graphics
|
||||
card gets here. The build without it is smaller and runs on the CPU, and
|
||||
fetching the Vulkan one for a machine that cannot load it would only make
|
||||
the download bigger.
|
||||
the download bigger. Windows spells the loader vulkan-1.dll.
|
||||
"""
|
||||
return bool(ctypes.util.find_library("vulkan"))
|
||||
return bool(ctypes.util.find_library("vulkan")
|
||||
or (sys.platform == "win32"
|
||||
and ctypes.util.find_library("vulkan-1")))
|
||||
|
||||
|
||||
def _wanted_assets(program):
|
||||
@@ -233,6 +245,20 @@ def _wanted_assets(program):
|
||||
arch = _arch()
|
||||
if sys.platform == "darwin":
|
||||
return () if program is WHISPER else (f"bin-macos-{arch}.tar.gz",)
|
||||
if sys.platform == "win32":
|
||||
if program is WHISPER:
|
||||
# The BLAS build first: on a plain CPU it transcribes about twice
|
||||
# as fast as the stock one, and it carries everything it needs.
|
||||
# Full names, because "bin-x64.zip" alone would also match the
|
||||
# CUDA archives, whichever the release happened to list first.
|
||||
#
|
||||
# x64 whatever this machine is, because whisper.cpp publishes no
|
||||
# arm64 build for Windows: a Snapdragon runs this one emulated,
|
||||
# which is slow but is the only local option there is.
|
||||
return ("whisper-blas-bin-x64.zip", "whisper-bin-x64.zip")
|
||||
if _has_vulkan() and arch == "x64":
|
||||
return ("bin-win-vulkan-x64.zip", f"bin-win-cpu-{arch}.zip")
|
||||
return (f"bin-win-cpu-{arch}.zip",)
|
||||
if program is LLAMA and _has_vulkan():
|
||||
return (f"bin-ubuntu-vulkan-{arch}.tar.gz", f"bin-ubuntu-{arch}.tar.gz")
|
||||
return (f"bin-ubuntu-{arch}.tar.gz",)
|
||||
@@ -278,6 +304,11 @@ def system_program(program):
|
||||
return bool(shutil.which(program.binary))
|
||||
|
||||
|
||||
def _binary_file(program):
|
||||
"""What the program's file is called on disk here."""
|
||||
return f"{program.binary}.exe" if sys.platform == "win32" else program.binary
|
||||
|
||||
|
||||
def _find_binary(root, name):
|
||||
for path in sorted(pathlib.Path(root).rglob(name)):
|
||||
if path.is_file():
|
||||
@@ -286,19 +317,24 @@ def _find_binary(root, name):
|
||||
|
||||
|
||||
def _extract(archive, into):
|
||||
"""Unpack a release tarball, refusing anything that reaches outside `into`.
|
||||
"""Unpack a release archive, refusing anything that reaches outside `into`.
|
||||
|
||||
The archives lay their libraries next to their binaries and are linked with
|
||||
an $ORIGIN runpath, so a whole directory is what has to survive the trip and
|
||||
the binary cannot be lifted out of it.
|
||||
the binary cannot be lifted out of it. Linux and macOS releases come as
|
||||
tarballs, Windows ones as zips; zipfile never writes outside its target.
|
||||
"""
|
||||
try:
|
||||
if str(archive).endswith(".zip"):
|
||||
with zipfile.ZipFile(archive) as bundle:
|
||||
bundle.extractall(into)
|
||||
return
|
||||
with tarfile.open(archive, "r:gz") as tar:
|
||||
try:
|
||||
tar.extractall(into, filter="data")
|
||||
except TypeError: # Python without the extraction filters
|
||||
tar.extractall(into)
|
||||
except (tarfile.TarError, OSError) as exc:
|
||||
except (tarfile.TarError, zipfile.BadZipFile, OSError) as exc:
|
||||
raise LocalError(t("Could not unpack {name}: {error}",
|
||||
name=os.path.basename(str(archive)), error=exc)) from exc
|
||||
|
||||
@@ -344,7 +380,7 @@ def install_program(program, tag="", on_progress=None, should_stop=None,
|
||||
if not download(item, archive, on_progress, should_stop):
|
||||
return ""
|
||||
_extract(archive, into)
|
||||
binary = _find_binary(into, program.binary)
|
||||
binary = _find_binary(into, _binary_file(program))
|
||||
if binary is None:
|
||||
raise LocalError(t("{name} was not in the download.",
|
||||
name=program.binary))
|
||||
@@ -501,6 +537,26 @@ def _tail(path, lines=3):
|
||||
return " | ".join(found[-lines:])
|
||||
|
||||
|
||||
def _win_image_name(pid):
|
||||
"""The lower-cased file name of the process's executable, or ''."""
|
||||
import ctypes
|
||||
kernel32 = ctypes.WinDLL("kernel32", use_last_error=True)
|
||||
kernel32.OpenProcess.restype = ctypes.c_void_p
|
||||
kernel32.OpenProcess.argtypes = [ctypes.c_uint32, ctypes.c_int, ctypes.c_uint32]
|
||||
kernel32.CloseHandle.argtypes = [ctypes.c_void_p]
|
||||
handle = kernel32.OpenProcess(0x1000, False, pid) # QUERY_LIMITED_INFORMATION
|
||||
if not handle:
|
||||
return ""
|
||||
try:
|
||||
buffer = ctypes.create_unicode_buffer(260)
|
||||
size = ctypes.c_uint32(len(buffer))
|
||||
ok = kernel32.QueryFullProcessImageNameW(
|
||||
ctypes.c_void_p(handle), 0, buffer, ctypes.byref(size))
|
||||
return os.path.basename(buffer.value).lower() if ok else ""
|
||||
finally:
|
||||
kernel32.CloseHandle(handle)
|
||||
|
||||
|
||||
class Server:
|
||||
"""One process, started when something needs it and stopped when nothing does.
|
||||
|
||||
@@ -602,6 +658,8 @@ class Server:
|
||||
args + ["--host", HOST, "--port", str(port)],
|
||||
stdout=sink, stderr=subprocess.STDOUT,
|
||||
stdin=subprocess.DEVNULL,
|
||||
# No console window of its own on Windows.
|
||||
creationflags=getattr(subprocess, "CREATE_NO_WINDOW", 0),
|
||||
)
|
||||
except OSError as exc:
|
||||
raise LocalError(t("Could not start {name}: {error}",
|
||||
@@ -699,8 +757,11 @@ class Server:
|
||||
number could belong to something else entirely, and killing it would be
|
||||
a good deal worse than the leak being cleaned up. The program name alone
|
||||
could be somebody else's copy; the name together with Dikte's own data
|
||||
directory on the command line could not.
|
||||
directory on the command line could not. Windows offers no command line
|
||||
to read, so the executable's name is the whole of the answer there.
|
||||
"""
|
||||
if sys.platform == "win32":
|
||||
return _win_image_name(pid) == _binary_file(self.program).lower()
|
||||
try:
|
||||
blob = pathlib.Path(f"/proc/{pid}/cmdline").read_bytes()
|
||||
except OSError:
|
||||
|
||||
+189
-13
@@ -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")
|
||||
|
||||
@@ -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.
|
||||
|
||||
|
||||
@@ -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}",
|
||||
@@ -186,6 +190,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",
|
||||
@@ -291,6 +314,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}",
|
||||
@@ -351,6 +378,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 "
|
||||
@@ -390,6 +422,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"
|
||||
@@ -641,6 +679,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.":
|
||||
|
||||
+124
-9
@@ -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 []
|
||||
|
||||
+15
-1
@@ -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]
|
||||
|
||||
|
||||
|
||||
@@ -0,0 +1,203 @@
|
||||
"""The parts of AppKit a dictation needs on macOS and Qt does not reach.
|
||||
|
||||
Two jobs, both about staying out of the user's way.
|
||||
|
||||
The first is keeping the indicator on screen. Qt draws it as a tool window,
|
||||
which on macOS is an NSPanel, and an NSPanel is hidden by the system the moment
|
||||
its application stops being the active one. For a dictation indicator that is
|
||||
exactly backwards: you press the shortcut inside some other program, so Dikte is
|
||||
never the active application, and the one window that has something to say
|
||||
disappears as soon as you look away from it. Three settings AppKit has and Qt
|
||||
does not expose:
|
||||
|
||||
hidesOnDeactivate = NO stay put when another application comes forward
|
||||
collectionBehavior show on whichever desktop is in front, including
|
||||
over a full screen window, and stay out of Cmd+Tab
|
||||
nonactivating panel come to the front without bringing Dikte with it
|
||||
|
||||
The second is putting the front back. Opening the microphone activates Dikte
|
||||
whatever the indicator does, so app.py watches for that and calls activate()
|
||||
here. See _give_the_front_back() there for the measurement.
|
||||
|
||||
Done through the Objective-C runtime rather than a binding, because Dikte has no
|
||||
third party Python packages and this is a handful of messages to three objects.
|
||||
The runtime is loaded in _appkit() rather than at import, the way paste.py loads
|
||||
its frameworks in _macos_api(): that is the one function a test fakes, and it is
|
||||
what lets the tests below run on a machine that has no AppKit at all.
|
||||
"""
|
||||
|
||||
import ctypes
|
||||
import ctypes.util
|
||||
import os
|
||||
|
||||
from PyQt6.QtGui import QGuiApplication
|
||||
|
||||
# NSWindowCollectionBehavior, as of the macOS these names come from:
|
||||
CAN_JOIN_ALL_SPACES = 1 << 0
|
||||
IGNORES_CYCLE = 1 << 6 # not a window Cmd+Tab should ever land on
|
||||
FULL_SCREEN_AUXILIARY = 1 << 8
|
||||
BEHAVIOUR = CAN_JOIN_ALL_SPACES | IGNORES_CYCLE | FULL_SCREEN_AUXILIARY
|
||||
|
||||
# NSWindowStyleMaskNonactivatingPanel. Without it, ordering the indicator to
|
||||
# the front brings Dikte to the front with it, and the application the user was
|
||||
# typing in loses focus the moment they start dictating: the Cmd+V at the end
|
||||
# then lands on the indicator instead of their document. Qt has no flag for
|
||||
# this; WA_ShowWithoutActivating governs the show, not what the panel does to
|
||||
# the application afterwards.
|
||||
NONACTIVATING_PANEL = 1 << 7
|
||||
|
||||
_appkit_runtime = None
|
||||
|
||||
|
||||
class _AppKit:
|
||||
"""objc_msgSend under the signatures this file sends it through.
|
||||
|
||||
It has no fixed signature of its own, and calling it through the wrong
|
||||
argument or return types is how a Mac crashes rather than raises, so each
|
||||
one is spelled out once here and used by name below.
|
||||
"""
|
||||
|
||||
def __init__(self, objc):
|
||||
self.objc = objc
|
||||
objc.sel_registerName.restype = ctypes.c_void_p
|
||||
objc.sel_registerName.argtypes = [ctypes.c_char_p]
|
||||
objc.objc_getClass.restype = ctypes.c_void_p
|
||||
objc.objc_getClass.argtypes = [ctypes.c_char_p]
|
||||
self.ask = self._as(ctypes.c_void_p)
|
||||
self.ask_bool = self._as(ctypes.c_bool)
|
||||
self.ask_pid = self._as(ctypes.c_int) # pid_t is an int32
|
||||
self.ask_unsigned = self._as(ctypes.c_ulong) # NSUInteger
|
||||
self.tell_bool = self._as(None, ctypes.c_bool)
|
||||
self.tell_unsigned = self._as(None, ctypes.c_ulong)
|
||||
self.ask_of_class = self._as(ctypes.c_bool, ctypes.c_void_p)
|
||||
self.ask_of_pid = self._as(ctypes.c_void_p, ctypes.c_int)
|
||||
self.ask_with_options = self._as(ctypes.c_bool, ctypes.c_ulong)
|
||||
|
||||
def _as(self, returns, *arguments):
|
||||
return ctypes.cast(self.objc.objc_msgSend, ctypes.CFUNCTYPE(
|
||||
returns, ctypes.c_void_p, ctypes.c_void_p, *arguments))
|
||||
|
||||
def selector(self, name):
|
||||
return self.objc.sel_registerName(name)
|
||||
|
||||
def shared(self, class_name, selector):
|
||||
"""A class's singleton, e.g. +[NSWorkspace sharedWorkspace]."""
|
||||
return ctypes.c_void_p(self.ask(
|
||||
ctypes.c_void_p(self.objc.objc_getClass(class_name)),
|
||||
self.selector(selector)))
|
||||
|
||||
|
||||
def _appkit():
|
||||
"""The Objective-C runtime, loaded the first time something needs it.
|
||||
|
||||
Loaded here rather than at import so that this module can be imported on a
|
||||
machine that has no AppKit: the tests stand on macOS from a Linux machine
|
||||
and back, and this is the one function they replace to do it.
|
||||
"""
|
||||
global _appkit_runtime
|
||||
if _appkit_runtime is None:
|
||||
_appkit_runtime = _AppKit(
|
||||
ctypes.cdll.LoadLibrary(ctypes.util.find_library("objc")))
|
||||
return _appkit_runtime
|
||||
|
||||
|
||||
def frontmost_pid():
|
||||
"""Which application is in front, by process id, or None when unasked.
|
||||
|
||||
A process id rather than the object itself: the object would have to be
|
||||
retained to survive the trip, and a number needs nothing looking after it.
|
||||
"""
|
||||
try:
|
||||
api = _appkit()
|
||||
workspace = api.shared(b"NSWorkspace", b"sharedWorkspace")
|
||||
running = ctypes.c_void_p(api.ask(
|
||||
workspace, api.selector(b"frontmostApplication")))
|
||||
if not running:
|
||||
return None
|
||||
return int(api.ask_pid(running, api.selector(b"processIdentifier")))
|
||||
except Exception:
|
||||
return None
|
||||
|
||||
|
||||
def activate(pid):
|
||||
"""Put the application with that process id back in front.
|
||||
|
||||
False when it has gone away in the meantime, or when the message could not
|
||||
be sent at all: a dictation is not worth failing over the window behind it.
|
||||
"""
|
||||
if not pid:
|
||||
return False
|
||||
try:
|
||||
api = _appkit()
|
||||
running = ctypes.c_void_p(api.ask_of_pid(
|
||||
ctypes.c_void_p(api.objc.objc_getClass(b"NSRunningApplication")),
|
||||
api.selector(b"runningApplicationWithProcessIdentifier:"),
|
||||
int(pid)))
|
||||
if not running:
|
||||
return False
|
||||
# activateWithOptions: rather than the deprecated activate, and with no
|
||||
# options: bringing every one of its windows forward is not asked for,
|
||||
# only the application it was before Dikte took the front from it.
|
||||
return bool(api.ask_with_options(
|
||||
running, api.selector(b"activateWithOptions:"), 0))
|
||||
except Exception:
|
||||
return False
|
||||
|
||||
|
||||
def is_frontmost():
|
||||
"""Whether Dikte itself is the application in front.
|
||||
|
||||
By process id rather than -[NSRunningApplication isActive] on our own
|
||||
process, which stays true once the application has ever been activated:
|
||||
measured True with another application plainly in front.
|
||||
"""
|
||||
pid = frontmost_pid()
|
||||
return pid is not None and pid == os.getpid()
|
||||
|
||||
|
||||
def _is_panel(api, window):
|
||||
"""Whether this window is an NSPanel, which is the only kind the
|
||||
nonactivating bit is legal on: setting it on a plain NSWindow raises an
|
||||
Objective-C exception, and an exception through ctypes takes the process
|
||||
down with it."""
|
||||
panel = api.objc.objc_getClass(b"NSPanel")
|
||||
if not panel:
|
||||
return False
|
||||
return bool(api.ask_of_class(window, api.selector(b"isKindOfClass:"),
|
||||
ctypes.c_void_p(panel)))
|
||||
|
||||
|
||||
def keep_on_screen(widget):
|
||||
"""Ask the window behind `widget` to stay while other programs are used.
|
||||
|
||||
Silent when anything is not as expected: an indicator that cannot be made
|
||||
to linger is still an indicator, and a dictation should not fail over the
|
||||
window it is drawn in.
|
||||
"""
|
||||
# Only the Cocoa backend hands out a real NSView. Under the offscreen
|
||||
# platform the tests run on, winId() is a number that means something else
|
||||
# entirely, and sending an Objective-C message to it is how a test run
|
||||
# turns into a crash.
|
||||
if QGuiApplication.platformName() != "cocoa":
|
||||
return False
|
||||
try:
|
||||
api = _appkit()
|
||||
view = ctypes.c_void_p(int(widget.winId()))
|
||||
window = api.ask(view, api.selector(b"window"))
|
||||
if not window:
|
||||
return False
|
||||
window = ctypes.c_void_p(window)
|
||||
api.tell_bool(window, api.selector(b"setHidesOnDeactivate:"), False)
|
||||
api.tell_unsigned(window, api.selector(b"setCollectionBehavior:"),
|
||||
BEHAVIOUR)
|
||||
# Only a panel may carry the nonactivating bit, and only a panel is
|
||||
# asked to: on anything else the message raises, and an Objective-C
|
||||
# exception through ctypes takes the process with it.
|
||||
if _is_panel(api, window):
|
||||
mask = api.ask_unsigned(window, api.selector(b"styleMask"))
|
||||
if not mask & NONACTIVATING_PANEL:
|
||||
api.tell_unsigned(window, api.selector(b"setStyleMask:"),
|
||||
mask | NONACTIVATING_PANEL)
|
||||
return True
|
||||
except (AttributeError, OSError, RuntimeError, ValueError):
|
||||
return False
|
||||
+10
-2
@@ -7,6 +7,8 @@ from PyQt6.QtCore import Qt, QTimer, QRectF, QPointF
|
||||
from PyQt6.QtGui import QColor, QCursor, QFont, QPainter, QPainterPath, QPen, QFontMetrics
|
||||
from PyQt6.QtWidgets import QWidget, QApplication
|
||||
|
||||
from . import mac_window
|
||||
|
||||
BARS = 22
|
||||
HEIGHT = 56
|
||||
MIN_WIDTH = 210
|
||||
@@ -68,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
|
||||
@@ -201,6 +204,11 @@ class Overlay(QWidget):
|
||||
self._reposition()
|
||||
if not self.isVisible():
|
||||
self.show()
|
||||
if sys.platform == "darwin":
|
||||
# After show(), because the window it works on does not exist until
|
||||
# then, and every time, because a window Qt rebuilt has the setting
|
||||
# again at its default.
|
||||
mac_window.keep_on_screen(self)
|
||||
if self._concealed:
|
||||
self.raise_()
|
||||
self._concealed = False
|
||||
|
||||
+224
-6
@@ -147,7 +147,9 @@ def _program_keyboard(program, command, hint=""):
|
||||
def ready():
|
||||
return shutil.which(program) is not None
|
||||
|
||||
def press(shortcut, delay):
|
||||
def press(shortcut, delay, _focus=None):
|
||||
# Nothing here takes the front from the window being dictated into, so
|
||||
# there is nothing to hand back: the process id is a macOS concern.
|
||||
if not ready():
|
||||
raise PasteError(t("{tool} not found, cannot paste automatically.",
|
||||
tool=program))
|
||||
@@ -296,11 +298,27 @@ def _ask_for_permission():
|
||||
pass
|
||||
|
||||
|
||||
def _macos_press(shortcut, delay):
|
||||
def _macos_press(shortcut, delay, focus=None):
|
||||
"""Post the key down and up straight into the window system.
|
||||
|
||||
Nothing is typed anywhere until macOS has been told to trust Dikte, and it
|
||||
only asks once, when the paste it was granted for is first tried.
|
||||
|
||||
`focus` is the application that was in front when the recording began. The
|
||||
keys land wherever the window system is pointing, so a Dikte that has ended
|
||||
up in front would swallow its own transcript; when that has happened the
|
||||
front is handed back before pressing. Nothing is taken from anyone else: an
|
||||
application the user went to while the transcription ran is where they want
|
||||
the text now.
|
||||
|
||||
This runs on the transcription's own thread rather than the main one, and
|
||||
the two calls it makes are the kind AppKit documents as answering
|
||||
atomically wherever they are asked from: NSRunningApplication is thread
|
||||
safe by its own header, and the workspace lookup behind it returns a
|
||||
reference rather than anything that has to be held. Stressed with four
|
||||
threads and 32000 lookups against a running main loop without a fault; if
|
||||
one ever does happen, mac_window answers None and the press goes ahead
|
||||
where it would have gone anyway.
|
||||
"""
|
||||
keycode, flags = _macos_keys(shortcut)
|
||||
services, core = _macos_api()
|
||||
@@ -310,6 +328,12 @@ def _macos_press(shortcut, delay):
|
||||
"macOS has not been told to let Dikte press keys. Turn Dikte on "
|
||||
"under System Settings → Privacy & Security → Accessibility."
|
||||
))
|
||||
if focus:
|
||||
# Imported here rather than at the top: it reaches for QtGui, and a
|
||||
# terminal that only wants the clipboard should not pay for that.
|
||||
from . import mac_window
|
||||
if mac_window.is_frontmost():
|
||||
mac_window.activate(focus)
|
||||
|
||||
time.sleep(delay) # let the selection settle and focus come back
|
||||
down = services.CGEventCreateKeyboardEvent(None, keycode, True)
|
||||
@@ -330,6 +354,169 @@ def _macos_press(shortcut, delay):
|
||||
core.CFRelease(up)
|
||||
|
||||
|
||||
def _win_keys(shortcut):
|
||||
"""'Ctrl+V' -> [0x11, 0x56]: Windows virtual-key codes, modifiers first."""
|
||||
codes = []
|
||||
for key in _keys(shortcut):
|
||||
if key not in WIN_KEYCODES:
|
||||
raise PasteError(t("Unknown key: {key}", key=key))
|
||||
codes.append(WIN_KEYCODES[key])
|
||||
return codes
|
||||
|
||||
|
||||
# Windows virtual-key codes (winuser.h). Like Apple's, they say where the key
|
||||
# sits rather than what a layout prints on it.
|
||||
WIN_KEYCODES = {
|
||||
"ctrl": 0x11, "control": 0x11, "shift": 0x10, "alt": 0x12,
|
||||
"super": 0x5B, "meta": 0x5B,
|
||||
"v": 0x56, "insert": 0x2D, "enter": 0x0D, "return": 0x0D,
|
||||
}
|
||||
_WIN_KEYUP = 0x0002 # KEYEVENTF_KEYUP
|
||||
_WIN_CF_UNICODETEXT = 13 # what the clipboard calls UTF-16 text
|
||||
_WIN_GMEM_MOVEABLE = 0x0002
|
||||
|
||||
|
||||
@functools.lru_cache(maxsize=1)
|
||||
def _win_api():
|
||||
"""user32 and kernel32 with their prototypes spelled out.
|
||||
|
||||
The default return type is a 32-bit int, which silently truncates the
|
||||
64-bit handles and pointers every one of these calls trades in.
|
||||
"""
|
||||
user32 = ctypes.WinDLL("user32", use_last_error=True)
|
||||
kernel32 = ctypes.WinDLL("kernel32", use_last_error=True)
|
||||
user32.OpenClipboard.argtypes = [ctypes.c_void_p]
|
||||
user32.GetClipboardData.restype = ctypes.c_void_p
|
||||
user32.GetClipboardData.argtypes = [ctypes.c_uint]
|
||||
user32.SetClipboardData.restype = ctypes.c_void_p
|
||||
user32.SetClipboardData.argtypes = [ctypes.c_uint, ctypes.c_void_p]
|
||||
kernel32.GlobalAlloc.restype = ctypes.c_void_p
|
||||
kernel32.GlobalAlloc.argtypes = [ctypes.c_uint, ctypes.c_size_t]
|
||||
kernel32.GlobalLock.restype = ctypes.c_void_p
|
||||
kernel32.GlobalLock.argtypes = [ctypes.c_void_p]
|
||||
kernel32.GlobalUnlock.argtypes = [ctypes.c_void_p]
|
||||
kernel32.GlobalFree.argtypes = [ctypes.c_void_p]
|
||||
return user32, kernel32
|
||||
|
||||
|
||||
def _win_error():
|
||||
"""GetLastError where it exists, so the failure paths run under any test."""
|
||||
return getattr(ctypes, "get_last_error", lambda: 0)()
|
||||
|
||||
|
||||
def _win_open_clipboard(user32):
|
||||
"""The clipboard is a lock another program may hold for a moment."""
|
||||
for _ in range(10):
|
||||
if user32.OpenClipboard(None):
|
||||
return True
|
||||
time.sleep(0.01)
|
||||
return False
|
||||
|
||||
|
||||
def _win_read_text():
|
||||
"""The clipboard's text, '' when it holds none, None when it cannot be read."""
|
||||
user32, kernel32 = _win_api()
|
||||
if not _win_open_clipboard(user32):
|
||||
return None
|
||||
try:
|
||||
handle = user32.GetClipboardData(_WIN_CF_UNICODETEXT)
|
||||
if not handle:
|
||||
return ""
|
||||
pointer = kernel32.GlobalLock(handle)
|
||||
if not pointer:
|
||||
return None
|
||||
try:
|
||||
return ctypes.wstring_at(pointer)
|
||||
finally:
|
||||
kernel32.GlobalUnlock(handle)
|
||||
finally:
|
||||
user32.CloseClipboard()
|
||||
|
||||
|
||||
def _win_write_text(text):
|
||||
user32, kernel32 = _win_api()
|
||||
payload = str(text).encode("utf-16-le") + b"\x00\x00"
|
||||
# Filled before the clipboard is opened at all. EmptyClipboard is what
|
||||
# throws away whatever was there, and a failure after it and before the
|
||||
# SetClipboardData would leave the clipboard holding nothing: the one way
|
||||
# this function could lose what it was called to put back.
|
||||
handle = kernel32.GlobalAlloc(_WIN_GMEM_MOVEABLE, len(payload))
|
||||
pointer = kernel32.GlobalLock(handle) if handle else None
|
||||
if not pointer:
|
||||
if handle:
|
||||
kernel32.GlobalFree(handle)
|
||||
raise PasteError(t("Could not copy to clipboard: {error}",
|
||||
error="out of memory"))
|
||||
ctypes.memmove(pointer, payload, len(payload))
|
||||
kernel32.GlobalUnlock(handle)
|
||||
|
||||
if not _win_open_clipboard(user32):
|
||||
kernel32.GlobalFree(handle)
|
||||
raise PasteError(t("Could not copy to clipboard: {error}",
|
||||
error="the clipboard is held by another program"))
|
||||
try:
|
||||
user32.EmptyClipboard()
|
||||
if not user32.SetClipboardData(_WIN_CF_UNICODETEXT, handle):
|
||||
raise PasteError(t("Could not copy to clipboard: {error}",
|
||||
error=f"error {_win_error()}"))
|
||||
handle = None # the clipboard owns it now
|
||||
finally:
|
||||
if handle:
|
||||
kernel32.GlobalFree(handle)
|
||||
user32.CloseClipboard()
|
||||
|
||||
|
||||
class _WinKeybdInput(ctypes.Structure):
|
||||
_fields_ = [("wVk", ctypes.c_ushort), ("wScan", ctypes.c_ushort),
|
||||
("dwFlags", ctypes.c_ulong), ("time", ctypes.c_ulong),
|
||||
("dwExtraInfo", ctypes.c_size_t)]
|
||||
|
||||
|
||||
class _WinMouseInput(ctypes.Structure):
|
||||
_fields_ = [("dx", ctypes.c_long), ("dy", ctypes.c_long),
|
||||
("mouseData", ctypes.c_ulong), ("dwFlags", ctypes.c_ulong),
|
||||
("time", ctypes.c_ulong), ("dwExtraInfo", ctypes.c_size_t)]
|
||||
|
||||
|
||||
class _WinInputUnion(ctypes.Union):
|
||||
_fields_ = [("mi", _WinMouseInput), ("ki", _WinKeybdInput)]
|
||||
|
||||
|
||||
class _WinInput(ctypes.Structure):
|
||||
# The union carries the mouse shape too: SendInput sizes its argument by
|
||||
# the biggest member whether or not it is the one being sent.
|
||||
_fields_ = [("type", ctypes.c_ulong), ("union", _WinInputUnion)]
|
||||
|
||||
|
||||
def _win_press(shortcut, delay, _focus=None):
|
||||
"""Post the presses and releases straight into the input queue.
|
||||
|
||||
No permission stands in front of SendInput the way Accessibility does on
|
||||
macOS: whatever window has focus receives the combination.
|
||||
|
||||
Nothing here takes the front from the window being dictated into, so the
|
||||
remembered process id has nothing to hand back to: it is a macOS concern.
|
||||
"""
|
||||
codes = _win_keys(shortcut)
|
||||
user32, _ = _win_api()
|
||||
time.sleep(delay) # let the selection settle and focus come back
|
||||
|
||||
events = ([(code, 0) for code in codes]
|
||||
+ [(code, _WIN_KEYUP) for code in reversed(codes)])
|
||||
inputs = (_WinInput * len(events))()
|
||||
for entry, (code, flags) in zip(inputs, events):
|
||||
entry.type = 1 # INPUT_KEYBOARD
|
||||
entry.union.ki = _WinKeybdInput(code, 0, flags, 0, 0)
|
||||
sent = user32.SendInput(len(inputs), inputs, ctypes.sizeof(_WinInput))
|
||||
if sent != len(inputs):
|
||||
raise PasteError(t("Could not run {tool}: {error}", tool="SendInput",
|
||||
error=f"error {_win_error()}"))
|
||||
|
||||
|
||||
def _win_ready():
|
||||
return True
|
||||
|
||||
|
||||
# --- which of them is here -------------------------------------------------
|
||||
|
||||
Desktop = collections.namedtuple(
|
||||
@@ -362,6 +549,17 @@ X11 = Desktop(
|
||||
**_program_keyboard("xdotool", _xdotool_command),
|
||||
)
|
||||
|
||||
WINDOWS = Desktop(
|
||||
clipboard="", # no program: both directions are calls into the system
|
||||
packages="",
|
||||
read_command=[],
|
||||
copy_command=[],
|
||||
shortcuts=["ctrl+v", "ctrl+shift+v", "shift+insert"],
|
||||
keyboard="",
|
||||
ready=_win_ready,
|
||||
press=_win_press,
|
||||
)
|
||||
|
||||
MACOS = Desktop(
|
||||
clipboard="pbcopy",
|
||||
packages="", # both are part of macOS; there is nothing to install
|
||||
@@ -383,6 +581,8 @@ def desktop():
|
||||
"""
|
||||
if sys.platform == "darwin":
|
||||
return MACOS
|
||||
if sys.platform == "win32":
|
||||
return WINDOWS
|
||||
if os.environ.get("XDG_SESSION_TYPE") == "x11":
|
||||
return X11
|
||||
if os.environ.get("DISPLAY") and not os.environ.get("WAYLAND_DISPLAY"):
|
||||
@@ -427,6 +627,9 @@ def _macos_restore(snapshot):
|
||||
|
||||
def read_clipboard():
|
||||
here = desktop()
|
||||
if here is WINDOWS:
|
||||
text = _win_read_text()
|
||||
return None if text is None else text.encode("utf-8")
|
||||
if here is MACOS and shutil.which("osascript"):
|
||||
snapshot = _macos_snapshot()
|
||||
if snapshot is not None:
|
||||
@@ -454,6 +657,9 @@ def _run_copy(payload):
|
||||
|
||||
def copy(text):
|
||||
here = desktop()
|
||||
if here is WINDOWS:
|
||||
_win_write_text(text)
|
||||
return
|
||||
if not shutil.which(here.clipboard):
|
||||
raise PasteError(
|
||||
t("{tool} not found. Install {packages}.",
|
||||
@@ -473,7 +679,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)
|
||||
@@ -488,7 +702,11 @@ def paste_ready():
|
||||
return desktop().ready()
|
||||
|
||||
|
||||
def press(shortcut="", delay=0.12):
|
||||
"""Press a paste combination, e.g. 'ctrl+v', or this desktop's own."""
|
||||
def press(shortcut="", delay=0.12, focus=None):
|
||||
"""Press a paste combination, e.g. 'ctrl+v', or this desktop's own.
|
||||
|
||||
`focus` is the process the keys are meant for, remembered when the
|
||||
recording started: see the macOS press for what is done with it.
|
||||
"""
|
||||
here = desktop()
|
||||
here.press(shortcut or here.shortcuts[0], delay)
|
||||
here.press(shortcut or here.shortcuts[0], delay, focus)
|
||||
|
||||
+13
-5
@@ -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()
|
||||
|
||||
@@ -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,10 +22,12 @@ from . import config as cfg
|
||||
from . import filetranscribe
|
||||
from . import ggml
|
||||
from . import hotkey
|
||||
from . import hub
|
||||
from . import i18n
|
||||
from . import ipc
|
||||
from . import meeting
|
||||
from . import paste
|
||||
from . import update
|
||||
from .filetranscribe import FileTranscriber
|
||||
from .i18n import t
|
||||
|
||||
@@ -517,11 +520,16 @@ class SettingsWindow(QDialog):
|
||||
# as the window is built, so the change cannot reach this window: the
|
||||
# owner replaces it with a fresh one instead.
|
||||
language_changed = 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)
|
||||
@@ -540,6 +548,9 @@ class SettingsWindow(QDialog):
|
||||
self._shown_provider = ""
|
||||
# What _save compares against to know a rebuild is due.
|
||||
self._built_language = i18n.language()
|
||||
# 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"))
|
||||
|
||||
@@ -574,6 +585,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)
|
||||
@@ -702,6 +714,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):
|
||||
@@ -1104,6 +1133,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 "
|
||||
@@ -1373,6 +1414,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
|
||||
@@ -1554,6 +1601,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])
|
||||
@@ -1647,6 +1696,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:
|
||||
@@ -1884,6 +1934,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):
|
||||
|
||||
+47
-6
@@ -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("<HHH", 0, 1, len(images))
|
||||
offset = len(header) + 16 * len(images)
|
||||
entries, data = b"", b""
|
||||
for size, png in images:
|
||||
# A side of 256 is written as 0: the field is one byte, and the format
|
||||
# spends it on the sizes below that rather than on the one above.
|
||||
entries += struct.pack("<BBBBHHII", size % 256, size % 256, 0, 0,
|
||||
1, 32, len(png), offset)
|
||||
offset += len(png)
|
||||
data += png
|
||||
path.parent.mkdir(parents=True, exist_ok=True)
|
||||
path.write_bytes(header + entries + data)
|
||||
return path
|
||||
|
||||
|
||||
def _main(argv):
|
||||
"""`trayicon.py <path>.iconset` for install-mac.sh, `--hicolor <dir>` for
|
||||
install.sh.
|
||||
install.sh, `--ico <path>` 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 <directory>.iconset\n"
|
||||
" trayicon.py --hicolor <icon directory>", file=sys.stderr)
|
||||
" trayicon.py --hicolor <icon directory>\n"
|
||||
" trayicon.py --ico <path>.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:
|
||||
|
||||
+158
@@ -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)
|
||||
+10
-5
@@ -50,16 +50,20 @@ class Pipeline(QObject):
|
||||
def busy(self):
|
||||
return self._thread is not None and self._thread.is_alive()
|
||||
|
||||
def run(self, wav_path, duration, rms_values=(), ask=False, paste=None):
|
||||
def run(self, wav_path, duration, rms_values=(), ask=False, paste=None,
|
||||
focus=None):
|
||||
"""`paste` overrides the setting for this one run, which is what a
|
||||
dictation asked for from a terminal wants: the text comes back down the
|
||||
socket, and pasting it into whatever had focus is nobody's intention."""
|
||||
socket, and pasting it into whatever had focus is nobody's intention.
|
||||
|
||||
`focus` is the application that was in front when the recording began,
|
||||
as a process id, and is where the paste is meant to land."""
|
||||
if self.busy:
|
||||
return
|
||||
self._stop.clear()
|
||||
self._thread = threading.Thread(
|
||||
target=self._work,
|
||||
args=(wav_path, duration, list(rms_values), ask, paste),
|
||||
args=(wav_path, duration, list(rms_values), ask, paste, focus),
|
||||
daemon=True,
|
||||
)
|
||||
self._thread.start()
|
||||
@@ -73,7 +77,8 @@ class Pipeline(QObject):
|
||||
"""
|
||||
self._stop.set()
|
||||
|
||||
def _work(self, wav_path, duration, rms_values, ask, paste_override=None):
|
||||
def _work(self, wav_path, duration, rms_values, ask, paste_override=None,
|
||||
focus=None):
|
||||
conf = self.conf
|
||||
started = time.monotonic()
|
||||
raw = ""
|
||||
@@ -144,7 +149,7 @@ class Pipeline(QObject):
|
||||
paste.copy(text)
|
||||
if wants_paste:
|
||||
self.stage.emit(t("Pasting…"))
|
||||
paste.press(conf["paste_shortcut"])
|
||||
paste.press(conf["paste_shortcut"], focus=focus)
|
||||
finally:
|
||||
if previous is not None:
|
||||
# Let the focused application consume the temporary
|
||||
|
||||
+82
@@ -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)."
|
||||
@@ -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"
|
||||
@@ -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;
|
||||
+40
-10
@@ -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",
|
||||
|
||||
+3
-2
@@ -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
|
||||
|
||||
+16
-1
@@ -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")
|
||||
|
||||
@@ -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):
|
||||
|
||||
@@ -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()
|
||||
|
||||
+135
-1
@@ -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()
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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))
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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()
|
||||
|
||||
+10
-4
@@ -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
|
||||
|
||||
@@ -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)
|
||||
@@ -440,6 +444,45 @@ class MacOS(ClipboardContract, DikteTest):
|
||||
self.assertFalse(paste.paste_ready())
|
||||
|
||||
|
||||
class MacPasteGoesWhereTheDictationStarted(MacOS):
|
||||
"""The keys land in the frontmost window, so the front is what decides
|
||||
where a transcript ends up."""
|
||||
|
||||
def setUp(self):
|
||||
super().setUp()
|
||||
from dikte import mac_window
|
||||
self.mac_window = mac_window
|
||||
self.activated = []
|
||||
self.patch_attr(mac_window, "activate", self.activated.append)
|
||||
|
||||
def frontmost(self, dikte_is):
|
||||
self.patch_attr(self.mac_window, "is_frontmost", lambda: dikte_is)
|
||||
|
||||
def test_a_dikte_that_took_the_front_hands_it_back_before_pressing(self):
|
||||
self.frontmost(True)
|
||||
paste.press("cmd+v", focus=4242)
|
||||
self.assertEqual(self.activated, [4242])
|
||||
self.assertEqual([event for _, event in self.api.posted], [1001, 1002])
|
||||
|
||||
def test_another_application_in_front_is_where_the_user_went_and_is_left(self):
|
||||
self.frontmost(False)
|
||||
paste.press("cmd+v", focus=4242)
|
||||
self.assertEqual(self.activated, [])
|
||||
|
||||
def test_a_run_that_remembered_nobody_asks_nothing(self):
|
||||
self.frontmost(True)
|
||||
paste.press("cmd+v")
|
||||
self.assertEqual(self.activated, [])
|
||||
|
||||
def test_the_front_is_handed_back_only_once_macos_trusts_dikte(self):
|
||||
"""Pulling the user out of their window and then failing to type would
|
||||
be the worst of both."""
|
||||
self.frontmost(True)
|
||||
self.api.trusted = False
|
||||
with self.assertRaises(paste.PasteError):
|
||||
paste.press("cmd+v", focus=4242)
|
||||
self.assertEqual(self.activated, [])
|
||||
|
||||
class MacClipboardSnapshot(DikteTest):
|
||||
def test_every_native_type_is_restored_and_the_files_are_removed(self):
|
||||
directory = tempfile.mkdtemp(prefix="dikte-test-clipboard-")
|
||||
@@ -465,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()
|
||||
|
||||
+27
-6
@@ -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):
|
||||
|
||||
@@ -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("<HHH", data)
|
||||
self.assertEqual((reserved, kind), (0, 1))
|
||||
self.assertEqual(count, len(trayicon.ICO_SIZES))
|
||||
for index, size in enumerate(trayicon.ICO_SIZES):
|
||||
with self.subTest(size=size):
|
||||
(width, height, _, _, _, _,
|
||||
length, offset) = struct.unpack_from("<BBBBHHII", data,
|
||||
6 + 16 * index)
|
||||
self.assertEqual((width, height), (size % 256, size % 256))
|
||||
image = QImage.fromData(data[offset:offset + length])
|
||||
self.assertEqual((image.width(), image.height()),
|
||||
(size, size))
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
|
||||
+385
-3
@@ -16,12 +16,16 @@ from PyQt6.QtCore import QPoint, QPointF, Qt
|
||||
from PyQt6.QtGui import QWheelEvent
|
||||
from PyQt6.QtWidgets import QApplication, QMessageBox
|
||||
|
||||
from dikte import audio
|
||||
from dikte import cleanup
|
||||
from dikte import config as cfg
|
||||
from dikte import ggml
|
||||
from dikte import hotkey
|
||||
from dikte import ipc
|
||||
from dikte import overlay as overlay_module
|
||||
from dikte import paste
|
||||
from dikte import settings_ui
|
||||
from dikte import update
|
||||
from tests.support import DikteTest, only_these_tools
|
||||
|
||||
# One application for the whole run; Qt allows no second one.
|
||||
@@ -100,6 +104,7 @@ CHANGED = {
|
||||
"pause_shortcut": "Meta+P",
|
||||
"evdev_hotkey": True,
|
||||
"history_limit": 50,
|
||||
"update_check": False,
|
||||
}
|
||||
|
||||
|
||||
@@ -249,6 +254,31 @@ class Settings(DikteTest):
|
||||
self.assertEqual(shown, [provider])
|
||||
self.assertFalse(box.isHidden())
|
||||
|
||||
def test_the_update_line_names_the_version_that_is_running(self):
|
||||
window = self.window(cfg.Config())
|
||||
self.assertIn(settings_ui.__version__, window.update_status.text())
|
||||
# Nothing to open until a check has found something to open.
|
||||
self.assertTrue(window.update_page.isHidden())
|
||||
|
||||
def test_a_newer_release_puts_the_page_button_on_screen(self):
|
||||
window = self.window(cfg.Config())
|
||||
told = []
|
||||
window.update_found.connect(told.append)
|
||||
release = update.Release("9.9.9", "https://example.invalid/9.9.9", "")
|
||||
window._on_update_checked(release, "")
|
||||
self.assertIn("9.9.9", window.update_status.text())
|
||||
self.assertFalse(window.update_page.isHidden())
|
||||
self.assertEqual(window._release_url, release.url)
|
||||
# And the tray hears about it from here rather than waiting a day.
|
||||
self.assertEqual(told, [release])
|
||||
|
||||
def test_a_check_that_failed_says_why_and_hands_the_button_back(self):
|
||||
window = self.window(cfg.Config())
|
||||
window.update_now.setEnabled(False)
|
||||
window._on_update_checked(None, "api.github.com answered HTTP 403.")
|
||||
self.assertIn("403", window.update_status.text())
|
||||
self.assertTrue(window.update_now.isEnabled())
|
||||
|
||||
def test_the_settings_the_window_does_not_show_are_left_alone(self):
|
||||
"""A tab nobody wrote must not reset what the command line set."""
|
||||
self.write_config({"silence_db": -42.0, "speech_margin_db": 15.0,
|
||||
@@ -281,7 +311,7 @@ class Settings(DikteTest):
|
||||
text = self.shortcut_tab_text(window)
|
||||
self.assertIn("i3 keeps no shortcut registry", text)
|
||||
self.assertNotIn("KWin", text)
|
||||
self.assertIn("__main__.py toggle", text)
|
||||
self.assertIn(ipc.command_for("toggle"), text)
|
||||
# Not a choice to offer where it is the only mechanism there is.
|
||||
self.assertTrue(window.evdev_enabled.isHidden())
|
||||
self.assertFalse([button for button in
|
||||
@@ -458,7 +488,7 @@ class MacSettings(Settings):
|
||||
text = self.shortcut_tab_text(window)
|
||||
self.assertIn("Dikte asks macOS for these combinations", text)
|
||||
self.assertNotIn("KWin", text)
|
||||
self.assertNotIn("__main__.py toggle", text)
|
||||
self.assertNotIn(ipc.command_for("toggle"), text)
|
||||
|
||||
def test_the_paste_keys_on_offer_are_the_ones_a_mac_uses(self):
|
||||
window = self.window(cfg.Config())
|
||||
@@ -482,12 +512,327 @@ class KdeSettings(Settings):
|
||||
self.assertIn("KWin only reads shortcut settings at startup", text)
|
||||
self.assertIn("Install as a KDE shortcut", text)
|
||||
self.assertNotIn("keeps no shortcut registry", text)
|
||||
self.assertNotIn("__main__.py toggle", text)
|
||||
self.assertNotIn(ipc.command_for("toggle"), text)
|
||||
# Here it is a choice: the wait for the next login, or the key press
|
||||
# reaching the focused application as well.
|
||||
self.assertFalse(window.evdev_enabled.isHidden())
|
||||
|
||||
|
||||
|
||||
|
||||
class FakeAppKit:
|
||||
"""The Objective-C runtime, answering rather than being asked.
|
||||
|
||||
Stands in for what mac_window._appkit() loads, so what the indicator's
|
||||
window would have been sent can be read off `told` on any machine. The
|
||||
selectors come back as the names they were registered under, which is what
|
||||
lets the tests below name the message they mean.
|
||||
"""
|
||||
|
||||
def __init__(self, window=4242, panel=True, mask=0xe):
|
||||
self.objc = self
|
||||
self.window, self.panel, self.mask = window, panel, mask
|
||||
self.told = {}
|
||||
|
||||
def objc_getClass(self, name):
|
||||
return 1 if name == b"NSPanel" else 0
|
||||
|
||||
def selector(self, name):
|
||||
return name.decode()
|
||||
|
||||
def shared(self, _class_name, _selector):
|
||||
return 1
|
||||
|
||||
def ask(self, _to, selector):
|
||||
return self.window if selector == "window" else 0
|
||||
|
||||
def ask_unsigned(self, _to, _selector):
|
||||
return self.mask
|
||||
|
||||
def ask_of_class(self, _to, _selector, _klass):
|
||||
return self.panel
|
||||
|
||||
def tell_bool(self, _to, selector, value):
|
||||
self.told[selector] = value
|
||||
|
||||
tell_unsigned = tell_bool
|
||||
|
||||
|
||||
class MacIndicatorWindow(DikteTest):
|
||||
"""Which of the three AppKit settings the indicator's window is sent.
|
||||
|
||||
The nonactivating bit is the one worth a test of its own: it is legal on an
|
||||
NSPanel and nowhere else, and sending it to a plain NSWindow raises an
|
||||
Objective-C exception, which through ctypes is not something Python can
|
||||
catch. It takes the whole process down. `_is_panel` is the only thing
|
||||
standing between the two, so what it answers has to decide what is sent.
|
||||
"""
|
||||
|
||||
def setUp(self):
|
||||
super().setUp()
|
||||
from dikte import mac_window
|
||||
self.mac_window = mac_window
|
||||
self.patch_attr(mac_window.QGuiApplication, "platformName",
|
||||
staticmethod(lambda: "cocoa"))
|
||||
|
||||
def told(self, **kwargs):
|
||||
"""What keep_on_screen sends an indicator, against this AppKit."""
|
||||
appkit = FakeAppKit(**kwargs)
|
||||
self.patch_attr(self.mac_window, "_appkit", lambda: appkit)
|
||||
widget = overlay_module.Overlay()
|
||||
self.addCleanup(widget.deleteLater)
|
||||
self.addCleanup(widget.close)
|
||||
self.answered = self.mac_window.keep_on_screen(widget)
|
||||
return appkit.told
|
||||
|
||||
def test_a_window_that_is_not_a_panel_is_never_sent_the_style_mask(self):
|
||||
"""The message that would kill the process. The other two still go."""
|
||||
told = self.told(panel=False)
|
||||
self.assertTrue(self.answered)
|
||||
self.assertNotIn("setStyleMask:", told)
|
||||
self.assertIs(told["setHidesOnDeactivate:"], False)
|
||||
self.assertEqual(told["setCollectionBehavior:"],
|
||||
self.mac_window.BEHAVIOUR)
|
||||
|
||||
def test_a_panel_without_the_bit_is_sent_the_mask_with_it_added(self):
|
||||
told = self.told(panel=True, mask=0xe)
|
||||
self.assertEqual(told["setStyleMask:"],
|
||||
0xe | self.mac_window.NONACTIVATING_PANEL)
|
||||
|
||||
def test_a_panel_that_already_has_the_bit_is_left_alone(self):
|
||||
"""Every dictation runs this again, and a mask Cocoa did not need is a
|
||||
window it rebuilds underneath the indicator."""
|
||||
told = self.told(panel=True,
|
||||
mask=0xe | self.mac_window.NONACTIVATING_PANEL)
|
||||
self.assertNotIn("setStyleMask:", told)
|
||||
|
||||
def test_a_window_the_view_does_not_have_yet_is_not_messaged(self):
|
||||
self.assertEqual(self.told(window=0), {})
|
||||
self.assertFalse(self.answered)
|
||||
|
||||
def test_nothing_is_sent_anywhere_but_cocoa(self):
|
||||
"""Every other platform hands out a winId that means something else
|
||||
entirely, and messaging it crashes the run."""
|
||||
self.patch_attr(self.mac_window.QGuiApplication, "platformName",
|
||||
staticmethod(lambda: "offscreen"))
|
||||
self.assertEqual(self.told(), {})
|
||||
self.assertFalse(self.answered)
|
||||
|
||||
|
||||
class GivingTheFrontBack(DikteTest):
|
||||
"""Opening the microphone brings Dikte to the front, and the window the
|
||||
user was dictating into goes inactive with the caret in it. Nothing can be
|
||||
asked of the capture session, so the front is put back afterwards.
|
||||
|
||||
The watch is driven by hand here: what matters is what it decides, not how
|
||||
long Qt takes to tick.
|
||||
"""
|
||||
|
||||
def setUp(self):
|
||||
super().setUp()
|
||||
from dikte import app as dikte_module
|
||||
from dikte import mac_window
|
||||
self.dikte = dikte_module
|
||||
self.activated = []
|
||||
self.patch_attr(mac_window, "activate", self.activate)
|
||||
self.ticks = []
|
||||
outer = self
|
||||
|
||||
class FakeTimer:
|
||||
"""Records what it was asked to do and hands over the tick."""
|
||||
|
||||
def __init__(self, _parent):
|
||||
self.interval = None
|
||||
self.running = False
|
||||
outer.ticks.append(self)
|
||||
|
||||
def setInterval(self, milliseconds):
|
||||
self.interval = milliseconds
|
||||
|
||||
def start(self):
|
||||
self.running = True
|
||||
|
||||
def stop(self):
|
||||
self.running = False
|
||||
|
||||
@property
|
||||
def timeout(self):
|
||||
return self
|
||||
|
||||
def connect(self, slot):
|
||||
self.tick = slot
|
||||
|
||||
self.patch_attr(dikte_module, "QTimer", FakeTimer)
|
||||
|
||||
class BareDikte:
|
||||
"""As much of the application as this one method touches."""
|
||||
|
||||
app = None
|
||||
_front_watch = None
|
||||
_the_front = dikte_module.Dikte._the_front
|
||||
_give_the_front_back = dikte_module.Dikte._give_the_front_back
|
||||
_stop_watching_the_front = dikte_module.Dikte._stop_watching_the_front
|
||||
|
||||
self.bare = BareDikte
|
||||
|
||||
def activate(self, pid):
|
||||
self.activated.append(pid)
|
||||
return True
|
||||
|
||||
def watching(self, was_in_front, dikte_in_front, on=None):
|
||||
from dikte import mac_window
|
||||
self.patch_attr(mac_window, "is_frontmost", lambda: dikte_in_front)
|
||||
dikte = on if on is not None else self.bare()
|
||||
dikte._give_the_front_back(was_in_front)
|
||||
return self.ticks[-1] if self.ticks else None
|
||||
|
||||
def test_the_front_goes_back_to_whoever_had_it(self):
|
||||
watch = self.watching(4242, dikte_in_front=True)
|
||||
watch.tick()
|
||||
self.assertEqual(self.activated, [4242])
|
||||
self.assertTrue(watch.running) # accepted is not the same as landed
|
||||
self.patch_attr(self.mac_window_module(), "is_frontmost", lambda: False)
|
||||
watch.tick()
|
||||
self.assertFalse(watch.running)
|
||||
|
||||
def test_an_accepted_restore_is_not_sent_again_while_it_is_landing(self):
|
||||
watch = self.watching(4242, dikte_in_front=True)
|
||||
watch.tick()
|
||||
watch.tick()
|
||||
self.assertEqual(self.activated, [4242])
|
||||
self.assertTrue(watch.running)
|
||||
|
||||
def test_an_accepted_restore_that_never_lands_still_times_out(self):
|
||||
watch = self.watching(4242, dikte_in_front=True)
|
||||
watch.tick()
|
||||
with mock.patch.object(self.dikte.time, "monotonic",
|
||||
return_value=self.dikte.time.monotonic() + 60):
|
||||
watch.tick()
|
||||
self.assertFalse(watch.running)
|
||||
self.assertEqual(self.activated, [4242])
|
||||
|
||||
def test_a_restore_the_system_refused_is_retried(self):
|
||||
from dikte import mac_window
|
||||
self.patch_attr(mac_window, "activate",
|
||||
lambda pid: self.activated.append(pid) or False)
|
||||
watch = self.watching(4242, dikte_in_front=True)
|
||||
watch.tick()
|
||||
watch.tick()
|
||||
self.assertEqual(self.activated, [4242, 4242])
|
||||
self.assertTrue(watch.running)
|
||||
|
||||
def test_a_front_that_was_never_taken_is_left_where_it_is(self):
|
||||
"""The microphone does not always take it, and pulling an application
|
||||
forward that is already there is one flicker for nothing."""
|
||||
watch = self.watching(4242, dikte_in_front=False)
|
||||
watch.tick()
|
||||
self.assertEqual(self.activated, [])
|
||||
self.assertTrue(watch.running) # still waiting for the moment
|
||||
|
||||
def test_it_gives_up_rather_than_watching_for_ever(self):
|
||||
watch = self.watching(4242, dikte_in_front=False)
|
||||
with mock.patch.object(self.dikte.time, "monotonic",
|
||||
return_value=self.dikte.time.monotonic() + 60):
|
||||
watch.tick()
|
||||
self.assertFalse(watch.running)
|
||||
self.assertEqual(self.activated, [])
|
||||
|
||||
def test_a_dictation_started_in_dikte_itself_watches_nothing(self):
|
||||
"""Settings is a window of ours, and the front is already where it
|
||||
belongs."""
|
||||
self.assertIsNone(self.watching(os.getpid(), dikte_in_front=True))
|
||||
|
||||
def test_a_second_recording_calls_off_the_watch_the_first_one_left(self):
|
||||
"""The older watch remembers where the older recording started, and by
|
||||
now that is the wrong window to be pulling forward."""
|
||||
dikte = self.bare()
|
||||
first = self.watching(4242, dikte_in_front=False, on=dikte)
|
||||
second = self.watching(1111, dikte_in_front=False, on=dikte)
|
||||
self.assertFalse(first.running)
|
||||
self.assertTrue(second.running)
|
||||
second.tick() # and the survivor is the new one
|
||||
self.patch_attr(self.mac_window_module(), "is_frontmost", lambda: True)
|
||||
second.tick()
|
||||
self.assertEqual(self.activated, [1111])
|
||||
|
||||
def test_a_recording_nobody_needs_watching_for_still_calls_off_the_old_one(self):
|
||||
"""Starting the next one from Dikte's own window is not a reason to
|
||||
leave the last one's watch running."""
|
||||
dikte = self.bare()
|
||||
first = self.watching(4242, dikte_in_front=False, on=dikte)
|
||||
self.watching(os.getpid(), dikte_in_front=False, on=dikte)
|
||||
self.assertFalse(first.running)
|
||||
self.assertIsNone(dikte._front_watch)
|
||||
|
||||
def test_nobody_in_front_is_nobody_to_go_back_to(self):
|
||||
self.assertIsNone(self.watching(None, dikte_in_front=True))
|
||||
|
||||
def mac_window_module(self):
|
||||
from dikte import mac_window
|
||||
return mac_window
|
||||
|
||||
|
||||
class EveryRecordingProtectsTheFront(DikteTest):
|
||||
"""Three ways in, dictation, agent and meeting, and all three open the same
|
||||
avfoundation capture, so all three take the front the same way. What is
|
||||
checked here is the order: the front has to be noted before the microphone
|
||||
is opened, and the watch armed after, or there is nothing to go back to.
|
||||
"""
|
||||
|
||||
def setUp(self):
|
||||
super().setUp()
|
||||
from dikte import app as dikte_module
|
||||
self.dikte = dikte_module
|
||||
self.order = []
|
||||
|
||||
def app(self, **attributes):
|
||||
"""A Dikte that records the order it does things in, and nothing else.
|
||||
|
||||
The methods under test are called unbound against it, so the stand-ins
|
||||
go on the object rather than on the class.
|
||||
"""
|
||||
dikte = mock.Mock(**attributes)
|
||||
dikte._run_id = 0
|
||||
dikte.conf = {"mic_target": "", "max_seconds": 60,
|
||||
"meeting_mic_target": "", "meeting_system_target": "",
|
||||
"meeting_max_seconds": 60}
|
||||
dikte._the_front.side_effect = lambda: self.order.append("noted") or 4242
|
||||
dikte._give_the_front_back.side_effect = (
|
||||
lambda pid: self.order.append(f"watching {pid}"))
|
||||
dikte._begin_recording = (
|
||||
lambda owner: self.dikte.Dikte._begin_recording(dikte, owner))
|
||||
dikte.recorder.start.side_effect = (
|
||||
lambda *_a: self.order.append("microphone"))
|
||||
dikte.meeting_recorder.start.side_effect = (
|
||||
lambda *_a: self.order.append("microphone"))
|
||||
return dikte
|
||||
|
||||
def test_a_dictation_notes_the_front_before_the_indicator_is_even_shown(self):
|
||||
dikte = self.app(state=self.dikte.IDLE, recording=False)
|
||||
dikte.overlay.show_recording.side_effect = (
|
||||
lambda *_a: self.order.append("indicator"))
|
||||
self.dikte.Dikte.start(dikte)
|
||||
self.assertEqual(self.order,
|
||||
["noted", "indicator", "microphone", "watching 4242"])
|
||||
|
||||
def test_the_agent_does_the_same(self):
|
||||
dikte = self.app(ask_state=self.dikte.IDLE, recording=False)
|
||||
self.dikte.Dikte.start_ask(dikte)
|
||||
self.assertEqual(self.order, ["noted", "microphone", "watching 4242"])
|
||||
|
||||
def test_a_meeting_does_the_same(self):
|
||||
"""The one most worth protecting: the user is in a call."""
|
||||
dikte = self.app(meeting_state=self.dikte.M_IDLE)
|
||||
dikte.meeting_recorder.active = True
|
||||
self.dikte.Dikte.start_meeting(dikte)
|
||||
self.assertEqual(self.order, ["noted", "microphone", "watching 4242"])
|
||||
|
||||
def test_a_meeting_whose_microphone_never_opened_watches_nothing(self):
|
||||
dikte = self.app(meeting_state=self.dikte.M_IDLE)
|
||||
dikte.meeting_recorder.active = False
|
||||
self.dikte.Dikte.start_meeting(dikte)
|
||||
self.assertEqual(self.order, ["noted", "microphone"])
|
||||
|
||||
class Overlay(DikteTest):
|
||||
def overlay(self, **kwargs):
|
||||
widget = overlay_module.Overlay(**kwargs)
|
||||
@@ -610,6 +955,36 @@ class Overlay(DikteTest):
|
||||
self.assertFalse(widget.muted)
|
||||
|
||||
|
||||
class MeetingSources(DikteTest):
|
||||
"""What the Meeting tab says about the far side, per sound system.
|
||||
|
||||
The box that picks it is empty on a system that cannot record it, and an
|
||||
empty box with nothing next to it reads as a list that has not loaded yet.
|
||||
"""
|
||||
|
||||
def notes(self, meetings):
|
||||
with mock.patch.object(audio, "sound",
|
||||
return_value=audio.PULSE._replace(
|
||||
meetings=meetings)), \
|
||||
only_these_tools(), \
|
||||
mock.patch.object(settings_ui.SettingsWindow, "_load_models"), \
|
||||
mock.patch.object(settings_ui.SettingsWindow,
|
||||
"_load_transcribe_models"):
|
||||
window = settings_ui.SettingsWindow(cfg.Config())
|
||||
self.addCleanup(window.deleteLater)
|
||||
self.addCleanup(window.close)
|
||||
return " ".join(label.text()
|
||||
for label in window.findChildren(settings_ui.QLabel))
|
||||
|
||||
def test_a_system_that_cannot_record_the_far_side_says_so(self):
|
||||
self.assertIn("nothing that records what the speakers",
|
||||
self.notes(meetings=False))
|
||||
|
||||
def test_a_system_that_can_says_nothing_of_the_sort(self):
|
||||
self.assertNotIn("nothing that records what the speakers",
|
||||
self.notes(meetings=True))
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
|
||||
@@ -617,6 +992,13 @@ if __name__ == "__main__":
|
||||
class LocalModels(DikteTest):
|
||||
"""The download boxes, without a network and without either program."""
|
||||
|
||||
def setUp(self):
|
||||
super().setUp()
|
||||
# A machine Dikte is actually installed on would otherwise answer the
|
||||
# "nothing can transcribe" question from its real binary and model.
|
||||
self.patch_attr(ggml, "BIN_DIR", self.path("bin"))
|
||||
self.patch_attr(ggml, "MODELS_DIR", self.path("models"))
|
||||
|
||||
def window(self, conf):
|
||||
window = settings_ui.SettingsWindow(conf)
|
||||
self.addCleanup(window.deleteLater)
|
||||
|
||||
@@ -0,0 +1,175 @@
|
||||
"""Whether a newer release is one worth telling somebody about.
|
||||
|
||||
Two things carry the weight here. A version is compared by its numbers alone,
|
||||
because a build off master carries the released number with its commit after
|
||||
it and is ahead of that release rather than behind it. And the clock lives in a
|
||||
file, so a day of asking nobody has to survive a restart.
|
||||
"""
|
||||
|
||||
import json
|
||||
import time
|
||||
|
||||
from dikte import hub
|
||||
from dikte import update
|
||||
from tests.support import DikteTest, fake_urlopen, url_error
|
||||
|
||||
RELEASE = {
|
||||
"tag_name": "v1.4.0",
|
||||
"html_url": "https://github.com/yusufipk/dikte/releases/tag/v1.4.0",
|
||||
"published_at": "2026-08-01T10:00:00Z",
|
||||
}
|
||||
|
||||
|
||||
class Numbers(DikteTest):
|
||||
def test_a_tag_and_a_bare_number_read_the_same(self):
|
||||
self.assertEqual(update._numbers("v1.4.0"), (1, 4, 0))
|
||||
self.assertEqual(update._numbers("1.4.0"), (1, 4, 0))
|
||||
|
||||
def test_a_short_number_is_filled_out(self):
|
||||
self.assertEqual(update._numbers("2"), (2, 0, 0))
|
||||
self.assertEqual(update._numbers("2.1"), (2, 1, 0))
|
||||
|
||||
def test_what_follows_the_number_is_dropped(self):
|
||||
self.assertEqual(update._numbers("1.0.1-dev.abc1234"), (1, 0, 1))
|
||||
self.assertEqual(update._numbers("1.0.1+build7"), (1, 0, 1))
|
||||
|
||||
def test_something_that_is_not_a_version_is_no_version(self):
|
||||
self.assertEqual(update._numbers("latest"), ())
|
||||
self.assertEqual(update._numbers(""), ())
|
||||
self.assertEqual(update._numbers(None), ())
|
||||
|
||||
|
||||
class Newer(DikteTest):
|
||||
def test_a_higher_number_is_newer(self):
|
||||
self.assertTrue(update.newer("1.4.0", "1.3.9"))
|
||||
self.assertTrue(update.newer("v2.0.0", "1.9.9"))
|
||||
|
||||
def test_the_same_number_is_not(self):
|
||||
self.assertFalse(update.newer("1.4.0", "1.4.0"))
|
||||
self.assertFalse(update.newer("1.3.0", "1.4.0"))
|
||||
|
||||
def test_a_build_off_master_is_ahead_of_the_release_it_names(self):
|
||||
"""1.0.1-dev.abc1234 was built after 1.0.1 went out, not before it.
|
||||
Read as a version suffix it would be older, and every nightly would be
|
||||
told to go back to the release it had already passed."""
|
||||
self.assertFalse(update.newer("1.0.1", "1.0.1-dev.abc1234"))
|
||||
self.assertTrue(update.newer("1.0.2", "1.0.1-dev.abc1234"))
|
||||
|
||||
def test_a_tag_that_is_not_a_version_is_never_newer(self):
|
||||
self.assertFalse(update.newer("nightly", "1.0.0"))
|
||||
|
||||
|
||||
class Asking(DikteTest):
|
||||
def setUp(self):
|
||||
super().setUp()
|
||||
self.patch_attr(hub, "CACHE_DIR", self.path("cache"))
|
||||
self.patch_attr(update, "__version__", "1.0.0")
|
||||
|
||||
def test_the_newest_release_comes_back_with_its_page(self):
|
||||
with fake_urlopen(RELEASE) as calls:
|
||||
release = update.latest()
|
||||
self.assertEqual(release.version, "1.4.0")
|
||||
self.assertEqual(release.url, RELEASE["html_url"])
|
||||
self.assertEqual(
|
||||
calls[0].full_url,
|
||||
"https://api.github.com/repos/yusufipk/dikte/releases/latest")
|
||||
|
||||
def test_a_release_with_no_page_falls_back_to_the_redirect(self):
|
||||
with fake_urlopen({"tag_name": "v1.4.0"}):
|
||||
release = update.latest()
|
||||
self.assertEqual(release.url, update.RELEASES_PAGE)
|
||||
|
||||
def test_a_repository_with_no_release_is_an_error(self):
|
||||
with fake_urlopen({"message": "Not Found"}):
|
||||
with self.assertRaises(hub.HubError):
|
||||
update.latest()
|
||||
|
||||
def test_a_check_answers_with_the_newer_release(self):
|
||||
with fake_urlopen(RELEASE):
|
||||
release = update.check()
|
||||
self.assertEqual(release.version, "1.4.0")
|
||||
|
||||
def test_a_check_that_finds_nothing_new_answers_with_nothing(self):
|
||||
self.patch_attr(update, "__version__", "1.4.0")
|
||||
with fake_urlopen(RELEASE):
|
||||
self.assertIsNone(update.check())
|
||||
|
||||
def test_a_second_check_the_same_day_asks_nobody(self):
|
||||
with fake_urlopen(RELEASE) as calls:
|
||||
update.check()
|
||||
release = update.check()
|
||||
self.assertEqual(len(calls), 1)
|
||||
# And still says what the first one found, since it is still true.
|
||||
self.assertEqual(release.version, "1.4.0")
|
||||
|
||||
def test_a_day_later_it_asks_again(self):
|
||||
with fake_urlopen(RELEASE) as calls:
|
||||
update.check()
|
||||
update._store(checked=time.time() - update.INTERVAL - 60)
|
||||
update.check()
|
||||
self.assertEqual(len(calls), 2)
|
||||
|
||||
def test_the_button_asks_whatever_the_clock_says(self):
|
||||
with fake_urlopen(RELEASE) as calls:
|
||||
update.check()
|
||||
update.check(force=True)
|
||||
self.assertEqual(len(calls), 2)
|
||||
|
||||
def test_a_check_that_cannot_reach_github_says_so(self):
|
||||
with fake_urlopen(url_error("no route to host")):
|
||||
with self.assertRaises(hub.HubError):
|
||||
update.check()
|
||||
|
||||
|
||||
class Remembering(DikteTest):
|
||||
def setUp(self):
|
||||
super().setUp()
|
||||
self.patch_attr(hub, "CACHE_DIR", self.path("cache"))
|
||||
self.patch_attr(update, "__version__", "1.0.0")
|
||||
|
||||
def test_what_the_last_check_found_survives_a_restart(self):
|
||||
with fake_urlopen(RELEASE):
|
||||
update.check()
|
||||
release = update.pending()
|
||||
self.assertEqual(release.version, "1.4.0")
|
||||
self.assertEqual(release.url, RELEASE["html_url"])
|
||||
|
||||
def test_nothing_was_ever_checked(self):
|
||||
self.assertIsNone(update.pending())
|
||||
self.assertEqual(update.state(), {})
|
||||
self.assertTrue(update.due())
|
||||
|
||||
def test_a_release_that_is_no_longer_newer_is_not_pending(self):
|
||||
"""The state file outlives the build that wrote it: an update that was
|
||||
found and then installed must not still be waiting afterwards."""
|
||||
update._store(version="1.4.0")
|
||||
self.patch_attr(update, "__version__", "1.4.0")
|
||||
self.assertIsNone(update.pending())
|
||||
|
||||
def test_a_version_is_announced_once(self):
|
||||
self.assertEqual(update.announced(), "")
|
||||
update.mark_announced("1.4.0")
|
||||
self.assertEqual(update.announced(), "1.4.0")
|
||||
|
||||
def test_a_state_file_that_is_rubbish_is_no_state_at_all(self):
|
||||
update.STATE_FILE.parent.mkdir(parents=True, exist_ok=True)
|
||||
update.STATE_FILE.write_text("half a {", encoding="utf-8")
|
||||
self.assertEqual(update.state(), {})
|
||||
self.assertIsNone(update.pending())
|
||||
|
||||
def test_a_state_file_that_cannot_be_written_is_not_a_failure(self):
|
||||
self.patch_attr(update, "STATE_FILE",
|
||||
self.path("nope") / "deeper" / "update.json")
|
||||
self.path("nope").write_text("a file where a directory would go")
|
||||
with fake_urlopen(RELEASE):
|
||||
release = update.check()
|
||||
self.assertEqual(release.version, "1.4.0")
|
||||
|
||||
def test_the_clock_is_kept_out_of_the_settings(self):
|
||||
"""A background check writes while the settings window may be open, and
|
||||
a write into config.json there would undo whatever it holds."""
|
||||
with fake_urlopen(RELEASE):
|
||||
update.check()
|
||||
stored = json.loads(update.STATE_FILE.read_text(encoding="utf-8"))
|
||||
self.assertEqual(stored["version"], "1.4.0")
|
||||
self.assertGreater(stored["checked"], 0)
|
||||
+13
-3
@@ -32,7 +32,8 @@ class Chain(DikteTest):
|
||||
transcript="uh, book it for Thursday",
|
||||
cleaned="Book it for Thursday.",
|
||||
cleanup_error=None, answer=("Booked.", ""), rms=None,
|
||||
clipboard=b"what was there before", paste_error=None):
|
||||
clipboard=b"what was there before", paste_error=None,
|
||||
focus=None):
|
||||
pipeline = worker.Pipeline(self.conf)
|
||||
done, failures, stages, cancels = [], [], [], []
|
||||
pipeline.finished.connect(lambda *args: done.append(args))
|
||||
@@ -60,7 +61,8 @@ class Chain(DikteTest):
|
||||
"copy": copy, "copy_bytes": copy_bytes, "press": press,
|
||||
"read_clipboard": read_clipboard}
|
||||
pipeline._work(self.wav, duration,
|
||||
self.rms if rms is None else rms, ask, paste_override)
|
||||
self.rms if rms is None else rms, ask, paste_override,
|
||||
focus)
|
||||
return {"done": done, "failures": failures, "stages": stages,
|
||||
"cancelled": cancels, **calls}
|
||||
|
||||
@@ -72,7 +74,15 @@ class Chain(DikteTest):
|
||||
self.assertEqual(run["done"][0],
|
||||
("uh, book it for Thursday", "Book it for Thursday.", ""))
|
||||
run["copy"].assert_called_once_with("Book it for Thursday.")
|
||||
run["press"].assert_called_once_with(self.conf["paste_shortcut"])
|
||||
run["press"].assert_called_once_with(self.conf["paste_shortcut"],
|
||||
focus=None)
|
||||
|
||||
def test_the_paste_is_told_where_the_dictation_started(self):
|
||||
"""Whoever was in front when the recording began is where the keys are
|
||||
meant to go, and the press is the only part that can act on it."""
|
||||
run = self.run_chain(focus=4242)
|
||||
run["press"].assert_called_once_with(self.conf["paste_shortcut"],
|
||||
focus=4242)
|
||||
|
||||
def test_the_stages_are_named_as_they_happen(self):
|
||||
run = self.run_chain()
|
||||
|
||||
Reference in New Issue
Block a user