feat: enhance ydotool integration for direct typing on Linux; improve error handling and logging in audio processing; update build scripts for better installation guidance

This commit is contained in:
Your Name
2026-06-09 10:14:33 -07:00
parent 52bb65f3a0
commit 2c5a0b70ed
7 changed files with 308 additions and 216 deletions
+1 -1
View File
@@ -347,7 +347,7 @@
const title = document.createElement('div');
title.style.fontWeight = '600';
title.textContent = ready ? 'Automatic paste ready' : 'Automatic paste needs ydotool setup';
title.textContent = ready ? 'Direct typing ready' : 'Direct typing needs ydotool setup';
el.appendChild(title);
const message = document.createElement('div');
+6 -6
View File
@@ -6,6 +6,7 @@ import (
"encoding/binary"
"fmt"
"os"
"runtime"
"sync"
"sync/atomic"
@@ -207,16 +208,15 @@ func (r *AudioRecorder) Stop() error {
// callbacks from writing to a closed file.
atomic.StoreInt32(&r.writing, 0)
// Stop the capture device. We keep the device initialized between recordings
// to avoid the expensive re-initialization cycle. ALSA privacy indicators
// will still turn off because we've stopped the stream.
// Stop the capture device.
if r.device != nil {
if err := r.device.Stop(); err != nil {
logger.Error("Failed to stop audio device: %v", err)
}
// Do NOT Uninit the device between recordings. Keeping it alive
// avoids expensive ALSA device re-probe and reduces CPU spikes.
// The device will be fully cleaned up in Cleanup().
if runtime.GOOS == "linux" {
r.device.Uninit()
r.device = nil
}
}
// Finalize WAV file
+22 -19
View File
@@ -338,34 +338,37 @@ func (hk *Hotkey) registerPortal() error {
return nil
}
// safeSendKeydown sends a keydown event to the hotkey channel, recovering from panic
// if the channel has been closed (e.g. during hotkey re-registration).
func (hk *Hotkey) safeSendKeydown() {
func (hk *Hotkey) sendPortalEvent(name string, ch chan<- Event) {
hk.mu.Lock()
stopCh := hk.portalStop
registered := hk.registered
hk.mu.Unlock()
if !registered || stopCh == nil {
return
}
defer func() {
if r := recover(); r != nil {
logger.Debug("safeSendKeydown: recovered from panic: %v", r)
logger.Debug("sendPortalEvent(%s): recovered from panic: %v", name, r)
}
}()
select {
case hk.keydownIn <- Event{}:
default:
// Channel buffer is full or closed; skip.
case ch <- Event{}:
case <-stopCh:
case <-time.After(2 * time.Second):
logger.Error("Timed out delivering Linux portal hotkey %s event", name)
}
}
// safeSendKeyup sends a keyup event to the hotkey channel, recovering from panic
// if the channel has been closed (e.g. during hotkey re-registration).
// safeSendKeydown sends a keydown event to the hotkey channel.
func (hk *Hotkey) safeSendKeydown() {
hk.sendPortalEvent("keydown", hk.keydownIn)
}
// safeSendKeyup sends a keyup event to the hotkey channel.
func (hk *Hotkey) safeSendKeyup() {
defer func() {
if r := recover(); r != nil {
logger.Debug("safeSendKeyup: recovered from panic: %v", r)
}
}()
select {
case hk.keyupIn <- Event{}:
default:
// Channel buffer is full or closed; skip.
}
hk.sendPortalEvent("keyup", hk.keyupIn)
}
func (hk *Hotkey) portalSignalLoop() {
+80 -5
View File
@@ -6,7 +6,10 @@ import (
"net/http"
"os"
"sync"
"sync/atomic"
"time"
"wis-free-v3/internal/logger"
)
const linuxPressAddr = "127.0.0.1:9876"
@@ -20,6 +23,7 @@ type linuxPressState struct {
recording bool
holdMode bool
detectingHold bool
cycle uint64
}
var pressState linuxPressState
@@ -68,12 +72,31 @@ func sendLinuxPressPing() error {
func (a *App) startLinuxPressDaemon() {
mux := http.NewServeMux()
mux.HandleFunc("/press", func(w http.ResponseWriter, r *http.Request) {
defer func() {
if recovered := recover(); recovered != nil {
logger.Error("Linux press handler recovered from panic: %v", recovered)
http.Error(w, "press handler failed", http.StatusInternalServerError)
}
}()
if r.Method != http.MethodGet && r.Method != http.MethodPost {
w.WriteHeader(http.StatusMethodNotAllowed)
return
}
a.handleLinuxPressPing()
w.WriteHeader(http.StatusNoContent)
})
go func() {
_ = http.ListenAndServe(linuxPressAddr, mux)
server := &http.Server{
Addr: linuxPressAddr,
Handler: mux,
ReadHeaderTimeout: 2 * time.Second,
}
if err := server.ListenAndServe(); err != nil && err != http.ErrServerClosed {
logger.Error("Linux press daemon stopped: %v", err)
}
}()
}
@@ -83,10 +106,12 @@ func (a *App) handleLinuxPressPing() {
if !pressState.recording {
// First ping: start recording with hold detection
pressState.cycle++
cycle := pressState.cycle
pressState.recording = true
pressState.holdMode = false
pressState.detectingHold = true
go a.StartRecording()
go a.startLinuxPressRecording(cycle)
if pressState.detectTimer != nil {
pressState.detectTimer.Stop()
@@ -94,7 +119,9 @@ func (a *App) handleLinuxPressPing() {
pressState.detectTimer = time.AfterFunc(linuxPressHoldDetectWindow, func() {
pressState.mu.Lock()
defer pressState.mu.Unlock()
if pressState.cycle == cycle {
pressState.detectingHold = false
}
})
return
}
@@ -121,10 +148,11 @@ func (a *App) resetLinuxPressReleaseTimerLocked() {
pressState.releaseTimer.Stop()
}
cycle := pressState.cycle
pressState.releaseTimer = time.AfterFunc(linuxPressReleaseGrace, func() {
pressState.mu.Lock()
defer pressState.mu.Unlock()
if pressState.recording && pressState.holdMode {
if pressState.cycle == cycle && pressState.recording && pressState.holdMode {
a.stopLinuxPressRecordingLocked()
}
})
@@ -132,6 +160,8 @@ func (a *App) resetLinuxPressReleaseTimerLocked() {
func (a *App) stopLinuxPressRecordingLocked() {
// Caller must hold pressState.mu
wasRecording := pressState.recording
pressState.cycle++
if pressState.releaseTimer != nil {
pressState.releaseTimer.Stop()
pressState.releaseTimer = nil
@@ -140,10 +170,55 @@ func (a *App) stopLinuxPressRecordingLocked() {
pressState.detectTimer.Stop()
pressState.detectTimer = nil
}
if pressState.recording {
pressState.recording = false
pressState.holdMode = false
pressState.detectingHold = false
go a.StopRecording()
if wasRecording {
go a.stopLinuxPressRecording()
}
}
func (a *App) startLinuxPressRecording(cycle uint64) {
defer func() {
if recovered := recover(); recovered != nil {
logger.Error("Linux press start recovered from panic: %v", recovered)
a.resetFailedLinuxPressCycle(cycle)
}
}()
a.StartRecording()
if atomic.LoadInt32(&a.recording) == 0 {
a.resetFailedLinuxPressCycle(cycle)
}
}
func (a *App) stopLinuxPressRecording() {
defer func() {
if recovered := recover(); recovered != nil {
logger.Error("Linux press stop recovered from panic: %v", recovered)
}
}()
a.StopRecording()
}
func (a *App) resetFailedLinuxPressCycle(cycle uint64) {
pressState.mu.Lock()
defer pressState.mu.Unlock()
if pressState.cycle != cycle {
return
}
pressState.cycle++
if pressState.releaseTimer != nil {
pressState.releaseTimer.Stop()
pressState.releaseTimer = nil
}
if pressState.detectTimer != nil {
pressState.detectTimer.Stop()
pressState.detectTimer = nil
}
pressState.recording = false
pressState.holdMode = false
pressState.detectingHold = false
}
+159 -105
View File
@@ -41,6 +41,159 @@ command_exists() {
command -v "$1" >/dev/null 2>&1
}
print_ydotool_install_help() {
if command_exists dnf; then
echo " sudo dnf install ydotool"
elif command_exists apt-get; then
echo " sudo apt install ydotool"
elif command_exists pacman; then
echo " sudo pacman -S ydotool"
else
echo " Install ydotool with your distribution's package manager."
fi
}
find_ydotoold() {
if command_exists ydotoold; then
command -v ydotoold
return 0
fi
for candidate in /usr/bin/ydotoold /usr/local/bin/ydotoold; do
if [ -x "$candidate" ]; then
printf '%s\n' "$candidate"
return 0
fi
done
return 1
}
setup_ydotool_systemd() {
echo ""
echo "========================================"
echo " Setting up ydotool systemd user service"
echo "========================================"
echo ""
if ! command_exists systemctl; then
echo "[ERROR] systemctl is not available on this system."
return 1
fi
if ! command_exists ydotool; then
echo "[ERROR] ydotool is not installed."
echo "Install it first:"
print_ydotool_install_help
return 1
fi
YDOTOOLD_BIN="$(find_ydotoold || true)"
if [ -z "$YDOTOOLD_BIN" ]; then
echo "[ERROR] ydotoold was not found after installing ydotool."
echo "Check your distribution's ydotool package or install the daemon package if it is split out."
return 1
fi
SYSTEMD_USER_DIR="${XDG_CONFIG_HOME:-$HOME/.config}/systemd/user"
mkdir -p "$SYSTEMD_USER_DIR"
SERVICE_FILE="$SYSTEMD_USER_DIR/ydotool.service"
cat > "$SERVICE_FILE" << YDSVCEOF
[Unit]
Description=ydotool daemon for WIS Free V3 direct keyboard injection
Documentation=man:ydotool(1)
After=graphical-session.target
[Service]
Type=simple
Environment=YDOTOOL_SOCKET=%t/.ydotool_socket
ExecStart=$YDOTOOLD_BIN
Restart=on-failure
RestartSec=2
[Install]
WantedBy=default.target
YDSVCEOF
echo "[INFO] Created $SERVICE_FILE"
UDEV_RULE_FILE="/etc/udev/rules.d/80-uinput.rules"
if [ -f "$UDEV_RULE_FILE" ] && grep -q 'KERNEL=="uinput"' "$UDEV_RULE_FILE"; then
echo "[INFO] uinput udev rule already exists at $UDEV_RULE_FILE"
else
echo ""
echo " /dev/uinput permission rule is needed for ydotool direct typing:"
echo ""
echo ' KERNEL=="uinput", SUBSYSTEM=="misc", TAG+="uaccess", OPTIONS+="static_node=uinput"'
echo ""
read -p " Create or update $UDEV_RULE_FILE now? (requires sudo) (y/N): " CREATE_UDEV
if [[ "$CREATE_UDEV" == "y" || "$CREATE_UDEV" == "Y" ]]; then
echo 'KERNEL=="uinput", SUBSYSTEM=="misc", TAG+="uaccess", OPTIONS+="static_node=uinput"' | \
sudo tee "$UDEV_RULE_FILE" > /dev/null
sudo udevadm control --reload-rules && sudo udevadm trigger
echo "[INFO] udev rule created and reloaded."
echo " Log out and back in, or reboot, if ydotool still cannot access /dev/uinput."
else
echo " Skipping udev rule creation. ydotoold may not be able to inject keystrokes."
fi
fi
echo ""
echo " Reloading systemd user daemon..."
systemctl --user daemon-reload
echo " Enabling ydotool user service..."
systemctl --user enable ydotool.service
echo " Starting ydotool user service..."
if systemctl --user restart ydotool.service; then
echo " ydotool systemd service is running."
else
echo " [WARN] Could not start ydotool.service. Check: systemctl --user status ydotool.service"
fi
echo ""
}
INSTALL_MODE="none" # none, system, user
INSTALL_SYSTEMD=false
SHOW_HELP=false
for arg in "$@"; do
case "$arg" in
--install)
INSTALL_MODE="system"
;;
--install-user)
INSTALL_MODE="user"
;;
--install-systemd)
INSTALL_SYSTEMD=true
;;
--help|-h)
SHOW_HELP=true
;;
*)
echo "[ERROR] Unknown option: $arg"
echo "Usage: $0 [--install|--install-user|--install-systemd|--help]"
exit 1
;;
esac
done
if [ "$SHOW_HELP" = true ]; then
echo "Usage: $0 [--install|--install-user|--install-systemd|--help]"
echo ""
echo " (no flags) Build only, then offer interactive install prompts"
echo " --install Build and install system-wide (/usr/local/bin)"
echo " --install-user Build and install per-user (~/.local/bin)"
echo " --install-systemd Generate, reload, enable, and start ydotool user service"
echo " --help Show this message"
echo ""
exit 0
fi
if [ "$INSTALL_SYSTEMD" = true ] && [ "$INSTALL_MODE" = "none" ]; then
setup_ydotool_systemd
exit $?
fi
# 1. Check for basic tools
if ! command_exists go; then
echo "[ERROR] Go is not installed. Please install Go 1.23+."
@@ -123,28 +276,20 @@ for dep in "${DEPS[@]}"; do
fi
done
# ydotool check for text injection
# ydotool check for direct text injection
if ! command_exists ydotool; then
echo ""
echo "==============================================================="
echo " WARNING: ydotool is not installed"
echo "==============================================================="
echo ""
echo " ydotool is required for automatic text injection (paste) on"
echo " Wayland. Without it, transcribed text will only be copied to"
echo " your clipboard."
echo " ydotool is required for direct keyboard injection on Wayland."
echo " Without it, WIS Free V3 cannot type transcribed text automatically"
echo " into the active Linux window."
echo ""
echo " Install ydotool and configure it:"
echo ""
if command_exists dnf; then
echo " sudo dnf install ydotool"
elif command_exists apt-get; then
echo " sudo apt install ydotool"
elif command_exists pacman; then
echo " sudo pacman -S ydotool"
else
echo " Install ydotool from your distribution's package manager."
fi
print_ydotool_install_help
echo ""
echo " Then set up udev rules for /dev/uinput access:"
echo " echo 'KERNEL==\"uinput\", SUBSYSTEM==\"misc\", TAG+=\"uaccess\", OPTIONS+=\"static_node=uinput\"' |"
@@ -305,35 +450,6 @@ echo " Without flags, the build-only mode finishes here."
echo " Binary is ready at: $EXECUTABLE"
echo ""
# Parse flags
INSTALL_MODE="none" # none, system, user
INSTALL_SYSTEMD=false
for arg in "$@"; do
case "$arg" in
--install)
INSTALL_MODE="system"
;;
--install-user)
INSTALL_MODE="user"
;;
--install-systemd)
INSTALL_SYSTEMD=true
;;
--help|-h)
echo "Usage: $0 [--install|--install-user|--install-systemd|--help]"
echo ""
echo " (no flags) Build only — outputs binary to $EXECUTABLE"
echo " --install Build + install system-wide (/usr/local/bin)"
echo " --install-user Build + install per-user (~/.local/bin)"
echo " --install-systemd Generate and enable systemd user units for ydotool"
echo " --help Show this message"
echo ""
exit 0
;;
esac
done
# If no install flags were passed, do interactive prompt (backward-compatible)
if [ "$INSTALL_MODE" = "none" ] && [ "$INSTALL_SYSTEMD" = false ]; then
read -p "Would you like to install it system-wide to /usr/local/bin? (y/n): " INSTALL
@@ -354,69 +470,7 @@ fi
# --- Systemd ydotool service setup ---
if [ "$INSTALL_SYSTEMD" = true ]; then
echo ""
echo "========================================"
echo " Setting up ydotool systemd user service"
echo "========================================"
echo ""
SYSTEMD_USER_DIR="${XDG_DATA_HOME:-$HOME/.local/share}/systemd/user"
mkdir -p "$SYSTEMD_USER_DIR"
# Create ydotool.service for the user session
cat > "$SYSTEMD_USER_DIR/ydotool.service" << 'YDSVCEOF'
[Unit]
Description=ydotool daemon — simulate keyboard input on Wayland
Documentation=man:ydotool(1)
[Service]
Type=simple
ExecStart=/usr/bin/ydotoold
Restart=on-failure
RestartSec=2
[Install]
WantedBy=default.target
YDSVCEOF
echo "[INFO] Created $SYSTEMD_USER_DIR/ydotool.service"
# Check/create udev rule for uinput
UDEV_RULE_FILE="/etc/udev/rules.d/80-uinput.rules"
if [ ! -f "$UDEV_RULE_FILE" ]; then
echo ""
echo " /dev/uinput permission rule not found."
echo " The following rule is needed for ydotool to inject keystrokes:"
echo ""
echo ' KERNEL=="uinput", SUBSYSTEM=="misc", TAG+="uaccess", OPTIONS+="static_node=uinput"'
echo ""
read -p " Create $UDEV_RULE_FILE now? (requires sudo) (y/N): " CREATE_UDEV
if [[ "$CREATE_UDEV" == "y" || "$CREATE_UDEV" == "Y" ]]; then
echo 'KERNEL=="uinput", SUBSYSTEM=="misc", TAG+="uaccess", OPTIONS+="static_node=uinput"' | \
sudo tee "$UDEV_RULE_FILE" > /dev/null
sudo udevadm control --reload-rules && sudo udevadm trigger
echo "[INFO] udev rule created and reloaded."
echo " You must log out and back in (or reboot) for the permissions to take effect."
else
echo " Skipping udev rule creation. ydotoold may not be able to inject keystrokes."
echo " You can create it manually later."
fi
else
echo "[INFO] udev rule already exists at $UDEV_RULE_FILE"
fi
# Reload systemd user daemon and enable the service
echo ""
echo " Reloading systemd user daemon..."
systemctl --user daemon-reload
echo " Enabling ydotool user service..."
systemctl --user enable ydotool.service
echo " Starting ydotool user service..."
systemctl --user start ydotool.service || echo " [WARN] Could not start (may need logout/login for udev perms)"
echo ""
echo " ydotool systemd service is now set up."
echo " You can check its status with: systemctl --user status ydotool"
echo ""
setup_ydotool_systemd
fi
# --- Binary installation ---
+6 -5
View File
@@ -80,15 +80,15 @@ if ! pkg-config --exists gtk+-3.0 || ! webkit2_ok || ! pkg-config --exists alsa
echo ""
echo "Ubuntu/Debian:"
echo " sudo apt update && sudo apt install -y build-essential pkg-config libgtk-3-dev libwebkit2gtk-4.0-dev libasound2-dev libayatana-appindicator3-dev dpkg-dev rpm"
echo " sudo apt install -y ydotool (recommended for automatic text injection)"
echo " sudo apt install -y ydotool (recommended for direct keyboard injection)"
echo ""
echo "Fedora:"
echo " sudo dnf install -y gcc gcc-c++ make pkgconf-pkg-config gtk3-devel webkit2gtk4.1-devel alsa-lib-devel libayatana-appindicator-gtk3-devel rpm-build"
echo " sudo dnf install -y ydotool (recommended for automatic text injection)"
echo " sudo dnf install -y ydotool (recommended for direct keyboard injection)"
echo ""
echo "Arch Linux:"
echo " sudo pacman -S base-devel pkgconf gtk3 webkit2gtk alsa-lib libayatana-appindicator"
echo " sudo pacman -S ydotool (recommended for automatic text injection)"
echo " sudo pacman -S ydotool (recommended for direct keyboard injection)"
echo ""
exit 1
fi
@@ -194,7 +194,7 @@ Depends: libgtk-3-0, libwebkit2gtk-4.0-37 | libwebkit2gtk-4.1-0, libasound2, lib
Recommends: ydotool
Description: $COMMENT
WIS Free V3 is a desktop voice dictation app built with Go and Wails.
On Wayland, install ydotool for automatic text injection.
On Wayland, install ydotool for direct keyboard injection.
EOF
dpkg-deb --build "$DEB_ROOT" "$DIST_DIR/${APP_NAME}_${PKG_VERSION}-${PKG_RELEASE}_${ARCH_DEB}.deb"
@@ -215,10 +215,11 @@ License: $LICENSE
Requires: gtk3
Requires: alsa-lib
Requires: libayatana-appindicator-gtk3
Recommends: ydotool
%description
WIS Free V3 is a desktop voice dictation app built with Go and Wails.
On Wayland, install ydotool for automatic text injection.
On Wayland, install ydotool for direct keyboard injection.
%install
rm -rf %{buildroot}
+17 -58
View File
@@ -21,32 +21,16 @@ import (
func (a *App) insertTranscription(text string) {
a.releaseLinuxInputFocus()
if isASCII(text) {
if err := typeLinuxTextWithYdotool(text); err == nil {
logger.Info("Typed transcription on Linux using ydotool (%d chars)", utf8.RuneCountInString(text))
return
} else {
logger.Error("Linux auto-type unavailable via ydotool: %v", err)
}
} else {
logger.Info("Skipping ydotool direct typing fallback for non-ASCII transcript")
logger.Error("Linux direct typing unavailable via ydotool: %v", err)
}
if err := wailsruntime.ClipboardSetText(a.ctx, text); err != nil {
logger.Error("Failed to copy transcription to clipboard: %v", err)
} else {
waitForLinuxClipboardText(a.ctx, text)
if err := pasteLinuxClipboardWithYdotool(); err == nil {
logger.Info("Pasted transcription on Linux using clipboard + ydotool (%d chars)", utf8.RuneCountInString(text))
return
} else {
logger.Error("Linux auto-paste unavailable via ydotool: %v", err)
}
}
logger.Info("Copied transcription to clipboard; install ydotool with ydotoold/uinput access for automatic paste on GNOME Wayland")
logger.Info("Transcription was not inserted; install ydotool with ydotoold/uinput access for direct Linux typing")
if a.overlay != nil {
a.overlay.Show("Copied transcript. Press Ctrl+V to paste.")
a.overlay.Show("Direct typing unavailable. Check ydotool setup.")
}
}
@@ -59,37 +43,6 @@ func (a *App) releaseLinuxInputFocus() {
time.Sleep(150 * time.Millisecond)
}
func isASCII(text string) bool {
for _, r := range text {
if r > 127 {
return false
}
}
return true
}
func waitForLinuxClipboardText(ctx context.Context, text string) {
deadline := time.Now().Add(700 * time.Millisecond)
for time.Now().Before(deadline) {
current, err := wailsruntime.ClipboardGetText(ctx)
if err == nil && current == text {
return
}
time.Sleep(50 * time.Millisecond)
}
}
func pasteLinuxClipboardWithYdotool() error {
time.Sleep(150 * time.Millisecond)
path, socketPath, err := getYdotoolCommand()
if err != nil {
return err
}
return runLinuxInputCommand(path, []string{"key", "-d", "20", "29:1", "47:1", "47:0", "29:0"}, "", 3*time.Second, socketPath)
}
func typeLinuxTextWithYdotool(text string) error {
path, socketPath, err := getYdotoolCommand()
if err != nil {
@@ -126,7 +79,10 @@ func linuxYdotoolStatus() map[string]interface{} {
"socket_path": "",
"message": "",
"setup_commands": []string{
"sudo dnf install ydotool",
"# Install ydotool with your package manager, for example:",
"sudo apt install ydotool # Debian/Ubuntu",
"sudo dnf install ydotool # Fedora",
"sudo pacman -S ydotool # Arch",
"echo 'KERNEL==\"uinput\", SUBSYSTEM==\"misc\", TAG+=\"uaccess\", OPTIONS+=\"static_node=uinput\"' | sudo tee /etc/udev/rules.d/80-uinput.rules",
"sudo udevadm control --reload-rules && sudo udevadm trigger",
"systemctl --user enable --now ydotool.service",
@@ -155,11 +111,9 @@ func linuxYdotoolStatus() map[string]interface{} {
}
status["ready"] = true
status["message"] = "ydotool is ready for automatic paste."
status["message"] = "ydotool is ready for direct typing."
return status
}
func getYdotoolSocketPath() (string, error) {
if socketPath := strings.TrimSpace(os.Getenv("YDOTOOL_SOCKET")); socketPath != "" {
if _, err := os.Stat(socketPath); err == nil {
return socketPath, nil
@@ -167,12 +121,17 @@ func getYdotoolSocketPath() (string, error) {
return "", fmt.Errorf("YDOTOOL_SOCKET is set but not accessible: %s", socketPath)
}
socketPath := filepath.Join("/run/user", fmt.Sprintf("%d", os.Getuid()), ".ydotool_socket")
if _, err := os.Stat(socketPath); err != nil {
return "", fmt.Errorf("ydotoold socket not found at %s; run `systemctl --user start ydotool.service` after configuring /dev/uinput permissions", socketPath)
candidates := []string{
filepath.Join("/run/user", fmt.Sprintf("%d", os.Getuid()), ".ydotool_socket"),
"/tmp/.ydotool_socket",
}
for _, socketPath := range candidates {
if _, err := os.Stat(socketPath); err == nil {
return socketPath, nil
}
}
return socketPath, nil
return "", fmt.Errorf("ydotoold socket not found; run `systemctl --user start ydotool.service` after configuring /dev/uinput permissions")
}
func runLinuxInputCommand(path string, args []string, stdin string, timeout time.Duration, socketPath string) error {