diff --git a/frontend/index.html b/frontend/index.html index da6061e..ab21342 100644 --- a/frontend/index.html +++ b/frontend/index.html @@ -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'); diff --git a/internal/audio/recorder/recorder.go b/internal/audio/recorder/recorder.go index 8164bd4..856ae4e 100644 --- a/internal/audio/recorder/recorder.go +++ b/internal/audio/recorder/recorder.go @@ -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 diff --git a/internal/xhotkey/hotkey_linux_portal.go b/internal/xhotkey/hotkey_linux_portal.go index ceb3cdc..dcca8d4 100644 --- a/internal/xhotkey/hotkey_linux_portal.go +++ b/internal/xhotkey/hotkey_linux_portal.go @@ -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() { @@ -458,4 +461,4 @@ func (hk *Hotkey) cleanupPortal() error { _ = conn.Close() } return nil -} \ No newline at end of file +} diff --git a/linux_press_daemon.go b/linux_press_daemon.go index 6968d9f..9465ec1 100644 --- a/linux_press_daemon.go +++ b/linux_press_daemon.go @@ -6,7 +6,10 @@ import ( "net/http" "os" "sync" + "sync/atomic" "time" + + "wis-free-v3/internal/logger" ) const linuxPressAddr = "127.0.0.1:9876" @@ -14,12 +17,13 @@ const linuxPressAddr = "127.0.0.1:9876" // linuxPressState holds the daemon's state, protected by a mutex. // All field access must be done while holding the mutex. type linuxPressState struct { - mu sync.Mutex - releaseTimer *time.Timer - detectTimer *time.Timer - recording bool - holdMode bool - detectingHold bool + mu sync.Mutex + releaseTimer *time.Timer + detectTimer *time.Timer + 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() - pressState.detectingHold = false + 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() + pressState.recording = false + pressState.holdMode = false + pressState.detectingHold = false + if wasRecording { + go a.stopLinuxPressRecording() } -} \ No newline at end of file +} + +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 +} diff --git a/scripts/build-linux.sh b/scripts/build-linux.sh index f2b138b..a285831 100755 --- a/scripts/build-linux.sh +++ b/scripts/build-linux.sh @@ -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 --- @@ -515,4 +569,4 @@ fi echo "========================================" echo " Build finished successfully!" -echo "========================================" \ No newline at end of file +echo "========================================" diff --git a/scripts/package-linux.sh b/scripts/package-linux.sh index 4fa66de..36c42c0 100644 --- a/scripts/package-linux.sh +++ b/scripts/package-linux.sh @@ -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} @@ -237,4 +238,4 @@ find "$RPM_TOP/RPMS" -type f -name "*.rpm" -exec cp {} "$DIST_DIR/" \; echo "" echo "Packages written to:" -find "$DIST_DIR" -maxdepth 1 -type f \( -name "*.deb" -o -name "*.rpm" \) -print | sort \ No newline at end of file +find "$DIST_DIR" -maxdepth 1 -type f \( -name "*.deb" -o -name "*.rpm" \) -print | sort diff --git a/text_insert_linux.go b/text_insert_linux.go index 50c243f..a60db02 100644 --- a/text_insert_linux.go +++ b/text_insert_linux.go @@ -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) - } + if err := typeLinuxTextWithYdotool(text); err == nil { + logger.Info("Typed transcription on Linux using ydotool (%d chars)", utf8.RuneCountInString(text)) + return } 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 {