mirror of
https://github.com/jahruz67/wisp-open.git
synced 2026-08-08 18:14:08 +00:00
feat: enhance Linux support with ydotool integration and improve logging
This commit is contained in:
@@ -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
|
||||||
}
|
}
|
||||||
@@ -277,8 +295,6 @@ func (r *AudioRecorder) onAudioData(_, inputSamples []byte, _ uint32) {
|
|||||||
// Normalize to 0.0 - 1.0 (max for int16 is 32767)
|
// Normalize to 0.0 - 1.0 (max for int16 is 32767)
|
||||||
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.
|
||||||
|
|||||||
@@ -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
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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.
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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
@@ -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()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
+230
-13
@@ -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"
|
||||||
if [[ "$INSTALL" == "y" || "$INSTALL" == "Y" ]]; then
|
echo ""
|
||||||
echo "Installing..."
|
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
|
||||||
|
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 "========================================"
|
||||||
@@ -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}
|
||||||
|
|||||||
Reference in New Issue
Block a user