refactor: implement cross-platform abstraction for process management, media control, and startup configuration (TESTING)

This commit is contained in:
jahruz67
2026-04-09 21:31:02 -07:00
parent cc644e3059
commit ba0e75c503
25 changed files with 458 additions and 48 deletions
+2 -1
View File
@@ -1,6 +1,6 @@
# wis-free-v3
A high-performance, native Windows 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.
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.
---
@@ -8,6 +8,7 @@ A high-performance, native Windows voice dictation application built in Go using
- **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.
+9 -10
View File
@@ -8,15 +8,14 @@ import (
"strings"
"time"
"wis-free-v3/internal/audio/media"
"wis-free-v3/internal/audio/recorder"
"wis-free-v3/internal/config"
"wis-free-v3/internal/hotkey"
"wis-free-v3/internal/logger"
"wis-free-v3/internal/platform"
"wis-free-v3/internal/services/transcriber"
"wis-free-v3/internal/services/whisper"
"wis-free-v3/internal/system/startup"
"wis-free-v3/internal/ui/overlay"
"wis-free-v3/internal/ui/tray"
"github.com/go-vgo/robotgo"
@@ -31,7 +30,7 @@ type App struct {
hotkeyListener *hotkey.Listener
transcriber *transcriber.Client
config *config.Config
overlay *overlay.Overlay
overlay platform.Overlay
recordingPath string
isQuitting bool
wasMediaPlaying bool
@@ -131,7 +130,7 @@ func (a *App) StartRecording() {
tray.UpdateStatus("Recording...")
// Pause media if playing (this is slow due to PowerShell)
a.wasMediaPlaying = media.PauseMedia()
a.wasMediaPlaying = platform.PauseMedia()
if a.wasMediaPlaying {
logger.Info("Media paused for recording")
}
@@ -143,7 +142,7 @@ func (a *App) StopRecording() {
logger.Info("StopRecording triggered")
// Resume media if it was playing before
media.ResumeMedia(a.wasMediaPlaying)
platform.ResumeMedia(a.wasMediaPlaying)
if a.wasMediaPlaying {
logger.Info("Media resumed after recording")
}
@@ -281,7 +280,7 @@ func (a *App) GetSettings() map[string]interface{} {
conf["language"] = a.config.Language
conf["microphone_device"] = a.config.MicrophoneDevice
conf["history"] = a.config.History
conf["startup"] = startup.IsInStartup()
conf["startup"] = platform.IsInStartup()
return conf
}
@@ -377,9 +376,9 @@ func (a *App) GetMicrophones() []map[string]interface{} {
func (a *App) ToggleStartup(enable bool) string {
var err error
if enable {
err = startup.AddToStartup()
err = platform.AddToStartup()
} else {
err = startup.RemoveFromStartup()
err = platform.RemoveFromStartup()
}
if err != nil {
@@ -445,7 +444,7 @@ func (a *App) startupHeadless() {
}
// Initialize Overlay
a.overlay = overlay.NewOverlay()
a.overlay = platform.NewOverlay()
// Connect volume feedback from recorder to overlay
if a.audioRecorder != nil && a.overlay != nil {
Binary file not shown.
+1 -1
View File
@@ -1,6 +1,6 @@
module wis-free-v3
go 1.24
go 1.24.0
require (
github.com/gen2brain/malgo v0.11.24
+42
View File
@@ -0,0 +1,42 @@
//go:build linux
package linux
import (
"os/exec"
"wis-free-v3/internal/logger"
)
// IsPlaying checks if media is currently playing using playerctl
func IsPlaying() bool {
cmd := exec.Command("playerctl", "status")
out, err := cmd.Output()
if err != nil {
return false
}
return string(out) == "Playing\n"
}
// TogglePlayPause sends the play-pause command via playerctl
func TogglePlayPause() {
cmd := exec.Command("playerctl", "play-pause")
err := cmd.Run()
if err != nil {
logger.Error("Failed to toggle media on Linux: %v", err)
}
}
// PauseMedia checks if playing, pauses if so, returns whether we paused
func PauseMedia() bool {
wasPlaying := IsPlaying()
if wasPlaying {
TogglePlayPause()
}
return wasPlaying
}
// ResumeMedia resumes only if we paused it
func ResumeMedia(wasPaused bool) {
if wasPaused {
TogglePlayPause()
}
}
+15
View File
@@ -0,0 +1,15 @@
//go:build linux
package linux
// linuxOverlay provides a simple dummy implementation for Linux.
// It relies on tray icon status updates for visual feedback instead of raw drawing.
type linuxOverlay struct{}
func NewOverlay() *linuxOverlay {
return &linuxOverlay{}
}
func (o *linuxOverlay) Show(message string) {}
func (o *linuxOverlay) Hide() {}
func (o *linuxOverlay) SetVolume(level float64) {}
func (o *linuxOverlay) Close() {}
+11
View File
@@ -0,0 +1,11 @@
//go:build linux
package linux
import "syscall"
// IsProcessRunning checks if a process with the given PID exists on Linux.
func IsProcessRunning(pid int) bool {
// sending signal 0 checks if the process exists and we have permission
err := syscall.Kill(pid, 0)
return err == nil
}
+91
View File
@@ -0,0 +1,91 @@
//go:build linux
package linux
import (
"fmt"
"os"
"path/filepath"
)
const (
appName = "wis-free-v3"
desktopFileContent = `[Desktop Entry]
Type=Application
Name=WIS Free V3
Exec="%s"
Terminal=false
Categories=Utility;
X-GNOME-Autostart-enabled=true
`
)
func getAutostartPath() (string, error) {
home, err := os.UserHomeDir()
if err != nil {
return "", err
}
configDir := os.Getenv("XDG_CONFIG_HOME")
if configDir == "" {
configDir = filepath.Join(home, ".config")
}
autostartDir := filepath.Join(configDir, "autostart")
if err := os.MkdirAll(autostartDir, 0755); err != nil {
return "", err
}
return filepath.Join(autostartDir, appName+".desktop"), nil
}
func getExecutablePath() (string, error) {
exe, err := os.Executable()
if err != nil {
return "", err
}
return filepath.Abs(exe)
}
func AddToStartup() error {
autostartPath, err := getAutostartPath()
if err != nil {
return fmt.Errorf("failed to determine autostart path: %w", err)
}
exePath, err := getExecutablePath()
if err != nil {
return fmt.Errorf("failed to get executable path: %w", err)
}
content := fmt.Sprintf(desktopFileContent, exePath)
if err := os.WriteFile(autostartPath, []byte(content), 0644); err != nil {
return fmt.Errorf("failed to write autostart file: %w", err)
}
return nil
}
func RemoveFromStartup() error {
autostartPath, err := getAutostartPath()
if err != nil {
return fmt.Errorf("failed to determine autostart path: %w", err)
}
if err := os.Remove(autostartPath); err != nil {
if !os.IsNotExist(err) {
return fmt.Errorf("failed to remove autostart file: %w", err)
}
}
return nil
}
func IsInStartup() bool {
autostartPath, err := getAutostartPath()
if err != nil {
return false
}
_, err = os.Stat(autostartPath)
return err == nil
}
+9
View File
@@ -0,0 +1,9 @@
package platform
// Overlay defines the cross-platform interface for screen overlays
type Overlay interface {
Show(message string)
Hide()
SetVolume(level float64)
Close()
}
+32
View File
@@ -0,0 +1,32 @@
//go:build linux
package platform
import "wis-free-v3/internal/linux"
func NewOverlay() Overlay {
return linux.NewOverlay()
}
func PauseMedia() bool {
return linux.PauseMedia()
}
func ResumeMedia(wasPaused bool) {
linux.ResumeMedia(wasPaused)
}
func AddToStartup() error {
return linux.AddToStartup()
}
func RemoveFromStartup() error {
return linux.RemoveFromStartup()
}
func IsInStartup() bool {
return linux.IsInStartup()
}
func IsProcessRunning(pid int) bool {
return linux.IsProcessRunning(pid)
}
+32
View File
@@ -0,0 +1,32 @@
//go:build windows
package platform
import "wis-free-v3/internal/windows"
func NewOverlay() Overlay {
return windows.NewOverlay()
}
func PauseMedia() bool {
return windows.PauseMedia()
}
func ResumeMedia(wasPaused bool) {
windows.ResumeMedia(wasPaused)
}
func AddToStartup() error {
return windows.AddToStartup()
}
func RemoveFromStartup() error {
return windows.RemoveFromStartup()
}
func IsInStartup() bool {
return windows.IsInStartup()
}
func IsProcessRunning(pid int) bool {
return windows.IsProcessRunning(pid)
}
@@ -0,0 +1,9 @@
//go:build !windows
package whisper
import "os/exec"
func hideWindowContext(cmd *exec.Cmd) {
// Not needed on non-Windows platforms
}
+15
View File
@@ -0,0 +1,15 @@
//go:build windows
package whisper
import (
"os/exec"
"syscall"
)
func hideWindowContext(cmd *exec.Cmd) {
cmd.SysProcAttr = &syscall.SysProcAttr{
HideWindow: true,
CreationFlags: 0x08000000, // CREATE_NO_WINDOW
}
}
+2 -6
View File
@@ -10,7 +10,6 @@ import (
"os/exec"
"path/filepath"
"strings"
"syscall"
"time"
"wis-free-v3/internal/logger"
@@ -285,11 +284,8 @@ func (m *Manager) Transcribe(audioPath string) (string, error) {
// Set working directory to the binary's location so it can find DLLs
cmd.Dir = filepath.Dir(binaryPath)
// Hide the console window
cmd.SysProcAttr = &syscall.SysProcAttr{
HideWindow: true,
CreationFlags: 0x08000000, // CREATE_NO_WINDOW
}
// Hide the console window on Windows
hideWindowContext(cmd)
output, err := cmd.CombinedOutput()
outputStr := string(output)
+8 -8
View File
@@ -8,7 +8,7 @@ import (
"wis-free-v3/internal/config"
"wis-free-v3/internal/logger"
"wis-free-v3/internal/system/startup"
"wis-free-v3/internal/platform"
"github.com/getlantern/systray"
)
@@ -50,9 +50,9 @@ func onReady(app App) {
menuSettings := systray.AddMenuItem("Settings", "Open settings window")
menuStartup := systray.AddMenuItemCheckbox(
"Start with Windows",
"Automatically start when Windows boots",
startup.IsInStartup(),
"Start with system",
"Automatically start when computer boots",
platform.IsInStartup(),
)
systray.AddSeparator()
@@ -82,18 +82,18 @@ func handleMenuEvents(app App, settings, startupItem, exit *systray.MenuItem) {
// toggleStartup handles the startup toggle menu item.
func toggleStartup(item *systray.MenuItem) {
if item.Checked() {
if err := startup.RemoveFromStartup(); err != nil {
if err := platform.RemoveFromStartup(); err != nil {
logger.Error("Failed to remove from startup: %v", err)
} else {
item.Uncheck()
logger.Info("Removed from Windows startup")
logger.Info("Removed from system startup")
}
} else {
if err := startup.AddToStartup(); err != nil {
if err := platform.AddToStartup(); err != nil {
logger.Error("Failed to add to startup: %v", err)
} else {
item.Check()
logger.Info("Added to Windows startup")
logger.Info("Added to system startup")
}
}
}
@@ -1,4 +1,5 @@
package media
//go:build windows
package windows
import (
_ "embed"
@@ -10,8 +11,8 @@ import (
)
var (
user32 = syscall.NewLazyDLL("user32.dll")
keybd_event = user32.NewProc("keybd_event")
user32Media = syscall.NewLazyDLL("user32.dll")
keybd_event = user32Media.NewProc("keybd_event")
)
//go:embed check-media.ps1
@@ -1,4 +1,5 @@
package overlay
//go:build windows
package windows
import (
"math"
@@ -255,8 +256,17 @@ func (o *Overlay) run() {
procTranslateMessage.Call(uintptr(unsafe.Pointer(&msg)))
procDispatchMessage.Call(uintptr(unsafe.Pointer(&msg)))
} else {
// Higher resolution sleep for better responsiveness while keeping CPU low
time.Sleep(2 * time.Millisecond)
o.mu.RLock()
isShowing := o.isShowing
o.mu.RUnlock()
if isShowing {
// Higher resolution sleep for smooth animation when visible
time.Sleep(2 * time.Millisecond)
} else {
// Greatly reduce wakeups to save CPU when hidden (app is idle 99% of the time)
time.Sleep(50 * time.Millisecond)
}
}
}
}
+16
View File
@@ -0,0 +1,16 @@
//go:build windows
package windows
import "syscall"
// IsProcessRunning checks if a process with the given PID exists on Windows.
func IsProcessRunning(pid int) bool {
const PROCESS_QUERY_LIMITED_INFORMATION = 0x1000
handle, err := syscall.OpenProcess(PROCESS_QUERY_LIMITED_INFORMATION, false, uint32(pid))
if err != nil {
return false
}
syscall.CloseHandle(handle)
return true
}
@@ -1,6 +1,5 @@
// Package startup manages Windows startup registry entries for the application.
// It allows the application to automatically start when the user logs in.
package startup
//go:build windows
package windows
import (
"fmt"
+2 -13
View File
@@ -7,9 +7,9 @@ import (
"os"
"path/filepath"
"strconv"
"syscall"
"wis-free-v3/internal/logger"
"wis-free-v3/internal/platform"
"github.com/wailsapp/wails/v2"
"github.com/wailsapp/wails/v2/pkg/options"
@@ -87,7 +87,7 @@ func acquireInstanceLock() bool {
// Check for existing lock file
if data, err := os.ReadFile(lockPath); err == nil {
if pid, err := strconv.Atoi(string(data)); err == nil {
if isProcessRunning(pid) {
if platform.IsProcessRunning(pid) {
logger.Info("Another instance is already running (PID: %d)", pid)
return false
}
@@ -136,16 +136,5 @@ func getLockPath() (string, error) {
return filepath.Join(homeDir, configDir, lockFile), nil
}
// isProcessRunning checks if a process with the given PID exists on Windows.
func isProcessRunning(pid int) bool {
const PROCESS_QUERY_LIMITED_INFORMATION = 0x1000
handle, err := syscall.OpenProcess(PROCESS_QUERY_LIMITED_INFORMATION, false, uint32(pid))
if err != nil {
return false
}
syscall.CloseHandle(handle)
return true
}
+143
View File
@@ -0,0 +1,143 @@
#!/bin/bash
# Exit on error
set -e
# Change to the root directory of the project
cd "$(dirname "$0")/.."
APP_NAME="wis-free-v3"
BUILD_DIR="build/bin"
EXECUTABLE="$BUILD_DIR/$APP_NAME"
echo "========================================"
echo " wis-free-v3 - Linux Build Script"
echo "========================================"
echo ""
# Function to check if a command exists
command_exists() {
command -v "$1" >/dev/null 2>&1
}
# 1. Check for basic tools
if ! command_exists go; then
echo "[ERROR] Go is not installed. Please install Go 1.23+."
exit 1
fi
if ! command_exists wails; then
echo "[INFO] Wails CLI not found. Installing..."
go install github.com/wailsapp/wails/v2/cmd/wails@latest
export PATH=$PATH:$(go env GOPATH)/bin
fi
# 2. Check for Linux dependencies
echo "[1/3] Checking system dependencies..."
MISSING_DEPS=0
DEPS=("gcc" "pkg-config")
for dep in "${DEPS[@]}"; do
if ! command_exists $dep; then
echo "[WARNING] Missing basic build tool: $dep"
MISSING_DEPS=1
fi
done
# We can't easily check C headers, but we can try to find them with pkg-config
if command_exists pkg-config; then
if ! pkg-config --exists gtk+-3.0 webkit2gtk-4.0; then
echo "[WARNING] Missing Wails dependencies (GTK3 / WebKit2GTK)."
MISSING_DEPS=1
fi
if ! pkg-config --exists x11 xtst xcb; then
echo "[WARNING] Missing gohook dependencies (X11 / Xtst / Xcb)."
MISSING_DEPS=1
fi
if ! pkg-config --exists alsa; then
echo "[WARNING] Missing audio dependencies (ALSA)."
MISSING_DEPS=1
fi
if ! pkg-config --exists ayatana-appindicator3-0.1; then
echo "[WARNING] Missing systray dependencies (ayatana-appindicator3)."
MISSING_DEPS=1
fi
fi
if [ $MISSING_DEPS -eq 1 ]; then
echo ""
echo "It looks like you are missing some required libraries."
DEBIAN_DEPS="build-essential pkg-config libgtk-3-dev libwebkit2gtk-4.0-dev libx11-dev libx11-xcb-dev libxtst-dev libasound2-dev libayatana-appindicator3-dev libxkbcommon-x11-dev"
ARCH_DEPS="base-devel pkgconf gtk3 webkit2gtk libx11 libxtst alsa-lib libayatana-appindicator libxkbcommon-x11"
echo "The full list of dependencies needed:"
echo " [Ubuntu/Debian]: sudo apt update && sudo apt install -y $DEBIAN_DEPS"
echo " [Arch Linux]: sudo pacman -S $ARCH_DEPS"
echo ""
if command_exists apt-get; then
read -p "Would you like to automatically install missing dependencies now? (Requires sudo) (y/N): " INSTALL_DEPS
if [[ "$INSTALL_DEPS" == "y" || "$INSTALL_DEPS" == "Y" ]]; then
echo "Installing dependencies..."
sudo apt update && sudo apt install -y $DEBIAN_DEPS
else
echo "Skipping installation."
read -p "Press Enter to attempt build anyway, or Ctrl+C to cancel..."
fi
else
read -p "Press Enter to attempt build anyway, or Ctrl+C to cancel..."
fi
fi
# 3. Build the application
echo "[2/3] Building with Wails..."
wails build -platform linux/amd64 -clean
if [ ! -f "$EXECUTABLE" ]; then
echo "[ERROR] Build failed. Binary not found at $EXECUTABLE."
exit 1
fi
echo "[3/3] 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..."
# Needs sudo
sudo cp "$EXECUTABLE" "/usr/local/bin/$APP_NAME"
sudo chmod +x "/usr/local/bin/$APP_NAME"
# Create Desktop shortcut
DESKTOP_FILE="/usr/share/applications/$APP_NAME.desktop"
# Try to grab the icon from the Wails build directory if available
ICON_PATH="/usr/share/pixmaps/$APP_NAME.png"
if [ -f "build/appicon.png" ]; then
sudo cp "build/appicon.png" "$ICON_PATH"
fi
cat << EOF > /tmp/$APP_NAME.desktop
[Desktop Entry]
Type=Application
Name=WIS Free V3
Comment=Voice Dictation App
Exec=$APP_NAME
Icon=$APP_NAME
Terminal=false
Categories=Utility;Audio;
EOF
sudo mv /tmp/$APP_NAME.desktop "$DESKTOP_FILE"
sudo chmod 644 "$DESKTOP_FILE"
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 "Skipping installation. You can run the app directly via: ./$EXECUTABLE"
fi
BIN
View File
Binary file not shown.