mirror of
https://github.com/jahruz67/wisp-open.git
synced 2026-08-08 18:14:08 +00:00
Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
69c9c7a364 | ||
|
|
71dbac5413 | ||
|
|
062f09a000 | ||
|
|
fe21a41532 | ||
|
|
d512425c72 | ||
|
|
ce49f66c89 | ||
|
|
593a5941a1 | ||
|
|
2c5a0b70ed | ||
|
|
52bb65f3a0 | ||
|
|
05c8fbc474 | ||
|
|
bc3d8cbce3 | ||
|
|
b0dbaa1f67 | ||
|
|
f7d34fff32 | ||
|
|
5fcef76424 | ||
|
|
9b08ed249a | ||
|
|
4131d77326 | ||
|
|
cacbe9bb7f | ||
|
|
5ab514abdd | ||
|
|
910877d35e | ||
|
|
181e663de4 | ||
|
|
1987f413e4 | ||
|
|
0bac8c2bbf | ||
|
|
02d26ce79b | ||
|
|
c4739dc38c | ||
|
|
7840918118 | ||
|
|
9d4d279a87 | ||
|
|
89af7f9841 |
@@ -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
|
||||
@@ -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_...",
|
||||
|
||||
@@ -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.
+4
File diff suppressed because one or more lines are too long
-3
File diff suppressed because one or more lines are too long
Vendored
+21
-5
@@ -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
@@ -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
@@ -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>;
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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:]
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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
@@ -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() {}
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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 (
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
@@ -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
@@ -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
|
||||
}
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
/*
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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 (
|
||||
|
||||
@@ -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
@@ -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
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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 (
|
||||
|
||||
@@ -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
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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() {}
|
||||
|
||||
@@ -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",
|
||||
},
|
||||
|
||||
@@ -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
@@ -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
@@ -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 "========================================"
|
||||
|
||||
@@ -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
|
||||
@@ -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
|
||||
}
|
||||
@@ -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")
|
||||
}
|
||||
@@ -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.",
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user