mirror of
https://github.com/yusufipk/dikte.git
synced 2026-09-11 10:56:10 +00:00
Add a Mac as the third system, beside Wayland and X11
Dikte already chose its clipboard programs once instead of in every function; macOS joins that table rather than adding a branch to each one. A Mac copies through pbcopy and presses Cmd+V straight into CoreGraphics, records through AVFoundation, and asks Carbon for its global shortcuts. The three tables are paste.Desktop, audio.Sound, and the pair of predicates in hotkey.py. Each reads sys.platform inside the chooser, so a test can stand somewhere else: 697 of the 737 tests now run on any machine, the Wayland and X11 halves included, and the suite passes whole whichever system it is run on. Two things a Mac does not have needed saying rather than pretending: there is no shortcut registry to install into, so Settings offers no Install button and the listener is the mechanism instead of a fallback; and nothing is offered as the sound the speakers are playing, so a meeting needs BlackHole or Loopback and says so. The KDE-only labels around them were already wrong on GNOME, and now name whichever desktop is there. Co-authored-by: firat <[email protected]>
This commit is contained in:
@@ -1,16 +1,20 @@
|
||||
"""Clipboard and key injection, through whichever pair of programs is here.
|
||||
"""Clipboard and key injection, through whatever this machine gives us.
|
||||
|
||||
A Wayland session has wl-clipboard and ydotool, an X11 one has xclip and
|
||||
xdotool, and a session is one or the other. Which it is gets decided in one
|
||||
place, and each desktop is a small group of functions below it: another desktop,
|
||||
or another operating system, adds a group and a line to the chooser rather than
|
||||
a branch inside every function here.
|
||||
xdotool, and macOS has pbcopy with the key press going straight to
|
||||
CoreGraphics. Which of them is here gets decided in one place, and each is a
|
||||
small group of functions below it: another desktop, or another operating
|
||||
system, adds a group and a line to the chooser rather than a branch inside
|
||||
every function here.
|
||||
"""
|
||||
|
||||
import collections
|
||||
import ctypes
|
||||
import functools
|
||||
import os
|
||||
import shutil
|
||||
import subprocess
|
||||
import sys
|
||||
import time
|
||||
|
||||
from i18n import t
|
||||
@@ -27,11 +31,30 @@ KEYCODES = {
|
||||
KEYSYMS = {"control": "ctrl", "meta": "super", "insert": "Insert",
|
||||
"enter": "Return", "return": "Return"}
|
||||
|
||||
# Apple virtual key codes, which say where a key sits rather than what is
|
||||
# printed on it: the same numbers on a Turkish and a US keyboard.
|
||||
MAC_KEYCODES = {
|
||||
"a": 0, "s": 1, "d": 2, "f": 3, "h": 4, "g": 5, "z": 6, "x": 7,
|
||||
"c": 8, "v": 9, "b": 11, "q": 12, "w": 13, "e": 14, "r": 15,
|
||||
"y": 16, "t": 17, "1": 18, "2": 19, "3": 20, "4": 21, "6": 22,
|
||||
"5": 23, "=": 24, "9": 25, "7": 26, "-": 27, "8": 28, "0": 29,
|
||||
"]": 30, "o": 31, "u": 32, "[": 33, "i": 34, "p": 35, "l": 37,
|
||||
"j": 38, "'": 39, "k": 40, ";": 41, "\\": 42, ",": 43, "/": 44,
|
||||
"n": 45, "m": 46, ".": 47, "`": 50, "enter": 36, "return": 36,
|
||||
}
|
||||
MAC_FLAGS = {"shift": 1 << 17, "ctrl": 1 << 18, "alt": 1 << 19, "command": 1 << 20}
|
||||
# What the same modifier is called on a Mac keyboard.
|
||||
MAC_ALIASES = {"cmd": "command", "meta": "command", "super": "command",
|
||||
"control": "ctrl", "option": "alt"}
|
||||
HID_EVENT_TAP = 0 # kCGHIDEventTap: the event goes in where the keyboard does
|
||||
|
||||
|
||||
class PasteError(Exception):
|
||||
pass
|
||||
|
||||
|
||||
# --- the key press, one group per system ----------------------------------
|
||||
|
||||
def _keys(shortcut):
|
||||
"""'Ctrl+V' -> ['ctrl', 'v'], every one of them a key we know."""
|
||||
parts = [key.strip().lower() for key in str(shortcut).split("+") if key.strip()]
|
||||
@@ -54,41 +77,200 @@ def _xdotool_command(shortcut):
|
||||
return ["xdotool", "key", "--clearmodifiers", "+".join(keys)]
|
||||
|
||||
|
||||
def _program_keyboard(program, command, hint=""):
|
||||
"""A desktop that presses keys by running another program.
|
||||
|
||||
Returns the three fields an entry below is built from: the program's name,
|
||||
whether it is here at all, and the press itself.
|
||||
"""
|
||||
|
||||
def ready():
|
||||
return shutil.which(program) is not None
|
||||
|
||||
def press(shortcut, delay):
|
||||
if not ready():
|
||||
raise PasteError(t("{tool} not found, cannot paste automatically.",
|
||||
tool=program))
|
||||
argv = command(shortcut)
|
||||
time.sleep(delay) # let the selection settle and focus come back
|
||||
try:
|
||||
res = subprocess.run(argv, capture_output=True, text=True, timeout=10)
|
||||
except (subprocess.SubprocessError, OSError) as exc:
|
||||
raise PasteError(t("Could not run {tool}: {error}",
|
||||
tool=program, error=exc)) from exc
|
||||
if res.returncode != 0:
|
||||
message = t("{tool} failed: {error}", tool=program,
|
||||
error=res.stderr.strip() or "unknown error")
|
||||
raise PasteError(f"{message}\n{t(hint)}" if hint else message)
|
||||
|
||||
return {"keyboard": program, "ready": ready, "press": press}
|
||||
|
||||
|
||||
def _macos_keys(shortcut):
|
||||
"""'Cmd+V' -> (9, 0x100000): where the key sits, and the modifiers on it."""
|
||||
parts = [key.strip().lower() for key in str(shortcut).split("+") if key.strip()]
|
||||
parts = [MAC_ALIASES.get(part, part) for part in parts]
|
||||
if not parts or parts[-1] not in MAC_KEYCODES:
|
||||
raise PasteError(t("Unknown key: {key}", key=parts[-1] if parts else shortcut))
|
||||
flags = 0
|
||||
for part in parts[:-1]:
|
||||
if part not in MAC_FLAGS:
|
||||
raise PasteError(t("Unknown key: {key}", key=part))
|
||||
flags |= MAC_FLAGS[part]
|
||||
return MAC_KEYCODES[parts[-1]], flags
|
||||
|
||||
|
||||
@functools.lru_cache(maxsize=1)
|
||||
def _macos_api():
|
||||
"""The bit of CoreGraphics and Accessibility a paste goes through.
|
||||
|
||||
Loaded on the first paste rather than at import: this module is read on
|
||||
every system, and these two frameworks exist on one of them.
|
||||
"""
|
||||
try:
|
||||
services = ctypes.CDLL(
|
||||
"/System/Library/Frameworks/ApplicationServices.framework"
|
||||
"/ApplicationServices"
|
||||
)
|
||||
core = ctypes.CDLL(
|
||||
"/System/Library/Frameworks/CoreFoundation.framework/CoreFoundation"
|
||||
)
|
||||
except OSError as exc:
|
||||
raise PasteError(t("Could not run {tool}: {error}",
|
||||
tool="CoreGraphics", error=exc)) from exc
|
||||
services.AXIsProcessTrusted.argtypes = []
|
||||
services.AXIsProcessTrusted.restype = ctypes.c_bool
|
||||
services.CGEventCreateKeyboardEvent.argtypes = [
|
||||
ctypes.c_void_p, ctypes.c_ushort, ctypes.c_bool,
|
||||
]
|
||||
services.CGEventCreateKeyboardEvent.restype = ctypes.c_void_p
|
||||
services.CGEventSetFlags.argtypes = [ctypes.c_void_p, ctypes.c_uint64]
|
||||
services.CGEventSetFlags.restype = None
|
||||
services.CGEventPost.argtypes = [ctypes.c_uint32, ctypes.c_void_p]
|
||||
services.CGEventPost.restype = None
|
||||
core.CFRelease.argtypes = [ctypes.c_void_p]
|
||||
core.CFRelease.restype = None
|
||||
return services, core
|
||||
|
||||
|
||||
def _macos_trusted():
|
||||
"""Whether macOS lets this process type into another application."""
|
||||
try:
|
||||
return bool(_macos_api()[0].AXIsProcessTrusted())
|
||||
except PasteError:
|
||||
return False
|
||||
|
||||
|
||||
_asked_for_permission = False
|
||||
|
||||
|
||||
def _ask_for_permission():
|
||||
"""Open the one settings pane that grants it, and only the first time.
|
||||
|
||||
Every dictation would otherwise reopen it until the box is ticked, which is
|
||||
a window in the user's face on top of the paste that did not happen.
|
||||
"""
|
||||
global _asked_for_permission
|
||||
if _asked_for_permission:
|
||||
return
|
||||
_asked_for_permission = True
|
||||
try:
|
||||
subprocess.Popen(
|
||||
["open", ("x-apple.systempreferences:com.apple.preference.security"
|
||||
"?Privacy_Accessibility")],
|
||||
stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL, close_fds=True,
|
||||
)
|
||||
except OSError:
|
||||
pass
|
||||
|
||||
|
||||
def _macos_press(shortcut, delay):
|
||||
"""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.
|
||||
"""
|
||||
keycode, flags = _macos_keys(shortcut)
|
||||
services, core = _macos_api()
|
||||
if not _macos_trusted():
|
||||
_ask_for_permission()
|
||||
raise PasteError(t(
|
||||
"macOS has not been told to let Dikte press keys. Turn Dikte on "
|
||||
"under System Settings → Privacy & Security → Accessibility."
|
||||
))
|
||||
|
||||
time.sleep(delay) # let the selection settle and focus come back
|
||||
down = services.CGEventCreateKeyboardEvent(None, keycode, True)
|
||||
up = services.CGEventCreateKeyboardEvent(None, keycode, False)
|
||||
if not down or not up:
|
||||
for event in (down, up):
|
||||
if event:
|
||||
core.CFRelease(event)
|
||||
raise PasteError(t("Could not run {tool}: {error}", tool="CoreGraphics",
|
||||
error="it would not make a keyboard event"))
|
||||
try:
|
||||
for event in (down, up):
|
||||
services.CGEventSetFlags(event, flags)
|
||||
services.CGEventPost(HID_EVENT_TAP, event)
|
||||
time.sleep(0.01)
|
||||
finally:
|
||||
core.CFRelease(down)
|
||||
core.CFRelease(up)
|
||||
|
||||
|
||||
# --- which of them is here -------------------------------------------------
|
||||
|
||||
Desktop = collections.namedtuple(
|
||||
"Desktop",
|
||||
# The two programs, the packages to install them from, how to build the key
|
||||
# press, and what else to say when the key press fails.
|
||||
"clipboard keyboard packages read_command copy_command key_command key_hint",
|
||||
# The clipboard program and the two commands it is run with, what to
|
||||
# install when it is missing, the paste combinations Settings offers, and
|
||||
# the key press: the program that does it, whether it can happen at all,
|
||||
# and the pressing itself.
|
||||
"clipboard packages read_command copy_command shortcuts keyboard ready press",
|
||||
)
|
||||
|
||||
WAYLAND = Desktop(
|
||||
clipboard="wl-copy",
|
||||
keyboard="ydotool",
|
||||
packages="wl-clipboard and ydotool",
|
||||
read_command=["wl-paste", "--no-newline"],
|
||||
copy_command=["wl-copy"],
|
||||
key_command=_ydotool_command,
|
||||
key_hint="Is ydotoold running? (systemctl --user status ydotool)",
|
||||
shortcuts=["ctrl+v", "ctrl+shift+v", "shift+insert"],
|
||||
**_program_keyboard(
|
||||
"ydotool", _ydotool_command,
|
||||
hint="Is ydotoold running? (systemctl --user status ydotool)",
|
||||
),
|
||||
)
|
||||
|
||||
X11 = Desktop(
|
||||
clipboard="xclip",
|
||||
keyboard="xdotool",
|
||||
packages="xclip and xdotool",
|
||||
read_command=["xclip", "-selection", "clipboard", "-out"],
|
||||
copy_command=["xclip", "-selection", "clipboard", "-in"],
|
||||
key_command=_xdotool_command,
|
||||
key_hint="",
|
||||
shortcuts=["ctrl+v", "ctrl+shift+v", "shift+insert"],
|
||||
**_program_keyboard("xdotool", _xdotool_command),
|
||||
)
|
||||
|
||||
MACOS = Desktop(
|
||||
clipboard="pbcopy",
|
||||
packages="", # both are part of macOS; there is nothing to install
|
||||
read_command=["pbpaste"],
|
||||
copy_command=["pbcopy"],
|
||||
shortcuts=["cmd+v", "cmd+shift+v", "cmd+alt+shift+v"],
|
||||
keyboard="", # no program: the key press is a call into the system
|
||||
ready=_macos_trusted,
|
||||
press=_macos_press,
|
||||
)
|
||||
|
||||
|
||||
def desktop():
|
||||
"""The pair of programs this session's clipboard and keyboard go through.
|
||||
"""The programs this session's clipboard and key press go through.
|
||||
|
||||
Read every time rather than settled at import: a session started before the
|
||||
display server was up would otherwise be stuck with the wrong answer, and a
|
||||
test would have nowhere to say which one it means.
|
||||
"""
|
||||
if sys.platform == "darwin":
|
||||
return MACOS
|
||||
if os.environ.get("XDG_SESSION_TYPE") == "x11":
|
||||
return X11
|
||||
if os.environ.get("DISPLAY") and not os.environ.get("WAYLAND_DISPLAY"):
|
||||
@@ -124,8 +306,11 @@ def _run_copy(payload):
|
||||
def copy(text):
|
||||
here = desktop()
|
||||
if not shutil.which(here.clipboard):
|
||||
raise PasteError(t("{tool} not found. Install {packages}.",
|
||||
tool=here.clipboard, packages=here.packages))
|
||||
raise PasteError(
|
||||
t("{tool} not found. Install {packages}.",
|
||||
tool=here.clipboard, packages=here.packages) if here.packages
|
||||
else t("{tool} not found.", tool=here.clipboard)
|
||||
)
|
||||
try:
|
||||
res = _run_copy(text.encode("utf-8"))
|
||||
except (subprocess.SubprocessError, OSError) as exc:
|
||||
@@ -147,25 +332,11 @@ def copy_bytes(data):
|
||||
# --- the key press ---------------------------------------------------------
|
||||
|
||||
def paste_ready():
|
||||
return shutil.which(desktop().keyboard) is not None
|
||||
"""Whether a paste can be sent: the program is here, or macOS trusts us."""
|
||||
return desktop().ready()
|
||||
|
||||
|
||||
def press(shortcut="ctrl+v", delay=0.12):
|
||||
"""Press a key combination, e.g. 'ctrl+v'."""
|
||||
def press(shortcut="", delay=0.12):
|
||||
"""Press a paste combination, e.g. 'ctrl+v', or this desktop's own."""
|
||||
here = desktop()
|
||||
if not paste_ready():
|
||||
raise PasteError(t("{tool} not found, cannot paste automatically.",
|
||||
tool=here.keyboard))
|
||||
|
||||
command = here.key_command(shortcut)
|
||||
time.sleep(delay) # let the selection settle and focus come back
|
||||
try:
|
||||
res = subprocess.run(command, capture_output=True, text=True, timeout=10)
|
||||
except (subprocess.SubprocessError, OSError) as exc:
|
||||
raise PasteError(t("Could not run {tool}: {error}",
|
||||
tool=here.keyboard, error=exc)) from exc
|
||||
if res.returncode != 0:
|
||||
message = t("{tool} failed: {error}", tool=here.keyboard,
|
||||
error=res.stderr.strip() or "unknown error")
|
||||
raise PasteError(f"{message}\n{t(here.key_hint)}" if here.key_hint
|
||||
else message)
|
||||
here.press(shortcut or here.shortcuts[0], delay)
|
||||
|
||||
Reference in New Issue
Block a user