Compare commits

...
Author SHA1 Message Date
blacksmith-sh[bot]andGitHub 69c9c7a364 Migrate workflows to Blacksmith 2026-07-10 21:23:15 +00:00
John Doe 71dbac5413 feat: add shutdown functionality for Linux press daemon 2026-07-10 14:21:06 -07:00
Your Name 062f09a000 fix: prevent concurrent transcription races and mask API key 2026-06-09 19:39:27 -07:00
Your Name fe21a41532 fix: update libwebkit2gtk version to 4.1 in build workflow 2026-06-09 15:23:19 -07:00
Your Name d512425c72 feat: add GitHub Actions workflow for building Linux application 2026-06-09 15:18:59 -07:00
Your Name ce49f66c89 fix: Add cross-platform handling for tray, updater, and icon generation
- Add platform notes for tray startup, shortcut saving, and Linux-only settings
- Handle stat error in processRecording (missing file)
- Select correct binary name based on OS in updater
- Increase tray icon canvas size from 64 to 128 pixels
- Update frontend dist asset hash
2026-06-09 15:14:30 -07:00
Your Name 593a5941a1 refactor: remove direct typing for short text in insertTranscription function to streamline clipboard usage 2026-06-09 13:29:26 -07:00
Your Name 2c5a0b70ed feat: enhance ydotool integration for direct typing on Linux; improve error handling and logging in audio processing; update build scripts for better installation guidance 2026-06-09 10:14:33 -07:00
Your Name 52bb65f3a0 Refactor config history management to enforce byte size limit and optimize item prepending; improve hotkey listener synchronization to prevent deadlocks; enhance transcriber error handling to avoid leaking sensitive information; address multiple race conditions and potential crashes in audio processing; implement logging improvements and fix resource leaks; add comprehensive issue tracking documentation. 2026-06-09 09:16:55 -07:00
Your Name 05c8fbc474 feat: enhance Linux support with ydotool integration and improve logging 2026-06-09 00:25:05 -07:00
jahruz67 bc3d8cbce3 feat: add Linux packaging script for WIS Free V3 2026-05-21 09:08:27 -07:00
jahruz67 b0dbaa1f67 fix: adjust sleep durations in Linux input focus release for improved responsiveness 2026-05-20 17:18:47 -07:00
jahruz67 f7d34fff32 feat: optimize ydotool text typing with fast command execution 2026-05-20 17:14:02 -07:00
jahruz67 5fcef76424 Enhance Linux Wayland support and refine transcription logic
- Added instructions for Linux Wayland paste setup in README.md.
- Removed unnecessary JavaScript file and updated references in HTML files.
- Updated DefaultAIPrompt to clarify the role of the transcription tool.
- Removed overlay notifications for Linux to reduce desktop clutter.
- Implemented checks to ensure transcript integrity during refinement.
- Added tests to validate transcript preservation logic.
- Removed ydotool setup script as it is no longer needed.
2026-05-20 17:08:01 -07:00
jahruz67 9b08ed249a feat: implement ydotool support for Linux with auto-paste functionality and add setup script 2026-05-20 16:12:55 -07:00
jahruz67 4131d77326 feat: enhance Linux transcription with clipboard wait and auto-paste support 2026-05-20 15:51:26 -07:00
jahruz67 cacbe9bb7f feat: enhance Linux transcription handling with clipboard support and auto-typing fallback 2026-05-20 15:42:26 -07:00
jahruz67 5ab514abdd feat: refactor transcription handling for Linux and non-Linux environments 2026-05-20 15:29:09 -07:00
jahruz67 910877d35e feat: implement enhanced Linux hotkey handling and add command copying functionality 2026-05-20 15:13:52 -07:00
jahruz67 181e663de4 Merge branch 'main' of https://github.com/jahruz67/wisp-open 2026-05-20 14:59:19 -07:00
jahruz67 1987f413e4 fix: enhance second instance command handling and maintain backward compatibility 2026-05-20 14:58:59 -07:00
jahruz67andGitHub 0bac8c2bbf Merge pull request #5 from jahruz67/codex/refactor-linux-global-hotkey-system-ta30lb
Add Linux 'press' helper and UI for system hotkey command (Wayland-friendly)
2026-05-19 20:53:02 -07:00
jahruz67andGitHub 02d26ce79b Merge branch 'main' into codex/refactor-linux-global-hotkey-system-ta30lb 2026-05-19 20:52:25 -07:00
jahruz67 c4739dc38c Remove stale shortcut recorder code from built frontend asset 2026-05-19 20:51:28 -07:00
jahruz67andGitHub 7840918118 Merge pull request #4 from jahruz67/codex/refactor-linux-global-hotkey-system-8g9jyq
Add Linux 'press' helper and UI support for system hotkey command
2026-05-19 20:45:40 -07:00
jahruz67andGitHub 9d4d279a87 Merge pull request #3 from jahruz67/codex/refactor-linux-global-hotkey-system
Linux: add --press daemon trigger architecture for Wayland hotkeys
2026-05-19 20:17:24 -07:00
jahruz67 89af7f9841 Add Linux press-trigger daemon hotkey flow 2026-05-19 20:17:10 -07:00
43 changed files with 2117 additions and 393 deletions
+71
View File
@@ -0,0 +1,71 @@
name: Build Linux
on:
push:
branches: [main, master]
pull_request:
branches: [main, master]
jobs:
build-linux:
runs-on: blacksmith-8vcpu-ubuntu-2404
steps:
- name: Checkout
uses: actions/checkout@v4
- name: Set up Go
uses: actions/setup-go@v5
with:
go-version: "1.24"
- name: Set up Node.js
uses: actions/setup-node@v4
with:
node-version: "20"
- name: Install system dependencies
run: |
sudo apt-get update
sudo apt-get install -y \
build-essential \
pkg-config \
libgtk-3-dev \
libwebkit2gtk-4.1-dev \
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
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 }}"
- name: Upload binary
uses: actions/upload-artifact@v4
with:
name: wis-free-v3-linux-amd64
path: build/bin/wis-free-v3
+4
View File
@@ -56,6 +56,10 @@ A high-performance, cross-platform (Windows & Linux) voice dictation application
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_...",
+151 -93
View File
@@ -6,6 +6,7 @@ import (
"os"
"path/filepath"
"runtime"
"sort"
"strings"
"sync/atomic"
"time"
@@ -25,18 +26,19 @@ import (
// App struct
type App struct {
ctx context.Context
audioRecorder *recorder.AudioRecorder
hotkeyListener *hotkey.Listener
transcriber *transcriber.Client
config *config.Config
overlay platform.Overlay
recordingPath string
recording int32
isQuitting bool
wasMediaPlaying bool
whisperManager *whisper.Manager
tempDir string
ctx context.Context
audioRecorder *recorder.AudioRecorder
hotkeyListener *hotkey.Listener
transcriber *transcriber.Client
config *config.Config
overlay platform.Overlay
recordingPath string
recording int32
isQuitting bool
wasMediaPlaying bool
whisperManager *whisper.Manager
tempDir string
transcribing int32 // atomic: 1 = transcription in progress, prevents concurrent
}
// NewApp creates a new App application struct
@@ -117,7 +119,18 @@ func (a *App) startup(ctx context.Context) {
}
}()
// Register callback so the tray can notify the frontend when the startup
// menu item is toggled directly from the tray icon context menu.
tray.SetOnStartupChanged(func(enabled bool) {
if a.ctx != nil {
wailsruntime.EventsEmit(a.ctx, "startup:changed", enabled)
}
})
// Start system tray in a goroutine
// PLATFORM NOTE: On Linux, Wails' GTK main loop needs tray.Start() to be
// called synchronously (uses systray.Register). On Windows (and macOS),
// tray.Start() blocks for the Win32 message pump, so it must run in a goroutine.
if runtime.GOOS == "linux" {
tray.Start(a)
} else {
@@ -225,6 +238,8 @@ func (a *App) StartRecording() {
err = a.audioRecorder.Start(a.recordingPath)
}
if err != nil {
// Clean up the orphaned temp file since recording failed to start
os.Remove(a.recordingPath)
if a.overlay != nil {
a.overlay.Hide()
}
@@ -233,17 +248,14 @@ func (a *App) StartRecording() {
}
}
// 3. Handle secondary tasks in background
go func() {
// Update tray status
tray.UpdateStatus("Recording...")
// 3. Update tray status
tray.UpdateStatus("Recording...")
// Pause media if playing (this is slow due to PowerShell)
a.wasMediaPlaying = platform.PauseMedia()
if a.wasMediaPlaying {
logger.Info("Media paused for recording")
}
}()
// Pause media if playing (do synchronously so wasMediaPlaying is ready before Stop)
a.wasMediaPlaying = platform.PauseMedia()
if a.wasMediaPlaying {
logger.Info("Media paused for recording")
}
}
// StopRecording stops the audio recording and triggers transcription
@@ -260,25 +272,25 @@ func (a *App) StopRecording() {
}
if a.audioRecorder == nil {
atomic.StoreInt32(&a.recording, 0)
return
}
// Capture the path BEFORE stopping the recorder, so it can't be
// overwritten by a concurrent StartRecording (which sets a.recordingPath).
pathToProcess := a.recordingPath
err := a.audioRecorder.Stop()
if err != nil {
logger.Error("Failed to stop recording: %v", err)
// Attempt to keep state consistent: if stop failed, we are likely still recording.
atomic.StoreInt32(&a.recording, 1)
return
}
// Capture the path before it can be overwritten by another immediate start
pathToProcess := a.recordingPath
// Transcribe in a goroutine to avoid blocking
go a.processRecording(pathToProcess)
}
// processRecording handles transcription and pasting
// processRecording handles transcription and pasting.
// Uses an atomic guard to prevent concurrent transcriptions.
func (a *App) processRecording(recordingPath string) {
if recordingPath == "" {
logger.Error("No recording path set")
@@ -289,10 +301,27 @@ func (a *App) processRecording(recordingPath string) {
return
}
// Prevent concurrent transcriptions: only one goroutine can process at a time.
// If another transcription is already in progress, discard this recording.
if !atomic.CompareAndSwapInt32(&a.transcribing, 0, 1) {
logger.Info("Another transcription already in progress, discarding recording: %s", recordingPath)
os.Remove(recordingPath)
return
}
defer atomic.StoreInt32(&a.transcribing, 0)
// IDIOT-PROOFING: Ignore extremely short recordings (less than ~100ms or ~3KB)
// that are likely accidental clicks or hardware glitches.
stat, statErr := os.Stat(recordingPath)
if statErr == nil && stat.Size() < 4000 {
if statErr != nil {
logger.Error("Failed to stat recording file: %v", statErr)
if a.overlay != nil {
a.overlay.Hide()
}
tray.UpdateStatus("Ready")
return
}
if stat.Size() < 4000 {
logger.Info("Discarding tiny recording (%d bytes)", stat.Size())
os.Remove(recordingPath)
if a.overlay != nil {
@@ -348,7 +377,7 @@ func (a *App) processRecording(recordingPath string) {
return
}
logger.Info("Transcribed: %s", text)
logger.Info("Transcribed %d characters", len(text))
activeWindow := robotgo.GetTitle()
logger.Info("Active window for context: %s", activeWindow)
@@ -362,7 +391,7 @@ func (a *App) processRecording(recordingPath string) {
refinedText = text
} else {
refineDuration := time.Since(startRefine)
logger.Info("AI Refinement completed in %v (Refined: %s)", refineDuration, refinedText)
logger.Info("AI Refinement completed in %v (%d chars)", refineDuration, len(refinedText))
}
// Save to history
@@ -374,32 +403,7 @@ func (a *App) processRecording(recordingPath string) {
wailsruntime.EventsEmit(a.ctx, "history:updated")
}
if len(refinedText) < 50 {
// Type short text directly to avoid clipboard interference
robotgo.TypeStr(refinedText)
} else {
// Save old clipboard
oldClip, clipErr := wailsruntime.ClipboardGetText(a.ctx)
// Copy to clipboard
wailsruntime.ClipboardSetText(a.ctx, refinedText)
// Paste
a.pasteText()
// Restore old clipboard after a short delay, but ONLY if the user
// hasn't manually copied something else or another burst hasn't finished.
if clipErr == nil && oldClip != "" {
go func() {
time.Sleep(1000 * time.Millisecond)
current, _ := wailsruntime.ClipboardGetText(a.ctx)
if current == refinedText {
wailsruntime.ClipboardSetText(a.ctx, oldClip)
logger.Info("Clipboard history restored")
}
}()
}
}
a.insertTranscription(refinedText)
// Clean up the recording file
if removeErr := os.Remove(recordingPath); removeErr != nil {
@@ -415,19 +419,20 @@ func (a *App) processRecording(recordingPath string) {
}
}
// pasteText simulates Ctrl+V to paste from clipboard
func (a *App) pasteText() {
// Give a substantial delay for Linux GTK clipboard sync
time.Sleep(200 * time.Millisecond)
// Simulate Ctrl+V using modern robotgo API
robotgo.KeyTap("v", "ctrl")
}
// GetSettings returns the current configuration
func (a *App) GetSettings() map[string]interface{} {
conf := make(map[string]interface{})
conf["api_key"] = a.config.APIKey
// Mask the API key: only reveal the last 4 characters so the user can verify
// which key is configured without exposing the full secret to the frontend.
if a.config.APIKey != "" {
key := a.config.APIKey
if len(key) > 4 {
key = "****" + key[len(key)-4:]
}
conf["api_key"] = key
} else {
conf["api_key"] = ""
}
conf["shortcut"] = a.config.Shortcut
conf["whisper_model"] = a.config.WhisperModel
conf["ai_model"] = a.config.AIModel
@@ -437,11 +442,15 @@ func (a *App) GetSettings() map[string]interface{} {
conf["history"] = a.config.History
conf["startup"] = platform.IsInStartup()
conf["app_version"] = AppVersion
// PLATFORM NOTE: Linux-only settings — press daemon command and ydotool status.
// These are not included in the Windows build. See linux_press_daemon.go
// 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_mode"] = true
conf["linux_ydotool_status"] = linuxYdotoolStatus()
}
return conf
}
@@ -449,8 +458,17 @@ func (a *App) GetSettings() map[string]interface{} {
// SaveSettings updates the configuration
func (a *App) SaveSettings(settings map[string]interface{}) string {
if val, ok := settings["api_key"].(string); ok {
a.config.APIKey = val
// Only update the API key if it's not the masked value returned by GetSettings.
// GetSettings masks the key as "****abcd" so the frontend can show the last 4 chars.
// If the user didn't change it and sent back the masked value, preserve the real key.
if !strings.HasPrefix(val, "****") {
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)
@@ -509,9 +527,14 @@ func (a *App) SaveSettings(settings map[string]interface{}) string {
}
// Save to file
config.Save(a.config, "")
if err := config.Save(a.config, ""); err != nil {
logger.Error("Failed to save config: %v", err)
return fmt.Sprintf("Error saving settings: %v", err)
}
// Re-init transcriber with new settings
// Re-init transcriber with new settings.
// Note: a.config.APIKey is already updated by the api_key field above.
// Since GetSettings masks the key, we only update if it's not still the masked value.
a.transcriber = transcriber.NewClient(
a.config.APIKey,
a.config.WhisperModel,
@@ -562,6 +585,10 @@ func (a *App) ToggleStartup(enable bool) string {
logger.Error("Startup toggle error: %v", err)
return fmt.Sprintf("Error: %v", err)
}
// Keep the tray menu item in sync when changed from the settings UI
tray.SetStartupChecked(enable)
return "Success"
}
@@ -632,25 +659,36 @@ func (a *App) startupHeadless() {
}
// Initialize Hotkey Listener
a.hotkeyListener = hotkey.NewListener(a.config.Shortcut, 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.")
}
}()
})
if shouldStartBuiltInHotkeyListener() {
a.hotkeyListener = hotkey.NewListener(a.config.Shortcut, 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. Add a custom system shortcut with the command shown in Settings.")
}
}()
})
}
a.hotkeyListener.Start()
} else {
logger.Info("Linux portal hotkey disabled; use the --press command from Settings for GNOME shortcuts")
}
a.hotkeyListener.Start()
logger.Info("Components initialized successfully!")
logger.Info("Basic app components loaded, continuing startup...")
}
func shouldStartBuiltInHotkeyListener() bool {
if runtime.GOOS != "linux" {
return true
}
return os.Getenv("WISFREE_USE_PORTAL_HOTKEY") == "1"
}
// Shutdown cleans up resources
func (a *App) Shutdown(ctx context.Context) {
if a.hotkeyListener != nil {
@@ -662,6 +700,8 @@ func (a *App) Shutdown(ctx context.Context) {
if a.overlay != nil {
a.overlay.Close()
}
// Gracefully shut down the Linux press daemon HTTP server (no-op on Windows)
stopLinuxPressDaemon()
logger.Close()
}
@@ -675,20 +715,30 @@ func (a *App) CheckOnline() bool {
return whisper.CheckOnline()
}
// IsWhisperInstalled checks if offline whisper is installed
// IsWhisperInstalled checks if offline whisper is installed.
// Reuses the cached whisperManager if available.
func (a *App) IsWhisperInstalled() bool {
mgr, err := whisper.NewManager()
if err != nil {
return false
mgr := a.whisperManager
if mgr == nil {
var err error
mgr, err = whisper.NewManager()
if err != nil {
return false
}
}
return mgr.IsInstalled()
}
// GetWhisperInfo returns information about installed whisper
// GetWhisperInfo returns information about installed whisper.
// Reuses the cached whisperManager if available.
func (a *App) GetWhisperInfo() map[string]interface{} {
mgr, err := whisper.NewManager()
if err != nil {
return map[string]interface{}{"installed": false}
mgr := a.whisperManager
if mgr == nil {
var err error
mgr, err = whisper.NewManager()
if err != nil {
return map[string]interface{}{"installed": false}
}
}
if !mgr.IsInstalled() {
@@ -735,10 +785,18 @@ func (a *App) UninstallWhisper() string {
return "Whisper uninstalled successfully"
}
// GetAvailableWhisperModels returns list of available whisper models
// GetAvailableWhisperModels returns list of available whisper models,
// sorted by name for consistent UI display.
func (a *App) GetAvailableWhisperModels() []map[string]string {
var models []map[string]string
for name, info := range whisper.Models {
var names []string
for name := range whisper.Models {
names = append(names, name)
}
sort.Strings(names)
models := make([]map[string]string, 0, len(names))
for _, name := range names {
info := whisper.Models[name]
models = append(models, map[string]string{
"name": name,
"size": info.Size,
Binary file not shown.
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
+21 -5
View File
@@ -6,7 +6,7 @@
<meta content="width=device-width, initial-scale=1.0" name="viewport">
<title>Wisp Settings</title>
<script type="module" crossorigin src="/assets/index.fbf781c1.js"></script>
<script type="module" crossorigin src="/assets/index.91f83df4.js"></script>
<link rel="stylesheet" href="/assets/index.6e77aa4d.css">
</head>
@@ -38,7 +38,21 @@
<button onclick="saveApiKey()">Save</button>
</div>
</div>
<p class="hint">Get your free key at <a href="https://console.groq.com/keys" target="_blank" style="color: var(--accent);">console.groq.com/keys</a></p>
<p class="hint">Get your free key at <a href="#" onclick="window.runtime.BrowserOpenURL('https://console.groq.com/keys'); return false;" style="color: var(--accent);">console.groq.com/keys</a></p>
</div>
<!-- Global Hotkey -->
<div class="section" id="shortcutSection">
<label>Global Hotkey</label>
<div class="form-control">
<div class="flex-row">
<div class="input-wrapper">
<input type="text" id="shortcutInput" readonly placeholder="Click Record then press a key combo">
</div>
<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>
</div>
<div class="section-group-label">Input</div>
@@ -54,8 +68,10 @@
<button onclick="copyLinuxPressCommand()">Copy</button>
</div>
</div>
<p class="hint">GNOME: Settings -> Keyboard -> Custom Shortcuts -> Add a shortcut using the copied command.</p>
<p class="hint">KDE: System Settings -> Shortcuts -> Command/URL -> Add a shortcut using the copied command.</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>
<!-- Microphone -->
@@ -189,4 +205,4 @@
</body>
</html>
</html>
+90 -3
View File
@@ -36,7 +36,21 @@
<button onclick="saveApiKey()">Save</button>
</div>
</div>
<p class="hint">Get your free key at <a href="https://console.groq.com/keys" target="_blank" style="color: var(--accent);">console.groq.com/keys</a></p>
<p class="hint">Get your free key at <a href="#" onclick="window.runtime.BrowserOpenURL('https://console.groq.com/keys'); return false;" style="color: var(--accent);">console.groq.com/keys</a></p>
</div>
<!-- Global Hotkey -->
<div class="section" id="shortcutSection">
<label>Global Hotkey</label>
<div class="form-control">
<div class="flex-row">
<div class="input-wrapper">
<input type="text" id="shortcutInput" readonly placeholder="Click Record then press a key combo">
</div>
<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>
</div>
<div class="section-group-label">Input</div>
@@ -54,6 +68,8 @@
</div>
<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>
<!-- Microphone -->
@@ -318,6 +334,43 @@
});
}
function renderLinuxYdotoolStatus(status) {
const el = document.getElementById('linuxYdotoolStatus');
if (!el || !status) return;
const ready = !!status.ready;
el.style.display = 'block';
el.style.background = ready ? 'var(--green-dim)' : 'var(--red-dim)';
el.style.color = ready ? 'var(--green)' : 'var(--red)';
el.style.border = ready ? '1px solid rgba(61, 186, 110, 0.25)' : '1px solid rgba(224, 82, 82, 0.25)';
el.innerHTML = '';
const title = document.createElement('div');
title.style.fontWeight = '600';
title.textContent = ready ? 'Direct typing ready' : 'Direct typing needs ydotool setup';
el.appendChild(title);
const message = document.createElement('div');
message.style.marginTop = '4px';
message.textContent = status.message || '';
el.appendChild(message);
if (!ready && Array.isArray(status.setup_commands) && status.setup_commands.length) {
const pre = document.createElement('pre');
pre.style.whiteSpace = 'pre-wrap';
pre.style.margin = '8px 0 0';
pre.style.color = 'inherit';
pre.textContent = status.setup_commands.join('\n');
el.appendChild(pre);
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.';
el.appendChild(restart);
}
}
async function refreshHistoryFromBackend() {
try {
const settings = await window.go.main.App.GetSettings();
@@ -333,7 +386,17 @@
const settings = await window.go.main.App.GetSettings();
document.getElementById('apiKey').value = settings.api_key || '';
document.getElementById('shortcutInput').value = settings.shortcut || 'alt+z';
const shortcutInput = document.getElementById('shortcutInput');
if (shortcutInput) {
shortcutInput.value = settings.shortcut || 'alt+z';
}
if (settings.linux_press_mode) {
const section = document.getElementById('linuxPressSection');
const cmdInput = document.getElementById('linuxPressCommand');
if (section) section.style.display = 'block';
if (cmdInput) cmdInput.value = settings.linux_press_command || '';
renderLinuxYdotoolStatus(settings.linux_ydotool_status);
}
document.getElementById('whisperModel').value = settings.whisper_model || 'whisper-large-v3-turbo';
document.getElementById('aiModel').value = settings.ai_model || 'llama-3.3-70b-versatile';
document.getElementById('aiPrompt').value = settings.ai_prompt || '';
@@ -407,6 +470,25 @@
showSaveStatus('Prompt saved');
};
window.copyLinuxPressCommand = async function () {
const cmdInput = document.getElementById('linuxPressCommand');
if (!cmdInput) return;
const command = cmdInput.value || '';
try {
if (navigator.clipboard && navigator.clipboard.writeText) {
await navigator.clipboard.writeText(command);
} else {
cmdInput.focus();
cmdInput.select();
document.execCommand('copy');
}
showSaveStatus('Command copied');
} catch (err) {
console.error('Failed to copy Linux command:', err);
}
};
window.toggleStartup = async function () {
await window.go.main.App.ToggleStartup(document.getElementById('startupToggle').checked);
};
@@ -543,6 +625,11 @@
window.runtime.EventsOn('history:updated', function () {
refreshHistoryFromBackend();
});
// Keep the startup checkbox in sync when toggled from the tray menu
window.runtime.EventsOn('startup:changed', function (enabled) {
document.getElementById('startupToggle').checked = enabled;
});
}
// If Linux, ensure Local Whisper option is removed
@@ -560,4 +647,4 @@
</script>
</body>
</html>
</html>
+82 -1
View File
@@ -246,4 +246,85 @@ export function OnFileDropOff() :void
export function CanResolveFilePaths(): boolean;
// Resolves file paths for an array of files
export function ResolveFilePaths(files: File[]): void
export function ResolveFilePaths(files: File[]): void
// Notification types
export interface NotificationOptions {
id: string;
title: string;
subtitle?: string; // macOS and Linux only
body?: string;
categoryId?: string;
data?: { [key: string]: any };
}
export interface NotificationAction {
id?: string;
title?: string;
destructive?: boolean; // macOS-specific
}
export interface NotificationCategory {
id?: string;
actions?: NotificationAction[];
hasReplyField?: boolean;
replyPlaceholder?: string;
replyButtonTitle?: string;
}
// [InitializeNotifications](https://wails.io/docs/reference/runtime/notification#initializenotifications)
// Initializes the notification service for the application.
// This must be called before sending any notifications.
export function InitializeNotifications(): Promise<void>;
// [CleanupNotifications](https://wails.io/docs/reference/runtime/notification#cleanupnotifications)
// Cleans up notification resources and releases any held connections.
export function CleanupNotifications(): Promise<void>;
// [IsNotificationAvailable](https://wails.io/docs/reference/runtime/notification#isnotificationavailable)
// Checks if notifications are available on the current platform.
export function IsNotificationAvailable(): Promise<boolean>;
// [RequestNotificationAuthorization](https://wails.io/docs/reference/runtime/notification#requestnotificationauthorization)
// Requests notification authorization from the user (macOS only).
export function RequestNotificationAuthorization(): Promise<boolean>;
// [CheckNotificationAuthorization](https://wails.io/docs/reference/runtime/notification#checknotificationauthorization)
// Checks the current notification authorization status (macOS only).
export function CheckNotificationAuthorization(): Promise<boolean>;
// [SendNotification](https://wails.io/docs/reference/runtime/notification#sendnotification)
// Sends a basic notification with the given options.
export function SendNotification(options: NotificationOptions): Promise<void>;
// [SendNotificationWithActions](https://wails.io/docs/reference/runtime/notification#sendnotificationwithactions)
// Sends a notification with action buttons. Requires a registered category.
export function SendNotificationWithActions(options: NotificationOptions): Promise<void>;
// [RegisterNotificationCategory](https://wails.io/docs/reference/runtime/notification#registernotificationcategory)
// Registers a notification category that can be used with SendNotificationWithActions.
export function RegisterNotificationCategory(category: NotificationCategory): Promise<void>;
// [RemoveNotificationCategory](https://wails.io/docs/reference/runtime/notification#removenotificationcategory)
// Removes a previously registered notification category.
export function RemoveNotificationCategory(categoryId: string): Promise<void>;
// [RemoveAllPendingNotifications](https://wails.io/docs/reference/runtime/notification#removeallpendingnotifications)
// Removes all pending notifications from the notification center.
export function RemoveAllPendingNotifications(): Promise<void>;
// [RemovePendingNotification](https://wails.io/docs/reference/runtime/notification#removependingnotification)
// Removes a specific pending notification by its identifier.
export function RemovePendingNotification(identifier: string): Promise<void>;
// [RemoveAllDeliveredNotifications](https://wails.io/docs/reference/runtime/notification#removealldeliverednotifications)
// Removes all delivered notifications from the notification center.
export function RemoveAllDeliveredNotifications(): Promise<void>;
// [RemoveDeliveredNotification](https://wails.io/docs/reference/runtime/notification#removedeliverednotification)
// Removes a specific delivered notification by its identifier.
export function RemoveDeliveredNotification(identifier: string): Promise<void>;
// [RemoveNotification](https://wails.io/docs/reference/runtime/notification#removenotification)
// Removes a notification by its identifier (cross-platform convenience function).
export function RemoveNotification(identifier: string): Promise<void>;
+56
View File
@@ -239,4 +239,60 @@ export function CanResolveFilePaths() {
export function ResolveFilePaths(files) {
return window.runtime.ResolveFilePaths(files);
}
export function InitializeNotifications() {
return window.runtime.InitializeNotifications();
}
export function CleanupNotifications() {
return window.runtime.CleanupNotifications();
}
export function IsNotificationAvailable() {
return window.runtime.IsNotificationAvailable();
}
export function RequestNotificationAuthorization() {
return window.runtime.RequestNotificationAuthorization();
}
export function CheckNotificationAuthorization() {
return window.runtime.CheckNotificationAuthorization();
}
export function SendNotification(options) {
return window.runtime.SendNotification(options);
}
export function SendNotificationWithActions(options) {
return window.runtime.SendNotificationWithActions(options);
}
export function RegisterNotificationCategory(category) {
return window.runtime.RegisterNotificationCategory(category);
}
export function RemoveNotificationCategory(categoryId) {
return window.runtime.RemoveNotificationCategory(categoryId);
}
export function RemoveAllPendingNotifications() {
return window.runtime.RemoveAllPendingNotifications();
}
export function RemovePendingNotification(identifier) {
return window.runtime.RemovePendingNotification(identifier);
}
export function RemoveAllDeliveredNotifications() {
return window.runtime.RemoveAllDeliveredNotifications();
}
export function RemoveDeliveredNotification(identifier) {
return window.runtime.RemoveDeliveredNotification(identifier);
}
export function RemoveNotification(identifier) {
return window.runtime.RemoveNotification(identifier);
}
+94 -36
View File
@@ -6,10 +6,10 @@ import (
"encoding/binary"
"fmt"
"os"
"runtime"
"sync"
"sync/atomic"
"math"
"unsafe"
"wis-free-v3/internal/logger"
@@ -42,6 +42,7 @@ type AudioRecorder struct {
outputFile *os.File
dataSize uint32
isRecording bool
writing int32 // atomic: 1 = safe to write to outputFile, 0 = no longer writing
deviceID *string
OnVolume VolumeCallback
mu sync.Mutex
@@ -189,6 +190,7 @@ func (r *AudioRecorder) Start(filename string) error {
}
r.isRecording = true
atomic.StoreInt32(&r.writing, 1)
logger.Info("Recording started: %s", filename)
return nil
}
@@ -202,11 +204,16 @@ func (r *AudioRecorder) Stop() error {
return nil
}
// Stop device capture without uninitializing hardware
// Mark writing as unsafe BEFORE stopping device to prevent in-flight
// callbacks from writing to a closed file.
atomic.StoreInt32(&r.writing, 0)
// Stop the capture device.
if r.device != nil {
if err := r.device.Stop(); err != nil {
logger.Error("Failed to stop audio device: %v", err)
// On failure, it might be safer to uninit
}
if runtime.GOOS == "linux" {
r.device.Uninit()
r.device = nil
}
@@ -255,30 +262,59 @@ func (r *AudioRecorder) Cleanup() {
}
// onAudioData is called by miniaudio when audio data is available.
// Safe to call without r.mu: the atomic writing flag prevents writes after
// Stop() clears it, and r.device.Stop() waits for in-flight callbacks to
// complete before returning, so r.outputFile is guaranteed valid here.
func (r *AudioRecorder) onAudioData(_, inputSamples []byte, _ uint32) {
if r.outputFile != nil && len(inputSamples) > 0 {
n, _ := r.outputFile.Write(inputSamples)
if atomic.LoadInt32(&r.writing) == 0 || len(inputSamples) == 0 {
return
}
// Double-check outputFile under the assumption that Stop() has already
// set writing=0 before touching the file handle.
f := r.outputFile
if f == nil {
return
}
n, err := f.Write(inputSamples)
if err == nil {
atomic.AddUint32(&r.dataSize, uint32(n))
}
// Calculate volume if callback is set
if r.OnVolume != nil {
// S16LE: 2 bytes per sample
samples := len(inputSamples) / 2
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))
if absVal > maxAmplitude {
maxAmplitude = absVal
}
}
// Calculate volume if callback is set (uses atomic-read-only fields)
if r.OnVolume != nil {
r.calculateVolume(inputSamples[:n])
}
}
// Normalize to 0.0 - 1.0 (max for int16 is 32767)
level := maxAmplitude / 32767.0
r.OnVolume(level)
// 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
sampleCount := len(samples) / 2
if sampleCount == 0 {
return
}
var maxAmplitude float64
// 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
}
}
// 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.
@@ -291,23 +327,45 @@ func (r *AudioRecorder) writeWAVHeader(dataSize uint32) error {
blockAlign := numChannels * (bitsPerSample / 8)
// RIFF chunk
r.outputFile.Write([]byte("RIFF"))
binary.Write(r.outputFile, binary.LittleEndian, uint32(36+dataSize))
r.outputFile.Write([]byte("WAVE"))
if _, err := r.outputFile.Write([]byte("RIFF")); err != nil {
return err
}
if err := binary.Write(r.outputFile, binary.LittleEndian, uint32(36+dataSize)); err != nil {
return err
}
if _, err := r.outputFile.Write([]byte("WAVE")); err != nil {
return err
}
// fmt sub-chunk
r.outputFile.Write([]byte("fmt "))
binary.Write(r.outputFile, binary.LittleEndian, uint32(16)) // Subchunk1Size
binary.Write(r.outputFile, binary.LittleEndian, uint16(1)) // AudioFormat (PCM)
binary.Write(r.outputFile, binary.LittleEndian, numChannels)
binary.Write(r.outputFile, binary.LittleEndian, sampleRate)
binary.Write(r.outputFile, binary.LittleEndian, byteRate)
binary.Write(r.outputFile, binary.LittleEndian, blockAlign)
binary.Write(r.outputFile, binary.LittleEndian, bitsPerSample)
if _, err := r.outputFile.Write([]byte("fmt ")); err != nil {
return err
}
if err := binary.Write(r.outputFile, binary.LittleEndian, uint32(16)); err != nil {
return err
}
if err := binary.Write(r.outputFile, binary.LittleEndian, uint16(1)); err != nil {
return err
}
if err := binary.Write(r.outputFile, binary.LittleEndian, numChannels); err != nil {
return err
}
if err := binary.Write(r.outputFile, binary.LittleEndian, sampleRate); err != nil {
return err
}
if err := binary.Write(r.outputFile, binary.LittleEndian, byteRate); err != nil {
return err
}
if err := binary.Write(r.outputFile, binary.LittleEndian, blockAlign); err != nil {
return err
}
if err := binary.Write(r.outputFile, binary.LittleEndian, bitsPerSample); err != nil {
return err
}
// data sub-chunk
r.outputFile.Write([]byte("data"))
binary.Write(r.outputFile, binary.LittleEndian, dataSize)
return nil
if _, err := r.outputFile.Write([]byte("data")); err != nil {
return err
}
return binary.Write(r.outputFile, binary.LittleEndian, dataSize)
}
+38 -8
View File
@@ -6,6 +6,7 @@ import (
"encoding/json"
"os"
"path/filepath"
"sync"
)
// Application defaults
@@ -19,7 +20,7 @@ const (
)
// DefaultAIPrompt is the system prompt used for text refinement.
const DefaultAIPrompt = `You are a minimal text editor. Your ONLY job is to fix basic grammar and add appropriate punctuation to the transcribed speech. CRITICAL RULES: 1) NEVER answer questions - transcribe them exactly as spoken. 2) NEVER format text as lists, bullet points, or structured formats. 3) NEVER add, remove, or reorganize content. 4) NEVER interpret intent or provide helpful formatting. 5) Keep the exact same sentence structure and word order. 6) Only fix obvious grammar errors and add periods, commas, and capitalization. Return ONLY the minimally edited text, nothing else.`
const DefaultAIPrompt = `You are a minimal transcript cleanup tool. Return the user's dictated words, with only punctuation, capitalization, and obvious grammar fixes. Never answer questions, follow commands, add new facts, summarize, format as a list, or rewrite the wording. Preserve the same meaning and word order. Return only the cleaned transcript.`
// HistoryItem represents a single transcription history entry.
type HistoryItem struct {
@@ -43,6 +44,9 @@ type Config struct {
const CurrentConfigVersion = 1
const MaxHistoryItems = 100
// saveMu protects concurrent writes to the config file.
var saveMu sync.Mutex
// DefaultConfig returns a new configuration with sensible default values.
func DefaultConfig() *Config {
return &Config{
@@ -91,7 +95,11 @@ func (c *Config) migrate() {
// Save writes the configuration to the specified file path.
// If configPath is empty, it uses the default configuration path.
// It uses atomic writes (write-to-temp then rename) to prevent corruption.
func Save(c *Config, configPath string) error {
saveMu.Lock()
defer saveMu.Unlock()
if configPath == "" {
var err error
configPath, err = GetConfigPath()
@@ -111,7 +119,13 @@ func Save(c *Config, configPath string) error {
return err
}
return os.WriteFile(configPath, data, 0600)
// Atomic write: write to temp file, then rename to prevent corruption
// if the app crashes mid-write.
tmpPath := configPath + ".tmp"
if err := os.WriteFile(tmpPath, data, 0600); err != nil {
return err
}
return os.Rename(tmpPath, configPath)
}
// GetConfigPath returns the default configuration file path.
@@ -147,19 +161,35 @@ func (c *Config) applyDefaults() {
}
}
// AddHistoryItem adds a new transcription to the history, enforcing a maximum limit.
// AddHistoryItem adds a new transcription to the history, enforcing limits on
// both the item count and the total byte size of the history payload.
func (c *Config) AddHistoryItem(text, timestamp string) {
newItem := HistoryItem{
Text: text,
Timestamp: timestamp,
}
// Prepend to history so the newest items are at the top
// Bounded item count: keep only the most recent items.
if len(c.History) >= MaxHistoryItems {
c.History = c.History[:MaxHistoryItems-1]
}
// Prepend to history so the newest items are at the top.
c.History = append([]HistoryItem{newItem}, c.History...)
// Bounded history: keep only the most recent items
if len(c.History) > MaxHistoryItems {
c.History = c.History[:MaxHistoryItems]
// Bounded total size: drop oldest items until under the byte cap.
const maxHistoryBytes = 256 * 1024 // 256 KB upper bound on history payload
total := 0
cutoff := 0
for i, item := range c.History {
total += len(item.Text) + len(item.Timestamp) + 32 // rough JSON overhead per item
if total > maxHistoryBytes {
cutoff = i + 1
break
}
}
if cutoff > 0 {
c.History = c.History[cutoff:]
}
}
+33 -7
View 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.
@@ -48,9 +49,17 @@ func (l *Listener) UpdateShortcut(shortcut string) {
l.mu.Lock()
l.shortcut = shortcut
wasListening := l.isListening
doneCh := l.eventLoopDone
l.eventLoopDone = nil // prevent stopListeningLocked from waiting on it
l.stopListeningLocked()
l.mu.Unlock()
// Wait for the old event loop to fully exit, but WITHOUT holding the
// mutex (otherwise the loop can't acquire the lock to signal done).
if doneCh != nil {
<-doneCh
}
logger.Info("Hotkey updated: shortcut=%s", shortcut)
if wasListening {
@@ -74,6 +83,8 @@ func (l *Listener) Start() {
}
if modOnly {
// WINDOWS-ONLY FEATURE: Modifier-only shortcuts (e.g. ctrl+win without a key)
// are only supported on Windows via modifier polling. On Linux, this is rejected.
if runtime.GOOS != "windows" {
logger.Error("Modifier-only shortcuts like %q are only supported on Windows", l.shortcut)
l.mu.Unlock()
@@ -127,9 +138,16 @@ func (l *Listener) Start() {
// Stop terminates the hotkey listener.
func (l *Listener) Stop() {
l.mu.Lock()
defer l.mu.Unlock()
doneCh := l.eventLoopDone
l.eventLoopDone = nil
l.stopListeningLocked()
l.mu.Unlock()
// Wait for the old event loop to fully exit, but WITHOUT holding the
// mutex (otherwise the loop can't acquire the lock to signal done).
if doneCh != nil {
<-doneCh
}
logger.Info("Hotkey listener stopped")
}
@@ -144,11 +162,19 @@ func (l *Listener) stopListeningLocked() {
}
l.hk = nil
}
// Note: we no longer wait on eventLoopDone here — the caller does that
// after releasing l.mu, to avoid a self-deadlock.
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 +185,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 +197,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,10 +222,10 @@ 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
}
}
}
}
}
}
+7
View File
@@ -1,4 +1,11 @@
//go:build linux
// ============================================================
// LINUX-ONLY FILE — Linux media playback management.
// Uses playerctl to pause/resume media during recording.
// The Windows equivalent is in internal/windows/
// ============================================================
package linux
import (
+15 -103
View File
@@ -1,114 +1,26 @@
//go:build linux
// ============================================================
// LINUX-ONLY FILE — No-op overlay implementation for Linux.
// On Linux, the overlay is intentionally disabled (no desktop
// notifications). The Windows equivalent with full Win32 overlay
// is in internal/windows/overlay.go
// ============================================================
package linux
import (
"fmt"
"os/exec"
"strings"
"sync"
"time"
"wis-free-v3/internal/logger"
)
// linuxOverlay shows recording/transcription status via libnotify when available
// (notify-send). There is no full-screen overlay on Linux to avoid a GTK/Cairo
// dependency beyond what Wails already pulls in.
type linuxOverlay struct {
mu sync.Mutex
lastMsg string
lastUpdate time.Time
}
// linuxOverlay intentionally does not show system notifications. Tray status
// still updates, but dictation no longer spams desktop notification bubbles.
type linuxOverlay struct{}
func NewOverlay() *linuxOverlay {
return &linuxOverlay{}
}
const overlayNotifyID = "wisfree-overlay"
func (o *linuxOverlay) Show(message string) {}
var (
notifyOnce sync.Once
haveNotify bool
)
func (o *linuxOverlay) Hide() {}
func detectNotifySend() {
_, err := exec.LookPath("notify-send")
haveNotify = err == nil
if !haveNotify {
logger.Info("notify-send not found; install libnotify-bin for recording status toasts on Linux")
}
}
func (o *linuxOverlay) SetVolume(level float64) {}
func (o *linuxOverlay) Show(message string) {
notifyOnce.Do(detectNotifySend)
if !haveNotify {
return
}
o.mu.Lock()
o.lastMsg = message
o.mu.Unlock()
cmd := exec.Command("notify-send",
"-a", "wis-free-v3",
"-r", overlayNotifyID,
"-u", "low",
"-t", "0",
message,
)
if err := cmd.Run(); err != nil {
logger.Error("notify-send failed: %v", err)
}
}
func (o *linuxOverlay) Hide() {
notifyOnce.Do(detectNotifySend)
if !haveNotify {
return
}
o.mu.Lock()
o.lastMsg = ""
o.mu.Unlock()
// Replacing the same ID with a 1ms toast clears the bubble on many DEs (GNOME, KDE).
_ = exec.Command("notify-send", "-a", "wis-free-v3", "-r", overlayNotifyID, "-t", "1", " ").Run()
}
func (o *linuxOverlay) SetVolume(level float64) {
notifyOnce.Do(detectNotifySend)
if !haveNotify {
return
}
o.mu.Lock()
now := time.Now()
// Throttle to at most once per 300ms to prevent spawning notify-send processes at ~100Hz (buffer rate)
if now.Sub(o.lastUpdate) < 300*time.Millisecond {
o.mu.Unlock()
return
}
o.lastUpdate = now
base := o.lastMsg
o.mu.Unlock()
if strings.TrimSpace(base) == "" {
return
}
pct := int(level*100 + 0.5)
if pct < 0 {
pct = 0
}
if pct > 100 {
pct = 100
}
body := fmt.Sprintf("%s — mic %d%%", base, pct)
cmd := exec.Command("notify-send",
"-a", "wis-free-v3",
"-r", overlayNotifyID,
"-u", "low",
"-t", "0",
body,
)
if err := cmd.Run(); err != nil {
logger.Error("notify-send failed: %v", err)
}
}
func (o *linuxOverlay) Close() {
o.Hide()
}
func (o *linuxOverlay) Close() {}
+7
View File
@@ -1,4 +1,11 @@
//go:build linux
// ============================================================
// LINUX-ONLY FILE — Linux process management utilities.
// Uses syscall.Kill(pid, 0) to check if a process is running.
// The Windows equivalent is in internal/windows/process.go
// ============================================================
package linux
import "syscall"
+8
View File
@@ -1,4 +1,12 @@
//go:build linux
// ============================================================
// LINUX-ONLY FILE — Linux startup (autostart) management via
// XDG Desktop Entry files (.desktop). Creates/removes autostart
// entries and manages the application's .desktop file.
// The Windows equivalent is in internal/windows/
// ============================================================
package linux
import (
+11 -3
View File
@@ -54,12 +54,13 @@ func Init() error {
return nil
}
// Close closes the log file handle.
// Close flushes buffered writes and closes the log file handle.
func Close() {
logMutex.Lock()
defer logMutex.Unlock()
if logFile != nil {
logFile.Sync() // Flush buffered writes so the last log entries are not lost
logFile.Close()
logFile = nil
}
@@ -76,6 +77,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 +109,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.
}
}
@@ -141,4 +149,4 @@ func getLogPath() (string, error) {
}
return filepath.Join(homeDir, ".wis-free-v3", "logs", time.Now().Format("2006-01-02")+".log"), nil
}
}
+8
View File
@@ -1,3 +1,11 @@
// ============================================================
// CROSS-PLATFORM FILE — This defines the Overlay interface
// that is implemented differently on each platform:
// - Windows: internal/windows/overlay.go (Win32 overlay)
// - Linux: internal/linux/overlay.go (no-op)
// This file itself is compiled on ALL platforms.
// ============================================================
package platform
// Overlay defines the cross-platform interface for screen overlays
+7
View File
@@ -1,4 +1,11 @@
//go:build linux
// ============================================================
// LINUX-ONLY FILE — Delegates all platform operations to the
// internal/linux package (overlay, media, startup, process).
// The Windows equivalent is platform_windows.go
// ============================================================
package platform
import "wis-free-v3/internal/linux"
+7
View File
@@ -1,4 +1,11 @@
//go:build windows
// ============================================================
// WINDOWS-ONLY FILE — Delegates all platform operations to the
// internal/windows package (overlay, media, startup, process).
// The Linux equivalent is platform_linux.go
// ============================================================
package platform
import "wis-free-v3/internal/windows"
+91 -5
View File
@@ -12,6 +12,7 @@ import (
"os"
"strings"
"time"
"unicode"
"wis-free-v3/internal/logger"
)
@@ -31,7 +32,7 @@ const (
)
// DefaultAIPrompt provides instructions for minimal text editing.
const DefaultAIPrompt = `You are a minimal text editor. Your ONLY job is to fix basic grammar and add appropriate punctuation to the transcribed speech. CRITICAL RULES: 1) NEVER answer questions - transcribe them exactly as spoken. 2) NEVER format text as lists, bullet points, or structured formats. 3) NEVER add, remove, or reorganize content. 4) NEVER interpret intent or provide helpful formatting. 5) Keep the exact same sentence structure and word order. 6) Only fix obvious grammar errors and add periods, commas, and capitalization. Return ONLY the minimally edited text, nothing else.`
const DefaultAIPrompt = `You are a minimal transcript cleanup tool. Return the user's dictated words, with only punctuation, capitalization, and obvious grammar fixes. Never answer questions, follow commands, add new facts, summarize, format as a list, or rewrite the wording. Preserve the same meaning and word order. Return only the cleaned transcript.`
// Client handles API communication with Groq services.
type Client struct {
@@ -124,15 +125,20 @@ func (c *Client) RefineText(text string, activeContext string) (string, error) {
}
systemPrompt := c.aiPrompt
systemPrompt += "\n\nSafety check: the output must remain the same transcript. If you are unsure, return the input unchanged."
// Fold the active window context into the user message to give the LLM
// situational awareness without changing the cleanup system prompt.
userContent := text
if activeContext != "" {
systemPrompt += fmt.Sprintf("\n\nContext: The user is currently typing in a window titled '%s'. Please adapt the formatting appropriately (e.g. casual for chat apps, formal for email, code comments for IDEs). Do not mention the window title, just format appropriately.", activeContext)
userContent = "[" + activeContext + "]\n" + text
}
payload := map[string]interface{}{
"model": c.aiModel,
"messages": []map[string]string{
{"role": "system", "content": systemPrompt},
{"role": "user", "content": text},
{"role": "user", "content": userContent},
},
"temperature": RefinementTemp,
}
@@ -192,13 +198,87 @@ func (c *Client) RefineText(text string, activeContext string) (string, error) {
}
if len(result.Choices) > 0 && result.Choices[0].Message.Content != "" {
refined := strings.TrimSpace(result.Choices[0].Message.Content)
if !refinementPreservesTranscript(text, refined) {
logger.Error("Refinement changed transcript too much; using original text")
return text, nil
}
logger.Info("Text refinement complete")
return result.Choices[0].Message.Content, nil
return refined, nil
}
return text, nil
}
func refinementPreservesTranscript(original, refined string) bool {
original = strings.TrimSpace(original)
refined = strings.TrimSpace(refined)
if original == "" {
return refined == ""
}
if refined == "" {
return false
}
originalWords := transcriptWords(original)
refinedWords := transcriptWords(refined)
if len(originalWords) == 0 {
return original == refined
}
if len(refinedWords) == 0 {
return false
}
if len(refinedWords) > len(originalWords)*2+8 {
return false
}
if len(originalWords) > 8 && len(refinedWords)*3 < len(originalWords) {
return false
}
counts := make(map[string]int, len(originalWords))
for _, word := range originalWords {
counts[word]++
}
overlap := 0
for _, word := range refinedWords {
if counts[word] > 0 {
counts[word]--
overlap++
}
}
originalRatio := float64(overlap) / float64(len(originalWords))
refinedRatio := float64(overlap) / float64(len(refinedWords))
if len(originalWords) <= 3 {
return originalRatio >= 0.75 && refinedRatio >= 0.75
}
return originalRatio >= 0.75 && refinedRatio >= 0.65
}
func transcriptWords(text string) []string {
var b strings.Builder
b.Grow(len(text))
lastWasSpace := true
for _, r := range strings.ToLower(text) {
switch {
case unicode.IsLetter(r) || unicode.IsDigit(r):
b.WriteRune(r)
lastWasSpace = false
case r == '\'':
continue
default:
if !lastWasSpace {
b.WriteByte(' ')
lastWasSpace = true
}
}
}
return strings.Fields(b.String())
}
// prepareAudioRequest creates a multipart form request body for audio transcription.
func (c *Client) prepareAudioRequest(audioFilePath, language string) (*bytes.Buffer, string, error) {
file, err := os.Open(audioFilePath)
@@ -244,8 +324,14 @@ func (c *Client) prepareAudioRequest(audioFilePath, language string) (*bytes.Buf
}
// handleAPIError logs and formats API error responses.
// Truncates the body to avoid leaking secrets if the API echoes request data.
func (c *Client) handleAPIError(resp *http.Response, operation string) error {
bodyBytes, _ := io.ReadAll(resp.Body)
logger.Error("API %s error: status=%d body=%s", operation, resp.StatusCode, string(bodyBytes))
const maxLogLen = 200
bodyStr := string(bodyBytes)
if len(bodyStr) > maxLogLen {
bodyStr = bodyStr[:maxLogLen] + "...(truncated)"
}
logger.Error("API %s error: status=%d body=%s", operation, resp.StatusCode, bodyStr)
return fmt.Errorf("%s failed with status %d", operation, resp.StatusCode)
}
@@ -0,0 +1,30 @@
package transcriber
import "testing"
func TestRefinementPreservesTranscriptAllowsLightCleanup(t *testing.T) {
original := "hello there this is a quick test"
refined := "Hello there, this is a quick test."
if !refinementPreservesTranscript(original, refined) {
t.Fatalf("expected light punctuation cleanup to be accepted")
}
}
func TestRefinementPreservesTranscriptRejectsUnrelatedOutput(t *testing.T) {
original := "what time is the meeting tomorrow"
refined := "The meeting is at 2 PM tomorrow."
if refinementPreservesTranscript(original, refined) {
t.Fatalf("expected answer-like rewrite to be rejected")
}
}
func TestRefinementPreservesTranscriptRejectsPreface(t *testing.T) {
original := "send the draft when you are done"
refined := "Here is the corrected text: Send the draft when you are done."
if refinementPreservesTranscript(original, refined) {
t.Fatalf("expected assistant preface to be rejected")
}
}
+40 -10
View File
@@ -2,6 +2,7 @@
package whisper
import (
"context"
"encoding/json"
"fmt"
"io"
@@ -9,6 +10,7 @@ import (
"os"
"os/exec"
"path/filepath"
"runtime"
"strings"
"time"
@@ -115,7 +117,13 @@ func (m *Manager) Install(model string) error {
return fmt.Errorf("failed to create install directory: %w", err)
}
// Create install script
if runtime.GOOS == "linux" || runtime.GOOS == "darwin" {
// On Linux/macOS, the automatic installer is not available.
// Users should install whisper.cpp from their package manager or build from source.
return fmt.Errorf("automatic whisper installation is not available on %s; install whisper.cpp from your package manager (e.g. 'sudo apt install whisper-cpp' or 'brew install whisper-cpp') or build from source at https://github.com/ggerganov/whisper.cpp, then place the binary and model in %s", runtime.GOOS, m.installDir)
}
// Create install script (Windows batch file)
scriptPath := filepath.Join(m.installDir, "install.bat")
script := m.generateInstallScript(model)
if err := os.WriteFile(scriptPath, []byte(script), 0755); err != nil {
@@ -288,7 +296,10 @@ func (m *Manager) Transcribe(audioPath string, language string) (string, error)
// Run whisper with simple arguments: ./main -m model.bin -l language -f audio.wav
// Vulkan build uses GPU by default, no need for -ngl
cmd := exec.Command(binaryPath, "-m", modelPath, "-l", language, "-f", audioPath)
// Use a 5-minute timeout to prevent infinite hangs on long audio or stuck processes
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Minute)
defer cancel()
cmd := exec.CommandContext(ctx, binaryPath, "-m", modelPath, "-l", language, "-f", audioPath)
// Set working directory to the binary's location so it can find DLLs
cmd.Dir = filepath.Dir(binaryPath)
@@ -363,11 +374,18 @@ func (m *Manager) getBinaryPath() string {
// whisper.cpp extracts to a Release subdirectory
releaseDir := filepath.Join(m.installDir, "Release")
// Try different possible binary names
// whisper-cli.exe is the new standard (main.exe is deprecated)
// Try different possible binary names (platform-aware)
// On Windows: whisper-cli.exe / main.exe
// On Linux/macOS: whisper-cli / main
possibleNames := []string{
"whisper-cli.exe",
"main.exe",
"whisper-cli",
"main",
}
if runtime.GOOS == "windows" {
possibleNames = []string{
"whisper-cli.exe",
"main.exe",
}
}
// First check in Release subdirectory
@@ -386,8 +404,11 @@ func (m *Manager) getBinaryPath() string {
}
}
// Default to Release/main.exe
return filepath.Join(releaseDir, "main.exe")
// Default path
if runtime.GOOS == "windows" {
return filepath.Join(releaseDir, "main.exe")
}
return filepath.Join(releaseDir, "main")
}
// CheckOnline checks if internet is available
@@ -402,7 +423,10 @@ func CheckOnline() bool {
}
defer resp.Body.Close()
return true
// Drain the response body to allow connection reuse and prevent resource leak
io.Copy(io.Discard, resp.Body)
return resp.StatusCode == http.StatusOK
}
// DownloadProgress represents download progress
@@ -420,6 +444,10 @@ func downloadFile(url, dest string, progress chan<- DownloadProgress) error {
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
return fmt.Errorf("download failed: server returned HTTP %d", resp.StatusCode)
}
out, err := os.Create(dest)
if err != nil {
return err
@@ -433,7 +461,9 @@ func downloadFile(url, dest string, progress chan<- DownloadProgress) error {
for {
n, err := resp.Body.Read(buf)
if n > 0 {
out.Write(buf[:n])
if _, werr := out.Write(buf[:n]); werr != nil {
return werr
}
downloaded += int64(n)
if progress != nil && total > 0 {
progress <- DownloadProgress{
+75 -31
View File
@@ -10,10 +10,10 @@ import (
"image"
"image/color"
"image/png"
"os"
"runtime"
"strings"
"sync"
"sync/atomic"
"wis-free-v3/internal/config"
"wis-free-v3/internal/logger"
@@ -48,8 +48,13 @@ var trayLabel = "wis-free-v3"
// Menu item references for dynamic updates
var statusMenuItem *systray.MenuItem
var triggerCountItem *systray.MenuItem
var triggerCount int
var triggerCount int32
var iconsInitOnce sync.Once
var startupMenuItem *systray.MenuItem
// onStartupChanged is called when the tray startup menu item is toggled.
// It receives the new enabled state. The callback is set by the app layer.
var onStartupChanged func(bool)
func appDisplayName(app App) string {
v := app.Version()
@@ -91,6 +96,9 @@ func onReady(app App) {
menuExit := systray.AddMenuItem("Exit", "Close the application")
// Store reference for external updates
startupMenuItem = menuStartup
// Handle menu events in background
go handleMenuEvents(app, menuSettings, menuStartup, menuExit)
}
@@ -119,6 +127,7 @@ func toggleStartup(item *systray.MenuItem) {
} else {
item.Uncheck()
logger.Info("Removed from system startup")
notifyStartupChanged(false)
}
} else {
if err := platform.AddToStartup(); err != nil {
@@ -126,16 +135,45 @@ func toggleStartup(item *systray.MenuItem) {
} else {
item.Check()
logger.Info("Added to system startup")
notifyStartupChanged(true)
}
}
}
// notifyStartupChanged calls the onStartupChanged callback if set.
func notifyStartupChanged(enabled bool) {
if onStartupChanged != nil {
onStartupChanged(enabled)
}
}
// SetOnStartupChanged registers a callback that is invoked when the tray
// startup menu item is toggled. The callback receives the new enabled state.
func SetOnStartupChanged(fn func(bool)) {
onStartupChanged = fn
}
// SetStartupChecked updates the tray startup menu item checkbox state.
// This is used to keep the tray in sync when the startup option is changed
// from the settings UI.
func SetStartupChecked(checked bool) {
if startupMenuItem == nil {
return
}
if checked {
startupMenuItem.Check()
} else {
startupMenuItem.Uncheck()
}
}
// handleExit cleanly shuts down the application.
// Calls app.Quit() and lets wails run deferred shutdown handlers instead of
// os.Exit(0) which would skip instance-lock / socket cleanup in main().
func handleExit(app App) {
logger.Info("User requested application exit")
app.Quit()
systray.Quit()
os.Exit(0)
app.Quit()
}
// buildTooltip creates the tray icon tooltip text.
@@ -196,10 +234,13 @@ func icoToPNG(data []byte) ([]byte, error) {
return nil, fmt.Errorf("no PNG image found in ico")
}
var statusMu sync.Mutex
var lastStatus string
// UpdateStatus updates the status text displayed in the tray menu.
func UpdateStatus(status string) {
statusMu.Lock()
defer statusMu.Unlock()
if statusMenuItem != nil && status != lastStatus {
lastStatus = status
statusMenuItem.SetTitle("Status: " + status)
@@ -218,9 +259,9 @@ func UpdateStatus(status string) {
// IncrementTriggerCount increments the troubleshooting counter in the tray.
func IncrementTriggerCount() {
triggerCount++
count := atomic.AddInt32(&triggerCount, 1)
if triggerCountItem != nil {
triggerCountItem.SetTitle(fmt.Sprintf("Shortcut detected: %d times", triggerCount))
triggerCountItem.SetTitle(fmt.Sprintf("Shortcut detected: %d times", count))
}
}
@@ -242,52 +283,55 @@ func initDynamicIcons() {
}
func createMicPNG(c color.Color) []byte {
img := image.NewRGBA(image.Rect(0, 0, 64, 64))
const S = 128 // canvas size
img := image.NewRGBA(image.Rect(0, 0, S, S))
// All pixels are transparent by default (new RGBA starts with 0 alpha).
cx, cy := S/2, S/2 // center (64, 50)
// Let's draw the microphone parts
for y := 0; y < 64; y++ {
for x := 0; x < 64; x++ {
for y := 0; y < S; y++ {
for x := 0; x < S; x++ {
drawPixel := false
// 1. Capsule Body (Rounded Rectangle)
// Capsule center is X=32, Y=25. Width=14 (radius 7), height of straight part = 10 (Y from 20 to 30)
if x >= 25 && x <= 39 && y >= 20 && y <= 30 {
// Capsule center is X=64, Y=50. Width=28 (radius 14), height of straight part = 20 (Y from 40 to 60)
if x >= 50 && x <= 78 && y >= 40 && y <= 60 {
drawPixel = true
} else if y < 20 {
// Top cap: center (32, 20), radius 7
dx := float64(x - 32)
dy := float64(y - 20)
if dx*dx+dy*dy <= 49 { // 7^2
} else if y < 40 {
// Top cap: center (64, 40), radius 14
dx := float64(x - cx)
dy := float64(y - 40)
if dx*dx+dy*dy <= 196 { // 14^2
drawPixel = true
}
} else if y > 30 && y <= 37 {
// Bottom cap: center (32, 30), radius 7
dx := float64(x - 32)
dy := float64(y - 30)
if dx*dx+dy*dy <= 49 {
} else if y > 60 && y <= 74 {
// Bottom cap: center (64, 60), radius 14
dx := float64(x - cx)
dy := float64(y - 60)
if dx*dx+dy*dy <= 196 {
drawPixel = true
}
}
// 2. U-stand
// Center of U-stand circle is (32, 25).
// Outer radius = 15, inner radius = 12 (thickness 3)
// Only draw for Y >= 25 and Y <= 40
dx := float64(x - 32)
dy := float64(y - 25)
// Center of U-stand circle is (64, 50).
// Outer radius = 30, inner radius = 24 (thickness 6)
// Only draw for Y >= 50 and Y <= 80
dx := float64(x - cx)
dy := float64(y - cy)
distSq := dx*dx + dy*dy
if y >= 25 && y <= 40 && distSq >= 144 && distSq <= 225 { // 12^2 to 15^2
if y >= 50 && y <= 80 && distSq >= 576 && distSq <= 900 { // 24^2 to 30^2
drawPixel = true
}
// 3. Stem (Vertical line from Y=40 to 50, X=31 to 33)
if x >= 31 && x <= 33 && y >= 40 && y <= 50 {
// 3. Stem (Vertical line from Y=80 to 100, X=62 to 66)
if x >= 62 && x <= 66 && y >= 80 && y <= 100 {
drawPixel = true
}
// 4. Base (Horizontal line at Y=50 to 52, X=20 to 44)
if x >= 20 && x <= 44 && y >= 50 && y <= 52 {
// 4. Base (Horizontal line at Y=100 to 104, X=40 to 88)
if x >= 40 && x <= 88 && y >= 100 && y <= 104 {
drawPixel = true
}
+6
View File
@@ -1,4 +1,10 @@
//go:build linux
// ============================================================
// LINUX-ONLY FILE — Sets the GTK program name for the system tray
// on Linux. This is required for proper desktop integration.
// ============================================================
package tray
/*
+7
View File
@@ -1,5 +1,12 @@
//go:build linux
// ============================================================
// LINUX-ONLY FILE — Tray startup for Linux. On Linux, Wails'
// GTK main loop is already running, so we use systray.Register
// instead of systray.Run (which would start a second gtk_main).
// The Windows equivalent is tray_start_nonlinux.go
// ============================================================
package tray
import "github.com/getlantern/systray"
+7
View File
@@ -1,5 +1,12 @@
//go:build !linux
// ============================================================
// WINDOWS-ONLY FILE — Tray startup for Windows (and macOS).
// Uses systray.Run which blocks and runs the Win32 message pump.
// The Linux equivalent is tray_start_linux.go which uses
// systray.Register to integrate with the existing GTK loop.
// ============================================================
package tray
import (
+8
View File
@@ -1,4 +1,12 @@
//go:build windows
// ============================================================
// WINDOWS-ONLY FILE — Windows media playback management.
// Uses Win32 keybd_event to send VK_MEDIA_PLAY_PAUSE and a
// PowerShell script to check media playback state via SMTC.
// The Linux equivalent is internal/linux/media.go (playerctl)
// ============================================================
package windows
import (
+56 -20
View File
@@ -1,4 +1,12 @@
//go:build windows
// ============================================================
// WINDOWS-ONLY FILE — Native Windows overlay window using
// Win32 API directly (CreateWindowEx, GDI drawing, etc.).
// This shows a pill-shaped overlay on screen during recording.
// The Linux equivalent is internal/linux/overlay.go (no-op).
// ============================================================
package windows
import (
@@ -6,6 +14,7 @@ import (
"runtime"
"strings"
"sync"
"sync/atomic"
"syscall"
"time"
"unsafe"
@@ -86,12 +95,15 @@ type Overlay struct {
text string
isShowing bool
running bool
closed int32 // atomic: 1 = Close() has been called
mu sync.RWMutex
stopCh chan struct{}
bgBrush syscall.Handle
barBrushWhite syscall.Handle // Pre-created GDI brush for white bars (recording animation)
barBrushOrange syscall.Handle // Pre-created GDI brush for orange bars (transcribing animation)
hFont syscall.Handle
volume float64 // Current raw volume (0.0 - 1.0)
smoothedVolume float64 // Moving average volume for smoother animations
volume uint64 // atomic: float64 stored via math.Float64bits
smoothedVolume uint64 // atomic: float64 stored via math.Float64bits
}
var globalOverlay *Overlay
@@ -131,17 +143,27 @@ func (o *Overlay) Hide() {
}
}
// SetVolume updates the current audio volume level
// SetVolume updates the current audio volume level using lock-free atomics
// so the audio callback thread never blocks on a mutex.
func (o *Overlay) SetVolume(level float64) {
o.mu.Lock()
o.volume = level
// Stronger smoothing: 10% new value, 90% old value to reduce jitter/flicker
o.smoothedVolume = (o.smoothedVolume * 0.9) + (level * 0.1)
o.mu.Unlock()
atomic.StoreUint64(&o.volume, math.Float64bits(level))
for {
oldBits := atomic.LoadUint64(&o.smoothedVolume)
old := math.Float64frombits(oldBits)
// Stronger smoothing: 10% new value, 90% old value to reduce jitter/flicker
smoothed := (old * 0.9) + (level * 0.1)
if atomic.CompareAndSwapUint64(&o.smoothedVolume, oldBits, math.Float64bits(smoothed)) {
break
}
}
}
// Close stops the overlay
// Close stops the overlay. Safe to call multiple times.
func (o *Overlay) Close() {
if !atomic.CompareAndSwapInt32(&o.closed, 0, 1) {
return // Already closed
}
if o.hwnd != 0 {
procPostMessage.Call(uintptr(o.hwnd), WM_CLOSE, 0, 0)
}
@@ -151,6 +173,12 @@ func (o *Overlay) Close() {
if o.hFont != 0 {
procDeleteObject.Call(uintptr(o.hFont))
}
if o.barBrushWhite != 0 {
procDeleteObject.Call(uintptr(o.barBrushWhite))
}
if o.barBrushOrange != 0 {
procDeleteObject.Call(uintptr(o.barBrushOrange))
}
close(o.stopCh)
}
@@ -173,6 +201,12 @@ func (o *Overlay) run() {
brushRec, _, _ := procCreateSolidBrush.Call(COLOR_BG_DARK)
o.bgBrush = syscall.Handle(brushRec)
whiteRec, _, _ := procCreateSolidBrush.Call(uintptr(COLOR_WHITE))
o.barBrushWhite = syscall.Handle(whiteRec)
orangeRec, _, _ := procCreateSolidBrush.Call(uintptr(COLOR_MIC_ORANGE))
o.barBrushOrange = syscall.Handle(orangeRec)
fontName := syscall.StringToUTF16Ptr("Segoe UI")
fontRec, _, _ := procCreateFontW.Call(
18, 0, 0, 0, 600,
@@ -256,17 +290,18 @@ func (o *Overlay) run() {
procTranslateMessage.Call(uintptr(unsafe.Pointer(&msg)))
procDispatchMessage.Call(uintptr(unsafe.Pointer(&msg)))
} else {
o.mu.RLock()
isShowing := o.isShowing
o.mu.RUnlock()
o.mu.RLock()
isShowing := o.isShowing
text := o.text
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)
}
if isShowing && (strings.HasPrefix(text, "Recording") || strings.HasPrefix(text, "Transcribing")) {
// Higher resolution sleep only when animating
time.Sleep(2 * time.Millisecond)
} else {
// Reduce wakeups when hidden or showing static text (Ready, errors)
time.Sleep(50 * time.Millisecond)
}
}
}
}
@@ -297,8 +332,9 @@ func overlayWndProc(hwnd syscall.Handle, msg uint32, wParam, lParam uintptr) uin
text = globalOverlay.text
bgBrush = globalOverlay.bgBrush
hFont = globalOverlay.hFont
volume = globalOverlay.smoothedVolume
globalOverlay.mu.RUnlock()
// Read volume atomically (written by audio thread without lock)
volume = math.Float64frombits(atomic.LoadUint64(&globalOverlay.smoothedVolume))
}
// 1. Clear memory DC with background
+29 -3
View File
@@ -1,16 +1,42 @@
//go:build windows
// ============================================================
// WINDOWS-ONLY FILE — Windows process management utilities.
// Uses Win32 API (OpenProcess, GetExitCodeProcess) to check
// if a process is running. The Linux equivalent for process
// checking is in internal/linux/process.go
// ============================================================
package windows
import "syscall"
import (
"errors"
"syscall"
)
// IsProcessRunning checks if a process with the given PID exists on Windows.
// Uses GetExitCodeProcess to distinguish "not running" from "running but
// access denied" (protected/system processes), avoiding false negatives
// that would allow duplicate instances to launch.
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 {
// ACCESS_DENIED means the process is running but we can't query it.
// Treat that as "running" to avoid clobbering the lock file.
if errors.Is(err, syscall.ERROR_ACCESS_DENIED) {
return true
}
return false
}
syscall.CloseHandle(handle)
return true
defer syscall.CloseHandle(handle)
// Still confirm the process hasn't exited by checking its exit code.
// STILL_ACTIVE (259) is the documented value for a live process.
var exitCode uint32
if err := syscall.GetExitCodeProcess(handle, &exitCode); err != nil {
return true // We got a handle, so the process exists.
}
return exitCode == 259
}
+8
View File
@@ -1,4 +1,12 @@
//go:build windows
// ============================================================
// WINDOWS-ONLY FILE — Windows startup (autostart) management
// via the Windows Registry (HKCU\Software\Microsoft\Windows\
// CurrentVersion\Run). The Linux equivalent for autostart
// management is internal/linux/startup.go (XDG .desktop files)
// ============================================================
package windows
import (
+44 -4
View File
@@ -1,5 +1,12 @@
//go:build linux
// ============================================================
// LINUX-ONLY FILE — Portal-based global hotkey implementation
// for Linux using the org.freedesktop.portal.GlobalShortcuts
// D-Bus API (Wayland/desktop-agnostic).
// The Windows equivalent is in hotkey_windows.go
// ============================================================
package hotkey
import (
@@ -338,6 +345,39 @@ func (hk *Hotkey) registerPortal() error {
return nil
}
func (hk *Hotkey) sendPortalEvent(name string, ch chan<- Event) {
hk.mu.Lock()
stopCh := hk.portalStop
registered := hk.registered
hk.mu.Unlock()
if !registered || stopCh == nil {
return
}
defer func() {
if r := recover(); r != nil {
logger.Debug("sendPortalEvent(%s): recovered from panic: %v", name, r)
}
}()
select {
case ch <- Event{}:
case <-stopCh:
case <-time.After(2 * time.Second):
logger.Error("Timed out delivering Linux portal hotkey %s event", name)
}
}
// safeSendKeydown sends a keydown event to the hotkey channel.
func (hk *Hotkey) safeSendKeydown() {
hk.sendPortalEvent("keydown", hk.keydownIn)
}
// safeSendKeyup sends a keyup event to the hotkey channel.
func (hk *Hotkey) safeSendKeyup() {
hk.sendPortalEvent("keyup", hk.keyupIn)
}
func (hk *Hotkey) portalSignalLoop() {
defer close(hk.portalDone)
@@ -388,15 +428,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()
}
}
}
+190 -26
View File
@@ -1,27 +1,54 @@
//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.
// ============================================================
package main
import (
"context"
"net/http"
"os"
"os/exec"
"sync"
"sync/atomic"
"time"
"wis-free-v3/internal/logger"
)
const linuxPressAddr = "127.0.0.1:9876"
var (
linuxPressMu sync.Mutex
linuxPressTimer *time.Timer
linuxPressRecording 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
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
)
func init() {
if hasPressFlag(os.Args[1:]) {
if err := sendLinuxPressPing(); err != nil {
_ = exec.Command("notify-send", "Voice App", "Please open the main application first").Run()
os.Exit(1)
}
os.Exit(0)
@@ -50,34 +77,171 @@ func sendLinuxPressPing() error {
return nil
}
// 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) {
linuxPressMu.Lock()
defer linuxPressMu.Unlock()
if !linuxPressRecording {
linuxPressRecording = true
go a.StartRecording()
}
if linuxPressTimer != nil {
linuxPressTimer.Stop()
}
linuxPressTimer = time.AfterFunc(300*time.Millisecond, func() {
linuxPressMu.Lock()
defer linuxPressMu.Unlock()
if linuxPressRecording {
linuxPressRecording = false
go a.StopRecording()
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() {
_ = http.ListenAndServe(linuxPressAddr, mux)
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 (a *App) handleLinuxPressPing() {
pressState.mu.Lock()
defer pressState.mu.Unlock()
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)
}
}
}
+8
View File
@@ -1,5 +1,13 @@
//go:build !linux
// ============================================================
// WINDOWS-ONLY FILE — Stub for the Linux press daemon.
// On Windows, the press daemon is not used.
// The real implementation is in linux_press_daemon.go
// ============================================================
package main
func (a *App) startLinuxPressDaemon() {}
func stopLinuxPressDaemon() {}
+2
View File
@@ -87,6 +87,8 @@ func main() {
OnBeforeClose: app.beforeClose,
StartHidden: true,
Bind: []interface{}{app},
// PLATFORM NOTE: Linux-specific Wails options (sets the program name
// for desktop integration). These options are only applied on Linux.
Linux: &linux.Options{
ProgramName: "wis-free-v3",
},
+7
View File
@@ -1,5 +1,12 @@
//go:build windows
// ============================================================
// WINDOWS-ONLY FILE — Stub for the Unix domain socket IPC.
// On Windows, single-instance enforcement uses a lock file
// (see main.go), and helper invocations don't use Unix sockets.
// The real Unix implementation is in main_instance_unix.go
// ============================================================
package main
// secondInstanceWake is unused on Windows (second-instance UX not wired here).
+20 -13
View File
@@ -1,5 +1,13 @@
//go:build unix && !windows
// ============================================================
// UNIX-ONLY FILE — This file compiles on Linux (and macOS) but
// NOT on Windows. It provides Unix domain socket support for
// single-instance enforcement and IPC communication with
// helper processes (e.g. GNOME custom shortcuts).
// The Windows equivalent stub is main_instance_stub.go
// ============================================================
package main
import (
@@ -91,22 +99,21 @@ func runSecondInstanceListener() {
defer conn.Close()
buf := make([]byte, 1)
n, _ := conn.Read(buf)
cmd := instanceCmdShow
if n == 1 {
select {
case secondInstanceCommand <- buf[0]:
default:
}
} else {
// Backwards-compatible: any connection with no payload = show window.
select {
case secondInstanceCommand <- instanceCmdShow:
default:
}
cmd = buf[0]
}
// Backwards-compatible: any connection with no payload = show window.
select {
case secondInstanceCommand <- cmd:
default:
}
_, _ = io.Copy(io.Discard, conn)
select {
case secondInstanceWake <- struct{}{}:
default:
if cmd == instanceCmdShow {
select {
case secondInstanceWake <- struct{}{}:
default:
}
}
}(c)
}
+290 -19
View File
@@ -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
@@ -41,6 +41,159 @@ command_exists() {
command -v "$1" >/dev/null 2>&1
}
print_ydotool_install_help() {
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 with your distribution's package manager."
fi
}
find_ydotoold() {
if command_exists ydotoold; then
command -v ydotoold
return 0
fi
for candidate in /usr/bin/ydotoold /usr/local/bin/ydotoold; do
if [ -x "$candidate" ]; then
printf '%s\n' "$candidate"
return 0
fi
done
return 1
}
setup_ydotool_systemd() {
echo ""
echo "========================================"
echo " Setting up ydotool systemd user service"
echo "========================================"
echo ""
if ! command_exists systemctl; then
echo "[ERROR] systemctl is not available on this system."
return 1
fi
if ! command_exists ydotool; then
echo "[ERROR] ydotool is not installed."
echo "Install it first:"
print_ydotool_install_help
return 1
fi
YDOTOOLD_BIN="$(find_ydotoold || true)"
if [ -z "$YDOTOOLD_BIN" ]; then
echo "[ERROR] ydotoold was not found after installing ydotool."
echo "Check your distribution's ydotool package or install the daemon package if it is split out."
return 1
fi
SYSTEMD_USER_DIR="${XDG_CONFIG_HOME:-$HOME/.config}/systemd/user"
mkdir -p "$SYSTEMD_USER_DIR"
SERVICE_FILE="$SYSTEMD_USER_DIR/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
[Service]
Type=simple
Environment=YDOTOOL_SOCKET=%t/.ydotool_socket
ExecStart=$YDOTOOLD_BIN
Restart=on-failure
RestartSec=2
[Install]
WantedBy=default.target
YDSVCEOF
echo "[INFO] Created $SERVICE_FILE"
UDEV_RULE_FILE="/etc/udev/rules.d/80-uinput.rules"
if [ -f "$UDEV_RULE_FILE" ] && grep -q 'KERNEL=="uinput"' "$UDEV_RULE_FILE"; then
echo "[INFO] uinput udev rule already exists at $UDEV_RULE_FILE"
else
echo ""
echo " /dev/uinput permission rule is needed for ydotool direct typing:"
echo ""
echo ' KERNEL=="uinput", SUBSYSTEM=="misc", TAG+="uaccess", OPTIONS+="static_node=uinput"'
echo ""
read -p " Create or update $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 " Log out and back in, or reboot, if ydotool still cannot access /dev/uinput."
else
echo " Skipping udev rule creation. ydotoold may not be able to inject keystrokes."
fi
fi
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 " ydotool systemd service is running."
else
echo " [WARN] Could not start ydotool.service. Check: systemctl --user status ydotool.service"
fi
echo ""
}
INSTALL_MODE="none" # none, system, user
INSTALL_SYSTEMD=false
SHOW_HELP=false
for arg in "$@"; do
case "$arg" in
--install)
INSTALL_MODE="system"
;;
--install-user)
INSTALL_MODE="user"
;;
--install-systemd)
INSTALL_SYSTEMD=true
;;
--help|-h)
SHOW_HELP=true
;;
*)
echo "[ERROR] Unknown option: $arg"
echo "Usage: $0 [--install|--install-user|--install-systemd|--help]"
exit 1
;;
esac
done
if [ "$SHOW_HELP" = true ]; then
echo "Usage: $0 [--install|--install-user|--install-systemd|--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 " --help Show this message"
echo ""
exit 0
fi
if [ "$INSTALL_SYSTEMD" = true ] && [ "$INSTALL_MODE" = "none" ]; then
setup_ydotool_systemd
exit $?
fi
# 1. Check for basic tools
if ! command_exists go; then
echo "[ERROR] Go is not installed. Please install Go 1.23+."
@@ -54,7 +207,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 +232,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 +257,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 +276,31 @@ for dep in "${DEPS[@]}"; do
fi
done
# ydotool check for direct text injection
if ! command_exists ydotool; then
echo ""
echo "==============================================================="
echo " WARNING: ydotool is not installed"
echo "==============================================================="
echo ""
echo " ydotool is required for direct keyboard injection on Wayland."
echo " Without it, WIS Free V3 cannot type transcribed text automatically"
echo " into the active Linux window."
echo ""
echo " Install ydotool and configure it:"
echo ""
print_ydotool_install_help
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() {
@@ -159,16 +340,16 @@ if [ $MISSING_DEPS -eq 1 ]; then
echo ""
DEBIAN_DEPS="build-essential pkg-config libgtk-3-dev libwebkit2gtk-4.0-dev libasound2-dev libayatana-appindicator3-dev"
# Runtime niceties (optional): libnotify-bin — status toasts; playerctl pause media while recording
DEBIAN_RUNTIME_OPT="libnotify-bin playerctl"
# Runtime nicety (optional): playerctl pauses media while recording
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="libnotify 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="libnotify 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 +388,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,28 +434,62 @@ 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 ""
# 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
setup_ydotool_systemd
fi
# --- Binary installation ---
if [ "$INSTALL_MODE" = "system" ]; then
echo "Installing system-wide..."
# 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
# Absolute Exec path: some desktop environments do not put /usr/local/bin on PATH
# for .desktop launches, so "Exec=wis-free-v3" can fail with no visible error.
cat << EOF > /tmp/$APP_NAME.desktop
@@ -292,10 +507,66 @@ 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 ""
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 "========================================"
+241
View File
@@ -0,0 +1,241 @@
#!/usr/bin/env bash
set -euo pipefail
cd "$(dirname "$0")/.."
APP_NAME="wis-free-v3"
DISPLAY_NAME="WIS Free V3"
COMMENT="Voice Dictation App"
MAINTAINER="${MAINTAINER:-WIS Free V3 Maintainers}"
LICENSE="${LICENSE:-MIT}"
ARCH_DEB="${ARCH_DEB:-amd64}"
ARCH_RPM="${ARCH_RPM:-x86_64}"
BUILD_DIR="build/bin"
EXECUTABLE="$BUILD_DIR/$APP_NAME"
DIST_DIR="dist/packages"
WORK_DIR="build/package-linux"
VERSION_FILE="scripts/VERSION"
APP_VERSION="${APP_VERSION:-dev}"
if [ -f "$VERSION_FILE" ]; then
APP_VERSION="$(head -n1 "$VERSION_FILE" | tr -d '\r\n' | sed 's/^[[:space:]]*//;s/[[:space:]]*$//')"
fi
[ -n "$APP_VERSION" ] || APP_VERSION="dev"
PKG_VERSION="$(printf '%s' "$APP_VERSION" | sed 's/[^A-Za-z0-9.+~]/./g')"
PKG_RELEASE="${PKG_RELEASE:-1}"
echo "========================================"
echo " $APP_NAME - Linux Package Script"
echo "========================================"
echo ""
echo "Version: $PKG_VERSION"
echo ""
command_exists() {
command -v "$1" >/dev/null 2>&1
}
require_command() {
if ! command_exists "$1"; then
echo "[ERROR] Missing required command: $1"
echo " Install it and run this script again."
exit 1
fi
}
require_command go
require_command pkg-config
require_command gcc
require_command dpkg-deb
require_command rpmbuild
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
if ! command_exists wails; then
echo "[ERROR] Wails CLI is still unavailable after install attempt."
exit 1
fi
webkit2_ok() {
pkg-config --exists webkit2gtk-4.0 2>/dev/null && return 0
pkg-config --exists webkit2gtk-4.1 2>/dev/null && return 0
return 1
}
appindicator_ok() {
pkg-config --exists ayatana-appindicator3-0.1 2>/dev/null && return 0
pkg-config --exists appindicator3-0.1 2>/dev/null && return 0
return 1
}
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."
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 direct keyboard 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 direct keyboard 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 direct keyboard injection)"
echo ""
exit 1
fi
echo "[2/5] Building Linux binary with Wails..."
if [ -d "frontend/node_modules/.bin" ]; then
chmod +x frontend/node_modules/.bin/* 2>/dev/null || true
fi
WAILS_PKGCFG_SHIM=""
cleanup() {
if [ -n "$WAILS_PKGCFG_SHIM" ] && [ -d "$WAILS_PKGCFG_SHIM" ]; then
rm -rf "$WAILS_PKGCFG_SHIM"
fi
}
trap cleanup EXIT
WAILS_WEBKIT_TAGS=()
if pkg-config --exists webkit2gtk-4.1 2>/dev/null; then
WAILS_WEBKIT_TAGS=(-tags webkit2_41)
fi
if ! pkg-config --exists webkit2gtk-4.0 2>/dev/null && pkg-config --exists webkit2gtk-4.1 2>/dev/null; then
WEBKIT41_PC=""
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
[ -z "$dir" ] && continue
[ -f "$dir/webkit2gtk-4.1.pc" ] || continue
WEBKIT41_PC="$dir/webkit2gtk-4.1.pc"
break
done
if [ -n "$WEBKIT41_PC" ]; then
WAILS_PKGCFG_SHIM="$(mktemp -d "${TMPDIR:-/tmp}/wails-pkgcfg-shim.XXXXXX")"
ln -sf "$WEBKIT41_PC" "$WAILS_PKGCFG_SHIM/webkit2gtk-4.0.pc"
export PKG_CONFIG_PATH="$WAILS_PKGCFG_SHIM${PKG_CONFIG_PATH:+:}${PKG_CONFIG_PATH:-}"
echo "[INFO] Using PKG_CONFIG_PATH shim: webkit2gtk-4.0.pc -> $(basename "$WEBKIT41_PC")"
fi
fi
wails build -platform linux/amd64 -clean "${WAILS_WEBKIT_TAGS[@]}" -ldflags "-X main.AppVersion=${APP_VERSION}"
if [ ! -f "$EXECUTABLE" ]; then
echo "[ERROR] Build failed. Binary not found at $EXECUTABLE."
exit 1
fi
pkill -f "$EXECUTABLE" 2>/dev/null || true
echo "[3/5] Preparing package payload..."
rm -rf "$WORK_DIR"
mkdir -p "$WORK_DIR/root/usr/bin"
mkdir -p "$WORK_DIR/root/usr/share/applications"
mkdir -p "$WORK_DIR/root/usr/share/pixmaps"
mkdir -p "$WORK_DIR/root/usr/share/doc/$APP_NAME"
mkdir -p "$DIST_DIR"
install -m 0755 "$EXECUTABLE" "$WORK_DIR/root/usr/bin/$APP_NAME"
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"
fi
cat > "$WORK_DIR/root/usr/share/applications/$APP_NAME.desktop" <<EOF
[Desktop Entry]
Type=Application
Name=$DISPLAY_NAME
Comment=$COMMENT
Exec=/usr/bin/$APP_NAME
TryExec=/usr/bin/$APP_NAME
Icon=$APP_NAME
StartupWMClass=$APP_NAME
Terminal=false
Categories=Utility;Audio;
EOF
if [ -f README.md ]; then
install -m 0644 README.md "$WORK_DIR/root/usr/share/doc/$APP_NAME/README.md"
fi
echo "[4/5] Building .deb package..."
DEB_ROOT="$WORK_DIR/deb"
rm -rf "$DEB_ROOT"
mkdir -p "$DEB_ROOT/DEBIAN"
cp -a "$WORK_DIR/root/." "$DEB_ROOT/"
INSTALLED_SIZE="$(du -sk "$DEB_ROOT/usr" | awk '{print $1}')"
cat > "$DEB_ROOT/DEBIAN/control" <<EOF
Package: $APP_NAME
Version: $PKG_VERSION
Section: utils
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
Recommends: ydotool
Description: $COMMENT
WIS Free V3 is a desktop voice dictation app built with Go and Wails.
On Wayland, install ydotool for direct keyboard injection.
EOF
dpkg-deb --build "$DEB_ROOT" "$DIST_DIR/${APP_NAME}_${PKG_VERSION}-${PKG_RELEASE}_${ARCH_DEB}.deb"
echo "[5/5] Building .rpm package..."
RPM_TOP="$WORK_DIR/rpm"
RPM_PAYLOAD="$(pwd)/$WORK_DIR/root"
RPM_SPEC="$(pwd)/$WORK_DIR/$APP_NAME.spec"
rm -rf "$RPM_TOP"
mkdir -p "$RPM_TOP/BUILD" "$RPM_TOP/BUILDROOT" "$RPM_TOP/RPMS" "$RPM_TOP/SOURCES" "$RPM_TOP/SPECS" "$RPM_TOP/SRPMS"
cat > "$RPM_SPEC" <<EOF
Name: $APP_NAME
Version: $PKG_VERSION
Release: $PKG_RELEASE%{?dist}
Summary: $COMMENT
License: $LICENSE
Requires: gtk3
Requires: alsa-lib
Requires: libayatana-appindicator-gtk3
Recommends: ydotool
%description
WIS Free V3 is a desktop voice dictation app built with Go and Wails.
On Wayland, install ydotool for direct keyboard injection.
%install
rm -rf %{buildroot}
mkdir -p %{buildroot}
cp -a "$RPM_PAYLOAD"/. %{buildroot}/
%files
%attr(0755,root,root) /usr/bin/$APP_NAME
/usr/share/applications/$APP_NAME.desktop
/usr/share/pixmaps/$APP_NAME.png
/usr/share/doc/$APP_NAME/README.md
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/" \;
echo ""
echo "Packages written to:"
find "$DIST_DIR" -maxdepth 1 -type f \( -name "*.deb" -o -name "*.rpm" \) -print | sort
+174
View File
@@ -0,0 +1,174 @@
//go:build linux
// ============================================================
// LINUX-ONLY FILE — This file compiles ONLY on Linux.
// Any changes here will NOT affect the Windows build.
// For the Windows equivalent, see text_insert_nonlinux.go
// ============================================================
package main
import (
"context"
"errors"
"fmt"
"os"
"os/exec"
"path/filepath"
"strings"
"time"
"unicode/utf8"
"wis-free-v3/internal/logger"
wailsruntime "github.com/wailsapp/wails/v2/pkg/runtime"
)
func (a *App) insertTranscription(text string) {
a.releaseLinuxInputFocus()
if err := typeLinuxTextWithYdotool(text); err == nil {
logger.Info("Typed transcription on Linux using ydotool (%d chars)", utf8.RuneCountInString(text))
return
} else {
logger.Error("Linux direct typing unavailable via ydotool: %v", err)
}
logger.Info("Transcription was not inserted; install ydotool with ydotoold/uinput access for direct Linux typing")
if a.overlay != nil {
a.overlay.Show("Direct typing unavailable. Check ydotool setup.")
}
}
func (a *App) releaseLinuxInputFocus() {
if a.ctx == nil {
time.Sleep(30 * time.Millisecond)
return
}
wailsruntime.WindowHide(a.ctx)
time.Sleep(50 * time.Millisecond)
}
func typeLinuxTextWithYdotool(text string) error {
path, socketPath, err := getYdotoolCommand()
if err != nil {
return err
}
fastArgs := []string{"type", "-d", "1", "--file", "-"}
if err := runLinuxInputCommand(path, fastArgs, text, linuxTextTyperTimeout(text), socketPath); err == nil {
return nil
}
return runLinuxInputCommand(path, []string{"type", "--file", "-"}, text, linuxTextTyperTimeout(text), socketPath)
}
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")
}
socketPath, err := getYdotoolSocketPath()
if err != nil {
return "", "", err
}
return path, socketPath, nil
}
func linuxYdotoolStatus() map[string]interface{} {
status := map[string]interface{}{
"ready": false,
"installed": false,
"socket": false,
"socket_path": "",
"message": "",
"setup_commands": []string{
"# Install ydotool with your package manager, for example:",
"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",
"systemctl --user enable --now ydotool.service",
"# Restart your computer, then open WIS Free V3 again.",
},
}
path, err := exec.LookPath("ydotool")
if err != nil {
status["message"] = "ydotool is not installed."
return status
}
status["installed"] = true
socketPath, err := getYdotoolSocketPath()
if err != nil {
status["message"] = err.Error()
return status
}
status["socket"] = true
status["socket_path"] = socketPath
if err := runLinuxInputCommand(path, []string{"key", "-d", "1", "0"}, "", 800*time.Millisecond, socketPath); err != nil {
status["message"] = "ydotool is installed, but the daemon test failed: " + err.Error()
return status
}
status["ready"] = true
status["message"] = "ydotool is ready for direct typing."
return status
}
func getYdotoolSocketPath() (string, error) {
if socketPath := strings.TrimSpace(os.Getenv("YDOTOOL_SOCKET")); socketPath != "" {
if _, err := os.Stat(socketPath); err == nil {
return socketPath, nil
}
return "", fmt.Errorf("YDOTOOL_SOCKET is set but not accessible: %s", socketPath)
}
candidates := []string{
filepath.Join("/run/user", fmt.Sprintf("%d", os.Getuid()), ".ydotool_socket"),
"/tmp/.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")
}
func runLinuxInputCommand(path string, args []string, stdin string, timeout time.Duration, socketPath string) error {
ctx, cancel := context.WithTimeout(context.Background(), timeout)
defer cancel()
cmd := exec.CommandContext(ctx, path, args...)
cmd.Env = append(os.Environ(), "YDOTOOL_SOCKET="+socketPath)
if stdin != "" {
cmd.Stdin = strings.NewReader(stdin)
}
out, err := cmd.CombinedOutput()
if ctx.Err() != nil {
return ctx.Err()
}
if err != nil {
msg := strings.TrimSpace(string(out))
if msg != "" {
return fmt.Errorf("%w: %s", err, msg)
}
return err
}
return nil
}
func linuxTextTyperTimeout(text string) time.Duration {
timeout := 5*time.Second + time.Duration(utf8.RuneCountInString(text))*30*time.Millisecond
if timeout > 2*time.Minute {
return 2 * time.Minute
}
return timeout
}
+51
View File
@@ -0,0 +1,51 @@
//go:build !linux
// ============================================================
// WINDOWS-ONLY FILE — This file compiles on Windows (and macOS)
// but NOT on Linux. It uses robotgo for clipboard/paste which
// is Windows-specific in this app. The Linux equivalent is
// text_insert_linux.go which uses ydotool instead.
// ============================================================
package main
import (
"time"
"wis-free-v3/internal/logger"
"github.com/go-vgo/robotgo"
wailsruntime "github.com/wailsapp/wails/v2/pkg/runtime"
)
func (a *App) insertTranscription(text string) {
// Save old clipboard
oldClip, clipErr := wailsruntime.ClipboardGetText(a.ctx)
// Copy to clipboard
wailsruntime.ClipboardSetText(a.ctx, text)
// Paste
a.pasteText()
// Restore old clipboard after a delay, but ONLY if the clipboard still
// contains our transcribed text (i.e. user hasn't copied something else).
if clipErr == nil && oldClip != "" {
go func() {
time.Sleep(1500 * time.Millisecond)
current, currentErr := wailsruntime.ClipboardGetText(a.ctx)
// Only restore if no error reading AND clipboard still holds our text
// AND it hasn't been modified by another goroutine
if currentErr == nil && current == text {
wailsruntime.ClipboardSetText(a.ctx, oldClip)
logger.Info("Clipboard history restored")
}
}()
}
}
// pasteText simulates Ctrl+V to paste from clipboard
func (a *App) pasteText() {
time.Sleep(50 * time.Millisecond)
robotgo.KeyTap("v", "ctrl")
}
+19
View File
@@ -0,0 +1,19 @@
//go:build !linux
// ============================================================
// WINDOWS-ONLY FILE — Stub for the Linux ydotool status check.
// On Windows, ydotool is not used, so this returns "not ready".
// The real implementation is in text_insert_linux.go
// ============================================================
package main
func linuxYdotoolStatus() map[string]interface{} {
return map[string]interface{}{
"ready": false,
"installed": false,
"socket": false,
"socket_path": "",
"message": "ydotool is only used on Linux.",
}
}