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
|
||||
```
|
||||
|
||||
`install.sh` adds the `dikte` command, a menu entry, an autostart entry and the
|
||||
KDE shortcut.
|
||||
On Ubuntu/GNOME X11, recording uses PulseAudio and clipboard/paste use the X11
|
||||
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
|
||||
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
|
||||
```
|
||||
|
||||
`install.sh` `dikte` komutunu, menü girdisini, oturum açılışında otomatik
|
||||
başlatmayı ve KDE kısayolunu kurar.
|
||||
Ubuntu/GNOME X11 için kayıt PulseAudio üzerinden, pano ve yapıştırma ise X11
|
||||
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
|
||||
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):
|
||||
"""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
|
||||
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):
|
||||
if self.active:
|
||||
return
|
||||
if not shutil.which("pw-record"):
|
||||
self.failed.emit(t("pw-record not found. Is pipewire-audio installed?"))
|
||||
cmd = recording_command(target)
|
||||
if not cmd:
|
||||
self.failed.emit(t(
|
||||
"No audio recorder found. Install pulseaudio-utils or pipewire-audio."
|
||||
))
|
||||
return
|
||||
|
||||
cmd = [
|
||||
"pw-record",
|
||||
"--raw",
|
||||
f"--rate={RATE}",
|
||||
f"--channels={CHANNELS}",
|
||||
"--format=s16",
|
||||
]
|
||||
if target:
|
||||
cmd.append(f"--target={target}")
|
||||
cmd.append("-")
|
||||
|
||||
try:
|
||||
self._proc = subprocess.Popen(
|
||||
cmd, stdout=subprocess.PIPE, stderr=subprocess.PIPE, bufsize=0
|
||||
@@ -84,7 +76,8 @@ class Recorder(QObject):
|
||||
self._thread.start()
|
||||
|
||||
def _pump(self):
|
||||
stdout = self._proc.stdout
|
||||
proc = self._proc
|
||||
stdout = proc.stdout
|
||||
try:
|
||||
while True:
|
||||
chunk = stdout.read(CHUNK_BYTES)
|
||||
@@ -101,6 +94,15 @@ class Recorder(QObject):
|
||||
break
|
||||
except (OSError, ValueError):
|
||||
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):
|
||||
proc = self._proc
|
||||
@@ -161,6 +163,33 @@ def write_wav(pcm, rate=RATE, channels=CHANNELS, width=SAMPLE_WIDTH):
|
||||
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):
|
||||
"""Microphone and speaker output into one stereo file: left is you, right is
|
||||
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 os
|
||||
import pathlib
|
||||
import re
|
||||
import select
|
||||
import shutil
|
||||
import struct
|
||||
import subprocess
|
||||
import threading
|
||||
@@ -19,6 +21,8 @@ ASK_DESKTOP_ID = "dikte-ask.desktop"
|
||||
APPLICATIONS_DIR = pathlib.Path.home() / ".local/share/applications"
|
||||
DESKTOP_FILE = APPLICATIONS_DIR / DESKTOP_ID
|
||||
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) --------------------------
|
||||
|
||||
@@ -174,6 +178,150 @@ class EvdevHotkey(QObject):
|
||||
|
||||
# --- 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",
|
||||
desktop_id=DESKTOP_ID):
|
||||
"""Write the desktop file and the kglobalshortcutsrc entry.
|
||||
|
||||
@@ -101,9 +101,20 @@ TR = {
|
||||
"pw-record not found. Is pipewire-audio installed?":
|
||||
"pw-record bulunamadı. pipewire-audio kurulu mu?",
|
||||
"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 bulunamadı. wl-clipboard paketini kur.",
|
||||
"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ı.",
|
||||
"ydotool not found, cannot paste automatically.":
|
||||
"ydotool bulunamadı, otomatik yapıştırma yapılamıyor.",
|
||||
@@ -265,9 +276,19 @@ TR = {
|
||||
|
||||
# --- settings: shortcut ------------------------------------------------
|
||||
"Install as a KDE shortcut": "KDE kısayolu olarak kur",
|
||||
"Install as a global shortcut": "Global kısayol olarak kur",
|
||||
"Remove": "Kaldır",
|
||||
"Registered in KDE: {shortcut}": "KDE'de kayıtlı: {shortcut}",
|
||||
"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":
|
||||
"Yerleşik dinleyici kullan (/dev/input), KDE kısayolu henüz etkin değilken",
|
||||
"Works immediately, no session restart. The only difference: the key "
|
||||
|
||||
+21
-6
@@ -19,21 +19,33 @@ echo "────────────────"
|
||||
|
||||
# 1. Dependencies ----------------------------------------------------------
|
||||
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")
|
||||
done
|
||||
python3 -c 'import PyQt6.QtWidgets' 2>/dev/null || missing+=("python-pyqt6")
|
||||
|
||||
if ((${#missing[@]})); then
|
||||
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
|
||||
else
|
||||
ok "All dependencies present"
|
||||
fi
|
||||
|
||||
# 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 \
|
||||
|| systemctl --user is-active --quiet ydotoold 2>/dev/null; then
|
||||
ok "ydotoold is running (auto-paste ready)"
|
||||
@@ -87,7 +99,10 @@ Type=Application
|
||||
X-KDE-GlobalAccel-CommandShortcut=true
|
||||
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 \
|
||||
--group services --group dikte-toggle.desktop \
|
||||
--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 "built-in listener to use it right away."
|
||||
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
|
||||
|
||||
echo
|
||||
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
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
"""Clipboard (wl-clipboard) and key injection (ydotool)."""
|
||||
"""Clipboard and key injection for Wayland and X11."""
|
||||
|
||||
import os
|
||||
import shutil
|
||||
import subprocess
|
||||
import time
|
||||
@@ -18,20 +19,23 @@ class PasteError(Exception):
|
||||
|
||||
|
||||
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
|
||||
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):
|
||||
return None
|
||||
return res.stdout if res.returncode == 0 else None
|
||||
|
||||
|
||||
def _run_wl_copy(payload):
|
||||
"""wl-copy forks to keep owning the selection; leaving its pipes open makes
|
||||
subprocess.run wait for EOF forever, hence DEVNULL."""
|
||||
def _run_copy(payload):
|
||||
"""The clipboard owner may fork; do not leave inherited pipes open."""
|
||||
command = (["xclip", "-selection", "clipboard", "-in"] if _x11()
|
||||
else ["wl-copy"])
|
||||
return subprocess.run(
|
||||
["wl-copy"],
|
||||
command,
|
||||
input=payload,
|
||||
stdout=subprocess.DEVNULL,
|
||||
stderr=subprocess.DEVNULL,
|
||||
@@ -40,33 +44,58 @@ def _run_wl_copy(payload):
|
||||
|
||||
|
||||
def copy(text):
|
||||
if not shutil.which("wl-copy"):
|
||||
raise PasteError(t("wl-copy not found. Install wl-clipboard."))
|
||||
tool = "xclip" if _x11() else "wl-copy"
|
||||
if not shutil.which(tool):
|
||||
raise PasteError(t("{tool} not found; clipboard copy is unavailable.", tool=tool))
|
||||
try:
|
||||
res = _run_wl_copy(text.encode("utf-8"))
|
||||
res = _run_copy(text.encode("utf-8"))
|
||||
except (subprocess.SubprocessError, OSError) as exc:
|
||||
raise PasteError(t("Could not copy to clipboard: {error}", error=exc)) from exc
|
||||
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):
|
||||
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
|
||||
try:
|
||||
_run_wl_copy(data)
|
||||
_run_copy(data)
|
||||
except (subprocess.SubprocessError, OSError):
|
||||
pass
|
||||
|
||||
|
||||
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):
|
||||
"""Press a key combination through ydotool, e.g. 'ctrl+v'."""
|
||||
"""Press a key combination through xdotool or ydotool."""
|
||||
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 = []
|
||||
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