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/atomic"
|
||||
|
||||
"math"
|
||||
"unsafe"
|
||||
"wis-free-v3/internal/logger"
|
||||
|
||||
@@ -202,14 +201,16 @@ func (r *AudioRecorder) Stop() error {
|
||||
return nil
|
||||
}
|
||||
|
||||
// Stop and release the capture device so Linux privacy indicators turn off
|
||||
// between recordings. The next Start will initialize it again.
|
||||
// Stop the capture device. We keep the device initialized between recordings
|
||||
// to avoid the expensive re-initialization cycle. ALSA privacy indicators
|
||||
// will still turn off because we've stopped the stream.
|
||||
if r.device != nil {
|
||||
if err := r.device.Stop(); err != nil {
|
||||
logger.Error("Failed to stop audio device: %v", err)
|
||||
}
|
||||
r.device.Uninit()
|
||||
r.device = nil
|
||||
// Do NOT Uninit the device between recordings. Keeping it alive
|
||||
// avoids expensive ALSA device re-probe and reduces CPU spikes.
|
||||
// The device will be fully cleaned up in Cleanup().
|
||||
}
|
||||
|
||||
// Finalize WAV file
|
||||
@@ -262,13 +263,30 @@ func (r *AudioRecorder) onAudioData(_, inputSamples []byte, _ uint32) {
|
||||
|
||||
// Calculate volume if callback is set
|
||||
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
|
||||
samples := len(inputSamples) / 2
|
||||
sampleCount := len(samples) / 2
|
||||
if sampleCount == 0 {
|
||||
return
|
||||
}
|
||||
|
||||
var maxAmplitude float64
|
||||
for i := 0; i < samples; i++ {
|
||||
// Read as int16
|
||||
val := int16(binary.LittleEndian.Uint16(inputSamples[i*2 : i*2+2]))
|
||||
absVal := math.Abs(float64(val))
|
||||
// Use direct slice indexing to avoid per-sample allocations
|
||||
for i := 0; i < sampleCount; i++ {
|
||||
offset := i * 2
|
||||
// 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 {
|
||||
maxAmplitude = absVal
|
||||
}
|
||||
@@ -277,8 +295,6 @@ func (r *AudioRecorder) onAudioData(_, inputSamples []byte, _ uint32) {
|
||||
// Normalize to 0.0 - 1.0 (max for int16 is 32767)
|
||||
level := maxAmplitude / 32767.0
|
||||
r.OnVolume(level)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// writeWAVHeader writes a standard RIFF WAV header to the output file.
|
||||
|
||||
@@ -21,6 +21,7 @@ type Listener struct {
|
||||
hk *xhk.Hotkey
|
||||
stopModPoll chan struct{}
|
||||
mu sync.RWMutex
|
||||
eventLoopDone chan struct{}
|
||||
}
|
||||
|
||||
// NewListener creates a new hotkey listener with the specified shortcut and callbacks.
|
||||
@@ -144,11 +145,22 @@ func (l *Listener) stopListeningLocked() {
|
||||
}
|
||||
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
|
||||
}
|
||||
|
||||
// eventLoop runs the main keyboard event processing loop.
|
||||
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
|
||||
|
||||
for {
|
||||
@@ -159,7 +171,7 @@ func (l *Listener) eventLoop(hk *xhk.Hotkey) {
|
||||
}
|
||||
if !isRecording {
|
||||
logger.Info("Shortcut activated: starting recording")
|
||||
go l.startCallback()
|
||||
l.startCallback()
|
||||
isRecording = true
|
||||
} else {
|
||||
// We received a second Keydown while already recording.
|
||||
@@ -171,9 +183,9 @@ func (l *Listener) eventLoop(hk *xhk.Hotkey) {
|
||||
// Genuine second press. Toggle off.
|
||||
logger.Info("Shortcut activated again: toggling recording (Wayland toggle fallback)")
|
||||
if l.stopCallback != nil {
|
||||
go l.stopCallback()
|
||||
l.stopCallback()
|
||||
} else {
|
||||
go l.startCallback()
|
||||
l.startCallback()
|
||||
}
|
||||
isRecording = false
|
||||
}
|
||||
@@ -196,7 +208,7 @@ func (l *Listener) eventLoop(hk *xhk.Hotkey) {
|
||||
case <-time.After(50 * time.Millisecond):
|
||||
// Key was genuinely physically released
|
||||
logger.Info("Shortcut released: stopping recording")
|
||||
go l.stopCallback()
|
||||
l.stopCallback()
|
||||
isRecording = false
|
||||
}
|
||||
}
|
||||
|
||||
@@ -76,6 +76,11 @@ func Error(format string, args ...interface{}) {
|
||||
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.
|
||||
func log(level, format string, args ...interface{}) {
|
||||
logMutex.Lock()
|
||||
@@ -103,7 +108,9 @@ func log(level, format string, args ...interface{}) {
|
||||
|
||||
if logFile != nil {
|
||||
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
|
||||
}
|
||||
|
||||
// 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() {
|
||||
defer close(hk.portalDone)
|
||||
|
||||
@@ -388,15 +418,15 @@ func (hk *Hotkey) portalSignalLoop() {
|
||||
|
||||
switch sig.Name {
|
||||
case ifaceGlobalShortcuts + ".Activated":
|
||||
go func() { hk.keydownIn <- Event{} }()
|
||||
hk.safeSendKeydown()
|
||||
case ifaceGlobalShortcuts + ".Deactivated":
|
||||
go func() { hk.keyupIn <- Event{} }()
|
||||
hk.safeSendKeyup()
|
||||
default:
|
||||
// Fallback for different bus routing names just in case
|
||||
if strings.HasSuffix(sig.Name, ".Activated") {
|
||||
go func() { hk.keydownIn <- Event{} }()
|
||||
hk.safeSendKeydown()
|
||||
} 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"
|
||||
|
||||
var (
|
||||
linuxPressMu sync.Mutex
|
||||
linuxPressReleaseTimer *time.Timer
|
||||
linuxPressDetectTimer *time.Timer
|
||||
linuxPressRecording bool
|
||||
linuxPressHoldMode bool
|
||||
linuxPressDetectingHold bool
|
||||
)
|
||||
// linuxPressState holds the daemon's state, protected by a mutex.
|
||||
// All field access must be done while holding the mutex.
|
||||
type linuxPressState struct {
|
||||
mu sync.Mutex
|
||||
releaseTimer *time.Timer
|
||||
detectTimer *time.Timer
|
||||
recording bool
|
||||
holdMode bool
|
||||
detectingHold bool
|
||||
}
|
||||
|
||||
var pressState linuxPressState
|
||||
|
||||
const (
|
||||
// GNOME custom shortcuts commonly auto-repeat while the key is held.
|
||||
// If we see a second ping quickly, treat the shortcut as push-to-talk.
|
||||
// linuxPressHoldDetectWindow is the window in which a second ping indicates
|
||||
// the shortcut is being held (push-to-talk mode).
|
||||
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
|
||||
)
|
||||
|
||||
@@ -63,41 +68,7 @@ func sendLinuxPressPing() error {
|
||||
func (a *App) startLinuxPressDaemon() {
|
||||
mux := http.NewServeMux()
|
||||
mux.HandleFunc("/press", func(w http.ResponseWriter, r *http.Request) {
|
||||
linuxPressMu.Lock()
|
||||
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)
|
||||
a.handleLinuxPressPing()
|
||||
w.WriteHeader(http.StatusNoContent)
|
||||
})
|
||||
|
||||
@@ -106,33 +77,73 @@ func (a *App) startLinuxPressDaemon() {
|
||||
}()
|
||||
}
|
||||
|
||||
func resetLinuxPressReleaseTimer(a *App) {
|
||||
if linuxPressReleaseTimer != nil {
|
||||
linuxPressReleaseTimer.Stop()
|
||||
func (a *App) handleLinuxPressPing() {
|
||||
pressState.mu.Lock()
|
||||
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() {
|
||||
linuxPressMu.Lock()
|
||||
defer linuxPressMu.Unlock()
|
||||
if linuxPressRecording && linuxPressHoldMode {
|
||||
stopLinuxPressRecording(a)
|
||||
// Already recording. If we're still in the hold-detect window or confirmed hold mode,
|
||||
// this is a hold-mode keepalive ping (key auto-repeat).
|
||||
if pressState.detectingHold || pressState.holdMode {
|
||||
pressState.holdMode = true
|
||||
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) {
|
||||
if linuxPressReleaseTimer != nil {
|
||||
linuxPressReleaseTimer.Stop()
|
||||
linuxPressReleaseTimer = nil
|
||||
func (a *App) stopLinuxPressRecordingLocked() {
|
||||
// Caller must hold pressState.mu
|
||||
if pressState.releaseTimer != nil {
|
||||
pressState.releaseTimer.Stop()
|
||||
pressState.releaseTimer = nil
|
||||
}
|
||||
if linuxPressDetectTimer != nil {
|
||||
linuxPressDetectTimer.Stop()
|
||||
linuxPressDetectTimer = nil
|
||||
if pressState.detectTimer != nil {
|
||||
pressState.detectTimer.Stop()
|
||||
pressState.detectTimer = nil
|
||||
}
|
||||
if linuxPressRecording {
|
||||
linuxPressRecording = false
|
||||
linuxPressHoldMode = false
|
||||
linuxPressDetectingHold = false
|
||||
if pressState.recording {
|
||||
pressState.recording = false
|
||||
pressState.holdMode = false
|
||||
pressState.detectingHold = false
|
||||
go a.StopRecording()
|
||||
}
|
||||
}
|
||||
+230
-13
@@ -33,7 +33,7 @@ fi
|
||||
if [ -z "$APP_VERSION" ]; then
|
||||
APP_VERSION="dev"
|
||||
fi
|
||||
echo "[0/3] App version: $APP_VERSION"
|
||||
echo "[0/4] App version: $APP_VERSION"
|
||||
echo ""
|
||||
|
||||
# Function to check if a command exists
|
||||
@@ -54,7 +54,7 @@ if ! command_exists wails; then
|
||||
fi
|
||||
|
||||
# 2. Check for Linux dependencies
|
||||
echo "[1/3] Checking system dependencies..."
|
||||
echo "[1/4] Checking system dependencies..."
|
||||
MISSING_DEPS=0
|
||||
|
||||
# 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
|
||||
echo ""
|
||||
echo "==============================================================="
|
||||
echo " ERROR: GNOME AppIndicator extension is not installed"
|
||||
echo " WARNING: GNOME AppIndicator extension is not installed"
|
||||
echo "==============================================================="
|
||||
echo ""
|
||||
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 ""
|
||||
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
|
||||
echo "[INFO] GNOME AppIndicator extension is installed."
|
||||
echo " Make sure it's enabled in Settings > Extensions."
|
||||
@@ -120,6 +123,39 @@ for dep in "${DEPS[@]}"; do
|
||||
fi
|
||||
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
|
||||
# (Fedora often ships webkit2gtk-4.1.pc; Debian/Ubuntu often use webkit2gtk-4.0.pc)
|
||||
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"
|
||||
# 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).
|
||||
# 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"
|
||||
# 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_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_RUNTIME_OPT="playerctl"
|
||||
ARCH_RUNTIME_OPT="playerctl ydotool"
|
||||
|
||||
echo "The full list of dependencies needed:"
|
||||
echo " [Ubuntu/Debian]: sudo apt update && sudo apt install -y $DEBIAN_DEPS"
|
||||
@@ -207,7 +243,7 @@ if [ $MISSING_DEPS -eq 1 ]; then
|
||||
fi
|
||||
|
||||
# 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)
|
||||
if [ -d "frontend/node_modules/.bin" ]; then
|
||||
@@ -253,14 +289,139 @@ fi
|
||||
# doesn't block the terminal while we ask about installation.
|
||||
pkill -f "$EXECUTABLE" 2>/dev/null || true
|
||||
|
||||
echo "[3/3] Build successful!"
|
||||
echo "[3/4] Build successful!"
|
||||
echo " Output: $EXECUTABLE"
|
||||
echo ""
|
||||
|
||||
# 4. Optional Installation
|
||||
read -p "Would you like to install it globally to /usr/local/bin and add a desktop shortcut? (y/n): " INSTALL
|
||||
if [[ "$INSTALL" == "y" || "$INSTALL" == "Y" ]]; then
|
||||
echo "Installing..."
|
||||
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
|
||||
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
|
||||
sudo cp "$EXECUTABLE" "/usr/local/bin/$APP_NAME"
|
||||
@@ -296,6 +457,62 @@ EOF
|
||||
echo ""
|
||||
echo "Installation complete! You can now launch 'WIS Free V3' from your app launcher,"
|
||||
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"
|
||||
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 "Ubuntu/Debian:"
|
||||
echo " sudo apt update && sudo apt install -y build-essential pkg-config libgtk-3-dev libwebkit2gtk-4.0-dev libasound2-dev libayatana-appindicator3-dev dpkg-dev rpm"
|
||||
echo " sudo apt install -y ydotool (recommended for automatic text injection)"
|
||||
echo ""
|
||||
echo "Fedora:"
|
||||
echo " sudo dnf install -y gcc gcc-c++ make pkgconf-pkg-config gtk3-devel webkit2gtk4.1-devel alsa-lib-devel libayatana-appindicator-gtk3-devel rpm-build"
|
||||
echo " sudo dnf install -y ydotool (recommended for automatic text injection)"
|
||||
echo ""
|
||||
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 ""
|
||||
exit 1
|
||||
fi
|
||||
@@ -185,8 +191,10 @@ Architecture: $ARCH_DEB
|
||||
Maintainer: $MAINTAINER
|
||||
Installed-Size: $INSTALLED_SIZE
|
||||
Depends: libgtk-3-0, libwebkit2gtk-4.0-37 | libwebkit2gtk-4.1-0, libasound2, libayatana-appindicator3-1
|
||||
Recommends: ydotool
|
||||
Description: $COMMENT
|
||||
WIS Free V3 is a desktop voice dictation app built with Go and Wails.
|
||||
On Wayland, install ydotool for automatic text injection.
|
||||
EOF
|
||||
|
||||
dpkg-deb --build "$DEB_ROOT" "$DIST_DIR/${APP_NAME}_${PKG_VERSION}-${PKG_RELEASE}_${ARCH_DEB}.deb"
|
||||
@@ -206,9 +214,11 @@ Summary: $COMMENT
|
||||
License: $LICENSE
|
||||
Requires: gtk3
|
||||
Requires: alsa-lib
|
||||
Requires: libayatana-appindicator-gtk3
|
||||
|
||||
%description
|
||||
WIS Free V3 is a desktop voice dictation app built with Go and Wails.
|
||||
On Wayland, install ydotool for automatic text injection.
|
||||
|
||||
%install
|
||||
rm -rf %{buildroot}
|
||||
|
||||
Reference in New Issue
Block a user