Refactor Linux hotkey handling and improve installation process

- Removed the legacy HTTP daemon for handling hotkey presses on Linux, replacing it with a simpler command-based approach using `--action=toggle`.
- Updated the build script to support non-interactive installation for CI environments with the `--no-install` flag.
- Enhanced the ydotool setup process to prefer distribution-provided systemd units and improved error messages for missing dependencies.
- Changed the way active window titles are retrieved on Linux to avoid using robotgo, which can fail on Wayland.
- Updated README and frontend hints to clarify the use of GlobalShortcuts portal and fallback commands for Linux.
- Improved package building scripts to allow building only Debian or RPM packages based on command-line flags.
- Adjusted the text insertion logic to handle different versions of ydotool more robustly.
This commit is contained in:
John Doe
2026-07-10 14:58:25 -07:00
parent 26022d67fb
commit 5b6e39c5e8
11 changed files with 392 additions and 378 deletions
+3 -25
View File
@@ -35,37 +35,15 @@ jobs:
libasound2-dev \
libayatana-appindicator3-dev
- name: Install Wails CLI
run: go install github.com/wailsapp/wails/v2/cmd/wails@latest
- name: Install frontend dependencies
run: npm install
run: npm ci
working-directory: frontend
- name: Determine version
id: version
run: |
if [ -f scripts/VERSION ]; then
VERSION=$(head -n1 scripts/VERSION | tr -d '\r\n' | xargs)
fi
if [ -z "$VERSION" ]; then
VERSION="dev"
fi
echo "app_version=$VERSION" >> "$GITHUB_OUTPUT"
- name: Fix npm bin permissions
run: chmod +x frontend/node_modules/.bin/* 2>/dev/null || true
- name: Build app
run: |
WAILS_WEBKIT_TAGS=""
if pkg-config --exists webkit2gtk-4.1 2>/dev/null; then
WAILS_WEBKIT_TAGS="-tags webkit2_41"
fi
wails build -platform linux/amd64 -clean $WAILS_WEBKIT_TAGS -ldflags "-X main.AppVersion=${{ steps.version.outputs.app_version }}"
run: bash scripts/build-linux.sh --no-install
- name: Upload binary
uses: actions/upload-artifact@v4
with:
name: wis-free-v3-linux-amd64
path: build/bin/wis-free-v3
path: build/bin/wis-free-v3
+156 -64
View File
@@ -1,78 +1,170 @@
# wis-free-v3
# WIS Free V3
A high-performance, cross-platform (Windows & Linux) voice dictation application built in Go using the Wails framework. **wis-free-v3** provides instant speech-to-text with AI-powered refinement, operating as a background service with global hotkey support.
WIS Free V3 is a desktop voice-dictation app built with Go and Wails. It records audio from a global shortcut, transcribes it with Groq Whisper or a local backend, optionally refines the result, and types the text into the focused application.
---
Windows and Linux are supported. Linux packages and source builds currently target `amd64`/`x86_64`.
## 🚀 Features
## Linux installation
- **Blazing Fast**: Native implementation ensures zero lag during recording and transcription.
- **Global Accessibility**: Trigger from anywhere via configurable global hotkeys.
- **Cross-Platform**: Supports Windows natively and Linux (X11 strongly recommended; Wayland users may need to map desktop shortcut triggers).
- **AI-Powered Refinement**: Integrates Groq (Whisper + Llama) for intelligent punctuation and grammar fixing.
- **Offline Capability**: Supports local Whisper.cpp for sensitive or offline workflows.
- **Micro-Automation**: Automatically pastes transcribed text directly into your active window.
- **Ultra-Leighton**: Low memory footprint while running in the system tray.
Install a package with the native package manager so it resolves runtime dependencies. Do not use `dpkg -i` on its own.
## 📂 Project Structure
### Debian and Ubuntu
```text
.
├── assets/ # Branding and icons
├── build/ # Wails build artifacts and manifests
├── frontend/ # Svelte/Vue/React settings UI
├── internal/ # Private application logic
│ ├── audio/ # Sound capture and processing
│ ├── config/ # Persistent configuration management
│ ├── hotkey/ # Global keyboard hooks
│ ├── logger/ # Structured logging utilities
│ ├── services/ # Cloud and local AI providers
│ ├── system/ # Windows OS integration (Startup/Tray)
│ └── ui/ # Native overlay and window management
├── scripts/ # Development and deployment automation
├── app.go # Application lifecycle management
├── main.go # Entry point
└── wails.json # Project configuration
Download the `.deb` for your architecture, then run:
```bash
sudo apt install ./wis-free-v3_<version>_amd64.deb
```
## 🛠️ Getting Started
If a previous `dpkg -i` attempt left dependencies unfinished, repair them first:
### Prerequisites
- **Go**: 1.23 or higher
- **Wails CLI**: `go install github.com/wailsapp/wails/v2/cmd/wails@latest`
- **Compiler**: GCC (TDM-GCC recommended for Windows)
### Installation & Build
1. Clone the repository.
2. Run the build script:
```powershell
.\scripts\build.bat
```
3. The executable will be available in `build\bin/wis-free-v3.exe`.
## ⚙️ Configuration
Settings are managed via the built-in UI (Right-click tray → Settings) or manually in `%USERPROFILE%\.wis-free-v3\config.json`.
### Linux Wayland Paste Setup
If Settings says automatic paste needs `ydotool` setup, run the commands shown there. After those commands finish, restart your computer, then open WIS Free V3 again.
```json
{
"api_key": "gsk_...",
"shortcut": "alt+z",
"whisper_model": "whisper-large-v3-turbo",
"ai_model": "llama-3.3-70b-versatile"
}
```bash
sudo apt -f install
sudo apt install ./wis-free-v3_<version>_amd64.deb
```
## 🤝 Contributing
### Fedora and other RPM-based distributions
This project is maintained with a focus on code quality and modular architecture. Please ensure all logic remains within the `internal/` package to maintain clean boundaries.
Install the RPM with the distribution package manager:
## 📄 License
```bash
sudo dnf install ./wis-free-v3-<version>-1.x86_64.rpm
```
Distributed under the MIT License. See `LICENSE` for more information.
For openSUSE, use `sudo zypper install ./wis-free-v3-<version>.x86_64.rpm`.
After installing, launch **WIS Free V3** from the application launcher or run `wis-free-v3` in a terminal. A reboot is not required.
## Linux first-run setup
### Global shortcut
On current GNOME, KDE Plasma, and other desktops that implement the XDG GlobalShortcuts portal, set the shortcut in WIS Free V3 Settings. The desktop may ask you to approve or change the shortcut.
If the portal is unavailable on your desktop, use the **Fallback System Shortcut** command shown in Settings:
- GNOME: **Settings -> Keyboard -> Custom Shortcuts**
- KDE Plasma: **System Settings -> Shortcuts -> Command/URL**
That command toggles recording: one press starts and the next press stops. It is shell-quoted automatically, including when the app is installed in a path containing spaces. It also works from a cold start because it uses `--action=toggle`.
Set `WISFREE_USE_PORTAL_HOTKEY=0` before launching the app only to deliberately disable portal shortcuts and use the fallback command instead.
### Direct typing with ydotool
WIS Free V3 uses `ydotool` to type transcriptions into the active application. This works on both Wayland and X11, but it needs the persistent `ydotoold` service.
Install the dependency, then enable the service for your user:
```bash
# Debian / Ubuntu
sudo apt install ydotool
# Fedora
sudo dnf install ydotool
# Arch Linux
sudo pacman -S ydotool
# All distributions that provide the standard user unit
systemctl --user enable --now ydotool.service
```
Distribution packages normally install the required `/dev/uinput` rule and the `ydotool.service` user unit. Do not add an extra manual udev rule unless your distribution's ydotool documentation says its package does not provide one.
Open Settings again and check the **Direct typing** status. If the socket is still unavailable after enabling the service, log out and back in once, then run:
```bash
systemctl --user restart ydotool.service
```
For a ydotool installation without a packaged user unit, the source checkout can create a separate WIS Free V3 user unit without overwriting distribution files:
```bash
bash scripts/build-linux.sh --install-systemd
```
To diagnose it, use:
```bash
systemctl --user status ydotool.service
journalctl --user -u ydotool.service -b
```
The app detects sockets from the standard user-runtime directory, `/tmp/.ydotool_socket`, and common system-service locations. You can override detection with `YDOTOOL_SOCKET=/path/to/socket` before launching WIS Free V3.
### GNOME tray icon
GNOME needs the **AppIndicator and KStatusNotifierItem Support** extension for a traditional system-tray icon. The app still runs without it, but use the launcher or a configured shortcut to open Settings. On Debian/Ubuntu:
```bash
sudo apt install gnome-shell-extension-appindicator
```
Enable the extension in **Settings -> Extensions**. Other major desktops generally expose the tray icon without an extra extension.
## Build from source on Linux
Install Go 1.24+, Node.js 20+, and native dependencies. The build script handles WebKit 4.0 versus 4.1 and Wails tags automatically.
### Debian / Ubuntu
```bash
sudo apt update
sudo apt install -y build-essential pkg-config libgtk-3-dev libwebkit2gtk-4.1-dev libasound2-dev libayatana-appindicator3-dev
```
If your release does not provide `libwebkit2gtk-4.1-dev`, install `libwebkit2gtk-4.0-dev` instead.
### Fedora
```bash
sudo dnf install -y gcc gcc-c++ make pkgconf-pkg-config gtk3-devel webkit2gtk4.1-devel alsa-lib-devel libayatana-appindicator-gtk3-devel
```
### Arch Linux
```bash
sudo pacman -S base-devel pkgconf gtk3 webkit2gtk alsa-lib libayatana-appindicator
```
Then clone the repository and build:
```bash
git clone <repository-url>
cd wisp-open
go install github.com/wailsapp/wails/v2/cmd/wails@latest
bash scripts/build-linux.sh --install-user
```
Use `--install` for `/usr/local/bin`, `--install-user` for `~/.local/bin`, or `--no-install` for a non-interactive build. The binary is written to `build/bin/wis-free-v3`.
## Building packages
Create distribution packages from a Linux checkout:
```bash
# Build only the format available on your build host
bash scripts/package-linux.sh --deb
bash scripts/package-linux.sh --rpm
```
With no flag, the script builds both formats and therefore requires both `dpkg-deb` and `rpmbuild`. Packages are placed in `dist/packages/`. The package metadata records the WebKit and AppIndicator ABI selected during the build, preventing a package built against one implementation from silently depending on the other.
## Troubleshooting
| Symptom | What to check |
| --- | --- |
| The package installed but the app will not launch | Install it with `apt install ./file.deb` or `dnf install ./file.rpm` so runtime libraries are resolved. Launch `wis-free-v3` from a terminal once to see any loader error. |
| The shortcut does nothing | Configure the portal shortcut in Settings. If your desktop rejects it, use the fallback command displayed there. |
| Transcription completes but text is not inserted | Open Settings and complete the ydotool setup. Check `systemctl --user status ydotool.service`. |
| `ydotoold socket not found` | Enable the user service, then log out/in once only if the service still cannot access `/dev/uinput`. |
| The app runs but no tray icon appears on GNOME | Install and enable the AppIndicator extension. |
## Configuration
Settings are available from the tray menu or the Settings window. The configuration file is stored in `~/.wis-free-v3/config.json`.
## License
Distributed under the MIT License.
+11
View File
@@ -0,0 +1,11 @@
//go:build linux
package main
// activeWindowTitle intentionally avoids robotgo on Linux. robotgo reads X11
// state directly and can fail or emit noisy errors on Wayland. Refinement is
// optional, so an empty application-context string is safer than risking a
// failed transcription after recording has completed.
func activeWindowTitle() string {
return ""
}
+9
View File
@@ -0,0 +1,9 @@
//go:build !linux
package main
import "github.com/go-vgo/robotgo"
func activeWindowTitle() string {
return robotgo.GetTitle()
}
+32 -38
View File
@@ -20,7 +20,6 @@ import (
"wis-free-v3/internal/services/whisper"
"wis-free-v3/internal/ui/tray"
"github.com/go-vgo/robotgo"
wailsruntime "github.com/wailsapp/wails/v2/pkg/runtime"
)
@@ -379,7 +378,10 @@ func (a *App) processRecording(recordingPath string) {
logger.Info("Transcribed %d characters", len(text))
activeWindow := robotgo.GetTitle()
// robotgo queries X11 directly. Do not invoke it from a Wayland session;
// the Linux helper returns an empty context instead of making transcription
// fail after the audio has already been captured.
activeWindow := activeWindowTitle()
logger.Info("Active window for context: %s", activeWindow)
// Refine text (optional)
@@ -447,7 +449,7 @@ func (a *App) GetSettings() map[string]interface{} {
// and text_insert_linux.go for the implementations.
if runtime.GOOS == "linux" {
if exePath, err := os.Executable(); err == nil {
conf["linux_press_command"] = exePath + " --press"
conf["linux_press_command"] = linuxShortcutCommand(exePath)
}
conf["linux_press_mode"] = true
conf["linux_ydotool_status"] = linuxYdotoolStatus()
@@ -465,41 +467,30 @@ func (a *App) SaveSettings(settings map[string]interface{}) string {
a.config.APIKey = val
}
}
// PLATFORM NOTE: Shortcut saving is disabled on Linux because Linux uses
// the `--press` daemon approach (GNOME custom shortcuts) instead of the
// built-in hotkey listener. On Windows, we allow the user to configure
// the shortcut through the settings UI.
if runtime.GOOS != "linux" {
if val, ok := settings["shortcut"].(string); ok {
_, _, modOnly, ok := hotkey.ParseShortcut(val)
if !ok {
logger.Error("Invalid shortcut: %s (rejected)", val)
return "Invalid shortcut - use modifiers plus a key (e.g. ctrl+k), or modifier-only on Windows (e.g. ctrl+win)"
}
if modOnly && runtime.GOOS != "windows" {
return "Modifier-only shortcuts (like ctrl+win) are only supported on Windows"
}
if val, ok := settings["shortcut"].(string); ok {
_, _, modOnly, ok := hotkey.ParseShortcut(val)
if !ok {
logger.Error("Invalid shortcut: %s (rejected)", val)
return "Invalid shortcut - use modifiers plus a key (e.g. ctrl+k), or modifier-only on Windows (e.g. ctrl+win)"
}
if modOnly && runtime.GOOS != "windows" {
return "Modifier-only shortcuts (like ctrl+win) are only supported on Windows"
}
a.config.Shortcut = val
// Update existing listener with new shortcut (hot-swap)
if a.hotkeyListener != nil {
a.hotkeyListener.UpdateShortcut(val)
} else {
// Should not happen if app started correctly, but just in case
a.hotkeyListener = hotkey.NewListener(val, a.StartRecording, a.StopRecording)
if runtime.GOOS != "windows" {
a.hotkeyListener.SetRegistrationErrorCallback(func(err error) {
logger.Error("Linux hotkey registration failed: %v", err)
go func() {
time.Sleep(2 * time.Second)
if a.overlay != nil {
a.overlay.Show("Shortcut registration failed. Please add a custom system shortcut calling 'wis-free-v3 --action=toggle' as a fallback.")
}
}()
})
}
a.hotkeyListener.Start()
a.config.Shortcut = val
// Hot-swap the listener on every platform. Linux uses the XDG
// GlobalShortcuts portal when the desktop implements it and otherwise
// exposes the custom-command fallback in Settings.
if a.hotkeyListener != nil {
a.hotkeyListener.UpdateShortcut(val)
} else {
a.hotkeyListener = hotkey.NewListener(val, a.StartRecording, a.StopRecording)
if runtime.GOOS != "windows" {
a.hotkeyListener.SetRegistrationErrorCallback(func(err error) {
logger.Error("Linux hotkey registration failed: %v", err)
})
}
a.hotkeyListener.Start()
}
}
if val, ok := settings["whisper_model"].(string); ok {
@@ -674,7 +665,7 @@ func (a *App) startupHeadless() {
}
a.hotkeyListener.Start()
} else {
logger.Info("Linux portal hotkey disabled; use the --press command from Settings for GNOME shortcuts")
logger.Info("Linux portal hotkey disabled; use the command shown in Settings for a desktop shortcut")
}
logger.Info("Components initialized successfully!")
@@ -686,7 +677,10 @@ func shouldStartBuiltInHotkeyListener() bool {
if runtime.GOOS != "linux" {
return true
}
return os.Getenv("WISFREE_USE_PORTAL_HOTKEY") == "1"
// The portal is the safest cross-desktop implementation on modern Linux.
// WISFREE_USE_PORTAL_HOTKEY=0 remains an escape hatch for desktops with a
// broken portal; Settings always provides a custom-shortcut fallback.
return os.Getenv("WISFREE_USE_PORTAL_HOTKEY") != "0"
}
// Shutdown cleans up resources
+5 -5
View File
@@ -50,14 +50,14 @@
<button id="recordBtn" onclick="recordShortcut()">Record</button>
</div>
</div>
<p class="hint">Press the key combination you want to use to start/stop recording (e.g. alt+z, ctrl+shift+space). Modifier-only combos like ctrl+alt are also supported on Windows.</p>
<p class="hint">Uses the desktop GlobalShortcuts portal when available. Press the key combination you want to use to start/stop recording (e.g. alt+z, ctrl+shift+space). Modifier-only combos like ctrl+alt are supported only on Windows.</p>
</div>
<div class="section-group-label">Input</div>
<!-- Linux Wayland Hotkey Command -->
<!-- Linux fallback shortcut command -->
<div class="section" id="linuxPressSection" style="display: none;">
<label>System Hotkey Command (Linux)</label>
<label>Fallback System Shortcut (Linux)</label>
<div class="form-control">
<div class="flex-row">
<div class="input-wrapper">
@@ -66,9 +66,9 @@
<button onclick="copyLinuxPressCommand()">Copy</button>
</div>
</div>
<p class="hint">Use this if your desktop does not support the GlobalShortcuts portal. The command toggles recording: one press starts, the next stops.</p>
<p class="hint">GNOME: Settings -> Keyboard -> Custom Shortcuts -> Add a shortcut with the copied command.</p>
<p class="hint">KDE: System Settings -> Shortcuts -> Command/URL -> Add a shortcut with the copied command.</p>
<p class="hint">After running the ydotool setup commands, restart your computer, then open WIS Free V3 again.</p>
<div id="linuxYdotoolStatus" style="display: none; margin-top: 12px; padding: 10px 12px; border-radius: 7px; font-size: 12px; line-height: 1.45;"></div>
</div>
@@ -366,7 +366,7 @@
const restart = document.createElement('div');
restart.style.marginTop = '8px';
restart.style.fontWeight = '600';
restart.textContent = 'Restart your computer after setup, then open WIS Free V3 again.';
restart.textContent = 'A restart is not normally needed. Log out and back in only if the ydotool service still cannot access /dev/uinput.';
el.appendChild(restart);
}
}
+31 -219
View File
@@ -1,57 +1,31 @@
//go:build linux
// ============================================================
// LINUX-ONLY FILE — Local HTTP daemon that receives hotkey
// press/release pings from the GNOME custom shortcut helper.
// This is the Linux equivalent of the Windows hotkey listener.
// Any changes here will NOT affect the Windows build.
// ============================================================
// Linux shortcut compatibility helpers.
//
// Older releases used a loopback HTTP server for the `--press` command. That
// made a normal desktop shortcut unreliable: a second key press inside the
// hold-detection window could be mistaken for key auto-repeat, and another
// local process could occupy the fixed TCP port. Linux already has a
// per-user Unix socket for single-instance IPC, so use that authenticated
// per-user path instead.
package main
import (
"context"
"net/http"
"os"
"sync"
"sync/atomic"
"time"
"wis-free-v3/internal/logger"
)
const linuxPressAddr = "127.0.0.1:9876"
// linuxPressState holds the daemon's state, protected by a mutex.
// All field access must be done while holding the mutex.
type linuxPressState struct {
mu sync.Mutex
releaseTimer *time.Timer
detectTimer *time.Timer
recording bool
holdMode bool
detectingHold bool
cycle uint64
}
var pressState linuxPressState
const (
// linuxPressHoldDetectWindow is the window in which a second ping indicates
// the shortcut is being held (push-to-talk mode).
linuxPressHoldDetectWindow = 1200 * time.Millisecond
// linuxPressReleaseGrace is how long after the last ping to wait before
// stopping recording in hold mode.
linuxPressReleaseGrace = 450 * time.Millisecond
"strings"
)
func init() {
// Keep `--press` working for existing custom shortcuts. A desktop shortcut
// invokes a command once, so its correct, predictable behaviour is toggle
// (start on the first press and stop on the next), not push-to-talk.
if hasPressFlag(os.Args[1:]) {
if err := sendLinuxPressPing(); err != nil {
os.Exit(1)
if tryNotifyRunningInstanceAction("toggle") {
os.Exit(0)
}
os.Exit(0)
// Do not launch a second GUI process from this legacy helper. The current
// settings UI emits --action=toggle, which can start the app if needed.
os.Exit(1)
}
}
@@ -64,184 +38,22 @@ func hasPressFlag(args []string) bool {
return false
}
func sendLinuxPressPing() error {
client := &http.Client{Timeout: 300 * time.Millisecond}
resp, err := client.Get("http://" + linuxPressAddr + "/press")
if err != nil {
return err
}
defer resp.Body.Close()
if resp.StatusCode < 200 || resp.StatusCode >= 300 {
return os.ErrNotExist
}
return nil
// linuxShortcutCommand returns a shell-safe command for GNOME/KDE custom
// shortcuts. Quote the executable because user home directories can contain
// spaces or shell metacharacters.
func linuxShortcutCommand(executable string) string {
return linuxShellQuote(executable) + " --action=toggle"
}
// linuxPressServer holds the HTTP server reference for graceful shutdown.
var linuxPressServer *http.Server
func (a *App) startLinuxPressDaemon() {
mux := http.NewServeMux()
mux.HandleFunc("/press", func(w http.ResponseWriter, r *http.Request) {
defer func() {
if recovered := recover(); recovered != nil {
logger.Error("Linux press handler recovered from panic: %v", recovered)
http.Error(w, "press handler failed", http.StatusInternalServerError)
}
}()
if r.Method != http.MethodGet && r.Method != http.MethodPost {
w.WriteHeader(http.StatusMethodNotAllowed)
return
}
a.handleLinuxPressPing()
w.WriteHeader(http.StatusNoContent)
})
go func() {
server := &http.Server{
Addr: linuxPressAddr,
Handler: mux,
ReadHeaderTimeout: 2 * time.Second,
IdleTimeout: 5 * time.Second,
}
linuxPressServer = server
if err := server.ListenAndServe(); err != nil && err != http.ErrServerClosed {
logger.Error("Linux press daemon stopped: %v", err)
}
}()
func linuxShellQuote(value string) string {
if value == "" {
return "''"
}
return "'" + strings.ReplaceAll(value, "'", "'\\''") + "'"
}
func (a *App) handleLinuxPressPing() {
pressState.mu.Lock()
defer pressState.mu.Unlock()
// The old daemon API remains a no-op so the common lifecycle code and
// non-Linux stub stay simple. Shortcut delivery now uses the instance socket.
func (a *App) startLinuxPressDaemon() {}
if !pressState.recording {
// First ping: start recording with hold detection
pressState.cycle++
cycle := pressState.cycle
pressState.recording = true
pressState.holdMode = false
pressState.detectingHold = true
go a.startLinuxPressRecording(cycle)
if pressState.detectTimer != nil {
pressState.detectTimer.Stop()
}
pressState.detectTimer = time.AfterFunc(linuxPressHoldDetectWindow, func() {
pressState.mu.Lock()
defer pressState.mu.Unlock()
if pressState.cycle == cycle {
pressState.detectingHold = false
}
})
return
}
// 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()
}
cycle := pressState.cycle
pressState.releaseTimer = time.AfterFunc(linuxPressReleaseGrace, func() {
pressState.mu.Lock()
defer pressState.mu.Unlock()
if pressState.cycle == cycle && pressState.recording && pressState.holdMode {
a.stopLinuxPressRecordingLocked()
}
})
}
func (a *App) stopLinuxPressRecordingLocked() {
// Caller must hold pressState.mu
wasRecording := pressState.recording
pressState.cycle++
if pressState.releaseTimer != nil {
pressState.releaseTimer.Stop()
pressState.releaseTimer = nil
}
if pressState.detectTimer != nil {
pressState.detectTimer.Stop()
pressState.detectTimer = nil
}
pressState.recording = false
pressState.holdMode = false
pressState.detectingHold = false
if wasRecording {
go a.stopLinuxPressRecording()
}
}
func (a *App) startLinuxPressRecording(cycle uint64) {
defer func() {
if recovered := recover(); recovered != nil {
logger.Error("Linux press start recovered from panic: %v", recovered)
a.resetFailedLinuxPressCycle(cycle)
}
}()
a.StartRecording()
if atomic.LoadInt32(&a.recording) == 0 {
a.resetFailedLinuxPressCycle(cycle)
}
}
func (a *App) stopLinuxPressRecording() {
defer func() {
if recovered := recover(); recovered != nil {
logger.Error("Linux press stop recovered from panic: %v", recovered)
}
}()
a.StopRecording()
}
func (a *App) resetFailedLinuxPressCycle(cycle uint64) {
pressState.mu.Lock()
defer pressState.mu.Unlock()
if pressState.cycle != cycle {
return
}
pressState.cycle++
if pressState.releaseTimer != nil {
pressState.releaseTimer.Stop()
pressState.releaseTimer = nil
}
if pressState.detectTimer != nil {
pressState.detectTimer.Stop()
pressState.detectTimer = nil
}
pressState.recording = false
pressState.holdMode = false
pressState.detectingHold = false
}
func stopLinuxPressDaemon() {
if linuxPressServer != nil {
ctx, cancel := context.WithTimeout(context.Background(), 2*time.Second)
defer cancel()
if err := linuxPressServer.Shutdown(ctx); err != nil {
logger.Error("Error shutting down Linux press daemon: %v", err)
}
}
}
func stopLinuxPressDaemon() {}
+4
View File
@@ -11,3 +11,7 @@ package main
func (a *App) startLinuxPressDaemon() {}
func stopLinuxPressDaemon() {}
func linuxShortcutCommand(executable string) string {
return executable + " --action=toggle"
}
+33 -12
View File
@@ -86,6 +86,17 @@ setup_ydotool_systemd() {
return 1
fi
# Prefer the distribution-provided unit. Debian's ydotool package ships
# both the udev rule and a user unit; replacing it caused fresh installs
# to use a different socket and often made typing work only after relogin.
for service in ydotool.service ydotoold.service; do
if systemctl --user cat "$service" >/dev/null 2>&1; then
echo "[INFO] Using the distribution-provided $service"
systemctl --user enable --now "$service"
return 0
fi
done
YDOTOOLD_BIN="$(find_ydotoold || true)"
if [ -z "$YDOTOOLD_BIN" ]; then
echo "[ERROR] ydotoold was not found after installing ydotool."
@@ -95,23 +106,23 @@ setup_ydotool_systemd() {
SYSTEMD_USER_DIR="${XDG_CONFIG_HOME:-$HOME/.config}/systemd/user"
mkdir -p "$SYSTEMD_USER_DIR"
SERVICE_FILE="$SYSTEMD_USER_DIR/ydotool.service"
SERVICE_FILE="$SYSTEMD_USER_DIR/wis-free-v3-ydotool.service"
cat > "$SERVICE_FILE" << YDSVCEOF
[Unit]
Description=ydotool daemon for WIS Free V3 direct keyboard injection
Documentation=man:ydotool(1)
After=graphical-session.target
PartOf=graphical-session.target
[Service]
Type=simple
Environment=YDOTOOL_SOCKET=%t/.ydotool_socket
ExecStart=$YDOTOOLD_BIN
ExecStart=$YDOTOOLD_BIN --socket-path=%t/.ydotool_socket
Restart=on-failure
RestartSec=2
[Install]
WantedBy=default.target
WantedBy=graphical-session.target
YDSVCEOF
echo "[INFO] Created $SERVICE_FILE"
@@ -140,13 +151,13 @@ YDSVCEOF
echo ""
echo " Reloading systemd user daemon..."
systemctl --user daemon-reload
echo " Enabling ydotool user service..."
systemctl --user enable ydotool.service
echo " Starting ydotool user service..."
if systemctl --user restart ydotool.service; then
echo " Enabling WIS Free V3 ydotool user service..."
systemctl --user enable wis-free-v3-ydotool.service
echo " Starting WIS Free V3 ydotool user service..."
if systemctl --user restart wis-free-v3-ydotool.service; then
echo " ydotool systemd service is running."
else
echo " [WARN] Could not start ydotool.service. Check: systemctl --user status ydotool.service"
echo " [WARN] Could not start the ydotool service. Check: systemctl --user status wis-free-v3-ydotool.service"
fi
echo ""
}
@@ -154,6 +165,7 @@ YDSVCEOF
INSTALL_MODE="none" # none, system, user
INSTALL_SYSTEMD=false
SHOW_HELP=false
NO_INSTALL=false
for arg in "$@"; do
case "$arg" in
@@ -166,24 +178,28 @@ for arg in "$@"; do
--install-systemd)
INSTALL_SYSTEMD=true
;;
--no-install)
NO_INSTALL=true
;;
--help|-h)
SHOW_HELP=true
;;
*)
echo "[ERROR] Unknown option: $arg"
echo "Usage: $0 [--install|--install-user|--install-systemd|--help]"
echo "Usage: $0 [--install|--install-user|--install-systemd|--no-install|--help]"
exit 1
;;
esac
done
if [ "$SHOW_HELP" = true ]; then
echo "Usage: $0 [--install|--install-user|--install-systemd|--help]"
echo "Usage: $0 [--install|--install-user|--install-systemd|--no-install|--help]"
echo ""
echo " (no flags) Build only, then offer interactive install prompts"
echo " --install Build and install system-wide (/usr/local/bin)"
echo " --install-user Build and install per-user (~/.local/bin)"
echo " --install-systemd Generate, reload, enable, and start ydotool user service"
echo " --no-install Build without prompting to install (for CI)"
echo " --help Show this message"
echo ""
exit 0
@@ -404,12 +420,17 @@ fi
if ! pkg-config --exists webkit2gtk-4.0 2>/dev/null && pkg-config --exists webkit2gtk-4.1 2>/dev/null; then
WEBKIT41_PC=""
WEBKIT41_PKGCONFIG_DIR="$(pkg-config --variable=pcfiledir webkit2gtk-4.1 2>/dev/null || true)"
if [ -n "$WEBKIT41_PKGCONFIG_DIR" ] && [ -f "$WEBKIT41_PKGCONFIG_DIR/webkit2gtk-4.1.pc" ]; then
WEBKIT41_PC="$WEBKIT41_PKGCONFIG_DIR/webkit2gtk-4.1.pc"
fi
for dir in \
$(printf '%s' "${PKG_CONFIG_PATH:-}" | tr ':' '\n') \
/usr/lib64/pkgconfig \
/usr/lib/pkgconfig \
/usr/local/lib64/pkgconfig \
/usr/local/lib/pkgconfig; do
[ -n "$WEBKIT41_PC" ] && break
[ -z "$dir" ] && continue
[ -f "$dir/webkit2gtk-4.1.pc" ] || continue
WEBKIT41_PC="$dir/webkit2gtk-4.1.pc"
@@ -451,7 +472,7 @@ echo " Binary is ready at: $EXECUTABLE"
echo ""
# If no install flags were passed, do interactive prompt (backward-compatible)
if [ "$INSTALL_MODE" = "none" ] && [ "$INSTALL_SYSTEMD" = false ]; then
if [ "$INSTALL_MODE" = "none" ] && [ "$INSTALL_SYSTEMD" = false ] && [ "$NO_INSTALL" = 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"
+59 -4
View File
@@ -25,6 +25,32 @@ fi
PKG_VERSION="$(printf '%s' "$APP_VERSION" | sed 's/[^A-Za-z0-9.+~]/./g')"
PKG_RELEASE="${PKG_RELEASE:-1}"
BUILD_DEB=true
BUILD_RPM=true
for arg in "$@"; do
case "$arg" in
--deb)
BUILD_DEB=true
BUILD_RPM=false
;;
--rpm)
BUILD_DEB=false
BUILD_RPM=true
;;
--help|-h)
echo "Usage: $0 [--deb|--rpm]"
echo " (no flag) Build both formats when their build tools are installed."
echo " --deb Build only a Debian package."
echo " --rpm Build only an RPM package."
exit 0
;;
*)
echo "[ERROR] Unknown option: $arg" >&2
exit 1
;;
esac
done
echo "========================================"
echo " $APP_NAME - Linux Package Script"
@@ -48,8 +74,12 @@ require_command() {
require_command go
require_command pkg-config
require_command gcc
require_command dpkg-deb
require_command rpmbuild
if [ "$BUILD_DEB" = true ]; then
require_command dpkg-deb
fi
if [ "$BUILD_RPM" = true ]; then
require_command rpmbuild
fi
if ! command_exists wails; then
echo "[INFO] Wails CLI not found. Installing..."
@@ -74,6 +104,20 @@ appindicator_ok() {
return 1
}
if pkg-config --exists ayatana-appindicator3-0.1 2>/dev/null; then
APPINDICATOR_DEB_DEP="libayatana-appindicator3-1"
elif pkg-config --exists appindicator3-0.1 2>/dev/null; then
APPINDICATOR_DEB_DEP="libappindicator3-1"
else
APPINDICATOR_DEB_DEP="libayatana-appindicator3-1"
fi
if pkg-config --exists webkit2gtk-4.1 2>/dev/null; then
WEBKIT_DEB_DEP="libwebkit2gtk-4.1-0"
else
WEBKIT_DEB_DEP="libwebkit2gtk-4.0-37"
fi
echo "[1/5] Checking Linux build dependencies..."
if ! pkg-config --exists gtk+-3.0 || ! webkit2_ok || ! pkg-config --exists alsa || ! appindicator_ok; then
echo "[ERROR] Missing one or more native build dependencies."
@@ -114,12 +158,17 @@ fi
if ! pkg-config --exists webkit2gtk-4.0 2>/dev/null && pkg-config --exists webkit2gtk-4.1 2>/dev/null; then
WEBKIT41_PC=""
WEBKIT41_PKGCONFIG_DIR="$(pkg-config --variable=pcfiledir webkit2gtk-4.1 2>/dev/null || true)"
if [ -n "$WEBKIT41_PKGCONFIG_DIR" ] && [ -f "$WEBKIT41_PKGCONFIG_DIR/webkit2gtk-4.1.pc" ]; then
WEBKIT41_PC="$WEBKIT41_PKGCONFIG_DIR/webkit2gtk-4.1.pc"
fi
for dir in \
$(printf '%s' "${PKG_CONFIG_PATH:-}" | tr ':' '\n') \
/usr/lib64/pkgconfig \
/usr/lib/pkgconfig \
/usr/local/lib64/pkgconfig \
/usr/local/lib/pkgconfig; do
[ -n "$WEBKIT41_PC" ] && break
[ -z "$dir" ] && continue
[ -f "$dir/webkit2gtk-4.1.pc" ] || continue
WEBKIT41_PC="$dir/webkit2gtk-4.1.pc"
@@ -156,6 +205,9 @@ if [ -f "build/appicon.png" ]; then
install -m 0644 "build/appicon.png" "$WORK_DIR/root/usr/share/pixmaps/$APP_NAME.png"
elif [ -f "frontend/src/assets/images/logo-universal.png" ]; then
install -m 0644 "frontend/src/assets/images/logo-universal.png" "$WORK_DIR/root/usr/share/pixmaps/$APP_NAME.png"
else
echo "[ERROR] Application icon was not found; refusing to build a package with a broken desktop entry."
exit 1
fi
cat > "$WORK_DIR/root/usr/share/applications/$APP_NAME.desktop" <<EOF
@@ -175,6 +227,7 @@ if [ -f README.md ]; then
install -m 0644 README.md "$WORK_DIR/root/usr/share/doc/$APP_NAME/README.md"
fi
if [ "$BUILD_DEB" = true ]; then
echo "[4/5] Building .deb package..."
DEB_ROOT="$WORK_DIR/deb"
rm -rf "$DEB_ROOT"
@@ -190,7 +243,7 @@ Priority: optional
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
Depends: libgtk-3-0, $WEBKIT_DEB_DEP, libasound2, $APPINDICATOR_DEB_DEP
Recommends: ydotool
Description: $COMMENT
WIS Free V3 is a desktop voice dictation app built with Go and Wails.
@@ -198,7 +251,9 @@ Description: $COMMENT
EOF
dpkg-deb --build "$DEB_ROOT" "$DIST_DIR/${APP_NAME}_${PKG_VERSION}-${PKG_RELEASE}_${ARCH_DEB}.deb"
fi
if [ "$BUILD_RPM" = true ]; then
echo "[5/5] Building .rpm package..."
RPM_TOP="$WORK_DIR/rpm"
RPM_PAYLOAD="$(pwd)/$WORK_DIR/root"
@@ -214,7 +269,6 @@ Summary: $COMMENT
License: $LICENSE
Requires: gtk3
Requires: alsa-lib
Requires: libayatana-appindicator-gtk3
Recommends: ydotool
%description
@@ -235,6 +289,7 @@ EOF
rpmbuild --target "$ARCH_RPM" --define "_topdir $(pwd)/$RPM_TOP" -bb "$RPM_SPEC"
find "$RPM_TOP/RPMS" -type f -name "*.rpm" -exec cp {} "$DIST_DIR/" \;
fi
echo ""
echo "Packages written to:"
+49 -11
View File
@@ -55,18 +55,50 @@ func typeLinuxTextWithYdotool(text string) error {
return err
}
fastArgs := []string{"type", "-d", "1", "--file", "-"}
if err := runLinuxInputCommand(path, fastArgs, text, linuxTextTyperTimeout(text), socketPath); err == nil {
return nil
timeout := linuxTextTyperTimeout(text)
// ydotool has changed option parsing across releases. Use stdin to avoid
// shell quoting and argument-size issues, but keep a direct-argument
// fallback for older packages whose `type --file` support is incomplete.
attempts := [][]string{
{"type", "-d", "1", "--file", "-"},
{"type", "-f", "-"},
{"type", "--file", "-"},
}
var lastErr error
for _, args := range attempts {
if err := runLinuxInputCommand(path, args, text, timeout, socketPath); err == nil {
return nil
} else {
lastErr = err
}
}
return runLinuxInputCommand(path, []string{"type", "--file", "-"}, text, linuxTextTyperTimeout(text), socketPath)
// Do not turn a leading dash into an option. Transcriptions are normally
// small enough for argv and exec.Command passes this value without a shell.
if text != "" && !strings.HasPrefix(text, "-") && !strings.ContainsRune(text, '\x00') {
if err := runLinuxInputCommand(path, []string{"type", text}, "", timeout, socketPath); err == nil {
return nil
} else {
lastErr = err
}
}
return lastErr
}
func getYdotoolCommand() (string, string, error) {
path, err := exec.LookPath("ydotool")
if err != nil {
return "", "", errors.New("ydotool not found; install ydotool and start the ydotool user service")
for _, candidate := range []string{"/usr/local/bin/ydotool", "/usr/bin/ydotool"} {
if info, statErr := os.Stat(candidate); statErr == nil && !info.IsDir() && info.Mode()&0111 != 0 {
path = candidate
err = nil
break
}
}
if err != nil {
return "", "", errors.New("ydotool not found; install ydotool and start its user service")
}
}
socketPath, err := getYdotoolSocketPath()
@@ -89,10 +121,10 @@ func linuxYdotoolStatus() map[string]interface{} {
"sudo apt install ydotool # Debian/Ubuntu",
"sudo dnf install ydotool # Fedora",
"sudo pacman -S ydotool # Arch",
"echo 'KERNEL==\"uinput\", SUBSYSTEM==\"misc\", TAG+=\"uaccess\", OPTIONS+=\"static_node=uinput\"' | sudo tee /etc/udev/rules.d/80-uinput.rules",
"sudo udevadm control --reload-rules && sudo udevadm trigger",
"# Distribution packages normally provide the uinput rule and service:",
"systemctl --user enable --now ydotool.service",
"# Restart your computer, then open WIS Free V3 again.",
"# If the socket is still missing, log out and back in, then run:",
"systemctl --user restart ydotool.service",
},
}
@@ -129,17 +161,23 @@ func getYdotoolSocketPath() (string, error) {
return "", fmt.Errorf("YDOTOOL_SOCKET is set but not accessible: %s", socketPath)
}
candidates := []string{
candidates := make([]string, 0, 6)
if runtimeDir := strings.TrimSpace(os.Getenv("XDG_RUNTIME_DIR")); runtimeDir != "" {
candidates = append(candidates, filepath.Join(runtimeDir, ".ydotool_socket"))
}
candidates = append(candidates,
filepath.Join("/run/user", fmt.Sprintf("%d", os.Getuid()), ".ydotool_socket"),
"/tmp/.ydotool_socket",
}
"/run/ydotoold/socket",
"/run/ydotoold/.ydotool_socket",
)
for _, socketPath := range candidates {
if _, err := os.Stat(socketPath); err == nil {
return socketPath, nil
}
}
return "", fmt.Errorf("ydotoold socket not found; run `systemctl --user start ydotool.service` after configuring /dev/uinput permissions")
return "", fmt.Errorf("ydotoold socket not found; run `systemctl --user enable --now ydotool.service` and log out/in only if the service still cannot access /dev/uinput")
}
func runLinuxInputCommand(path string, args []string, stdin string, timeout time.Duration, socketPath string) error {