mirror of
https://github.com/jahruz67/wisp-open.git
synced 2026-08-08 18:14:08 +00:00
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:
+1
-1
@@ -347,7 +347,7 @@
|
|||||||
|
|
||||||
const title = document.createElement('div');
|
const title = document.createElement('div');
|
||||||
title.style.fontWeight = '600';
|
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);
|
el.appendChild(title);
|
||||||
|
|
||||||
const message = document.createElement('div');
|
const message = document.createElement('div');
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ import (
|
|||||||
"encoding/binary"
|
"encoding/binary"
|
||||||
"fmt"
|
"fmt"
|
||||||
"os"
|
"os"
|
||||||
|
"runtime"
|
||||||
"sync"
|
"sync"
|
||||||
"sync/atomic"
|
"sync/atomic"
|
||||||
|
|
||||||
@@ -207,16 +208,15 @@ func (r *AudioRecorder) Stop() error {
|
|||||||
// callbacks from writing to a closed file.
|
// callbacks from writing to a closed file.
|
||||||
atomic.StoreInt32(&r.writing, 0)
|
atomic.StoreInt32(&r.writing, 0)
|
||||||
|
|
||||||
// Stop the capture device. We keep the device initialized between recordings
|
// Stop the capture device.
|
||||||
// to avoid the expensive re-initialization cycle. ALSA privacy indicators
|
|
||||||
// will still turn off because we've stopped the stream.
|
|
||||||
if r.device != nil {
|
if r.device != nil {
|
||||||
if err := r.device.Stop(); err != nil {
|
if err := r.device.Stop(); err != nil {
|
||||||
logger.Error("Failed to stop audio device: %v", err)
|
logger.Error("Failed to stop audio device: %v", err)
|
||||||
}
|
}
|
||||||
// Do NOT Uninit the device between recordings. Keeping it alive
|
if runtime.GOOS == "linux" {
|
||||||
// avoids expensive ALSA device re-probe and reduces CPU spikes.
|
r.device.Uninit()
|
||||||
// The device will be fully cleaned up in Cleanup().
|
r.device = nil
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Finalize WAV file
|
// Finalize WAV file
|
||||||
|
|||||||
@@ -338,34 +338,37 @@ func (hk *Hotkey) registerPortal() error {
|
|||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// safeSendKeydown sends a keydown event to the hotkey channel, recovering from panic
|
func (hk *Hotkey) sendPortalEvent(name string, ch chan<- Event) {
|
||||||
// if the channel has been closed (e.g. during hotkey re-registration).
|
hk.mu.Lock()
|
||||||
func (hk *Hotkey) safeSendKeydown() {
|
stopCh := hk.portalStop
|
||||||
|
registered := hk.registered
|
||||||
|
hk.mu.Unlock()
|
||||||
|
if !registered || stopCh == nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
defer func() {
|
defer func() {
|
||||||
if r := recover(); r != nil {
|
if r := recover(); r != nil {
|
||||||
logger.Debug("safeSendKeydown: recovered from panic: %v", r)
|
logger.Debug("sendPortalEvent(%s): recovered from panic: %v", name, r)
|
||||||
}
|
}
|
||||||
}()
|
}()
|
||||||
|
|
||||||
select {
|
select {
|
||||||
case hk.keydownIn <- Event{}:
|
case ch <- Event{}:
|
||||||
default:
|
case <-stopCh:
|
||||||
// Channel buffer is full or closed; skip.
|
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
|
// safeSendKeydown sends a keydown event to the hotkey channel.
|
||||||
// if the channel has been closed (e.g. during hotkey re-registration).
|
func (hk *Hotkey) safeSendKeydown() {
|
||||||
|
hk.sendPortalEvent("keydown", hk.keydownIn)
|
||||||
|
}
|
||||||
|
|
||||||
|
// safeSendKeyup sends a keyup event to the hotkey channel.
|
||||||
func (hk *Hotkey) safeSendKeyup() {
|
func (hk *Hotkey) safeSendKeyup() {
|
||||||
defer func() {
|
hk.sendPortalEvent("keyup", hk.keyupIn)
|
||||||
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.
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func (hk *Hotkey) portalSignalLoop() {
|
func (hk *Hotkey) portalSignalLoop() {
|
||||||
|
|||||||
+80
-5
@@ -6,7 +6,10 @@ import (
|
|||||||
"net/http"
|
"net/http"
|
||||||
"os"
|
"os"
|
||||||
"sync"
|
"sync"
|
||||||
|
"sync/atomic"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
|
"wis-free-v3/internal/logger"
|
||||||
)
|
)
|
||||||
|
|
||||||
const linuxPressAddr = "127.0.0.1:9876"
|
const linuxPressAddr = "127.0.0.1:9876"
|
||||||
@@ -20,6 +23,7 @@ type linuxPressState struct {
|
|||||||
recording bool
|
recording bool
|
||||||
holdMode bool
|
holdMode bool
|
||||||
detectingHold bool
|
detectingHold bool
|
||||||
|
cycle uint64
|
||||||
}
|
}
|
||||||
|
|
||||||
var pressState linuxPressState
|
var pressState linuxPressState
|
||||||
@@ -68,12 +72,31 @@ func sendLinuxPressPing() error {
|
|||||||
func (a *App) startLinuxPressDaemon() {
|
func (a *App) startLinuxPressDaemon() {
|
||||||
mux := http.NewServeMux()
|
mux := http.NewServeMux()
|
||||||
mux.HandleFunc("/press", func(w http.ResponseWriter, r *http.Request) {
|
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()
|
a.handleLinuxPressPing()
|
||||||
w.WriteHeader(http.StatusNoContent)
|
w.WriteHeader(http.StatusNoContent)
|
||||||
})
|
})
|
||||||
|
|
||||||
go func() {
|
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 {
|
if !pressState.recording {
|
||||||
// First ping: start recording with hold detection
|
// First ping: start recording with hold detection
|
||||||
|
pressState.cycle++
|
||||||
|
cycle := pressState.cycle
|
||||||
pressState.recording = true
|
pressState.recording = true
|
||||||
pressState.holdMode = false
|
pressState.holdMode = false
|
||||||
pressState.detectingHold = true
|
pressState.detectingHold = true
|
||||||
go a.StartRecording()
|
go a.startLinuxPressRecording(cycle)
|
||||||
|
|
||||||
if pressState.detectTimer != nil {
|
if pressState.detectTimer != nil {
|
||||||
pressState.detectTimer.Stop()
|
pressState.detectTimer.Stop()
|
||||||
@@ -94,7 +119,9 @@ func (a *App) handleLinuxPressPing() {
|
|||||||
pressState.detectTimer = time.AfterFunc(linuxPressHoldDetectWindow, func() {
|
pressState.detectTimer = time.AfterFunc(linuxPressHoldDetectWindow, func() {
|
||||||
pressState.mu.Lock()
|
pressState.mu.Lock()
|
||||||
defer pressState.mu.Unlock()
|
defer pressState.mu.Unlock()
|
||||||
|
if pressState.cycle == cycle {
|
||||||
pressState.detectingHold = false
|
pressState.detectingHold = false
|
||||||
|
}
|
||||||
})
|
})
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
@@ -121,10 +148,11 @@ func (a *App) resetLinuxPressReleaseTimerLocked() {
|
|||||||
pressState.releaseTimer.Stop()
|
pressState.releaseTimer.Stop()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
cycle := pressState.cycle
|
||||||
pressState.releaseTimer = time.AfterFunc(linuxPressReleaseGrace, func() {
|
pressState.releaseTimer = time.AfterFunc(linuxPressReleaseGrace, func() {
|
||||||
pressState.mu.Lock()
|
pressState.mu.Lock()
|
||||||
defer pressState.mu.Unlock()
|
defer pressState.mu.Unlock()
|
||||||
if pressState.recording && pressState.holdMode {
|
if pressState.cycle == cycle && pressState.recording && pressState.holdMode {
|
||||||
a.stopLinuxPressRecordingLocked()
|
a.stopLinuxPressRecordingLocked()
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
@@ -132,6 +160,8 @@ func (a *App) resetLinuxPressReleaseTimerLocked() {
|
|||||||
|
|
||||||
func (a *App) stopLinuxPressRecordingLocked() {
|
func (a *App) stopLinuxPressRecordingLocked() {
|
||||||
// Caller must hold pressState.mu
|
// Caller must hold pressState.mu
|
||||||
|
wasRecording := pressState.recording
|
||||||
|
pressState.cycle++
|
||||||
if pressState.releaseTimer != nil {
|
if pressState.releaseTimer != nil {
|
||||||
pressState.releaseTimer.Stop()
|
pressState.releaseTimer.Stop()
|
||||||
pressState.releaseTimer = nil
|
pressState.releaseTimer = nil
|
||||||
@@ -140,10 +170,55 @@ func (a *App) stopLinuxPressRecordingLocked() {
|
|||||||
pressState.detectTimer.Stop()
|
pressState.detectTimer.Stop()
|
||||||
pressState.detectTimer = nil
|
pressState.detectTimer = nil
|
||||||
}
|
}
|
||||||
if pressState.recording {
|
|
||||||
pressState.recording = false
|
pressState.recording = false
|
||||||
pressState.holdMode = false
|
pressState.holdMode = false
|
||||||
pressState.detectingHold = 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
@@ -41,6 +41,159 @@ command_exists() {
|
|||||||
command -v "$1" >/dev/null 2>&1
|
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
|
# 1. Check for basic tools
|
||||||
if ! command_exists go; then
|
if ! command_exists go; then
|
||||||
echo "[ERROR] Go is not installed. Please install Go 1.23+."
|
echo "[ERROR] Go is not installed. Please install Go 1.23+."
|
||||||
@@ -123,28 +276,20 @@ for dep in "${DEPS[@]}"; do
|
|||||||
fi
|
fi
|
||||||
done
|
done
|
||||||
|
|
||||||
# ydotool check for text injection
|
# ydotool check for direct text injection
|
||||||
if ! command_exists ydotool; then
|
if ! command_exists ydotool; then
|
||||||
echo ""
|
echo ""
|
||||||
echo "==============================================================="
|
echo "==============================================================="
|
||||||
echo " WARNING: ydotool is not installed"
|
echo " WARNING: ydotool is not installed"
|
||||||
echo "==============================================================="
|
echo "==============================================================="
|
||||||
echo ""
|
echo ""
|
||||||
echo " ydotool is required for automatic text injection (paste) on"
|
echo " ydotool is required for direct keyboard injection on Wayland."
|
||||||
echo " Wayland. Without it, transcribed text will only be copied to"
|
echo " Without it, WIS Free V3 cannot type transcribed text automatically"
|
||||||
echo " your clipboard."
|
echo " into the active Linux window."
|
||||||
echo ""
|
echo ""
|
||||||
echo " Install ydotool and configure it:"
|
echo " Install ydotool and configure it:"
|
||||||
echo ""
|
echo ""
|
||||||
if command_exists dnf; then
|
print_ydotool_install_help
|
||||||
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
|
|
||||||
echo ""
|
echo ""
|
||||||
echo " Then set up udev rules for /dev/uinput access:"
|
echo " Then set up udev rules for /dev/uinput access:"
|
||||||
echo " echo 'KERNEL==\"uinput\", SUBSYSTEM==\"misc\", TAG+=\"uaccess\", OPTIONS+=\"static_node=uinput\"' |"
|
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 " Binary is ready at: $EXECUTABLE"
|
||||||
echo ""
|
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 no install flags were passed, do interactive prompt (backward-compatible)
|
||||||
if [ "$INSTALL_MODE" = "none" ] && [ "$INSTALL_SYSTEMD" = false ]; then
|
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
|
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 ---
|
# --- Systemd ydotool service setup ---
|
||||||
if [ "$INSTALL_SYSTEMD" = true ]; then
|
if [ "$INSTALL_SYSTEMD" = true ]; then
|
||||||
echo ""
|
setup_ydotool_systemd
|
||||||
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 ""
|
|
||||||
fi
|
fi
|
||||||
|
|
||||||
# --- Binary installation ---
|
# --- Binary installation ---
|
||||||
|
|||||||
@@ -80,15 +80,15 @@ if ! pkg-config --exists gtk+-3.0 || ! webkit2_ok || ! pkg-config --exists alsa
|
|||||||
echo ""
|
echo ""
|
||||||
echo "Ubuntu/Debian:"
|
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 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 ""
|
||||||
echo "Fedora:"
|
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 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 ""
|
||||||
echo "Arch Linux:"
|
echo "Arch Linux:"
|
||||||
echo " sudo pacman -S base-devel pkgconf gtk3 webkit2gtk alsa-lib libayatana-appindicator"
|
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 ""
|
echo ""
|
||||||
exit 1
|
exit 1
|
||||||
fi
|
fi
|
||||||
@@ -194,7 +194,7 @@ Depends: libgtk-3-0, libwebkit2gtk-4.0-37 | libwebkit2gtk-4.1-0, libasound2, lib
|
|||||||
Recommends: ydotool
|
Recommends: ydotool
|
||||||
Description: $COMMENT
|
Description: $COMMENT
|
||||||
WIS Free V3 is a desktop voice dictation app built with Go and Wails.
|
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
|
EOF
|
||||||
|
|
||||||
dpkg-deb --build "$DEB_ROOT" "$DIST_DIR/${APP_NAME}_${PKG_VERSION}-${PKG_RELEASE}_${ARCH_DEB}.deb"
|
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: gtk3
|
||||||
Requires: alsa-lib
|
Requires: alsa-lib
|
||||||
Requires: libayatana-appindicator-gtk3
|
Requires: libayatana-appindicator-gtk3
|
||||||
|
Recommends: ydotool
|
||||||
|
|
||||||
%description
|
%description
|
||||||
WIS Free V3 is a desktop voice dictation app built with Go and Wails.
|
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
|
%install
|
||||||
rm -rf %{buildroot}
|
rm -rf %{buildroot}
|
||||||
|
|||||||
+17
-58
@@ -21,32 +21,16 @@ import (
|
|||||||
func (a *App) insertTranscription(text string) {
|
func (a *App) insertTranscription(text string) {
|
||||||
a.releaseLinuxInputFocus()
|
a.releaseLinuxInputFocus()
|
||||||
|
|
||||||
if isASCII(text) {
|
|
||||||
if err := typeLinuxTextWithYdotool(text); err == nil {
|
if err := typeLinuxTextWithYdotool(text); err == nil {
|
||||||
logger.Info("Typed transcription on Linux using ydotool (%d chars)", utf8.RuneCountInString(text))
|
logger.Info("Typed transcription on Linux using ydotool (%d chars)", utf8.RuneCountInString(text))
|
||||||
return
|
return
|
||||||
} else {
|
} else {
|
||||||
logger.Error("Linux auto-type unavailable via ydotool: %v", err)
|
logger.Error("Linux direct typing unavailable via ydotool: %v", err)
|
||||||
}
|
|
||||||
} else {
|
|
||||||
logger.Info("Skipping ydotool direct typing fallback for non-ASCII transcript")
|
|
||||||
}
|
}
|
||||||
|
|
||||||
if err := wailsruntime.ClipboardSetText(a.ctx, text); err != nil {
|
logger.Info("Transcription was not inserted; install ydotool with ydotoold/uinput access for direct Linux typing")
|
||||||
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")
|
|
||||||
if a.overlay != nil {
|
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)
|
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 {
|
func typeLinuxTextWithYdotool(text string) error {
|
||||||
path, socketPath, err := getYdotoolCommand()
|
path, socketPath, err := getYdotoolCommand()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -126,7 +79,10 @@ func linuxYdotoolStatus() map[string]interface{} {
|
|||||||
"socket_path": "",
|
"socket_path": "",
|
||||||
"message": "",
|
"message": "",
|
||||||
"setup_commands": []string{
|
"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",
|
"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",
|
"sudo udevadm control --reload-rules && sudo udevadm trigger",
|
||||||
"systemctl --user enable --now ydotool.service",
|
"systemctl --user enable --now ydotool.service",
|
||||||
@@ -155,11 +111,9 @@ func linuxYdotoolStatus() map[string]interface{} {
|
|||||||
}
|
}
|
||||||
|
|
||||||
status["ready"] = true
|
status["ready"] = true
|
||||||
status["message"] = "ydotool is ready for automatic paste."
|
status["message"] = "ydotool is ready for direct typing."
|
||||||
return status
|
return status
|
||||||
}
|
}
|
||||||
|
|
||||||
func getYdotoolSocketPath() (string, error) {
|
|
||||||
if socketPath := strings.TrimSpace(os.Getenv("YDOTOOL_SOCKET")); socketPath != "" {
|
if socketPath := strings.TrimSpace(os.Getenv("YDOTOOL_SOCKET")); socketPath != "" {
|
||||||
if _, err := os.Stat(socketPath); err == nil {
|
if _, err := os.Stat(socketPath); err == nil {
|
||||||
return socketPath, nil
|
return socketPath, nil
|
||||||
@@ -167,12 +121,17 @@ func getYdotoolSocketPath() (string, error) {
|
|||||||
return "", fmt.Errorf("YDOTOOL_SOCKET is set but not accessible: %s", socketPath)
|
return "", fmt.Errorf("YDOTOOL_SOCKET is set but not accessible: %s", socketPath)
|
||||||
}
|
}
|
||||||
|
|
||||||
socketPath := filepath.Join("/run/user", fmt.Sprintf("%d", os.Getuid()), ".ydotool_socket")
|
candidates := []string{
|
||||||
if _, err := os.Stat(socketPath); err != nil {
|
filepath.Join("/run/user", fmt.Sprintf("%d", os.Getuid()), ".ydotool_socket"),
|
||||||
return "", fmt.Errorf("ydotoold socket not found at %s; run `systemctl --user start ydotool.service` after configuring /dev/uinput permissions", socketPath)
|
"/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 {
|
func runLinuxInputCommand(path string, args []string, stdin string, timeout time.Duration, socketPath string) error {
|
||||||
|
|||||||
Reference in New Issue
Block a user