Put the modules in a package and the scripts in a folder

Twenty-two files at the top of the tree was the first thing anybody saw of
this repository. They are one package now, imported relatively, and the three
scripts that are not the front door moved under scripts/. install.sh stays
where the README has always said it is.

What starts the application is dikte/__main__.py: python3 -m dikte runs it,
and so does naming the file, which is what the launcher symlink, both .desktop
files, the macOS bundle and every registered shortcut do. Run by path there is
no package around it, so it puts the checkout on sys.path itself.

The installers now keep the keys you chose when they are given none, which is
what an update is: update.sh no longer has to read them out and pass them back.
An updater from before this commit cannot read them at all, so the one thing it
can say, the default key with an empty discard key, is read as "nothing was
asked for" rather than obeyed. That guard can go once nobody is updating across
this commit.
This commit is contained in:
2026-08-16 14:01:01 +03:00
parent 8444b6822d
commit 2f59029261
50 changed files with 316 additions and 215 deletions
+3 -3
View File
@@ -71,6 +71,6 @@ jobs:
- name: Check the installer parses
run: |
bash -n install.sh
bash -n install-mac.sh
bash -n update.sh
bash -n uninstall.sh
bash -n scripts/install-mac.sh
bash -n scripts/update.sh
bash -n scripts/uninstall.sh
+12 -6
View File
@@ -56,7 +56,7 @@ tools instead:
sudo apt install pulseaudio-utils xclip xdotool ffmpeg
```
On macOS the same `./install.sh` runs and hands over to `install-mac.sh`, which
On macOS the same `./install.sh` runs and hands over to `scripts/install-mac.sh`, which
puts down a `Dikte.app` in `~/Applications`, the `dikte` command and a
LaunchAgent:
@@ -84,9 +84,10 @@ transcribe in the cloud. A meeting needs BlackHole or Loopback
(`brew install blackhole-2ch`); dictation does not.
`install.sh` adds the `dikte` command, a menu entry, an autostart entry and the
two global shortcuts, whose keys are its two arguments. `./update.sh` pulls and
puts all of that back, keeping the keys you chose; `./uninstall.sh` takes it away
again and leaves your settings and dictations alone unless you pass `--purge`.
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`.
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:
@@ -201,8 +202,13 @@ keys.
## Layout
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 and uninstall.sh; install.sh stays at
the top, and `tests/` has a file per module.
```
dikte.py entry point, tray icon, state machine
app.py entry point, tray icon, state machine
cli.py the command line: every verb, and what it answers with
ipc.py one request and one reply over the local socket
audio.py PCM capture: pw-record for dictation, ffmpeg for a meeting
@@ -224,7 +230,7 @@ i18n.py the string table
```
The indicator is drawn through XWayland, because a Wayland client cannot place a
window in a screen corner; `dikte.py` sets `QT_QPA_PLATFORM=xcb` for that.
window in a screen corner; `app.py` sets `QT_QPA_PLATFORM=xcb` for that.
## License
+12 -7
View File
@@ -55,7 +55,7 @@ araçlarıyla çalışır:
sudo apt install pulseaudio-utils xclip xdotool ffmpeg
```
macOS'ta da aynı `./install.sh` çalışır, işi `install-mac.sh`'a devreder;
macOS'ta da aynı `./install.sh` çalışır, işi `scripts/install-mac.sh`'a devreder;
`~/Applications` içine bir `Dikte.app`, `dikte` komutunu ve bir LaunchAgent
kurar:
@@ -83,10 +83,10 @@ BlackHole veya Loopback gerekiyor (`brew install blackhole-2ch`); dikte için
gerekmiyor.
`install.sh` `dikte` komutunu, menü girdisini, oturum açılışında otomatik
başlatmayı ve iki global kısayolu kurar; tuşları da iki argümanı. `./update.sh`
son sürümü çeker ve bunları senin seçtiğin tuşlarla yerine koyar;
`./uninstall.sh` hepsini geri alır, `--purge` demedikçe ayarlarına ve
diktelerine dokunmaz.
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.
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
@@ -198,8 +198,13 @@ Kısayollar sekmesi bağlanacak komutu gösterir.
## Dosyalar
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 ve uninstall.sh var; install.sh en
üstte kalır, `tests/` içinde de her modülün bir dosyası.
```
dikte.py giriş noktası, tepsi simgesi, durum makinesi
app.py giriş noktası, tepsi simgesi, durum makinesi
cli.py komut satırı: bütün fiiller ve verdikleri cevap
ipc.py yerel sokette bir istek, bir cevap
audio.py PCM kaydı: diktede pw-record, toplantıda ffmpeg
@@ -221,7 +226,7 @@ i18n.py metin tablosu
```
Gösterge XWayland üzerinden çizilir; Wayland'da bir pencereyi belirli bir köşeye
yerleştirmenin yolu yok, `dikte.py` bu yüzden `QT_QPA_PLATFORM=xcb` ayarlar.
yerleştirmenin yolu yok, `app.py` bu yüzden `QT_QPA_PLATFORM=xcb` ayarlar.
## Lisans
+6
View File
@@ -0,0 +1,6 @@
"""Dikte: press a key, talk, press again to transcribe, clean up and paste.
The package is the application. Nothing is imported here on purpose: `dikte
config get` runs through the same package as the tray icon does, and it has no
business loading Qt to answer one question.
"""
+25
View File
@@ -0,0 +1,25 @@
#!/usr/bin/env python3
"""What `python3 -m dikte` and the installed `dikte` command both run.
The file is also executed by path, because that is what a desktop shortcut and
the launcher in ~/.local/bin do: neither knows a working directory to be in.
Run that way there is no package around it, so the checkout has to be put on
the import path here, before the first line of the application is imported.
"""
import os
import sys
if not __package__:
# realpath, because the launcher is a symlink into the checkout: what has to
# end up on the path is the checkout, not ~/.local/bin. The directory this
# file is in comes off the path in exchange, so that a module beside it is
# only ever reachable as part of the package.
here = os.path.dirname(os.path.realpath(__file__))
sys.path[:] = [p for p in sys.path if os.path.realpath(p or ".") != here]
sys.path.insert(0, os.path.dirname(here))
from dikte.app import main # noqa: E402
if __name__ == "__main__":
sys.exit(main())
+2 -2
View File
@@ -22,8 +22,8 @@ import threading
import urllib.error
import urllib.request
import ggml
from i18n import t
from . import ggml
from .i18n import t
APP_URL = "https://github.com/yusufipk/dikte"
USER_AGENT = f"dikte/1.0 (+{APP_URL})"
Executable → Regular
+18 -21
View File
@@ -1,10 +1,11 @@
#!/usr/bin/env python3
"""Dikte: press Ctrl+Space, talk, press again to transcribe, clean up and paste.
This is the application: the tray icon, the state machine, and the socket the
terminal talks to. Every verb it answers is in cli.py, which is also what runs
`dikte.py --help`; the only argument handled here is --gui, which is how the
`dikte --help`; the only argument handled here is --gui, which is how the
command line says "there is no instance to talk to, so be one".
Nothing runs this file directly; __main__.py is what the launchers start.
"""
import contextlib
@@ -36,21 +37,21 @@ from PyQt6.QtGui import QAction, QIcon # noqa: E402
from PyQt6.QtNetwork import QLocalServer, QLocalSocket # noqa: E402
from PyQt6.QtWidgets import QApplication, QMenu, QSystemTrayIcon # noqa: E402
import assistant # noqa: E402
import audio # noqa: E402
import cli # noqa: E402
import config as cfg # noqa: E402
import ggml # noqa: E402
import hotkey # noqa: E402
import i18n # noqa: E402
import ipc # noqa: E402
import meeting # noqa: E402
import trayicon # noqa: E402
from i18n import t # noqa: E402
from meeting import MeetingPipeline # noqa: E402
from overlay import Overlay # noqa: E402
from settings_ui import SettingsWindow # noqa: E402
from worker import Pipeline # noqa: E402
from . import assistant # noqa: E402
from . import audio # noqa: E402
from . import cli # noqa: E402
from . import config as cfg # noqa: E402
from . import ggml # noqa: E402
from . import hotkey # noqa: E402
from . import i18n # noqa: E402
from . import ipc # noqa: E402
from . import meeting # noqa: E402
from . import trayicon # noqa: E402
from .i18n import t # noqa: E402
from .meeting import MeetingPipeline # noqa: E402
from .overlay import Overlay # noqa: E402
from .settings_ui import SettingsWindow # noqa: E402
from .worker import Pipeline # noqa: E402
SERVER_NAME = ipc.SERVER_NAME
IDLE, RECORDING, BUSY = "idle", "recording", "busy"
@@ -1168,7 +1169,3 @@ def run_app(args):
QTimer.singleShot(0, dikte.toggle_meeting)
return app.exec()
if __name__ == "__main__":
sys.exit(main())
+3 -3
View File
@@ -29,9 +29,9 @@ import subprocess
import threading
import time
import api
import config as cfg
from i18n import t
from . import api
from . import config as cfg
from .i18n import t
SESSION_FILE = cfg.DATA_DIR / "assistant.json"
PROVIDERS = ("claude", "codex", "openrouter")
+1 -1
View File
@@ -31,7 +31,7 @@ import wave
from PyQt6.QtCore import QObject, pyqtSignal
from i18n import t
from .i18n import t
RATE = 16000
CHANNELS = 1
+4 -4
View File
@@ -18,10 +18,10 @@ import shutil
import subprocess
import tempfile
import api
import assistant
import ggml
from i18n import t
from . import api
from . import assistant
from . import ggml
from .i18n import t
PROVIDERS = ("openrouter", "local", "claude", "codex")
+10 -10
View File
@@ -22,16 +22,16 @@ import time
from PyQt6.QtCore import QCoreApplication, QTimer
import api
import assistant
import audio
import cleanup
import config as cfg
import filetranscribe
import hotkey
import ipc
import meeting
import paste
from . import api
from . import assistant
from . import audio
from . import cleanup
from . import config as cfg
from . import filetranscribe
from . import hotkey
from . import ipc
from . import meeting
from . import paste
NOT_RUNNING = 3
+6 -6
View File
@@ -6,12 +6,12 @@ import json
import os
import sys
import api
import ggml
import i18n
import paste
import paths
from i18n import t
from . import api
from . import ggml
from . import i18n
from . import paste
from . import paths
from .i18n import t
_MACOS = sys.platform == "darwin"
@@ -23,10 +23,10 @@ import wave
from PyQt6.QtCore import QObject, pyqtSignal
import api
import cleanup
import ggml
from i18n import t
from . import api
from . import cleanup
from . import ggml
from .i18n import t
UPLOAD_LIMIT = 24 * 1024 * 1024 # the APIs take 25 MB; leave the form its room
MP3_BITRATE = "48k" # mono speech at 16 kHz: whisper hears nothing less
+3 -3
View File
@@ -44,9 +44,9 @@ import time
import urllib.error
import urllib.request
import hub
import paths
from i18n import t
from . import hub
from . import paths
from .i18n import t
HOST = "127.0.0.1"
# The path api.py asks for, so its URL and the server's line up.
+1 -1
View File
@@ -30,7 +30,7 @@ import threading
from PyQt6.QtCore import QObject, pyqtSignal
from i18n import t
from .i18n import t
DESKTOP_ID = "dikte-toggle.desktop"
CANCEL_DESKTOP_ID = "dikte-cancel.desktop"
+1 -1
View File
@@ -25,7 +25,7 @@ import urllib.error
import urllib.parse
import urllib.request
from i18n import t
from .i18n import t
GITHUB_API = "https://api.github.com"
HF_API = "https://huggingface.co/api"
View File
+6 -1
View File
@@ -22,8 +22,13 @@ CONNECT_MS = 800
def script_path():
"""The package entry point, as a path.
A shortcut and a relaunch both start a second process, and neither has a
working directory to run `-m dikte` from, so the file is named outright.
"""
return os.path.realpath(
os.path.join(os.path.dirname(os.path.abspath(__file__)), "dikte.py")
os.path.join(os.path.dirname(os.path.abspath(__file__)), "__main__.py")
)
+7 -7
View File
@@ -24,13 +24,13 @@ import wave
from PyQt6.QtCore import QObject, pyqtSignal
import api
import cleanup
import config as cfg
import filetranscribe
import vad
from filetranscribe import Cancelled, format_timestamp
from i18n import t
from . import api
from . import cleanup
from . import config as cfg
from . import filetranscribe
from . import vad
from .filetranscribe import Cancelled, format_timestamp
from .i18n import t
# Where the document stops being prose and starts being the transcript. It is a
# comment, so it never shows up in a rendered document, and it is what a retry
View File
+1 -1
View File
@@ -19,7 +19,7 @@ import sys
import tempfile
import time
from i18n import t
from .i18n import t
# Linux input event codes (linux/input-event-codes.h), which is what ydotool
# takes. They are also the list of keys a paste shortcut may be built from, so
View File
+13 -13
View File
@@ -13,19 +13,19 @@ from PyQt6.QtWidgets import (
QPushButton, QScrollArea, QSpinBox, QTabWidget, QVBoxLayout, QWidget,
)
import api
import assistant
import audio
import cleanup
import config as cfg
import filetranscribe
import ggml
import hotkey
import ipc
import meeting
import paste
from filetranscribe import FileTranscriber
from i18n import t
from . import api
from . import assistant
from . import audio
from . import cleanup
from . import config as cfg
from . import filetranscribe
from . import ggml
from . import hotkey
from . import ipc
from . import meeting
from . import paste
from .filetranscribe import FileTranscriber
from .i18n import t
UI_LANGUAGES = [("Automatic (system)", "auto"), ("Turkish", "tr"), ("English", "en")]
LANGUAGES = [
+1 -1
View File
@@ -118,7 +118,7 @@ def _working(painter, size, ink):
painter.drawPath(head)
# The names Linux themes use, which are what dikte.py asks for either way.
# The names Linux themes use, which are what app.py asks for either way.
SHAPES = {
"audio-input-microphone": _microphone,
"media-record": _record,
View File
+9 -9
View File
@@ -15,15 +15,15 @@ import traceback
from PyQt6.QtCore import QObject, pyqtSignal
import api
import assistant
import audio
import cleanup
import config as cfg
import i18n
import paste
import vad
from i18n import t
from . import api
from . import assistant
from . import audio
from . import cleanup
from . import config as cfg
from . import i18n
from . import paste
from . import vad
from .i18n import t
CHUNK_SECONDS = audio.CHUNK_FRAMES / audio.RATE
+43 -14
View File
@@ -9,18 +9,35 @@ DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
# path. That is a second script rather than a branch through this one, and
# update.sh reaches it through here without having to know which it is on.
if [[ "$(uname -s)" == "Darwin" ]]; then
exec "$DIR/install-mac.sh" "$@"
exec "$DIR/scripts/install-mac.sh" "$@"
fi
PY="$(command -v python3)"
# The one file that starts the application, whoever is asking: the launcher
# below, both .desktop files, and every shortcut Dikte registers.
ENTRY="$DIR/dikte/__main__.py"
BIN_DIR="$HOME/.local/bin"
APP_DIR="$HOME/.local/share/applications"
AUTOSTART_DIR="$HOME/.config/autostart"
ICON_DIR="${XDG_DATA_HOME:-$HOME/.local/share}/icons"
SHORTCUT="${1:-Ctrl+Space}"
# Without the colon, so that a second argument given as "" stays empty. That is
# how update.sh says "this one was turned off", as against not saying anything.
CANCEL_SHORTCUT="${2-Ctrl+Alt+Space}"
# Only the one: the discard key's default is the settings' own, read back below.
DEFAULT_SHORTCUT="Ctrl+Space"
# Given as arguments, or asked of the settings further down. An installer run
# again, which is what every update does, must not undo a key you chose in
# Settings, so silence here means "keep whatever is there".
SHORTCUT="${1:-}"
CANCEL_SHORTCUT="${2-}"
# Passed as "" means the discard key is off, which is not the same answer as
# not being passed at all.
CANCEL_GIVEN=$(( $# >= 2 ))
# One caller says both without meaning either: an updater from before Dikte
# became a package looks for the settings at a path that no longer exists, and
# so passes the default key and an empty discard key rather than yours. This
# can go once nobody is updating across that commit any more.
if [[ "$SHORTCUT" == "$DEFAULT_SHORTCUT" && $CANCEL_GIVEN == 1 && -z "$CANCEL_SHORTCUT" ]]; then
SHORTCUT=""
CANCEL_GIVEN=0
fi
say() { printf ' %s\n' "$1"; }
ok() { printf ' \033[32m✓\033[0m %s\n' "$1"; }
@@ -83,8 +100,8 @@ fi
# 3. Launchers -------------------------------------------------------------
mkdir -p "$BIN_DIR" "$APP_DIR" "$AUTOSTART_DIR" "$ICON_DIR"
ln -sf "$DIR/dikte.py" "$BIN_DIR/dikte"
chmod +x "$DIR/dikte.py"
ln -sf "$ENTRY" "$BIN_DIR/dikte"
chmod +x "$ENTRY"
ok "Command installed: $BIN_DIR/dikte"
case ":$PATH:" in
*":$BIN_DIR:"*) ;;
@@ -97,7 +114,7 @@ esac
# bare X11 login Qt is left with hicolor, which has no such name, and the entry
# comes out blank. hicolor is also where this goes, since it is the theme every
# desktop must fall back to.
if "$PY" "$DIR/trayicon.py" --hicolor "$ICON_DIR" >/dev/null 2>&1; then
if PYTHONPATH="$DIR" "$PY" -m dikte.trayicon --hicolor "$ICON_DIR" >/dev/null 2>&1; then
ICON=dikte
# Only GTK reads a cache, and only if one is already there; a stale cache
# would otherwise hide the file we just wrote.
@@ -116,7 +133,7 @@ cat > "$APP_DIR/dikte.desktop" <<EOF
Type=Application
Name=Dikte
Comment=Voice dictation: record, transcribe, clean up, paste
Exec=$PY $DIR/dikte.py
Exec=$PY $ENTRY
Icon=$ICON
Categories=Utility;AudioVideo;
StartupNotify=false
@@ -127,7 +144,7 @@ cat > "$AUTOSTART_DIR/dikte.desktop" <<EOF
[Desktop Entry]
Type=Application
Name=Dikte
Exec=$PY $DIR/dikte.py
Exec=$PY $ENTRY
Icon=$ICON
X-GNOME-Autostart-enabled=true
StartupNotify=false
@@ -143,14 +160,26 @@ ok "Will start automatically on login"
# knows which desktop it is on, and it stores the combination in the settings
# as well, which is where the built-in listener reads it from. A key written
# to only one of the two places is a key that half works.
if [[ "$SHORTCUT" == "$CANCEL_SHORTCUT" ]]; then
warn "Both arguments are $SHORTCUT, so the discard key was left out."
# What was not asked for is read back out of the settings, which is where Dikte
# keeps the keys and so what a second run of this script has to leave alone.
stored() { PYTHONPATH="$DIR" "$PY" -m dikte config get "$1" 2>/dev/null || true; }
if [[ -z "$SHORTCUT" ]]; then
SHORTCUT="$(stored shortcut)"
SHORTCUT="${SHORTCUT:-$DEFAULT_SHORTCUT}"
fi
if [[ $CANCEL_GIVEN == 0 ]]; then
# An empty answer here is a discard key that was turned off, and it stays off.
CANCEL_SHORTCUT="$(stored cancel_shortcut)"
fi
if [[ -n "$CANCEL_SHORTCUT" && "$SHORTCUT" == "$CANCEL_SHORTCUT" ]]; then
warn "Both keys are $SHORTCUT, so the discard key was left out."
say "Pass two different combinations, or set it in Settings → Shortcuts."
CANCEL_SHORTCUT=""
fi
register() { # which combination label
if out="$("$PY" "$DIR/dikte.py" shortcut install "$1" --combo "$2" 2>&1)"; then
if out="$("$PY" "$ENTRY" shortcut install "$1" --combo "$2" 2>&1)"; then
ok "$3: $2"
else
# One line: the rest of what it has to say about KWin is printed below.
@@ -167,7 +196,7 @@ if python3 -c 'import PyQt6.QtWidgets' 2>/dev/null; then
# not this script's. Guessing from XDG_CURRENT_DESKTOP here is how every
# session that was neither GNOME nor KDE used to be promised a KWin that was
# never running.
case "$("$PY" -c 'import sys; sys.path.insert(0, sys.argv[1]); import hotkey; print(hotkey.backend())' "$DIR" 2>/dev/null)" in
case "$(PYTHONPATH="$DIR" "$PY" -c 'from dikte import hotkey; print(hotkey.backend())' 2>/dev/null)" in
kde)
warn "KWin only reads these at startup, so they go live after your next"
say "login. Until then open Settings → Shortcuts and turn on the"
+46 -14
View File
@@ -11,19 +11,38 @@
# Homebrew moves that copy.
set -euo pipefail
DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
# The checkout, one level up: this script lives in scripts/, everything it
# touches is at the top of the tree.
DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
# The one file that starts the application, whoever is asking: the wrapper in
# ~/.local/bin, the bundle, and every shortcut Dikte registers.
ENTRY="$DIR/dikte/__main__.py"
APP_DIR="$HOME/Applications"
APP="$APP_DIR/Dikte.app"
BIN_DIR="$HOME/.local/bin"
AGENT_DIR="$HOME/Library/LaunchAgents"
AGENT_ID="io.github.yusufipk.dikte"
AGENT="$AGENT_DIR/$AGENT_ID.plist"
SHORTCUT="${1:-Ctrl+Option+Space}"
# Without the colon, so that a second argument given as "" stays empty. That is
# how update.sh says "this one was turned off", as against not saying anything.
CANCEL_SHORTCUT="${2-Ctrl+Option+D}"
# Only the one: the discard key's default is the settings' own, read back below.
DEFAULT_SHORTCUT="Ctrl+Option+Space"
# Given as arguments, or asked of the settings further down. An installer run
# again, which is what every update does, must not undo a key you chose in
# Settings, so silence here means "keep whatever is there".
SHORTCUT="${1:-}"
CANCEL_SHORTCUT="${2-}"
# Passed as "" means the discard key is off, which is not the same answer as
# not being passed at all.
CANCEL_GIVEN=$(( $# >= 2 ))
# One caller says both without meaning either: an updater from before Dikte
# became a package looks for the settings at a path that no longer exists, and
# so passes the default key and an empty discard key rather than yours. This
# can go once nobody is updating across that commit any more.
if [[ "$SHORTCUT" == "$DEFAULT_SHORTCUT" && $CANCEL_GIVEN == 1 && -z "$CANCEL_SHORTCUT" ]]; then
SHORTCUT=""
CANCEL_GIVEN=0
fi
# The two places Homebrew installs to, in front, for the same reason dikte.py
# The two places Homebrew installs to, in front, for the same reason the app
# puts them there: a shell that has not been logged into since Homebrew was
# installed does not have them, and this script would then report ffmpeg as
# missing while the application finds it perfectly well.
@@ -147,7 +166,7 @@ if [ ! -d "\$PYTHONHOME" ]; then
osascript -e 'display alert "Dikte" message "The Python this was installed against is gone, most likely after a brew upgrade. Run ./install.sh again."' >/dev/null 2>&1
exit 1
fi
exec "\$HERE/python3" "$DIR/dikte.py" --gui "\$@"
exec "\$HERE/python3" "$ENTRY" --gui "\$@"
EOF
chmod +x "$APP/Contents/MacOS/Dikte"
@@ -186,7 +205,7 @@ printf 'APPL????' > "$APP/Contents/PkgInfo"
# and no second place to change what Dikte looks like. Failing to draw it is
# not worth stopping for: a bundle with no icon gets the generic one.
iconset="$(mktemp -d)/Dikte.iconset"
if "$PY" "$DIR/trayicon.py" "$iconset" >/dev/null 2>&1 \
if PYTHONPATH="$DIR" "$PY" -m dikte.trayicon "$iconset" >/dev/null 2>&1 \
&& iconutil -c icns "$iconset" -o "$APP/Contents/Resources/Dikte.icns" 2>/dev/null; then
ok "Icon drawn"
else
@@ -212,7 +231,7 @@ fi
-f "$APP" >/dev/null 2>&1 || true
# 4. The command ------------------------------------------------------------
# A wrapper, where Linux gets a symlink to dikte.py. The shebang there is
# A wrapper, where Linux gets a symlink to the entry point. The shebang there is
# `env python3`, and on a Mac that is Apple's 3.9: the symlink would resolve to
# the one interpreter that cannot run this. Naming the interpreter here also
# gives update.sh and uninstall.sh somewhere to read it from, so that the three
@@ -221,9 +240,9 @@ mkdir -p "$BIN_DIR"
cat > "$BIN_DIR/dikte" <<EOF
#!/bin/sh
# Written by install-mac.sh. Edit that, not this.
exec "$PY" "$DIR/dikte.py" "\$@"
exec "$PY" "$ENTRY" "\$@"
EOF
chmod +x "$BIN_DIR/dikte" "$DIR/dikte.py"
chmod +x "$BIN_DIR/dikte" "$ENTRY"
ok "Command installed: $BIN_DIR/dikte"
case ":$PATH:" in
*":$BIN_DIR:"*) ;;
@@ -271,14 +290,27 @@ fi
# shortcut registry at all, so "installed" means the running application is
# holding the combination, and the settings file is where it reads it from
# after a restart. Nothing here needs a logout, unlike KDE.
if [[ "$SHORTCUT" == "$CANCEL_SHORTCUT" ]]; then
warn "Both arguments are $SHORTCUT, so the discard key was left out."
#
# What was not asked for is read back out of the settings, which is what makes
# a second run of this script leave the keys you chose alone.
stored() { PYTHONPATH="$DIR" "$PY" -m dikte config get "$1" 2>/dev/null || true; }
if [[ -z "$SHORTCUT" ]]; then
SHORTCUT="$(stored shortcut)"
SHORTCUT="${SHORTCUT:-$DEFAULT_SHORTCUT}"
fi
if [[ $CANCEL_GIVEN == 0 ]]; then
# An empty answer here is a discard key that was turned off, and it stays off.
CANCEL_SHORTCUT="$(stored cancel_shortcut)"
fi
if [[ -n "$CANCEL_SHORTCUT" && "$SHORTCUT" == "$CANCEL_SHORTCUT" ]]; then
warn "Both keys are $SHORTCUT, so the discard key was left out."
say "Pass two different combinations, or set it in Settings → Shortcuts."
CANCEL_SHORTCUT=""
fi
register() { # which combination label
if out="$("$PY" "$DIR/dikte.py" shortcut install "$1" --combo "$2" 2>&1)"; then
if out="$("$PY" "$ENTRY" shortcut install "$1" --combo "$2" 2>&1)"; then
ok "$3: $2"
else
warn "${out%%$'\n'*}"
+14 -9
View File
@@ -4,7 +4,10 @@
# is the word that deletes them.
set -euo pipefail
DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
# The checkout, one level up: this script lives in scripts/, everything it
# touches is at the top of the tree.
DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
ENTRY="$DIR/dikte/__main__.py"
USER_NAME="$(id -un)"
BIN_DIR="$HOME/.local/bin"
@@ -49,7 +52,7 @@ count() {
usage() {
cat <<EOF
Usage: ./uninstall.sh [--purge] [--yes]
Usage: ./scripts/uninstall.sh [--purge] [--yes]
--purge also delete the settings ($CONFIG_DIR)
and the dictations, meetings and recordings ($DATA_DIR)
@@ -93,9 +96,9 @@ if ((MACOS)); then
say "Nothing to unregister: macOS shortcuts live only while Dikte runs."
elif [[ -n "$PY" ]] && "$PY" -c 'import PyQt6.QtWidgets' 2>/dev/null; then
for which in toggle pause cancel ask meeting; do
"$PY" "$DIR/dikte.py" shortcut remove "$which" >/dev/null 2>&1 || true
"$PY" "$ENTRY" shortcut remove "$which" >/dev/null 2>&1 || true
done
case "$("$PY" -c 'import sys; sys.path.insert(0, sys.argv[1]); import hotkey; print(hotkey.backend())' "$DIR" 2>/dev/null)" in
case "$(PYTHONPATH="$DIR" "$PY" -c 'from dikte import hotkey; print(hotkey.backend())' 2>/dev/null)" in
kde)
ok "Global shortcuts unregistered"
say "KWin reads that file at startup, so the keys are free after your next login."
@@ -111,10 +114,12 @@ fi
# 2. The running instance --------------------------------------------------
# It holds a tray icon and a socket; asking it to quit is tidier than pulling
# its launchers out from under it.
if pgrep -u "$USER_NAME" -f 'dikte\.py' >/dev/null 2>&1; then
[[ -n "$PY" ]] && "$PY" "$DIR/dikte.py" quit >/dev/null 2>&1 || true
# The pattern matches an instance started before Dikte became a package as well,
# which is what an uninstall run straight after an update finds running.
if pgrep -u "$USER_NAME" -f 'dikte(/__main__|)\.py' >/dev/null 2>&1; then
[[ -n "$PY" ]] && "$PY" "$ENTRY" quit >/dev/null 2>&1 || true
sleep 0.5
if pgrep -u "$USER_NAME" -f 'dikte\.py' >/dev/null 2>&1; then
if pgrep -u "$USER_NAME" -f 'dikte(/__main__|)\.py' >/dev/null 2>&1; then
warn "Dikte is still running; close it from the tray icon"
else
ok "Stopped the running instance"
@@ -216,11 +221,11 @@ elif [[ "$CONFIG_DIR" == "$DATA_DIR" ]]; then
# macOS keeps both in the one directory a Mac user's backup already knows
# about, so naming it twice would only look like two things were kept.
say "Settings and dictations kept: $CONFIG_DIR"
say "Delete them too with: ./uninstall.sh --purge"
say "Delete them too with: ./scripts/uninstall.sh --purge"
else
say "Settings kept: $CONFIG_DIR"
say "Dictations kept: $DATA_DIR"
say "Delete them too with: ./uninstall.sh --purge"
say "Delete them too with: ./scripts/uninstall.sh --purge"
fi
echo
+11 -20
View File
@@ -2,7 +2,10 @@
# Dikte updater: pull, put the launchers back, restart what was running.
set -euo pipefail
DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
# The checkout, one level up: this script lives in scripts/, everything it
# touches is at the top of the tree.
DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
ENTRY="$DIR/dikte/__main__.py"
USER_NAME="$(id -un)"
if [[ "$(uname -s)" == "Darwin" ]]; then
@@ -13,10 +16,8 @@ if [[ "$(uname -s)" == "Darwin" ]]; then
# make a missing file the end of the script rather than a question answered no.
PY="$(sed -n 's/^exec "\([^"]*\)".*/\1/p' "$HOME/.local/bin/dikte" 2>/dev/null | head -1 || true)"
[[ -x "$PY" ]] || PY="$(command -v python3 || true)"
DEFAULT_SHORTCUT="Ctrl+Option+Space"
else
PY="$(command -v python3 || true)"
DEFAULT_SHORTCUT="Ctrl+Space"
fi
say() { printf ' %s\n' "$1"; }
@@ -24,13 +25,6 @@ ok() { printf ' \033[32m✓\033[0m %s\n' "$1"; }
warn() { printf ' \033[33m!\033[0m %s\n' "$1"; }
die() { printf ' \033[31m✗\033[0m %s\n' "$1"; echo; exit 1; }
# The combination stored in the settings, which is where Dikte itself reads it
# from and the one place that is the same whichever mechanism the session has.
setting() {
[[ -n "$PY" ]] || return 0
"$PY" "$DIR/dikte.py" config get "$1" 2>/dev/null || true
}
echo
echo "Updating Dikte"
echo "──────────────"
@@ -90,18 +84,15 @@ echo
# 4. Launchers --------------------------------------------------------------
# An update can add a dependency or move a file, so the installer runs again.
# It would otherwise register its own defaults over the keys you chose, so it
# is told what those are. Read before the installer runs, since it is the one
# writing them.
shortcut="$(setting shortcut)"
cancel_shortcut="$(setting cancel_shortcut)"
# Positional, so a chosen discard key cannot be passed without the other one.
"$DIR/install.sh" "${shortcut:-$DEFAULT_SHORTCUT}" "${cancel_shortcut:-}"
# With no keys named it keeps the ones stored in the settings, which are the
# ones you chose.
"$DIR/install.sh"
# 5. The running instance ---------------------------------------------------
# It is still running the code from before the pull.
if pgrep -u "$USER_NAME" -f 'dikte\.py' >/dev/null 2>&1; then
if [[ -n "$PY" ]] && "$PY" "$DIR/dikte.py" restart >/dev/null 2>&1; then
# It is still running the code from before the pull, which on an update across
# the move into a package is a process still named after the old entry point.
if pgrep -u "$USER_NAME" -f 'dikte(/__main__|)\.py' >/dev/null 2>&1; then
if [[ -n "$PY" ]] && "$PY" "$ENTRY" restart >/dev/null 2>&1; then
ok "Restarted, so the new version is the one running"
else
warn "Could not restart it; use the tray menu → Restart"
+3 -3
View File
@@ -22,9 +22,9 @@ import urllib.request
import wave
from unittest import mock
import assistant
import config as cfg
import i18n
from dikte import assistant
from dikte import config as cfg
from dikte import i18n
# 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
+2 -2
View File
@@ -16,8 +16,8 @@ import threading
import time
import unittest
import api
import ggml
from dikte import api
from dikte import ggml
from tests.support import (
DikteTest,
fake_urlopen,
+1 -1
View File
@@ -14,7 +14,7 @@ import time
import unittest
from unittest import mock
import assistant
from dikte import assistant
from tests.support import DikteTest, fake_urlopen, only_these_tools
+1 -1
View File
@@ -21,7 +21,7 @@ import unittest
import wave
from unittest import mock
import audio
from dikte import audio
from tests.support import (
DikteTest,
FakeCompleted,
+3 -3
View File
@@ -11,9 +11,9 @@ import subprocess
import unittest
from unittest import mock
import api
import cleanup
import ggml
from dikte import api
from dikte import cleanup
from dikte import ggml
from tests.support import DikteTest, fake_urlopen, sent_json, url_error
from tests.test_api import FakeServer, chat_reply
+4 -4
View File
@@ -12,10 +12,10 @@ import json
import unittest
from unittest import mock
import cli
import config as cfg
import hotkey
import ipc
from dikte import cli
from dikte import config as cfg
from dikte import hotkey
from dikte import ipc
from tests.support import DikteTest, fake_urlopen
+6 -6
View File
@@ -11,12 +11,12 @@ import os
import unittest
from unittest import mock
import api
import cleanup
import config as cfg
import ggml
import i18n
import paste
from dikte import api
from dikte import cleanup
from dikte import config as cfg
from dikte import ggml
from dikte import i18n
from dikte import paste
from tests.support import DikteTest
+2 -2
View File
@@ -13,8 +13,8 @@ import unittest
import wave
from unittest import mock
import api
import filetranscribe as ft
from dikte import api
from dikte import filetranscribe as ft
from tests.support import DikteTest, make_wav, silence, tone
+2 -2
View File
@@ -17,8 +17,8 @@ import threading
import time
from unittest import mock
import ggml
import hub
from dikte import ggml
from dikte import hub
from tests.support import (DikteTest, fake_urlopen, http_error, json_body,
linux_only, url_error)
+2 -2
View File
@@ -6,8 +6,8 @@ import subprocess
import unittest
from unittest import mock
import config as cfg
import hotkey
from dikte import config as cfg
from dikte import hotkey
from tests.support import DikteTest, FakeCompleted, linux_only
SHORTCUTS_RC = """[services][dikte-toggle.desktop]
+1 -1
View File
@@ -2,7 +2,7 @@
import json
import hub
from dikte import hub
from tests.support import DikteTest, fake_urlopen, http_error, url_error
RELEASE = {
+1 -1
View File
@@ -9,7 +9,7 @@ import string
import unittest
from unittest import mock
import i18n
from dikte import i18n
from tests.support import DikteTest
+2 -2
View File
@@ -11,7 +11,7 @@ import sys
import unittest
from unittest import mock
import ipc
from dikte import ipc
class FakeSocket:
@@ -57,7 +57,7 @@ class FakeSocket:
class Paths(unittest.TestCase):
def test_script_path_points_at_dikte(self):
self.assertTrue(ipc.script_path().endswith("dikte.py"))
self.assertTrue(ipc.script_path().endswith("dikte/__main__.py"))
self.assertTrue(os.path.exists(ipc.script_path()))
def test_the_shortcut_command_runs_it_with_this_interpreter(self):
+3 -3
View File
@@ -11,9 +11,9 @@ import unittest
import wave
from unittest import mock
import api
import config as cfg
import meeting
from dikte import api
from dikte import config as cfg
from dikte import meeting
from tests.support import DikteTest, make_wav, silence, speech, stereo, tone
+1 -1
View File
@@ -22,7 +22,7 @@ import unittest
from typing import ClassVar
from unittest import mock
import paste
from dikte import paste
from tests.support import DikteTest, FakeCompleted, only_these_tools
+3 -3
View File
@@ -9,9 +9,9 @@ import os
import unittest
from unittest import mock
import config as cfg
import ggml
import paths
from dikte import config as cfg
from dikte import ggml
from dikte import paths
class Directories(unittest.TestCase):
+2 -2
View File
@@ -15,7 +15,7 @@ from unittest import mock
from PyQt6.QtGui import QImage
from PyQt6.QtWidgets import QApplication
import trayicon
from dikte import trayicon
from tests.support import DikteTest
# One application for the whole run; Qt allows no second one.
@@ -66,7 +66,7 @@ class Tray(DikteTest):
self.patch_attr(trayicon, "_cache", {})
def test_a_name_we_do_not_draw_is_a_null_icon(self):
# dikte.py asks the theme first and falls through to here, so anything
# app.py asks the theme first and falls through to here, so anything
# answered with a picture would be one the theme should have given.
self.assertTrue(trayicon.icon("emblem-important").isNull())
+9 -9
View File
@@ -16,12 +16,12 @@ from PyQt6.QtCore import QPoint, QPointF, Qt
from PyQt6.QtGui import QWheelEvent
from PyQt6.QtWidgets import QApplication, QMessageBox
import cleanup
import config as cfg
import hotkey
import overlay as overlay_module
import paste
import settings_ui
from dikte import cleanup
from dikte import config as cfg
from dikte import hotkey
from dikte import overlay as overlay_module
from dikte import paste
from dikte import settings_ui
from tests.support import DikteTest, only_these_tools
# One application for the whole run; Qt allows no second one.
@@ -281,7 +281,7 @@ class Settings(DikteTest):
text = self.shortcut_tab_text(window)
self.assertIn("i3 keeps no shortcut registry", text)
self.assertNotIn("KWin", text)
self.assertIn("dikte.py toggle", text)
self.assertIn("__main__.py 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 +458,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("dikte.py toggle", text)
self.assertNotIn("__main__.py toggle", text)
def test_the_paste_keys_on_offer_are_the_ones_a_mac_uses(self):
window = self.window(cfg.Config())
@@ -482,7 +482,7 @@ class KdeSettings(Settings):
self.assertIn("KWin only reads shortcut settings at startup", text)
self.assertIn("Install as a KDE shortcut", text)
self.assertNotIn("keeps no shortcut registry", text)
self.assertNotIn("dikte.py toggle", text)
self.assertNotIn("__main__.py 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())
+1 -1
View File
@@ -2,7 +2,7 @@
import unittest
import vad
from dikte import vad
from tests.support import DikteTest
CHUNK = 1024 / 16000 # what worker.py feeds it: one chunk of the level meter
+5 -5
View File
@@ -11,11 +11,11 @@ import os
import unittest
from unittest import mock
import api
import assistant
import config as cfg
import paste
import worker
from dikte import api
from dikte import assistant
from dikte import config as cfg
from dikte import paste
from dikte import worker
from tests.support import DikteTest, make_wav, speech