mirror of
https://github.com/yusufipk/dikte.git
synced 2026-09-11 10:56:10 +00:00
Add GNOME X11 and PulseAudio support
This commit is contained in:
@@ -29,8 +29,15 @@ systemctl --user enable --now ydotool # needed for auto-paste
|
|||||||
dikte # the settings window opens on first run
|
dikte # the settings window opens on first run
|
||||||
```
|
```
|
||||||
|
|
||||||
`install.sh` adds the `dikte` command, a menu entry, an autostart entry and the
|
On Ubuntu/GNOME X11, recording uses PulseAudio and clipboard/paste use the X11
|
||||||
KDE shortcut.
|
tools instead:
|
||||||
|
|
||||||
|
```sh
|
||||||
|
sudo apt install pulseaudio-utils xclip xdotool ffmpeg
|
||||||
|
```
|
||||||
|
|
||||||
|
`install.sh` adds the `dikte` command, a menu entry and an autostart entry. The
|
||||||
|
settings window installs a GNOME or KDE global shortcut.
|
||||||
|
|
||||||
Two keys go in the settings window: **OpenAI** and **OpenRouter**. Speech to text
|
Two keys go in the settings window: **OpenAI** and **OpenRouter**. Speech to text
|
||||||
runs on either one (`gpt-4o-transcribe` by default), cleanup always on
|
runs on either one (`gpt-4o-transcribe` by default), cleanup always on
|
||||||
|
|||||||
+9
-2
@@ -29,8 +29,15 @@ systemctl --user enable --now ydotool # otomatik yapıştırma için
|
|||||||
dikte # ilk açılışta ayarlar penceresi gelir
|
dikte # ilk açılışta ayarlar penceresi gelir
|
||||||
```
|
```
|
||||||
|
|
||||||
`install.sh` `dikte` komutunu, menü girdisini, oturum açılışında otomatik
|
Ubuntu/GNOME X11 için kayıt PulseAudio üzerinden, pano ve yapıştırma ise X11
|
||||||
başlatmayı ve KDE kısayolunu kurar.
|
araçlarıyla çalışır:
|
||||||
|
|
||||||
|
```sh
|
||||||
|
sudo apt install pulseaudio-utils xclip xdotool ffmpeg
|
||||||
|
```
|
||||||
|
|
||||||
|
`install.sh` `dikte` komutunu, menü girdisini ve oturum açılışında otomatik
|
||||||
|
başlatmayı kurar. Ayarlar penceresi GNOME veya KDE global kısayolunu kurar.
|
||||||
|
|
||||||
Ayarlar penceresinde iki anahtar istenir: **OpenAI** ve **OpenRouter**. Sesi
|
Ayarlar penceresinde iki anahtar istenir: **OpenAI** ve **OpenRouter**. Sesi
|
||||||
yazıya çevirme ikisinden birinde çalışır (varsayılan `gpt-4o-transcribe`),
|
yazıya çevirme ikisinden birinde çalışır (varsayılan `gpt-4o-transcribe`),
|
||||||
|
|||||||
@@ -31,7 +31,7 @@ MIN_FRAMES = int(RATE * 0.25)
|
|||||||
|
|
||||||
|
|
||||||
class Recorder(QObject):
|
class Recorder(QObject):
|
||||||
"""Runs pw-record as a child process and reads raw PCM from its stdout."""
|
"""Runs the available sound-server recorder and reads raw PCM from stdout."""
|
||||||
|
|
||||||
level = pyqtSignal(float) # 0.0 - 1.0, for the waveform
|
level = pyqtSignal(float) # 0.0 - 1.0, for the waveform
|
||||||
stopped = pyqtSignal(str, float, object) # wav path, duration (s), per-chunk RMS
|
stopped = pyqtSignal(str, float, object) # wav path, duration (s), per-chunk RMS
|
||||||
@@ -53,21 +53,13 @@ class Recorder(QObject):
|
|||||||
def start(self, target="", max_seconds=300):
|
def start(self, target="", max_seconds=300):
|
||||||
if self.active:
|
if self.active:
|
||||||
return
|
return
|
||||||
if not shutil.which("pw-record"):
|
cmd = recording_command(target)
|
||||||
self.failed.emit(t("pw-record not found. Is pipewire-audio installed?"))
|
if not cmd:
|
||||||
|
self.failed.emit(t(
|
||||||
|
"No audio recorder found. Install pulseaudio-utils or pipewire-audio."
|
||||||
|
))
|
||||||
return
|
return
|
||||||
|
|
||||||
cmd = [
|
|
||||||
"pw-record",
|
|
||||||
"--raw",
|
|
||||||
f"--rate={RATE}",
|
|
||||||
f"--channels={CHANNELS}",
|
|
||||||
"--format=s16",
|
|
||||||
]
|
|
||||||
if target:
|
|
||||||
cmd.append(f"--target={target}")
|
|
||||||
cmd.append("-")
|
|
||||||
|
|
||||||
try:
|
try:
|
||||||
self._proc = subprocess.Popen(
|
self._proc = subprocess.Popen(
|
||||||
cmd, stdout=subprocess.PIPE, stderr=subprocess.PIPE, bufsize=0
|
cmd, stdout=subprocess.PIPE, stderr=subprocess.PIPE, bufsize=0
|
||||||
@@ -84,7 +76,8 @@ class Recorder(QObject):
|
|||||||
self._thread.start()
|
self._thread.start()
|
||||||
|
|
||||||
def _pump(self):
|
def _pump(self):
|
||||||
stdout = self._proc.stdout
|
proc = self._proc
|
||||||
|
stdout = proc.stdout
|
||||||
try:
|
try:
|
||||||
while True:
|
while True:
|
||||||
chunk = stdout.read(CHUNK_BYTES)
|
chunk = stdout.read(CHUNK_BYTES)
|
||||||
@@ -101,6 +94,15 @@ class Recorder(QObject):
|
|||||||
break
|
break
|
||||||
except (OSError, ValueError):
|
except (OSError, ValueError):
|
||||||
pass
|
pass
|
||||||
|
if not self._cancelled and not self._buffer and proc.poll() is not None:
|
||||||
|
try:
|
||||||
|
detail = proc.stderr.read().decode("utf-8", "replace").strip()
|
||||||
|
except (AttributeError, OSError):
|
||||||
|
detail = ""
|
||||||
|
self.failed.emit(t(
|
||||||
|
"Audio recorder stopped before receiving sound: {error}",
|
||||||
|
error=detail or f"exit code {proc.returncode}",
|
||||||
|
))
|
||||||
|
|
||||||
def _terminate(self):
|
def _terminate(self):
|
||||||
proc = self._proc
|
proc = self._proc
|
||||||
@@ -161,6 +163,33 @@ def write_wav(pcm, rate=RATE, channels=CHANNELS, width=SAMPLE_WIDTH):
|
|||||||
return path
|
return path
|
||||||
|
|
||||||
|
|
||||||
|
def recording_command(target=""):
|
||||||
|
"""Return a raw-s16 capture command for the sound server on this desktop.
|
||||||
|
|
||||||
|
parec works with both PulseAudio and PipeWire's PulseAudio compatibility
|
||||||
|
service, and its source names are the same ones shown by list_sources().
|
||||||
|
Keep pw-record as the fallback for minimal native-PipeWire installations.
|
||||||
|
"""
|
||||||
|
if shutil.which("parec"):
|
||||||
|
cmd = [
|
||||||
|
"parec", "--record", "--raw", f"--rate={RATE}",
|
||||||
|
f"--channels={CHANNELS}", "--format=s16le",
|
||||||
|
]
|
||||||
|
if target:
|
||||||
|
cmd.append(f"--device={target}")
|
||||||
|
return cmd
|
||||||
|
if shutil.which("pw-record"):
|
||||||
|
cmd = [
|
||||||
|
"pw-record", "--raw", f"--rate={RATE}",
|
||||||
|
f"--channels={CHANNELS}", "--format=s16",
|
||||||
|
]
|
||||||
|
if target:
|
||||||
|
cmd.append(f"--target={target}")
|
||||||
|
cmd.append("-")
|
||||||
|
return cmd
|
||||||
|
return []
|
||||||
|
|
||||||
|
|
||||||
class MeetingRecorder(QObject):
|
class MeetingRecorder(QObject):
|
||||||
"""Microphone and speaker output into one stereo file: left is you, right is
|
"""Microphone and speaker output into one stereo file: left is you, right is
|
||||||
everyone else.
|
everyone else.
|
||||||
|
|||||||
@@ -1,10 +1,12 @@
|
|||||||
"""Global shortcut: KDE custom-shortcut installation plus a built-in evdev listener."""
|
"""GNOME/KDE global-shortcut installation plus a built-in evdev listener."""
|
||||||
|
|
||||||
|
import ast
|
||||||
import glob
|
import glob
|
||||||
import os
|
import os
|
||||||
import pathlib
|
import pathlib
|
||||||
import re
|
import re
|
||||||
import select
|
import select
|
||||||
|
import shutil
|
||||||
import struct
|
import struct
|
||||||
import subprocess
|
import subprocess
|
||||||
import threading
|
import threading
|
||||||
@@ -19,6 +21,8 @@ ASK_DESKTOP_ID = "dikte-ask.desktop"
|
|||||||
APPLICATIONS_DIR = pathlib.Path.home() / ".local/share/applications"
|
APPLICATIONS_DIR = pathlib.Path.home() / ".local/share/applications"
|
||||||
DESKTOP_FILE = APPLICATIONS_DIR / DESKTOP_ID
|
DESKTOP_FILE = APPLICATIONS_DIR / DESKTOP_ID
|
||||||
SHORTCUTS_FILE = pathlib.Path.home() / ".config/kglobalshortcutsrc"
|
SHORTCUTS_FILE = pathlib.Path.home() / ".config/kglobalshortcutsrc"
|
||||||
|
GNOME_MEDIA_SCHEMA = "org.gnome.settings-daemon.plugins.media-keys"
|
||||||
|
GNOME_BINDING_SCHEMA = "org.gnome.settings-daemon.plugins.media-keys.custom-keybinding"
|
||||||
|
|
||||||
# --- evdev key codes (linux/input-event-codes.h) --------------------------
|
# --- evdev key codes (linux/input-event-codes.h) --------------------------
|
||||||
|
|
||||||
@@ -174,6 +178,150 @@ class EvdevHotkey(QObject):
|
|||||||
|
|
||||||
# --- KDE custom shortcut --------------------------------------------------
|
# --- KDE custom shortcut --------------------------------------------------
|
||||||
|
|
||||||
|
def _gnome():
|
||||||
|
desktop = os.environ.get("XDG_CURRENT_DESKTOP", "").lower()
|
||||||
|
return "gnome" in desktop and shutil.which("gsettings") is not None
|
||||||
|
|
||||||
|
|
||||||
|
def _gnome_path(desktop_id):
|
||||||
|
name = re.sub(r"[^a-zA-Z0-9_-]+", "-", desktop_id.removesuffix(".desktop"))
|
||||||
|
return f"/org/gnome/settings-daemon/plugins/media-keys/custom-keybindings/{name}/"
|
||||||
|
|
||||||
|
|
||||||
|
def gnome_accelerator(shortcut):
|
||||||
|
"""Translate Qt-style Ctrl+Alt+A into GNOME's <Primary><Alt>a syntax."""
|
||||||
|
parts = [part.strip() for part in str(shortcut).split("+") if part.strip()]
|
||||||
|
modifiers = []
|
||||||
|
key = ""
|
||||||
|
names = {
|
||||||
|
"ctrl": "<Primary>", "control": "<Primary>",
|
||||||
|
"alt": "<Alt>", "shift": "<Shift>",
|
||||||
|
"super": "<Super>", "meta": "<Super>",
|
||||||
|
}
|
||||||
|
for part in parts:
|
||||||
|
modifier = names.get(part.lower())
|
||||||
|
if modifier:
|
||||||
|
if modifier not in modifiers:
|
||||||
|
modifiers.append(modifier)
|
||||||
|
else:
|
||||||
|
key = part.lower() if len(part) == 1 else part
|
||||||
|
return "".join(modifiers) + key if key else ""
|
||||||
|
|
||||||
|
|
||||||
|
def display_accelerator(accelerator):
|
||||||
|
"""Translate a GNOME accelerator back to the form shown in Dikte."""
|
||||||
|
text = str(accelerator)
|
||||||
|
parts = []
|
||||||
|
for token, label in (("<Primary>", "Ctrl"), ("<Control>", "Ctrl"),
|
||||||
|
("<Alt>", "Alt"), ("<Shift>", "Shift"),
|
||||||
|
("<Super>", "Super")):
|
||||||
|
if token.lower() in text.lower():
|
||||||
|
parts.append(label)
|
||||||
|
text = re.sub(re.escape(token), "", text, flags=re.IGNORECASE)
|
||||||
|
key = text.strip()
|
||||||
|
if len(key) == 1:
|
||||||
|
key = key.upper()
|
||||||
|
if key:
|
||||||
|
parts.append(key)
|
||||||
|
return "+".join(parts)
|
||||||
|
|
||||||
|
|
||||||
|
def _gsettings(*args, check=True):
|
||||||
|
return subprocess.run(
|
||||||
|
["gsettings", *args], capture_output=True, text=True, timeout=10, check=check,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _gsettings_array(value):
|
||||||
|
"""Parse a gsettings string-array, including the empty `@as []` form."""
|
||||||
|
text = str(value).strip()
|
||||||
|
if text.startswith("@as "):
|
||||||
|
text = text[4:].strip()
|
||||||
|
parsed = ast.literal_eval(text) if text else []
|
||||||
|
if not isinstance(parsed, (list, tuple)):
|
||||||
|
raise ValueError(f"not a string array: {value}")
|
||||||
|
return list(parsed)
|
||||||
|
|
||||||
|
|
||||||
|
def install_gnome_shortcut(shortcut, exec_command,
|
||||||
|
name="Dikte: start/stop recording",
|
||||||
|
desktop_id=DESKTOP_ID):
|
||||||
|
path = _gnome_path(desktop_id)
|
||||||
|
try:
|
||||||
|
current = _gsettings(
|
||||||
|
"get", GNOME_MEDIA_SCHEMA, "custom-keybindings"
|
||||||
|
).stdout.strip()
|
||||||
|
paths = _gsettings_array(current)
|
||||||
|
if path not in paths:
|
||||||
|
paths.append(path)
|
||||||
|
_gsettings("set", GNOME_MEDIA_SCHEMA, "custom-keybindings", repr(paths))
|
||||||
|
schema = f"{GNOME_BINDING_SCHEMA}:{path}"
|
||||||
|
_gsettings("set", schema, "name", repr(name))
|
||||||
|
_gsettings("set", schema, "command", repr(exec_command))
|
||||||
|
accelerator = gnome_accelerator(shortcut)
|
||||||
|
if not accelerator:
|
||||||
|
raise ValueError(t("Could not parse the shortcut: {shortcut}",
|
||||||
|
shortcut=shortcut))
|
||||||
|
_gsettings("set", schema, "binding", repr(accelerator))
|
||||||
|
except (ValueError, SyntaxError, subprocess.SubprocessError, OSError) as exc:
|
||||||
|
return False, t("Could not register the GNOME shortcut: {error}", error=exc)
|
||||||
|
return True, t("Shortcut saved: {shortcut}", shortcut=shortcut)
|
||||||
|
|
||||||
|
|
||||||
|
def remove_gnome_shortcut(desktop_id=DESKTOP_ID):
|
||||||
|
path = _gnome_path(desktop_id)
|
||||||
|
try:
|
||||||
|
current = _gsettings(
|
||||||
|
"get", GNOME_MEDIA_SCHEMA, "custom-keybindings"
|
||||||
|
).stdout.strip()
|
||||||
|
paths = _gsettings_array(current)
|
||||||
|
if path in paths:
|
||||||
|
paths.remove(path)
|
||||||
|
_gsettings("set", GNOME_MEDIA_SCHEMA, "custom-keybindings", repr(paths))
|
||||||
|
except (ValueError, SyntaxError, subprocess.SubprocessError, OSError):
|
||||||
|
pass
|
||||||
|
|
||||||
|
|
||||||
|
def gnome_shortcut_status(desktop_id=DESKTOP_ID):
|
||||||
|
path = _gnome_path(desktop_id)
|
||||||
|
try:
|
||||||
|
current = _gsettings(
|
||||||
|
"get", GNOME_MEDIA_SCHEMA, "custom-keybindings"
|
||||||
|
).stdout.strip()
|
||||||
|
paths = _gsettings_array(current)
|
||||||
|
if path not in paths:
|
||||||
|
return None
|
||||||
|
value = _gsettings(
|
||||||
|
"get", f"{GNOME_BINDING_SCHEMA}:{path}", "binding"
|
||||||
|
).stdout.strip()
|
||||||
|
accelerator = ast.literal_eval(value)
|
||||||
|
return display_accelerator(accelerator) if accelerator else None
|
||||||
|
except (ValueError, SyntaxError, subprocess.SubprocessError, OSError):
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def install_shortcut(shortcut, exec_command, name="Dikte: start/stop recording",
|
||||||
|
desktop_id=DESKTOP_ID):
|
||||||
|
if _gnome():
|
||||||
|
return install_gnome_shortcut(shortcut, exec_command, name, desktop_id)
|
||||||
|
return install_kde_shortcut(shortcut, exec_command, name, desktop_id)
|
||||||
|
|
||||||
|
|
||||||
|
def remove_shortcut(desktop_id=DESKTOP_ID):
|
||||||
|
if _gnome():
|
||||||
|
remove_gnome_shortcut(desktop_id)
|
||||||
|
else:
|
||||||
|
remove_kde_shortcut(desktop_id)
|
||||||
|
|
||||||
|
|
||||||
|
def shortcut_status(desktop_id=DESKTOP_ID):
|
||||||
|
return (gnome_shortcut_status(desktop_id) if _gnome()
|
||||||
|
else kde_shortcut_status(desktop_id))
|
||||||
|
|
||||||
|
|
||||||
|
def desktop_name():
|
||||||
|
return "GNOME" if _gnome() else "KDE"
|
||||||
|
|
||||||
def install_kde_shortcut(shortcut, exec_command, name="Dikte: start/stop recording",
|
def install_kde_shortcut(shortcut, exec_command, name="Dikte: start/stop recording",
|
||||||
desktop_id=DESKTOP_ID):
|
desktop_id=DESKTOP_ID):
|
||||||
"""Write the desktop file and the kglobalshortcutsrc entry.
|
"""Write the desktop file and the kglobalshortcutsrc entry.
|
||||||
|
|||||||
@@ -101,9 +101,20 @@ TR = {
|
|||||||
"pw-record not found. Is pipewire-audio installed?":
|
"pw-record not found. Is pipewire-audio installed?":
|
||||||
"pw-record bulunamadı. pipewire-audio kurulu mu?",
|
"pw-record bulunamadı. pipewire-audio kurulu mu?",
|
||||||
"Could not start recording: {error}": "Kayıt başlatılamadı: {error}",
|
"Could not start recording: {error}": "Kayıt başlatılamadı: {error}",
|
||||||
|
"No audio recorder found. Install pulseaudio-utils or pipewire-audio.":
|
||||||
|
"Ses kayıt aracı bulunamadı. pulseaudio-utils ya da pipewire-audio kur.",
|
||||||
|
"Audio recorder stopped before receiving sound: {error}":
|
||||||
|
"Ses kayıt aracı veri alamadan kapandı: {error}",
|
||||||
"wl-copy not found. Install wl-clipboard.":
|
"wl-copy not found. Install wl-clipboard.":
|
||||||
"wl-copy bulunamadı. wl-clipboard paketini kur.",
|
"wl-copy bulunamadı. wl-clipboard paketini kur.",
|
||||||
"Could not copy to clipboard: {error}": "Panoya kopyalanamadı: {error}",
|
"Could not copy to clipboard: {error}": "Panoya kopyalanamadı: {error}",
|
||||||
|
"{tool} not found; clipboard copy is unavailable.":
|
||||||
|
"{tool} bulunamadı; panoya kopyalama kullanılamıyor.",
|
||||||
|
"{tool} exited with code {code}.": "{tool} {code} koduyla çıktı.",
|
||||||
|
"{tool} not found, cannot paste automatically.":
|
||||||
|
"{tool} bulunamadı, otomatik yapıştırma yapılamıyor.",
|
||||||
|
"Could not run xdotool: {error}": "xdotool çalıştırılamadı: {error}",
|
||||||
|
"xdotool failed: {error}": "xdotool hatası: {error}",
|
||||||
"wl-copy exited with code {code}.": "wl-copy {code} koduyla çıktı.",
|
"wl-copy exited with code {code}.": "wl-copy {code} koduyla çıktı.",
|
||||||
"ydotool not found, cannot paste automatically.":
|
"ydotool not found, cannot paste automatically.":
|
||||||
"ydotool bulunamadı, otomatik yapıştırma yapılamıyor.",
|
"ydotool bulunamadı, otomatik yapıştırma yapılamıyor.",
|
||||||
@@ -265,9 +276,19 @@ TR = {
|
|||||||
|
|
||||||
# --- settings: shortcut ------------------------------------------------
|
# --- settings: shortcut ------------------------------------------------
|
||||||
"Install as a KDE shortcut": "KDE kısayolu olarak kur",
|
"Install as a KDE shortcut": "KDE kısayolu olarak kur",
|
||||||
|
"Install as a global shortcut": "Global kısayol olarak kur",
|
||||||
"Remove": "Kaldır",
|
"Remove": "Kaldır",
|
||||||
"Registered in KDE: {shortcut}": "KDE'de kayıtlı: {shortcut}",
|
"Registered in KDE: {shortcut}": "KDE'de kayıtlı: {shortcut}",
|
||||||
"No KDE shortcut installed.": "KDE kısayolu kurulu değil.",
|
"No KDE shortcut installed.": "KDE kısayolu kurulu değil.",
|
||||||
|
"Registered in {desktop}: {shortcut}": "{desktop}'da kayıtlı: {shortcut}",
|
||||||
|
"No global shortcut installed.": "Global kısayol kurulu değil.",
|
||||||
|
"No global shortcut installed. The tray menu starts a meeting too.":
|
||||||
|
"Global kısayol kurulu değil. Toplantı tepsi menüsünden de başlatılabilir.",
|
||||||
|
"No global shortcut installed. The tray menu asks it too.":
|
||||||
|
"Global kısayol kurulu değil. Tepsi menüsünden de soru sorulabilir.",
|
||||||
|
"Shortcut saved: {shortcut}": "Kısayol kaydedildi: {shortcut}",
|
||||||
|
"Could not register the GNOME shortcut: {error}":
|
||||||
|
"GNOME kısayolu kaydedilemedi: {error}",
|
||||||
"Use the built-in listener (/dev/input), for when the KDE shortcut is not active yet":
|
"Use the built-in listener (/dev/input), for when the KDE shortcut is not active yet":
|
||||||
"Yerleşik dinleyici kullan (/dev/input), KDE kısayolu henüz etkin değilken",
|
"Yerleşik dinleyici kullan (/dev/input), KDE kısayolu henüz etkin değilken",
|
||||||
"Works immediately, no session restart. The only difference: the key "
|
"Works immediately, no session restart. The only difference: the key "
|
||||||
|
|||||||
+21
-6
@@ -19,21 +19,33 @@ echo "────────────────"
|
|||||||
|
|
||||||
# 1. Dependencies ----------------------------------------------------------
|
# 1. Dependencies ----------------------------------------------------------
|
||||||
missing=()
|
missing=()
|
||||||
for cmd in pw-record wl-copy wl-paste ydotool ffmpeg; do
|
audio_cmds=(ffmpeg)
|
||||||
|
if command -v parec >/dev/null || command -v pw-record >/dev/null; then
|
||||||
|
:
|
||||||
|
else
|
||||||
|
missing+=("pulseaudio-utils-or-pipewire-audio")
|
||||||
|
fi
|
||||||
|
if [[ "${XDG_SESSION_TYPE:-}" == "x11" ]]; then
|
||||||
|
desktop_cmds=(xclip xdotool)
|
||||||
|
else
|
||||||
|
desktop_cmds=(wl-copy wl-paste ydotool)
|
||||||
|
fi
|
||||||
|
for cmd in "${audio_cmds[@]}" "${desktop_cmds[@]}"; do
|
||||||
command -v "$cmd" >/dev/null || missing+=("$cmd")
|
command -v "$cmd" >/dev/null || missing+=("$cmd")
|
||||||
done
|
done
|
||||||
python3 -c 'import PyQt6.QtWidgets' 2>/dev/null || missing+=("python-pyqt6")
|
python3 -c 'import PyQt6.QtWidgets' 2>/dev/null || missing+=("python-pyqt6")
|
||||||
|
|
||||||
if ((${#missing[@]})); then
|
if ((${#missing[@]})); then
|
||||||
warn "Missing: ${missing[*]}"
|
warn "Missing: ${missing[*]}"
|
||||||
say "Arch/CachyOS: sudo pacman -S --needed pipewire-audio wl-clipboard ydotool ffmpeg python-pyqt6"
|
say "Ubuntu X11: sudo apt install pulseaudio-utils xclip xdotool ffmpeg"
|
||||||
|
say "Arch Wayland: sudo pacman -S --needed pipewire-audio wl-clipboard ydotool ffmpeg python-pyqt6"
|
||||||
echo
|
echo
|
||||||
else
|
else
|
||||||
ok "All dependencies present"
|
ok "All dependencies present"
|
||||||
fi
|
fi
|
||||||
|
|
||||||
# 2. ydotoold --------------------------------------------------------------
|
# 2. ydotoold --------------------------------------------------------------
|
||||||
if command -v ydotool >/dev/null; then
|
if [[ "${XDG_SESSION_TYPE:-}" != "x11" ]] && command -v ydotool >/dev/null; then
|
||||||
if systemctl --user is-active --quiet ydotool 2>/dev/null \
|
if systemctl --user is-active --quiet ydotool 2>/dev/null \
|
||||||
|| systemctl --user is-active --quiet ydotoold 2>/dev/null; then
|
|| systemctl --user is-active --quiet ydotoold 2>/dev/null; then
|
||||||
ok "ydotoold is running (auto-paste ready)"
|
ok "ydotoold is running (auto-paste ready)"
|
||||||
@@ -87,7 +99,10 @@ Type=Application
|
|||||||
X-KDE-GlobalAccel-CommandShortcut=true
|
X-KDE-GlobalAccel-CommandShortcut=true
|
||||||
EOF
|
EOF
|
||||||
|
|
||||||
if command -v kwriteconfig6 >/dev/null; then
|
if [[ "${XDG_CURRENT_DESKTOP:-}" == *GNOME* || "${XDG_CURRENT_DESKTOP:-}" == *gnome* ]]; then
|
||||||
|
ok "GNOME detected"
|
||||||
|
say "Open Dikte Settings > Shortcut to install the global shortcut."
|
||||||
|
elif command -v kwriteconfig6 >/dev/null; then
|
||||||
kwriteconfig6 --notify --file kglobalshortcutsrc \
|
kwriteconfig6 --notify --file kglobalshortcutsrc \
|
||||||
--group services --group dikte-toggle.desktop \
|
--group services --group dikte-toggle.desktop \
|
||||||
--key _launch "$SHORTCUT"
|
--key _launch "$SHORTCUT"
|
||||||
@@ -96,10 +111,10 @@ if command -v kwriteconfig6 >/dev/null; then
|
|||||||
say "next login. Until then open Settings → Shortcut and turn on the"
|
say "next login. Until then open Settings → Shortcut and turn on the"
|
||||||
say "built-in listener to use it right away."
|
say "built-in listener to use it right away."
|
||||||
else
|
else
|
||||||
warn "kwriteconfig6 not found. Add the shortcut via System Settings > Shortcuts"
|
warn "No supported shortcut manager found. Add the shortcut in desktop settings."
|
||||||
fi
|
fi
|
||||||
|
|
||||||
echo
|
echo
|
||||||
ok "Done. Start it with: dikte"
|
ok "Done. Start it with: dikte"
|
||||||
say "The settings window opens on first run; add your OpenAI and OpenRouter keys."
|
say "The settings window opens on first run; add an OpenAI, Groq or OpenRouter key."
|
||||||
echo
|
echo
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
"""Clipboard (wl-clipboard) and key injection (ydotool)."""
|
"""Clipboard and key injection for Wayland and X11."""
|
||||||
|
|
||||||
|
import os
|
||||||
import shutil
|
import shutil
|
||||||
import subprocess
|
import subprocess
|
||||||
import time
|
import time
|
||||||
@@ -18,20 +19,23 @@ class PasteError(Exception):
|
|||||||
|
|
||||||
|
|
||||||
def read_clipboard():
|
def read_clipboard():
|
||||||
if not shutil.which("wl-paste"):
|
command = (["xclip", "-selection", "clipboard", "-out"] if _x11()
|
||||||
|
else ["wl-paste", "--no-newline"])
|
||||||
|
if not shutil.which(command[0]):
|
||||||
return None
|
return None
|
||||||
try:
|
try:
|
||||||
res = subprocess.run(["wl-paste", "--no-newline"], capture_output=True, timeout=5)
|
res = subprocess.run(command, capture_output=True, timeout=5)
|
||||||
except (subprocess.SubprocessError, OSError):
|
except (subprocess.SubprocessError, OSError):
|
||||||
return None
|
return None
|
||||||
return res.stdout if res.returncode == 0 else None
|
return res.stdout if res.returncode == 0 else None
|
||||||
|
|
||||||
|
|
||||||
def _run_wl_copy(payload):
|
def _run_copy(payload):
|
||||||
"""wl-copy forks to keep owning the selection; leaving its pipes open makes
|
"""The clipboard owner may fork; do not leave inherited pipes open."""
|
||||||
subprocess.run wait for EOF forever, hence DEVNULL."""
|
command = (["xclip", "-selection", "clipboard", "-in"] if _x11()
|
||||||
|
else ["wl-copy"])
|
||||||
return subprocess.run(
|
return subprocess.run(
|
||||||
["wl-copy"],
|
command,
|
||||||
input=payload,
|
input=payload,
|
||||||
stdout=subprocess.DEVNULL,
|
stdout=subprocess.DEVNULL,
|
||||||
stderr=subprocess.DEVNULL,
|
stderr=subprocess.DEVNULL,
|
||||||
@@ -40,33 +44,58 @@ def _run_wl_copy(payload):
|
|||||||
|
|
||||||
|
|
||||||
def copy(text):
|
def copy(text):
|
||||||
if not shutil.which("wl-copy"):
|
tool = "xclip" if _x11() else "wl-copy"
|
||||||
raise PasteError(t("wl-copy not found. Install wl-clipboard."))
|
if not shutil.which(tool):
|
||||||
|
raise PasteError(t("{tool} not found; clipboard copy is unavailable.", tool=tool))
|
||||||
try:
|
try:
|
||||||
res = _run_wl_copy(text.encode("utf-8"))
|
res = _run_copy(text.encode("utf-8"))
|
||||||
except (subprocess.SubprocessError, OSError) as exc:
|
except (subprocess.SubprocessError, OSError) as exc:
|
||||||
raise PasteError(t("Could not copy to clipboard: {error}", error=exc)) from exc
|
raise PasteError(t("Could not copy to clipboard: {error}", error=exc)) from exc
|
||||||
if res.returncode != 0:
|
if res.returncode != 0:
|
||||||
raise PasteError(t("wl-copy exited with code {code}.", code=res.returncode))
|
raise PasteError(t("{tool} exited with code {code}.",
|
||||||
|
tool=tool, code=res.returncode))
|
||||||
|
|
||||||
|
|
||||||
def copy_bytes(data):
|
def copy_bytes(data):
|
||||||
if data is None or not shutil.which("wl-copy"):
|
tool = "xclip" if _x11() else "wl-copy"
|
||||||
|
if data is None or not shutil.which(tool):
|
||||||
return
|
return
|
||||||
try:
|
try:
|
||||||
_run_wl_copy(data)
|
_run_copy(data)
|
||||||
except (subprocess.SubprocessError, OSError):
|
except (subprocess.SubprocessError, OSError):
|
||||||
pass
|
pass
|
||||||
|
|
||||||
|
|
||||||
def ydotool_ready():
|
def ydotool_ready():
|
||||||
return shutil.which("ydotool") is not None
|
tool = "xdotool" if _x11() else "ydotool"
|
||||||
|
return shutil.which(tool) is not None
|
||||||
|
|
||||||
|
|
||||||
|
def _x11():
|
||||||
|
return (os.environ.get("XDG_SESSION_TYPE") == "x11"
|
||||||
|
or bool(os.environ.get("DISPLAY") and not os.environ.get("WAYLAND_DISPLAY")))
|
||||||
|
|
||||||
|
|
||||||
def press(shortcut="ctrl+v", delay=0.12):
|
def press(shortcut="ctrl+v", delay=0.12):
|
||||||
"""Press a key combination through ydotool, e.g. 'ctrl+v'."""
|
"""Press a key combination through xdotool or ydotool."""
|
||||||
if not ydotool_ready():
|
if not ydotool_ready():
|
||||||
raise PasteError(t("ydotool not found, cannot paste automatically."))
|
tool = "xdotool" if _x11() else "ydotool"
|
||||||
|
raise PasteError(t("{tool} not found, cannot paste automatically.", tool=tool))
|
||||||
|
|
||||||
|
if _x11():
|
||||||
|
key = shortcut.lower().replace("control", "ctrl")
|
||||||
|
time.sleep(delay)
|
||||||
|
try:
|
||||||
|
res = subprocess.run(
|
||||||
|
["xdotool", "key", "--clearmodifiers", key],
|
||||||
|
capture_output=True, text=True, timeout=10,
|
||||||
|
)
|
||||||
|
except (subprocess.SubprocessError, OSError) as exc:
|
||||||
|
raise PasteError(t("Could not run xdotool: {error}", error=exc)) from exc
|
||||||
|
if res.returncode != 0:
|
||||||
|
raise PasteError(t("xdotool failed: {error}",
|
||||||
|
error=res.stderr.strip() or "unknown error"))
|
||||||
|
return
|
||||||
|
|
||||||
codes = []
|
codes = []
|
||||||
for key in (k.strip().lower() for k in shortcut.split("+") if k.strip()):
|
for key in (k.strip().lower() for k in shortcut.split("+") if k.strip()):
|
||||||
|
|||||||
@@ -0,0 +1,55 @@
|
|||||||
|
import os
|
||||||
|
import unittest
|
||||||
|
from unittest import mock
|
||||||
|
|
||||||
|
import audio
|
||||||
|
import hotkey
|
||||||
|
import paste
|
||||||
|
|
||||||
|
|
||||||
|
class AudioBackendTests(unittest.TestCase):
|
||||||
|
def test_parec_is_preferred(self):
|
||||||
|
with mock.patch.object(audio.shutil, "which", side_effect=lambda cmd: f"/usr/bin/{cmd}"):
|
||||||
|
self.assertEqual(audio.recording_command()[0], "parec")
|
||||||
|
|
||||||
|
def test_pw_record_remains_the_fallback(self):
|
||||||
|
with mock.patch.object(
|
||||||
|
audio.shutil, "which", side_effect=lambda cmd: "/usr/bin/pw-record"
|
||||||
|
if cmd == "pw-record" else None,
|
||||||
|
):
|
||||||
|
self.assertEqual(audio.recording_command()[0], "pw-record")
|
||||||
|
|
||||||
|
|
||||||
|
class DesktopBackendTests(unittest.TestCase):
|
||||||
|
def test_x11_uses_xclip(self):
|
||||||
|
result = mock.Mock(returncode=0)
|
||||||
|
with mock.patch.dict(os.environ, {"XDG_SESSION_TYPE": "x11"}), \
|
||||||
|
mock.patch.object(paste.shutil, "which", return_value="/usr/bin/xclip"), \
|
||||||
|
mock.patch.object(paste.subprocess, "run", return_value=result) as run:
|
||||||
|
paste.copy("hello")
|
||||||
|
self.assertEqual(run.call_args.args[0][:3],
|
||||||
|
["xclip", "-selection", "clipboard"])
|
||||||
|
|
||||||
|
def test_x11_uses_xdotool(self):
|
||||||
|
result = mock.Mock(returncode=0, stderr="")
|
||||||
|
with mock.patch.dict(os.environ, {"XDG_SESSION_TYPE": "x11"}), \
|
||||||
|
mock.patch.object(paste.shutil, "which", return_value="/usr/bin/xdotool"), \
|
||||||
|
mock.patch.object(paste.time, "sleep"), \
|
||||||
|
mock.patch.object(paste.subprocess, "run", return_value=result) as run:
|
||||||
|
paste.press("ctrl+shift+v")
|
||||||
|
self.assertEqual(run.call_args.args[0],
|
||||||
|
["xdotool", "key", "--clearmodifiers", "ctrl+shift+v"])
|
||||||
|
|
||||||
|
|
||||||
|
class GnomeShortcutTests(unittest.TestCase):
|
||||||
|
def test_accelerator_round_trip(self):
|
||||||
|
accelerator = hotkey.gnome_accelerator("Ctrl+Alt+A")
|
||||||
|
self.assertEqual(accelerator, "<Primary><Alt>a")
|
||||||
|
self.assertEqual(hotkey.display_accelerator(accelerator), "Ctrl+Alt+A")
|
||||||
|
|
||||||
|
def test_empty_gsettings_array(self):
|
||||||
|
self.assertEqual(hotkey._gsettings_array("@as []"), [])
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
unittest.main()
|
||||||
Reference in New Issue
Block a user