feat: enhance Linux support with ydotool integration and improve logging

This commit is contained in:
Your Name
2026-06-09 00:25:05 -07:00
parent bc3d8cbce3
commit 05c8fbc474
7 changed files with 421 additions and 118 deletions
+28 -12
View File
@@ -9,7 +9,6 @@ import (
"sync" "sync"
"sync/atomic" "sync/atomic"
"math"
"unsafe" "unsafe"
"wis-free-v3/internal/logger" "wis-free-v3/internal/logger"
@@ -202,14 +201,16 @@ func (r *AudioRecorder) Stop() error {
return nil return nil
} }
// Stop and release the capture device so Linux privacy indicators turn off // Stop the capture device. We keep the device initialized between recordings
// between recordings. The next Start will initialize it again. // 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)
} }
r.device.Uninit() // Do NOT Uninit the device between recordings. Keeping it alive
r.device = nil // avoids expensive ALSA device re-probe and reduces CPU spikes.
// The device will be fully cleaned up in Cleanup().
} }
// Finalize WAV file // Finalize WAV file
@@ -262,13 +263,30 @@ func (r *AudioRecorder) onAudioData(_, inputSamples []byte, _ uint32) {
// Calculate volume if callback is set // Calculate volume if callback is set
if r.OnVolume != nil { if r.OnVolume != nil {
r.calculateVolume(inputSamples[:n])
}
}
}
// calculateVolume processes audio samples to compute the current volume level.
// Extracted to avoid allocations in the hot audio callback path.
func (r *AudioRecorder) calculateVolume(samples []byte) {
// S16LE: 2 bytes per sample // S16LE: 2 bytes per sample
samples := len(inputSamples) / 2 sampleCount := len(samples) / 2
if sampleCount == 0 {
return
}
var maxAmplitude float64 var maxAmplitude float64
for i := 0; i < samples; i++ { // Use direct slice indexing to avoid per-sample allocations
// Read as int16 for i := 0; i < sampleCount; i++ {
val := int16(binary.LittleEndian.Uint16(inputSamples[i*2 : i*2+2])) offset := i * 2
absVal := math.Abs(float64(val)) // Read as int16 via inlined LittleEndian to avoid function call overhead
val := int16(samples[offset]) | int16(samples[offset+1])<<8
absVal := float64(val)
if absVal < 0 {
absVal = -absVal
}
if absVal > maxAmplitude { if absVal > maxAmplitude {
maxAmplitude = absVal maxAmplitude = absVal
} }
@@ -278,8 +296,6 @@ func (r *AudioRecorder) onAudioData(_, inputSamples []byte, _ uint32) {
level := maxAmplitude / 32767.0 level := maxAmplitude / 32767.0
r.OnVolume(level) r.OnVolume(level)
} }
}
}
// writeWAVHeader writes a standard RIFF WAV header to the output file. // writeWAVHeader writes a standard RIFF WAV header to the output file.
func (r *AudioRecorder) writeWAVHeader(dataSize uint32) error { func (r *AudioRecorder) writeWAVHeader(dataSize uint32) error {
+16 -4
View File
@@ -21,6 +21,7 @@ type Listener struct {
hk *xhk.Hotkey hk *xhk.Hotkey
stopModPoll chan struct{} stopModPoll chan struct{}
mu sync.RWMutex mu sync.RWMutex
eventLoopDone chan struct{}
} }
// NewListener creates a new hotkey listener with the specified shortcut and callbacks. // NewListener creates a new hotkey listener with the specified shortcut and callbacks.
@@ -144,11 +145,22 @@ func (l *Listener) stopListeningLocked() {
} }
l.hk = nil l.hk = nil
} }
// Wait for event loop to fully terminate before allowing re-registration
if l.eventLoopDone != nil {
<-l.eventLoopDone
l.eventLoopDone = nil
}
l.isListening = false l.isListening = false
} }
// eventLoop runs the main keyboard event processing loop. // eventLoop runs the main keyboard event processing loop.
func (l *Listener) eventLoop(hk *xhk.Hotkey) { func (l *Listener) eventLoop(hk *xhk.Hotkey) {
done := make(chan struct{})
l.mu.Lock()
l.eventLoopDone = done
l.mu.Unlock()
defer close(done)
var isRecording bool var isRecording bool
for { for {
@@ -159,7 +171,7 @@ func (l *Listener) eventLoop(hk *xhk.Hotkey) {
} }
if !isRecording { if !isRecording {
logger.Info("Shortcut activated: starting recording") logger.Info("Shortcut activated: starting recording")
go l.startCallback() l.startCallback()
isRecording = true isRecording = true
} else { } else {
// We received a second Keydown while already recording. // We received a second Keydown while already recording.
@@ -171,9 +183,9 @@ func (l *Listener) eventLoop(hk *xhk.Hotkey) {
// Genuine second press. Toggle off. // Genuine second press. Toggle off.
logger.Info("Shortcut activated again: toggling recording (Wayland toggle fallback)") logger.Info("Shortcut activated again: toggling recording (Wayland toggle fallback)")
if l.stopCallback != nil { if l.stopCallback != nil {
go l.stopCallback() l.stopCallback()
} else { } else {
go l.startCallback() l.startCallback()
} }
isRecording = false isRecording = false
} }
@@ -196,7 +208,7 @@ func (l *Listener) eventLoop(hk *xhk.Hotkey) {
case <-time.After(50 * time.Millisecond): case <-time.After(50 * time.Millisecond):
// Key was genuinely physically released // Key was genuinely physically released
logger.Info("Shortcut released: stopping recording") logger.Info("Shortcut released: stopping recording")
go l.stopCallback() l.stopCallback()
isRecording = false isRecording = false
} }
} }
+8 -1
View File
@@ -76,6 +76,11 @@ func Error(format string, args ...interface{}) {
log("ERROR", format, args...) log("ERROR", format, args...)
} }
// Debug logs a debug message with timestamp.
func Debug(format string, args ...interface{}) {
log("DEBUG", format, args...)
}
// log writes a formatted log message to the log file and console. // log writes a formatted log message to the log file and console.
func log(level, format string, args ...interface{}) { func log(level, format string, args ...interface{}) {
logMutex.Lock() logMutex.Lock()
@@ -103,7 +108,9 @@ func log(level, format string, args ...interface{}) {
if logFile != nil { if logFile != nil {
logFile.WriteString(logLine) logFile.WriteString(logLine)
logFile.Sync() // Do NOT call Sync() on every write — that's a massive performance
// bottleneck (forces fsync to disk for every single log line).
// The kernel will flush buffered writes in its own time.
} }
} }
+34 -4
View File
@@ -338,6 +338,36 @@ func (hk *Hotkey) registerPortal() error {
return nil 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() {
defer func() {
if r := recover(); r != nil {
logger.Debug("safeSendKeydown: recovered from panic: %v", r)
}
}()
select {
case hk.keydownIn <- Event{}:
default:
// Channel buffer is full or closed; skip.
}
}
// safeSendKeyup sends a keyup event to the hotkey channel, recovering from panic
// if the channel has been closed (e.g. during hotkey re-registration).
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.
}
}
func (hk *Hotkey) portalSignalLoop() { func (hk *Hotkey) portalSignalLoop() {
defer close(hk.portalDone) defer close(hk.portalDone)
@@ -388,15 +418,15 @@ func (hk *Hotkey) portalSignalLoop() {
switch sig.Name { switch sig.Name {
case ifaceGlobalShortcuts + ".Activated": case ifaceGlobalShortcuts + ".Activated":
go func() { hk.keydownIn <- Event{} }() hk.safeSendKeydown()
case ifaceGlobalShortcuts + ".Deactivated": case ifaceGlobalShortcuts + ".Deactivated":
go func() { hk.keyupIn <- Event{} }() hk.safeSendKeyup()
default: default:
// Fallback for different bus routing names just in case // Fallback for different bus routing names just in case
if strings.HasSuffix(sig.Name, ".Activated") { if strings.HasSuffix(sig.Name, ".Activated") {
go func() { hk.keydownIn <- Event{} }() hk.safeSendKeydown()
} else if strings.HasSuffix(sig.Name, ".Deactivated") { } else if strings.HasSuffix(sig.Name, ".Deactivated") {
go func() { hk.keyupIn <- Event{} }() hk.safeSendKeyup()
} }
} }
} }
+76 -65
View File
@@ -11,21 +11,26 @@ import (
const linuxPressAddr = "127.0.0.1:9876" const linuxPressAddr = "127.0.0.1:9876"
var ( // linuxPressState holds the daemon's state, protected by a mutex.
linuxPressMu sync.Mutex // All field access must be done while holding the mutex.
linuxPressReleaseTimer *time.Timer type linuxPressState struct {
linuxPressDetectTimer *time.Timer mu sync.Mutex
linuxPressRecording bool releaseTimer *time.Timer
linuxPressHoldMode bool detectTimer *time.Timer
linuxPressDetectingHold bool recording bool
) holdMode bool
detectingHold bool
}
var pressState linuxPressState
const ( const (
// GNOME custom shortcuts commonly auto-repeat while the key is held. // linuxPressHoldDetectWindow is the window in which a second ping indicates
// If we see a second ping quickly, treat the shortcut as push-to-talk. // the shortcut is being held (push-to-talk mode).
linuxPressHoldDetectWindow = 1200 * time.Millisecond linuxPressHoldDetectWindow = 1200 * time.Millisecond
// Once hold mode is confirmed, lack of fresh pings means the key was released. // linuxPressReleaseGrace is how long after the last ping to wait before
// stopping recording in hold mode.
linuxPressReleaseGrace = 450 * time.Millisecond linuxPressReleaseGrace = 450 * time.Millisecond
) )
@@ -63,41 +68,7 @@ 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) {
linuxPressMu.Lock() a.handleLinuxPressPing()
defer linuxPressMu.Unlock()
if !linuxPressRecording {
linuxPressRecording = true
linuxPressHoldMode = false
linuxPressDetectingHold = true
go a.StartRecording()
if linuxPressDetectTimer != nil {
linuxPressDetectTimer.Stop()
}
linuxPressDetectTimer = time.AfterFunc(linuxPressHoldDetectWindow, func() {
linuxPressMu.Lock()
defer linuxPressMu.Unlock()
linuxPressDetectingHold = false
})
w.WriteHeader(http.StatusNoContent)
return
}
if linuxPressDetectingHold || linuxPressHoldMode {
linuxPressHoldMode = true
linuxPressDetectingHold = false
if linuxPressDetectTimer != nil {
linuxPressDetectTimer.Stop()
linuxPressDetectTimer = nil
}
resetLinuxPressReleaseTimer(a)
w.WriteHeader(http.StatusNoContent)
return
}
stopLinuxPressRecording(a)
w.WriteHeader(http.StatusNoContent) w.WriteHeader(http.StatusNoContent)
}) })
@@ -106,33 +77,73 @@ func (a *App) startLinuxPressDaemon() {
}() }()
} }
func resetLinuxPressReleaseTimer(a *App) { func (a *App) handleLinuxPressPing() {
if linuxPressReleaseTimer != nil { pressState.mu.Lock()
linuxPressReleaseTimer.Stop() defer pressState.mu.Unlock()
if !pressState.recording {
// First ping: start recording with hold detection
pressState.recording = true
pressState.holdMode = false
pressState.detectingHold = true
go a.StartRecording()
if pressState.detectTimer != nil {
pressState.detectTimer.Stop()
}
pressState.detectTimer = time.AfterFunc(linuxPressHoldDetectWindow, func() {
pressState.mu.Lock()
defer pressState.mu.Unlock()
pressState.detectingHold = false
})
return
} }
linuxPressReleaseTimer = time.AfterFunc(linuxPressReleaseGrace, func() { // Already recording. If we're still in the hold-detect window or confirmed hold mode,
linuxPressMu.Lock() // this is a hold-mode keepalive ping (key auto-repeat).
defer linuxPressMu.Unlock() if pressState.detectingHold || pressState.holdMode {
if linuxPressRecording && linuxPressHoldMode { pressState.holdMode = true
stopLinuxPressRecording(a) pressState.detectingHold = false
if pressState.detectTimer != nil {
pressState.detectTimer.Stop()
pressState.detectTimer = nil
}
a.resetLinuxPressReleaseTimerLocked()
return
}
// Already recording but not in hold mode: this is a toggle-off ping (second press).
a.stopLinuxPressRecordingLocked()
}
func (a *App) resetLinuxPressReleaseTimerLocked() {
if pressState.releaseTimer != nil {
pressState.releaseTimer.Stop()
}
pressState.releaseTimer = time.AfterFunc(linuxPressReleaseGrace, func() {
pressState.mu.Lock()
defer pressState.mu.Unlock()
if pressState.recording && pressState.holdMode {
a.stopLinuxPressRecordingLocked()
} }
}) })
} }
func stopLinuxPressRecording(a *App) { func (a *App) stopLinuxPressRecordingLocked() {
if linuxPressReleaseTimer != nil { // Caller must hold pressState.mu
linuxPressReleaseTimer.Stop() if pressState.releaseTimer != nil {
linuxPressReleaseTimer = nil pressState.releaseTimer.Stop()
pressState.releaseTimer = nil
} }
if linuxPressDetectTimer != nil { if pressState.detectTimer != nil {
linuxPressDetectTimer.Stop() pressState.detectTimer.Stop()
linuxPressDetectTimer = nil pressState.detectTimer = nil
} }
if linuxPressRecording { if pressState.recording {
linuxPressRecording = false pressState.recording = false
linuxPressHoldMode = false pressState.holdMode = false
linuxPressDetectingHold = false pressState.detectingHold = false
go a.StopRecording() go a.StopRecording()
} }
} }
+229 -12
View File
@@ -33,7 +33,7 @@ fi
if [ -z "$APP_VERSION" ]; then if [ -z "$APP_VERSION" ]; then
APP_VERSION="dev" APP_VERSION="dev"
fi fi
echo "[0/3] App version: $APP_VERSION" echo "[0/4] App version: $APP_VERSION"
echo "" echo ""
# Function to check if a command exists # Function to check if a command exists
@@ -54,7 +54,7 @@ if ! command_exists wails; then
fi fi
# 2. Check for Linux dependencies # 2. Check for Linux dependencies
echo "[1/3] Checking system dependencies..." echo "[1/4] Checking system dependencies..."
MISSING_DEPS=0 MISSING_DEPS=0
# 2a. GNOME tray icon support check (before build — avoids launching app if this will fail) # 2a. GNOME tray icon support check (before build — avoids launching app if this will fail)
@@ -79,7 +79,7 @@ if [ "$XDG_CURRENT_DESKTOP" = "GNOME" ] || [ "$XDG_CURRENT_DESKTOP" = "ubuntu:GN
if [ "$EXT_INSTALLED" = false ]; then if [ "$EXT_INSTALLED" = false ]; then
echo "" echo ""
echo "===============================================================" echo "==============================================================="
echo " ERROR: GNOME AppIndicator extension is not installed" echo " WARNING: GNOME AppIndicator extension is not installed"
echo "===============================================================" echo "==============================================================="
echo "" echo ""
echo " GNOME does not show system tray icons without this extension." echo " GNOME does not show system tray icons without this extension."
@@ -104,7 +104,10 @@ if [ "$XDG_CURRENT_DESKTOP" = "GNOME" ] || [ "$XDG_CURRENT_DESKTOP" = "ubuntu:GN
echo " Settings > Extensions > AppIndicator and KStatusNotifierItem Support" echo " Settings > Extensions > AppIndicator and KStatusNotifierItem Support"
echo "" echo ""
echo "===============================================================" echo "==============================================================="
exit 1 # Do NOT exit — just warn and continue. The user can still use the app
# with a custom shortcut even without the tray icon.
echo " Continuing (the app will work, but the tray icon may be hidden)."
echo ""
else else
echo "[INFO] GNOME AppIndicator extension is installed." echo "[INFO] GNOME AppIndicator extension is installed."
echo " Make sure it's enabled in Settings > Extensions." echo " Make sure it's enabled in Settings > Extensions."
@@ -120,6 +123,39 @@ for dep in "${DEPS[@]}"; do
fi fi
done done
# ydotool check for 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 ""
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
echo ""
echo " Then set up udev rules for /dev/uinput access:"
echo " echo 'KERNEL==\"uinput\", SUBSYSTEM==\"misc\", TAG+=\"uaccess\", OPTIONS+=\"static_node=uinput\"' |"
echo " sudo tee /etc/udev/rules.d/80-uinput.rules"
echo " sudo udevadm control --reload-rules && sudo udevadm trigger"
echo ""
echo " Then enable the ydotool user service (see the --install-systemd flag below)."
echo "==============================================================="
echo ""
fi
# We can't easily check C headers, but we can try to find them with pkg-config # We can't easily check C headers, but we can try to find them with pkg-config
# (Fedora often ships webkit2gtk-4.1.pc; Debian/Ubuntu often use webkit2gtk-4.0.pc) # (Fedora often ships webkit2gtk-4.1.pc; Debian/Ubuntu often use webkit2gtk-4.0.pc)
webkit2_ok() { webkit2_ok() {
@@ -160,15 +196,15 @@ if [ $MISSING_DEPS -eq 1 ]; then
DEBIAN_DEPS="build-essential pkg-config libgtk-3-dev libwebkit2gtk-4.0-dev libasound2-dev libayatana-appindicator3-dev" DEBIAN_DEPS="build-essential pkg-config libgtk-3-dev libwebkit2gtk-4.0-dev libasound2-dev libayatana-appindicator3-dev"
# Runtime nicety (optional): playerctl pauses media while recording # Runtime nicety (optional): playerctl pauses media while recording
DEBIAN_RUNTIME_OPT="playerctl" DEBIAN_RUNTIME_OPT="playerctl ydotool"
# Fedora 40+: WebKit2GTK 4.0 packages are gone; use 4.1 + Wails -tags webkit2_41 (see wails build below). # Fedora 40+: WebKit2GTK 4.0 packages are gone; use 4.1 + Wails -tags webkit2_41 (see wails build below).
# pkgconf-pkg-config provides `pkg-config` on Fedora. # pkgconf-pkg-config provides `pkg-config` on Fedora.
FEDORA_DEPS="gcc gcc-c++ make pkgconf-pkg-config gtk3-devel webkit2gtk4.1-devel alsa-lib-devel libayatana-appindicator-gtk3-devel" FEDORA_DEPS="gcc gcc-c++ make pkgconf-pkg-config gtk3-devel webkit2gtk4.1-devel alsa-lib-devel libayatana-appindicator-gtk3-devel"
# Same as FEDORA_DEPS but classic libappindicator (some spins/repos lack Ayatana -devel) # Same as FEDORA_DEPS but classic libappindicator (some spins/repos lack Ayatana -devel)
FEDORA_DEPS_ALT="gcc gcc-c++ make pkgconf-pkg-config gtk3-devel webkit2gtk4.1-devel alsa-lib-devel libappindicator-gtk3-devel" FEDORA_DEPS_ALT="gcc gcc-c++ make pkgconf-pkg-config gtk3-devel webkit2gtk4.1-devel alsa-lib-devel libappindicator-gtk3-devel"
FEDORA_RUNTIME_OPT="playerctl xdg-desktop-portal" FEDORA_RUNTIME_OPT="playerctl xdg-desktop-portal ydotool"
ARCH_DEPS="base-devel pkgconf gtk3 webkit2gtk alsa-lib libayatana-appindicator" ARCH_DEPS="base-devel pkgconf gtk3 webkit2gtk alsa-lib libayatana-appindicator"
ARCH_RUNTIME_OPT="playerctl" ARCH_RUNTIME_OPT="playerctl ydotool"
echo "The full list of dependencies needed:" echo "The full list of dependencies needed:"
echo " [Ubuntu/Debian]: sudo apt update && sudo apt install -y $DEBIAN_DEPS" echo " [Ubuntu/Debian]: sudo apt update && sudo apt install -y $DEBIAN_DEPS"
@@ -207,7 +243,7 @@ if [ $MISSING_DEPS -eq 1 ]; then
fi fi
# 3. Build the application # 3. Build the application
echo "[2/3] Building with Wails..." echo "[2/4] Building with Wails..."
# Pre-emptively fix npm bin permissions if they got messed up (common issue on some systems) # Pre-emptively fix npm bin permissions if they got messed up (common issue on some systems)
if [ -d "frontend/node_modules/.bin" ]; then if [ -d "frontend/node_modules/.bin" ]; then
@@ -253,14 +289,139 @@ fi
# doesn't block the terminal while we ask about installation. # doesn't block the terminal while we ask about installation.
pkill -f "$EXECUTABLE" 2>/dev/null || true pkill -f "$EXECUTABLE" 2>/dev/null || true
echo "[3/3] Build successful!" echo "[3/4] Build successful!"
echo " Output: $EXECUTABLE" echo " Output: $EXECUTABLE"
echo "" echo ""
# 4. Optional Installation # 4. Optional Installation
read -p "Would you like to install it globally to /usr/local/bin and add a desktop shortcut? (y/n): " INSTALL echo "[4/4] Installation options"
echo ""
echo " (i) Install systemwide: sudo ./$0 --install"
echo " (u) Install user-local: ./$0 --install-user"
echo " (s) Install systemd service (ydotool): ./$0 --install-systemd"
echo " (h) Show this help"
echo ""
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
if [[ "$INSTALL" == "y" || "$INSTALL" == "Y" ]]; then if [[ "$INSTALL" == "y" || "$INSTALL" == "Y" ]]; then
echo "Installing..." INSTALL_MODE="system"
else
read -p "Install per-user to ~/.local/bin? (y/n): " INSTALL_USER
if [[ "$INSTALL_USER" == "y" || "$INSTALL_USER" == "Y" ]]; then
INSTALL_MODE="user"
fi
fi
read -p "Would you like to set up the ydotool systemd user service? (y/n): " SETUP_SYSTEMD
if [[ "$SETUP_SYSTEMD" == "y" || "$SETUP_SYSTEMD" == "Y" ]]; then
INSTALL_SYSTEMD=true
fi
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 ""
fi
# --- Binary installation ---
if [ "$INSTALL_MODE" = "system" ]; then
echo "Installing system-wide..."
# Needs sudo # Needs sudo
sudo cp "$EXECUTABLE" "/usr/local/bin/$APP_NAME" sudo cp "$EXECUTABLE" "/usr/local/bin/$APP_NAME"
@@ -296,6 +457,62 @@ EOF
echo "" echo ""
echo "Installation complete! You can now launch 'WIS Free V3' from your app launcher," echo "Installation complete! You can now launch 'WIS Free V3' from your app launcher,"
echo "or by typing '$APP_NAME' in your terminal." echo "or by typing '$APP_NAME' in your terminal."
else echo ""
elif [ "$INSTALL_MODE" = "user" ]; then
echo "Installing per-user..."
LOCAL_BIN_DIR="$HOME/.local/bin"
mkdir -p "$LOCAL_BIN_DIR"
cp "$EXECUTABLE" "$LOCAL_BIN_DIR/$APP_NAME"
chmod +x "$LOCAL_BIN_DIR/$APP_NAME"
# Add to PATH warning
case ":${PATH}:" in
*:"$LOCAL_BIN_DIR":*) ;;
*)
echo "[WARNING] $LOCAL_BIN_DIR is not in your PATH."
echo " Add it to your shell profile:"
echo ' export PATH="$HOME/.local/bin:$PATH"'
echo ""
;;
esac
# Create user-local desktop file
DESKTOP_DIR="$HOME/.local/share/applications"
mkdir -p "$DESKTOP_DIR"
ICONS_DIR="$HOME/.local/share/pixmaps"
mkdir -p "$ICONS_DIR"
if [ -f "build/appicon.png" ]; then
cp "build/appicon.png" "$ICONS_DIR/$APP_NAME.png"
fi
cat > "$DESKTOP_DIR/$APP_NAME.desktop" << EOF
[Desktop Entry]
Type=Application
Name=WIS Free V3
Comment=Voice Dictation App
Exec=$LOCAL_BIN_DIR/$APP_NAME
TryExec=$LOCAL_BIN_DIR/$APP_NAME
Icon=$APP_NAME
StartupWMClass=$APP_NAME
Terminal=false
Categories=Utility;Audio;
EOF
chmod 644 "$DESKTOP_DIR/$APP_NAME.desktop"
echo ""
echo "User-local installation complete!"
echo " Binary: $LOCAL_BIN_DIR/$APP_NAME"
echo " Desktop: $DESKTOP_DIR/$APP_NAME.desktop"
echo ""
elif [ "$INSTALL_MODE" = "none" ] && [ "$INSTALL_SYSTEMD" = false ]; then
echo "Skipping installation. You can run the app directly via: ./$EXECUTABLE" echo "Skipping installation. You can run the app directly via: ./$EXECUTABLE"
fi fi
echo "========================================"
echo " Build finished successfully!"
echo "========================================"
+10
View File
@@ -80,9 +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 "" 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 ""
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 "" echo ""
exit 1 exit 1
fi fi
@@ -185,8 +191,10 @@ Architecture: $ARCH_DEB
Maintainer: $MAINTAINER Maintainer: $MAINTAINER
Installed-Size: $INSTALLED_SIZE Installed-Size: $INSTALLED_SIZE
Depends: libgtk-3-0, libwebkit2gtk-4.0-37 | libwebkit2gtk-4.1-0, libasound2, libayatana-appindicator3-1 Depends: libgtk-3-0, libwebkit2gtk-4.0-37 | libwebkit2gtk-4.1-0, libasound2, libayatana-appindicator3-1
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.
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"
@@ -206,9 +214,11 @@ Summary: $COMMENT
License: $LICENSE License: $LICENSE
Requires: gtk3 Requires: gtk3
Requires: alsa-lib Requires: alsa-lib
Requires: libayatana-appindicator-gtk3
%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.
%install %install
rm -rf %{buildroot} rm -rf %{buildroot}