feat: add backend configuration and windows utilities, and initialize frontend dependencies

This commit is contained in:
jahruz67
2026-05-15 10:39:30 -07:00
parent e645102d3a
commit b0f4ea3e6f
43 changed files with 931 additions and 541 deletions
+103 -32
View File
@@ -36,6 +36,7 @@ type App struct {
isQuitting bool
wasMediaPlaying bool
whisperManager *whisper.Manager
tempDir string
}
// NewApp creates a new App application struct
@@ -64,6 +65,20 @@ func (a *App) ShowSettings() {
func (a *App) startup(ctx context.Context) {
a.ctx = ctx
// Create and clean private temp directory for recordings (Cleanup in background)
a.tempDir = filepath.Join(os.TempDir(), "wis-free-v3-recordings")
os.MkdirAll(a.tempDir, 0700)
go func() {
// Clean up any orphaned files from previous sessions
if files, err := os.ReadDir(a.tempDir); err == nil {
for _, f := range files {
os.Remove(filepath.Join(a.tempDir, f.Name()))
}
}
logger.Info("Orphaned temp files cleaned")
}()
title := appTitle
switch AppVersion {
case "", "dev":
@@ -150,7 +165,7 @@ func (a *App) ToggleRecording() {
// Debounce toggle calls to prevent rapid firing from double-binds or Wayland glitches
now := time.Now().UnixNano()
last := atomic.LoadInt64(&lastToggleUnixNano)
if last != 0 && time.Duration(now-last) < 500*time.Millisecond {
if last != 0 && time.Duration(now-last) < 250*time.Millisecond {
return
}
atomic.StoreInt64(&lastToggleUnixNano, now)
@@ -186,13 +201,21 @@ func (a *App) StartRecording() {
return
}
// Prepare path
tempDir := os.TempDir()
timestamp := time.Now().Format("20060102_150405")
a.recordingPath = filepath.Join(tempDir, fmt.Sprintf("wis_recording_%s.wav", timestamp))
// Prepare path safely in our private temp directory
tempFile, err := os.CreateTemp(a.tempDir, "rec_*.wav")
if err != nil {
logger.Error("Failed to create temp file: %v", err)
if a.overlay != nil {
a.overlay.Hide()
}
atomic.StoreInt32(&a.recording, 0)
return
}
a.recordingPath = tempFile.Name()
tempFile.Close() // We just need the path, recorder will open it
// Start recording
err := a.audioRecorder.Start(a.recordingPath)
err = a.audioRecorder.Start(a.recordingPath)
if err != nil {
logger.Error("Failed to start recording: %v", err)
// IDIOT-PROOFING: Fallback to default
@@ -248,13 +271,15 @@ func (a *App) StopRecording() {
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()
go a.processRecording(pathToProcess)
}
// processRecording handles transcription and pasting
func (a *App) processRecording() {
if a.recordingPath == "" {
func (a *App) processRecording(recordingPath string) {
if recordingPath == "" {
logger.Error("No recording path set")
tray.UpdateStatus("Ready")
if a.overlay != nil {
@@ -263,12 +288,26 @@ func (a *App) processRecording() {
return
}
logger.Info("Transcribing audio...")
// 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 {
logger.Info("Discarding tiny recording (%d bytes)", stat.Size())
os.Remove(recordingPath)
if a.overlay != nil {
a.overlay.Hide()
}
tray.UpdateStatus("Ready")
return
}
logger.Info("Transcribing audio (%d bytes)...", stat.Size())
tray.UpdateStatus("Transcribing...")
if a.overlay != nil {
a.overlay.Show("Transcribing...")
}
startTranscribe := time.Now()
var text string
var err error
@@ -290,12 +329,15 @@ func (a *App) processRecording() {
}
if a.whisperManager != nil {
text, err = a.whisperManager.Transcribe(a.recordingPath)
text, err = a.whisperManager.Transcribe(recordingPath, a.config.Language)
}
} else {
// Use cloud API
text, err = a.transcriber.TranscribeAudio(a.recordingPath, a.config.Language)
text, err = a.transcriber.TranscribeAudio(recordingPath, a.config.Language)
}
transcribeDuration := time.Since(startTranscribe)
logger.Info("Transcription completed in %v", transcribeDuration)
if err != nil {
logger.Error("Transcription failed: %v", err)
@@ -308,41 +350,61 @@ func (a *App) processRecording() {
logger.Info("Transcribed: %s", text)
activeWindow := robotgo.GetTitle()
logger.Info("Active window for context: %s", activeWindow)
// Refine text (optional)
refinedText, err := a.transcriber.RefineText(text)
startRefine := time.Now()
refinedText, err := a.transcriber.RefineText(text, activeWindow)
if err != nil {
logger.Error("Refinement failed: %v", err)
// Fallback to original text
refinedText = text
} else {
logger.Info("Refined: %s", refinedText)
refineDuration := time.Since(startRefine)
logger.Info("AI Refinement completed in %v (Refined: %s)", refineDuration, refinedText)
}
// Save to history
historyItem := config.HistoryItem{
Text: refinedText,
Timestamp: time.Now().Format(time.RFC3339),
}
// Prepend to history
a.config.History = append([]config.HistoryItem{historyItem}, a.config.History...)
// Keep only last 50 items
if len(a.config.History) > 50 {
a.config.History = a.config.History[:50]
}
a.config.AddHistoryItem(refinedText, time.Now().Format(time.RFC3339))
if err := config.Save(a.config, ""); err != nil {
logger.Error("Failed to save config after history update: %v", err)
} else if a.ctx != nil {
wailsruntime.EventsEmit(a.ctx, "history:updated")
}
// Copy to clipboard
wailsruntime.ClipboardSetText(a.ctx, refinedText)
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()
// 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")
}
}()
}
}
// Clean up the recording file
os.Remove(a.recordingPath)
if removeErr := os.Remove(recordingPath); removeErr != nil {
logger.Error("Failed to remove temporary recording file: %v", removeErr)
}
logger.Info("Processing complete!")
tray.UpdateStatus("Ready")
@@ -399,7 +461,11 @@ func (a *App) SaveSettings(settings map[string]interface{}) string {
a.hotkeyListener.UpdateShortcut(val)
} else {
// Should not happen if app started correctly, but just in case
a.hotkeyListener = hotkey.NewListener(val, a.ToggleRecording, func() {})
if runtime.GOOS == "windows" {
a.hotkeyListener = hotkey.NewListener(val, a.StartRecording, a.StopRecording)
} else {
a.hotkeyListener = hotkey.NewListener(val, a.ToggleRecording, func() {})
}
a.hotkeyListener.Start()
}
}
@@ -550,8 +616,13 @@ func (a *App) startupHeadless() {
logger.Error("Failed to ensure desktop file: %v", err)
}
// Initialize Hotkey Listener (toggle mode: keydown toggles, keyup ignored)
a.hotkeyListener = hotkey.NewListener(a.config.Shortcut, a.ToggleRecording, func() {})
// Initialize Hotkey Listener
if runtime.GOOS == "windows" {
a.hotkeyListener = hotkey.NewListener(a.config.Shortcut, a.StartRecording, a.StopRecording)
} else {
// Linux (Wayland fallback) uses toggle mode: keydown toggles, keyup ignored
a.hotkeyListener = hotkey.NewListener(a.config.Shortcut, a.ToggleRecording, func() {})
}
a.hotkeyListener.Start()
logger.Info("Components initialized successfully!")
Binary file not shown.
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
-1
View File
@@ -1 +0,0 @@
:root{--bg-color: #0b0f1a;--card-bg: rgba(30, 41, 59, .7);--input-bg: rgba(51, 65, 85, .5);--text-color: #f8fafc;--text-muted: #94a3b8;--primary-color: #3b82f6;--primary-hover: #2563eb;--accent-color: #6366f1;--border-color: rgba(75, 85, 99, .4);--success-color: #10b981;--glass-border: rgba(255, 255, 255, .1);color-scheme:dark}body{background:radial-gradient(circle at top right,#1e293b,#0b0f1a);color:var(--text-color);font-family:Inter,system-ui,-apple-system,sans-serif;margin:0;padding:30px;min-height:100vh;letter-spacing:-.01em}.container{max-width:720px;margin:0 auto}h1{font-size:28px;font-weight:700;background:linear-gradient(135deg,#60a5fa,#a78bfa);-webkit-background-clip:text;background-clip:text;-webkit-text-fill-color:transparent;margin-bottom:30px}.section{background:var(--card-bg);backdrop-filter:blur(12px);-webkit-backdrop-filter:blur(12px);padding:24px;border-radius:16px;margin-bottom:24px;border:1px solid var(--glass-border);box-shadow:0 10px 15px -3px #0003;transition:transform .2s ease}.section:hover{border-color:#fff3}label{display:block;color:var(--text-muted);font-size:13px;font-weight:600;text-transform:uppercase;letter-spacing:.05em;margin-bottom:10px}input[type=checkbox]{accent-color:var(--primary-color);width:18px;height:18px;cursor:pointer}input[type=text],input[type=password],select,textarea{width:100%;background:var(--input-bg);border:1px solid var(--border-color);color:#fff;padding:12px 14px;border-radius:10px;font-size:14px;transition:all .2s ease;box-sizing:border-box}select{appearance:none;-webkit-appearance:none;background-image:url("data:image/svg+xml;charset=UTF-8,%3csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24' fill='none' stroke='%2394a3b8' stroke-width='2' stroke-linecap='round' stroke-linejoin='round'%3e%3cpolyline points='6 9 12 15 18 9'%3e%3c/polyline%3e%3c/svg%3e");background-repeat:no-repeat;background-position:right 14px center;background-size:16px;padding-right:40px}select option{background:var(--bg-color);color:#fff}textarea{resize:vertical;min-height:80px}input:focus,select:focus,textarea:focus{outline:none;border-color:var(--primary-color);background:rgba(51,65,85,.8);box-shadow:0 0 0 3px #3b82f633}.form-control{max-width:480px;width:100%}.input-wrapper{position:relative;flex:1;min-width:0}.input-wrapper input{width:100%;padding-right:44px;box-sizing:border-box;display:block}.eye-btn{position:absolute;right:8px;top:50%;transform:translateY(-50%);background:transparent!important;border:none!important;box-shadow:none!important;cursor:pointer;padding:4px!important;font-size:16px;opacity:.5;transition:opacity .2s;line-height:1;display:flex;align-items:center;justify-content:center}.eye-btn:hover{opacity:1;transform:translateY(-50%) scale(1.1)}.save-status{position:fixed;bottom:30px;right:30px;background:var(--success-color);color:#fff;padding:12px 24px;border-radius:12px;font-weight:600;box-shadow:0 10px 15px -3px #0003;transform:translateY(100px);opacity:0;transition:all .3s cubic-bezier(.4,0,.2,1);z-index:1000}.save-status.show{transform:translateY(0);opacity:1}button{background-color:var(--primary-color);color:#fff;border:none;padding:10px 20px;border-radius:10px;cursor:pointer;font-weight:600;font-size:14px;transition:all .2s cubic-bezier(.4,0,.2,1);box-shadow:0 4px 6px -1px #0003;white-space:nowrap;flex-shrink:0}button:hover{transform:translateY(-1px);box-shadow:0 10px 15px -3px #0000004d;background-color:var(--primary-hover)}button:active{transform:translateY(0)}button[style*="background: transparent"]{background:transparent!important;box-shadow:none!important;text-decoration:underline;opacity:.7}button[style*="background: transparent"]:hover{opacity:1}.flex-row{display:flex;gap:12px;align-items:center;flex-wrap:nowrap}.flex-between{display:flex;justify-content:space-between;align-items:center}.history-list{max-height:250px;overflow-y:auto;margin-top:10px;padding-right:5px}.history-item{background:rgba(255,255,255,.03);padding:14px;border-radius:12px;margin-bottom:10px;border:1px solid rgba(255,255,255,.05);transition:all .2s ease}.history-item:hover{background:rgba(255,255,255,.06);transform:translate(2px)}.history-time{color:var(--primary-color);font-size:11px;font-weight:700;margin-bottom:6px;display:block}::-webkit-scrollbar{width:6px}::-webkit-scrollbar-track{background:transparent}::-webkit-scrollbar-thumb{background:var(--border-color);border-radius:10px}::-webkit-scrollbar-thumb:hover{background:var(--text-muted)}
File diff suppressed because one or more lines are too long
+65 -63
View File
@@ -4,25 +4,28 @@
<head>
<meta charset="UTF-8">
<meta content="width=device-width, initial-scale=1.0" name="viewport">
<title>wis-free-v3 Settings</title>
<title>Wisp Settings</title>
<script type="module" crossorigin src="/assets/index.c31e3052.js"></script>
<link rel="stylesheet" href="/assets/index.d8635aeb.css">
<script type="module" crossorigin src="/assets/index.fbf781c1.js"></script>
<link rel="stylesheet" href="/assets/index.6e77aa4d.css">
</head>
<body>
<div id="app" class="container">
<!-- Header -->
<div class="flex-between" style="margin-bottom: 30px;">
<h1 style="margin: 0;">wis-free-v3 Settings</h1>
<div id="appVersion" style="color: var(--text-muted); font-size: 12px;"></div>
<div class="flex-between" style="margin-bottom: 28px;">
<h1>Settings</h1>
<div id="appVersion" style="color: var(--text-2); font-size: 12px; font-weight: 500;"></div>
</div>
<div id="saveStatus" class="save-status">Settings Saved!</div>
<div id="saveStatus" class="save-status">Saved</div>
<!-- Settings Form -->
<div id="settingsForm">
<div class="section-group-label">Authentication</div>
<!-- API Key -->
<div class="section">
<label>Groq API Key</label>
@@ -35,15 +38,14 @@
<button onclick="saveApiKey()">Save</button>
</div>
</div>
<p style="font-size: 12px; color: var(--text-muted); margin-top: 8px;">
Get your free API key at <a href="https://console.groq.com/keys" target="_blank"
style="color: var(--primary);">console.groq.com/keys</a>
</p>
<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>
</div>
<div class="section-group-label">Input</div>
<!-- Shortcut -->
<div class="section">
<label>Shortcut (Hold to Record)</label>
<label>Hotkey</label>
<div class="form-control">
<div class="flex-row">
<div class="input-wrapper">
@@ -52,11 +54,19 @@
<button onclick="recordShortcut()" id="recordBtn">Record</button>
</div>
</div>
<p style="font-size: 12px; color: var(--text-muted); margin-top: 8px;">
Click Record, then press your desired key combination (e.g., Ctrl+X)
</p>
<p class="hint">Click Record, then press your desired key combination (e.g. Ctrl+X).</p>
</div>
<!-- Microphone -->
<div class="section">
<label>Microphone</label>
<select id="micDevice" onchange="saveMicDevice()" class="form-control">
<option value="default">System Default</option>
</select>
</div>
<div class="section-group-label">Transcription</div>
<!-- Whisper Model -->
<div class="section">
<label>Whisper Model</label>
@@ -68,19 +78,22 @@
<!-- Language -->
<div class="section">
<label>Transcription Language</label>
<label>Language</label>
<select id="language" onchange="saveLanguage()" class="form-control">
<option value="auto">Auto-Detect</option>
<option value="en">English (en)</option>
<option value="es">Spanish (es)</option>
</select>
<p style="font-size: 11px; color: var(--text-muted); margin-top: 5px;">Specifying language reduces latency and improves accuracy.</p>
<p class="hint">Specifying language reduces latency and improves accuracy.</p>
</div>
<div class="section-group-label">AI Refinement</div>
<!-- AI Model -->
<div class="section">
<label>AI Model (Text Refinement)</label>
<label>AI Model</label>
<select id="aiModel" onchange="saveAiModel()" class="form-control">
<option value="None">None (Skip Refinement)</option>
<option value="None">None — skip refinement</option>
<option value="openai/gpt-oss-120b-high">openai/gpt-oss-120b (high reasoning)</option>
<option value="openai/gpt-oss-120b">openai/gpt-oss-120b (low reasoning)</option>
<option value="openai/gpt-oss-20b-high">openai/gpt-oss-20b (high reasoning)</option>
@@ -90,91 +103,80 @@
</select>
</div>
<!-- Microphone -->
<div class="section">
<label>Microphone</label>
<select id="micDevice" onchange="saveMicDevice()" class="form-control">
<option value="default">System Default</option>
</select>
</div>
<!-- AI Prompt -->
<div class="section">
<label>AI Prompt (System Message)</label>
<textarea id="aiPrompt" rows="3" placeholder="Custom instructions for text refinement..."
class="form-control"></textarea>
<label>System Prompt</label>
<textarea id="aiPrompt" rows="3" placeholder="Custom instructions for text refinement..." class="form-control"></textarea>
<div style="text-align: right; margin-top: 10px;">
<button onclick="savePrompt()">Save Prompt</button>
</div>
</div>
<div class="section-group-label">System</div>
<!-- Startup -->
<div class="section flex-between">
<span id="startupLabel" style="font-weight: 500;">Run on System Startup</span>
<div>
<span id="startupLabel" style="font-weight: 500;">Run on System Startup</span>
<p class="hint" style="margin-top: 4px; margin-bottom: 0;">Launch automatically when you log in.</p>
</div>
<label class="flex-row" style="margin: 0; cursor: pointer;">
<input type="checkbox" id="startupToggle" onchange="toggleStartup()" style="width: auto;">
<span>Enable</span>
</label>
</div>
<!-- History -->
<div class="section">
<div class="flex-between" style="margin-bottom: 10px;">
<div class="flex-between" style="margin-bottom: 12px;">
<label style="margin: 0;">Transcription History</label>
<button onclick="clearHistory()"
style="background: transparent; color: #f87171; padding: 0; font-size: 12px;">Clear</button>
<button onclick="clearHistory()" class="btn-link">Clear all</button>
</div>
<div id="historyList" class="history-list">
<div style="text-align: center; color: var(--text-muted); padding: 20px;">No history yet.</div>
<div style="text-align: center; color: var(--text-2); padding: 24px 0; font-size: 13px;">No history yet.</div>
</div>
</div>
<!-- Offline Mode -->
<div id="offlineWhisperSection" class="section"
style="margin-top: 30px; padding-top: 20px; border-top: 1px solid var(--border-color);">
<!-- Offline Whisper -->
<div id="offlineWhisperSection" class="section" style="margin-top: 20px; padding-top: 20px; border-top: 1px solid var(--border);">
<label>Offline Whisper</label>
<!-- Online/Offline Status -->
<div id="connectionStatus"
style="margin-bottom: 15px; padding: 10px; border-radius: 8px; text-align: center;">
<!-- Connection Status -->
<div id="connectionStatus" style="margin-bottom: 14px; padding: 9px 14px; border-radius: 7px; text-align: center; font-size: 13px;">
Checking connection...
</div>
<!-- Whisper Status -->
<div id="whisperSection">
<div id="whisperNotInstalled">
<p style="font-size: 13px; color: var(--text-muted); margin-bottom: 15px;">
<p class="hint" style="margin-bottom: 14px;">
Install offline transcription for use without internet. Uses GPU acceleration if available.
</p>
<div style="margin-bottom: 15px;">
<label style="font-size: 12px;">Model Size:</label>
<select id="whisperModelSelect" style="margin-top: 5px;" class="form-control">
<option value="tiny">Tiny (75 MB) - Fastest</option>
<option value="base">Base (150 MB) - Fast</option>
<option value="small" selected>Small (500 MB) - Recommended</option>
<option value="medium">Medium (1.5 GB) - High Quality</option>
<div style="margin-bottom: 14px;">
<label style="font-size: 12px; color: var(--text-2); margin-bottom: 6px;">Model Size</label>
<select id="whisperModelSelect" class="form-control">
<option value="tiny">Tiny (75 MB) Fastest</option>
<option value="base">Base (150 MB) Fast</option>
<option value="small" selected>Small (500 MB) Recommended</option>
<option value="medium">Medium (1.5 GB) — Best Quality</option>
</select>
</div>
<button onclick="installWhisper()" id="installBtn"
style="background: linear-gradient(135deg, #6366f1, #8b5cf6); padding: 12px 24px; font-size: 14px; width: 100%;">
🚀 Install Offline Whisper
<button onclick="installWhisper()" id="installBtn">
Install Offline Whisper
</button>
</div>
<div id="whisperInstalled" style="display: none;">
<div
style="background: rgba(34, 197, 94, 0.1); border: 1px solid rgba(34, 197, 94, 0.3); border-radius: 8px; padding: 15px; margin-bottom: 15px;">
<div style="color: #22c55e; font-weight: 500;">✓ Offline Whisper Installed</div>
<div style="font-size: 12px; color: var(--text-muted); margin-top: 5px;">
Model: <span id="installedModel">-</span>
<div style="background: var(--green-dim); border: 1px solid rgba(61, 186, 110, 0.25); border-radius: 7px; padding: 13px 16px; margin-bottom: 14px;">
<div style="color: var(--green); font-weight: 500; font-size: 13px;">✓ Offline Whisper installed</div>
<div style="font-size: 12px; color: var(--text-2); margin-top: 4px;">
Model: <span id="installedModel"></span>
</div>
</div>
<p style="font-size: 12px; color: var(--text-muted); margin-bottom: 15px;">
Select "Local - Whisper" in the Whisper Model dropdown above to use offline transcription.
<p class="hint" style="margin-bottom: 14px;">
Select "Local Whisper" in the Whisper Model dropdown above to use offline transcription.
</p>
<button onclick="uninstallWhisper()"
style="background: transparent; border: 1px solid #f87171; color: #f87171; padding: 10px 20px; font-size: 13px;">
🗑️ Uninstall Offline Whisper
<button onclick="uninstallWhisper()" class="btn-danger">
Uninstall Offline Whisper
</button>
</div>
</div>
+84 -89
View File
@@ -4,23 +4,26 @@
<head>
<meta charset="UTF-8">
<meta content="width=device-width, initial-scale=1.0" name="viewport">
<title>wis-free-v3 Settings</title>
<title>Wisp Settings</title>
<link rel="stylesheet" href="./src/style.css">
</head>
<body>
<div id="app" class="container">
<!-- Header -->
<div class="flex-between" style="margin-bottom: 30px;">
<h1 style="margin: 0;">wis-free-v3 Settings</h1>
<div id="appVersion" style="color: var(--text-muted); font-size: 12px;"></div>
<div class="flex-between" style="margin-bottom: 28px;">
<h1>Settings</h1>
<div id="appVersion" style="color: var(--text-2); font-size: 12px; font-weight: 500;"></div>
</div>
<div id="saveStatus" class="save-status">Settings Saved!</div>
<div id="saveStatus" class="save-status">Saved</div>
<!-- Settings Form -->
<div id="settingsForm">
<div class="section-group-label">Authentication</div>
<!-- API Key -->
<div class="section">
<label>Groq API Key</label>
@@ -33,15 +36,14 @@
<button onclick="saveApiKey()">Save</button>
</div>
</div>
<p style="font-size: 12px; color: var(--text-muted); margin-top: 8px;">
Get your free API key at <a href="https://console.groq.com/keys" target="_blank"
style="color: var(--primary);">console.groq.com/keys</a>
</p>
<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>
</div>
<div class="section-group-label">Input</div>
<!-- Shortcut -->
<div class="section">
<label>Shortcut (Hold to Record)</label>
<label>Hotkey</label>
<div class="form-control">
<div class="flex-row">
<div class="input-wrapper">
@@ -50,11 +52,19 @@
<button onclick="recordShortcut()" id="recordBtn">Record</button>
</div>
</div>
<p style="font-size: 12px; color: var(--text-muted); margin-top: 8px;">
Click Record, then press your desired key combination (e.g., Ctrl+X)
</p>
<p class="hint">Click Record, then press your desired key combination (e.g. Ctrl+X).</p>
</div>
<!-- Microphone -->
<div class="section">
<label>Microphone</label>
<select id="micDevice" onchange="saveMicDevice()" class="form-control">
<option value="default">System Default</option>
</select>
</div>
<div class="section-group-label">Transcription</div>
<!-- Whisper Model -->
<div class="section">
<label>Whisper Model</label>
@@ -66,19 +76,22 @@
<!-- Language -->
<div class="section">
<label>Transcription Language</label>
<label>Language</label>
<select id="language" onchange="saveLanguage()" class="form-control">
<option value="auto">Auto-Detect</option>
<option value="en">English (en)</option>
<option value="es">Spanish (es)</option>
</select>
<p style="font-size: 11px; color: var(--text-muted); margin-top: 5px;">Specifying language reduces latency and improves accuracy.</p>
<p class="hint">Specifying language reduces latency and improves accuracy.</p>
</div>
<div class="section-group-label">AI Refinement</div>
<!-- AI Model -->
<div class="section">
<label>AI Model (Text Refinement)</label>
<label>AI Model</label>
<select id="aiModel" onchange="saveAiModel()" class="form-control">
<option value="None">None (Skip Refinement)</option>
<option value="None">None — skip refinement</option>
<option value="openai/gpt-oss-120b-high">openai/gpt-oss-120b (high reasoning)</option>
<option value="openai/gpt-oss-120b">openai/gpt-oss-120b (low reasoning)</option>
<option value="openai/gpt-oss-20b-high">openai/gpt-oss-20b (high reasoning)</option>
@@ -88,91 +101,80 @@
</select>
</div>
<!-- Microphone -->
<div class="section">
<label>Microphone</label>
<select id="micDevice" onchange="saveMicDevice()" class="form-control">
<option value="default">System Default</option>
</select>
</div>
<!-- AI Prompt -->
<div class="section">
<label>AI Prompt (System Message)</label>
<textarea id="aiPrompt" rows="3" placeholder="Custom instructions for text refinement..."
class="form-control"></textarea>
<label>System Prompt</label>
<textarea id="aiPrompt" rows="3" placeholder="Custom instructions for text refinement..." class="form-control"></textarea>
<div style="text-align: right; margin-top: 10px;">
<button onclick="savePrompt()">Save Prompt</button>
</div>
</div>
<div class="section-group-label">System</div>
<!-- Startup -->
<div class="section flex-between">
<span id="startupLabel" style="font-weight: 500;">Run on System Startup</span>
<div>
<span id="startupLabel" style="font-weight: 500;">Run on System Startup</span>
<p class="hint" style="margin-top: 4px; margin-bottom: 0;">Launch automatically when you log in.</p>
</div>
<label class="flex-row" style="margin: 0; cursor: pointer;">
<input type="checkbox" id="startupToggle" onchange="toggleStartup()" style="width: auto;">
<span>Enable</span>
</label>
</div>
<!-- History -->
<div class="section">
<div class="flex-between" style="margin-bottom: 10px;">
<div class="flex-between" style="margin-bottom: 12px;">
<label style="margin: 0;">Transcription History</label>
<button onclick="clearHistory()"
style="background: transparent; color: #f87171; padding: 0; font-size: 12px;">Clear</button>
<button onclick="clearHistory()" class="btn-link">Clear all</button>
</div>
<div id="historyList" class="history-list">
<div style="text-align: center; color: var(--text-muted); padding: 20px;">No history yet.</div>
<div style="text-align: center; color: var(--text-2); padding: 24px 0; font-size: 13px;">No history yet.</div>
</div>
</div>
<!-- Offline Mode -->
<div id="offlineWhisperSection" class="section"
style="margin-top: 30px; padding-top: 20px; border-top: 1px solid var(--border-color);">
<!-- Offline Whisper -->
<div id="offlineWhisperSection" class="section" style="margin-top: 20px; padding-top: 20px; border-top: 1px solid var(--border);">
<label>Offline Whisper</label>
<!-- Online/Offline Status -->
<div id="connectionStatus"
style="margin-bottom: 15px; padding: 10px; border-radius: 8px; text-align: center;">
<!-- Connection Status -->
<div id="connectionStatus" style="margin-bottom: 14px; padding: 9px 14px; border-radius: 7px; text-align: center; font-size: 13px;">
Checking connection...
</div>
<!-- Whisper Status -->
<div id="whisperSection">
<div id="whisperNotInstalled">
<p style="font-size: 13px; color: var(--text-muted); margin-bottom: 15px;">
<p class="hint" style="margin-bottom: 14px;">
Install offline transcription for use without internet. Uses GPU acceleration if available.
</p>
<div style="margin-bottom: 15px;">
<label style="font-size: 12px;">Model Size:</label>
<select id="whisperModelSelect" style="margin-top: 5px;" class="form-control">
<option value="tiny">Tiny (75 MB) - Fastest</option>
<option value="base">Base (150 MB) - Fast</option>
<option value="small" selected>Small (500 MB) - Recommended</option>
<option value="medium">Medium (1.5 GB) - High Quality</option>
<div style="margin-bottom: 14px;">
<label style="font-size: 12px; color: var(--text-2); margin-bottom: 6px;">Model Size</label>
<select id="whisperModelSelect" class="form-control">
<option value="tiny">Tiny (75 MB) Fastest</option>
<option value="base">Base (150 MB) Fast</option>
<option value="small" selected>Small (500 MB) Recommended</option>
<option value="medium">Medium (1.5 GB) — Best Quality</option>
</select>
</div>
<button onclick="installWhisper()" id="installBtn"
style="background: linear-gradient(135deg, #6366f1, #8b5cf6); padding: 12px 24px; font-size: 14px; width: 100%;">
🚀 Install Offline Whisper
<button onclick="installWhisper()" id="installBtn">
Install Offline Whisper
</button>
</div>
<div id="whisperInstalled" style="display: none;">
<div
style="background: rgba(34, 197, 94, 0.1); border: 1px solid rgba(34, 197, 94, 0.3); border-radius: 8px; padding: 15px; margin-bottom: 15px;">
<div style="color: #22c55e; font-weight: 500;">✓ Offline Whisper Installed</div>
<div style="font-size: 12px; color: var(--text-muted); margin-top: 5px;">
Model: <span id="installedModel">-</span>
<div style="background: var(--green-dim); border: 1px solid rgba(61, 186, 110, 0.25); border-radius: 7px; padding: 13px 16px; margin-bottom: 14px;">
<div style="color: var(--green); font-weight: 500; font-size: 13px;">✓ Offline Whisper installed</div>
<div style="font-size: 12px; color: var(--text-2); margin-top: 4px;">
Model: <span id="installedModel"></span>
</div>
</div>
<p style="font-size: 12px; color: var(--text-muted); margin-bottom: 15px;">
Select "Local - Whisper" in the Whisper Model dropdown above to use offline transcription.
<p class="hint" style="margin-bottom: 14px;">
Select "Local Whisper" in the Whisper Model dropdown above to use offline transcription.
</p>
<button onclick="uninstallWhisper()"
style="background: transparent; border: 1px solid #f87171; color: #f87171; padding: 10px 20px; font-size: 13px;">
🗑️ Uninstall Offline Whisper
<button onclick="uninstallWhisper()" class="btn-danger">
Uninstall Offline Whisper
</button>
</div>
</div>
@@ -292,7 +294,7 @@
function renderHistoryList(history) {
const container = document.getElementById('historyList');
if (!history || history.length === 0) {
container.innerHTML = '<div style="text-align: center; color: var(--text-muted); padding: 20px;">No history yet.</div>';
container.innerHTML = '<div style="text-align: center; color: var(--text-2); padding: 24px 0; font-size: 13px;">No history yet.</div>';
return;
}
container.innerHTML = '';
@@ -371,7 +373,7 @@
showSaveStatus();
};
function showSaveStatus(message = 'Settings Saved!') {
function showSaveStatus(message = 'Saved') {
const el = document.getElementById('saveStatus');
el.textContent = message;
el.classList.add('show');
@@ -390,7 +392,7 @@
window.saveLanguage = async function () {
await window.go.main.App.SaveSettings({ language: document.getElementById('language').value });
showSaveStatus('Language Saved!');
showSaveStatus();
};
window.saveMicDevice = async function () {
@@ -401,7 +403,7 @@
window.savePrompt = async function () {
await window.go.main.App.SaveSettings({ ai_prompt: document.getElementById('aiPrompt').value.trim() });
showSaveStatus('Prompt Saved!');
showSaveStatus('Prompt saved');
};
window.toggleStartup = async function () {
@@ -409,7 +411,7 @@
};
window.clearHistory = async function () {
if (confirm('Clear all history?')) {
if (confirm('Clear all transcription history?')) {
await window.go.main.App.ClearHistory();
renderHistoryList([]);
}
@@ -422,19 +424,19 @@
const statusEl = document.getElementById('connectionStatus');
if (isOnline) {
statusEl.innerHTML = '🟢 Online - Cloud transcription available';
statusEl.style.background = 'rgba(34, 197, 94, 0.1)';
statusEl.style.color = '#22c55e';
statusEl.innerHTML = ' Online';
statusEl.style.background = 'var(--green-dim)';
statusEl.style.color = 'var(--green)';
statusEl.style.border = '1px solid rgba(61, 186, 110, 0.2)';
} else {
statusEl.innerHTML = '🔴 Offline - Install local Whisper for transcription';
statusEl.style.background = 'rgba(239, 68, 68, 0.1)';
statusEl.style.color = '#ef4444';
statusEl.innerHTML = ' Offline — install local Whisper for transcription';
statusEl.style.background = 'var(--red-dim)';
statusEl.style.color = 'var(--red)';
statusEl.style.border = '1px solid rgba(224, 82, 82, 0.2)';
// Disable cloud AI options when offline
const aiSelect = document.getElementById('aiModel');
const whisperSelect = document.getElementById('whisperModel');
// Add offline notice to options
for (let opt of aiSelect.options) {
if (opt.value !== 'None' && !opt.value.startsWith('local-')) {
opt.disabled = true;
@@ -467,7 +469,7 @@
// Add local option to whisper dropdown
const localOption = document.createElement('option');
localOption.value = 'local-' + info.model;
localOption.textContent = '🖥️ Local - ' + info.model + ' (offline)';
localOption.textContent = ' Local ' + info.model + ' (offline)';
localOption.style.fontWeight = 'bold';
whisperSelect.insertBefore(localOption, whisperSelect.firstChild);
} else {
@@ -498,12 +500,12 @@
}
btn.disabled = false;
btn.textContent = '🚀 Install Offline Whisper';
btn.textContent = 'Install Offline Whisper';
};
// Uninstall whisper
window.uninstallWhisper = async function () {
if (!confirm('Are you sure you want to uninstall offline Whisper? This will delete the downloaded model.')) {
if (!confirm('Uninstall offline Whisper? This will delete the downloaded model.')) {
return;
}
@@ -518,21 +520,14 @@
// Init
async function initialize() {
// Check platform definitively using userAgent to guarantee it fires immediately without Wails timing issues
try {
const isLinux = navigator.userAgent.toLowerCase().includes('linux');
if (isLinux) {
// Hide Offline Whisper section
const offlineSection = document.getElementById('offlineWhisperSection');
if (offlineSection) {
offlineSection.style.display = 'none';
}
// Clean up 'Windows' reference in startup text specifically for Linux OS
if (offlineSection) offlineSection.style.display = 'none';
const startupLabel = document.getElementById('startupLabel');
if (startupLabel) {
startupLabel.innerText = 'Run on Linux OS Startup';
}
if (startupLabel) startupLabel.innerText = 'Run on Startup';
}
} catch (err) {
console.error('Failed to get environment:', err);
@@ -548,8 +543,8 @@
refreshHistoryFromBackend();
});
}
// If Linux, ensure Local Whisper option is removed from Whisper Select as a secondary guard
// If Linux, ensure Local Whisper option is removed
if (navigator.userAgent.toLowerCase().includes('linux')) {
const whisperSelect = document.getElementById('whisperModel');
if (whisperSelect) {
+16 -1
View File
@@ -1 +1,16 @@
../esbuild/bin/esbuild
#!/bin/sh
basedir=$(dirname "$(echo "$0" | sed -e 's,\\,/,g')")
case `uname` in
*CYGWIN*|*MINGW*|*MSYS*)
if command -v cygpath > /dev/null 2>&1; then
basedir=`cygpath -w "$basedir"`
fi
;;
esac
if [ -x "$basedir/node" ]; then
exec "$basedir/node" "$basedir/../esbuild/bin/esbuild" "$@"
else
exec node "$basedir/../esbuild/bin/esbuild" "$@"
fi
+17
View File
@@ -0,0 +1,17 @@
@ECHO off
GOTO start
:find_dp0
SET dp0=%~dp0
EXIT /b
:start
SETLOCAL
CALL :find_dp0
IF EXIST "%dp0%\node.exe" (
SET "_prog=%dp0%\node.exe"
) ELSE (
SET "_prog=node"
SET PATHEXT=%PATHEXT:;.JS;=;%
)
endLocal & goto #_undefined_# 2>NUL || title %COMSPEC% & "%_prog%" "%dp0%\..\esbuild\bin\esbuild" %*
+28
View File
@@ -0,0 +1,28 @@
#!/usr/bin/env pwsh
$basedir=Split-Path $MyInvocation.MyCommand.Definition -Parent
$exe=""
if ($PSVersionTable.PSVersion -lt "6.0" -or $IsWindows) {
# Fix case when both the Windows and Linux builds of Node
# are installed in the same directory
$exe=".exe"
}
$ret=0
if (Test-Path "$basedir/node$exe") {
# Support pipeline input
if ($MyInvocation.ExpectingInput) {
$input | & "$basedir/node$exe" "$basedir/../esbuild/bin/esbuild" $args
} else {
& "$basedir/node$exe" "$basedir/../esbuild/bin/esbuild" $args
}
$ret=$LASTEXITCODE
} else {
# Support pipeline input
if ($MyInvocation.ExpectingInput) {
$input | & "node$exe" "$basedir/../esbuild/bin/esbuild" $args
} else {
& "node$exe" "$basedir/../esbuild/bin/esbuild" $args
}
$ret=$LASTEXITCODE
}
exit $ret
+16 -1
View File
@@ -1 +1,16 @@
../nanoid/bin/nanoid.cjs
#!/bin/sh
basedir=$(dirname "$(echo "$0" | sed -e 's,\\,/,g')")
case `uname` in
*CYGWIN*|*MINGW*|*MSYS*)
if command -v cygpath > /dev/null 2>&1; then
basedir=`cygpath -w "$basedir"`
fi
;;
esac
if [ -x "$basedir/node" ]; then
exec "$basedir/node" "$basedir/../nanoid/bin/nanoid.cjs" "$@"
else
exec node "$basedir/../nanoid/bin/nanoid.cjs" "$@"
fi
+17
View File
@@ -0,0 +1,17 @@
@ECHO off
GOTO start
:find_dp0
SET dp0=%~dp0
EXIT /b
:start
SETLOCAL
CALL :find_dp0
IF EXIST "%dp0%\node.exe" (
SET "_prog=%dp0%\node.exe"
) ELSE (
SET "_prog=node"
SET PATHEXT=%PATHEXT:;.JS;=;%
)
endLocal & goto #_undefined_# 2>NUL || title %COMSPEC% & "%_prog%" "%dp0%\..\nanoid\bin\nanoid.cjs" %*
+28
View File
@@ -0,0 +1,28 @@
#!/usr/bin/env pwsh
$basedir=Split-Path $MyInvocation.MyCommand.Definition -Parent
$exe=""
if ($PSVersionTable.PSVersion -lt "6.0" -or $IsWindows) {
# Fix case when both the Windows and Linux builds of Node
# are installed in the same directory
$exe=".exe"
}
$ret=0
if (Test-Path "$basedir/node$exe") {
# Support pipeline input
if ($MyInvocation.ExpectingInput) {
$input | & "$basedir/node$exe" "$basedir/../nanoid/bin/nanoid.cjs" $args
} else {
& "$basedir/node$exe" "$basedir/../nanoid/bin/nanoid.cjs" $args
}
$ret=$LASTEXITCODE
} else {
# Support pipeline input
if ($MyInvocation.ExpectingInput) {
$input | & "node$exe" "$basedir/../nanoid/bin/nanoid.cjs" $args
} else {
& "node$exe" "$basedir/../nanoid/bin/nanoid.cjs" $args
}
$ret=$LASTEXITCODE
}
exit $ret
+16 -1
View File
@@ -1 +1,16 @@
../resolve/bin/resolve
#!/bin/sh
basedir=$(dirname "$(echo "$0" | sed -e 's,\\,/,g')")
case `uname` in
*CYGWIN*|*MINGW*|*MSYS*)
if command -v cygpath > /dev/null 2>&1; then
basedir=`cygpath -w "$basedir"`
fi
;;
esac
if [ -x "$basedir/node" ]; then
exec "$basedir/node" "$basedir/../resolve/bin/resolve" "$@"
else
exec node "$basedir/../resolve/bin/resolve" "$@"
fi
+17
View File
@@ -0,0 +1,17 @@
@ECHO off
GOTO start
:find_dp0
SET dp0=%~dp0
EXIT /b
:start
SETLOCAL
CALL :find_dp0
IF EXIST "%dp0%\node.exe" (
SET "_prog=%dp0%\node.exe"
) ELSE (
SET "_prog=node"
SET PATHEXT=%PATHEXT:;.JS;=;%
)
endLocal & goto #_undefined_# 2>NUL || title %COMSPEC% & "%_prog%" "%dp0%\..\resolve\bin\resolve" %*
+28
View File
@@ -0,0 +1,28 @@
#!/usr/bin/env pwsh
$basedir=Split-Path $MyInvocation.MyCommand.Definition -Parent
$exe=""
if ($PSVersionTable.PSVersion -lt "6.0" -or $IsWindows) {
# Fix case when both the Windows and Linux builds of Node
# are installed in the same directory
$exe=".exe"
}
$ret=0
if (Test-Path "$basedir/node$exe") {
# Support pipeline input
if ($MyInvocation.ExpectingInput) {
$input | & "$basedir/node$exe" "$basedir/../resolve/bin/resolve" $args
} else {
& "$basedir/node$exe" "$basedir/../resolve/bin/resolve" $args
}
$ret=$LASTEXITCODE
} else {
# Support pipeline input
if ($MyInvocation.ExpectingInput) {
$input | & "node$exe" "$basedir/../resolve/bin/resolve" $args
} else {
& "node$exe" "$basedir/../resolve/bin/resolve" $args
}
$ret=$LASTEXITCODE
}
exit $ret
+16 -1
View File
@@ -1 +1,16 @@
../rollup/dist/bin/rollup
#!/bin/sh
basedir=$(dirname "$(echo "$0" | sed -e 's,\\,/,g')")
case `uname` in
*CYGWIN*|*MINGW*|*MSYS*)
if command -v cygpath > /dev/null 2>&1; then
basedir=`cygpath -w "$basedir"`
fi
;;
esac
if [ -x "$basedir/node" ]; then
exec "$basedir/node" "$basedir/../rollup/dist/bin/rollup" "$@"
else
exec node "$basedir/../rollup/dist/bin/rollup" "$@"
fi
+17
View File
@@ -0,0 +1,17 @@
@ECHO off
GOTO start
:find_dp0
SET dp0=%~dp0
EXIT /b
:start
SETLOCAL
CALL :find_dp0
IF EXIST "%dp0%\node.exe" (
SET "_prog=%dp0%\node.exe"
) ELSE (
SET "_prog=node"
SET PATHEXT=%PATHEXT:;.JS;=;%
)
endLocal & goto #_undefined_# 2>NUL || title %COMSPEC% & "%_prog%" "%dp0%\..\rollup\dist\bin\rollup" %*
+28
View File
@@ -0,0 +1,28 @@
#!/usr/bin/env pwsh
$basedir=Split-Path $MyInvocation.MyCommand.Definition -Parent
$exe=""
if ($PSVersionTable.PSVersion -lt "6.0" -or $IsWindows) {
# Fix case when both the Windows and Linux builds of Node
# are installed in the same directory
$exe=".exe"
}
$ret=0
if (Test-Path "$basedir/node$exe") {
# Support pipeline input
if ($MyInvocation.ExpectingInput) {
$input | & "$basedir/node$exe" "$basedir/../rollup/dist/bin/rollup" $args
} else {
& "$basedir/node$exe" "$basedir/../rollup/dist/bin/rollup" $args
}
$ret=$LASTEXITCODE
} else {
# Support pipeline input
if ($MyInvocation.ExpectingInput) {
$input | & "node$exe" "$basedir/../rollup/dist/bin/rollup" $args
} else {
& "node$exe" "$basedir/../rollup/dist/bin/rollup" $args
}
$ret=$LASTEXITCODE
}
exit $ret
+16 -1
View File
@@ -1 +1,16 @@
../vite/bin/vite.js
#!/bin/sh
basedir=$(dirname "$(echo "$0" | sed -e 's,\\,/,g')")
case `uname` in
*CYGWIN*|*MINGW*|*MSYS*)
if command -v cygpath > /dev/null 2>&1; then
basedir=`cygpath -w "$basedir"`
fi
;;
esac
if [ -x "$basedir/node" ]; then
exec "$basedir/node" "$basedir/../vite/bin/vite.js" "$@"
else
exec node "$basedir/../vite/bin/vite.js" "$@"
fi
+17
View File
@@ -0,0 +1,17 @@
@ECHO off
GOTO start
:find_dp0
SET dp0=%~dp0
EXIT /b
:start
SETLOCAL
CALL :find_dp0
IF EXIST "%dp0%\node.exe" (
SET "_prog=%dp0%\node.exe"
) ELSE (
SET "_prog=node"
SET PATHEXT=%PATHEXT:;.JS;=;%
)
endLocal & goto #_undefined_# 2>NUL || title %COMSPEC% & "%_prog%" "%dp0%\..\vite\bin\vite.js" %*
+28
View File
@@ -0,0 +1,28 @@
#!/usr/bin/env pwsh
$basedir=Split-Path $MyInvocation.MyCommand.Definition -Parent
$exe=""
if ($PSVersionTable.PSVersion -lt "6.0" -or $IsWindows) {
# Fix case when both the Windows and Linux builds of Node
# are installed in the same directory
$exe=".exe"
}
$ret=0
if (Test-Path "$basedir/node$exe") {
# Support pipeline input
if ($MyInvocation.ExpectingInput) {
$input | & "$basedir/node$exe" "$basedir/../vite/bin/vite.js" $args
} else {
& "$basedir/node$exe" "$basedir/../vite/bin/vite.js" $args
}
$ret=$LASTEXITCODE
} else {
# Support pipeline input
if ($MyInvocation.ExpectingInput) {
$input | & "node$exe" "$basedir/../vite/bin/vite.js" $args
} else {
& "node$exe" "$basedir/../vite/bin/vite.js" $args
}
$ret=$LASTEXITCODE
}
exit $ret
+4 -4
View File
@@ -42,10 +42,10 @@
"esbuild-windows-arm64": "0.15.18"
}
},
"node_modules/esbuild-linux-64": {
"node_modules/esbuild-windows-64": {
"version": "0.15.18",
"resolved": "https://registry.npmjs.org/esbuild-linux-64/-/esbuild-linux-64-0.15.18.tgz",
"integrity": "sha512-hNSeP97IviD7oxLKFuii5sDPJ+QHeiFTFLoLm7NZQligur8poNOWGIgpQ7Qf8Balb69hptMZzyOBIPtY09GZYw==",
"resolved": "https://registry.npmjs.org/esbuild-windows-64/-/esbuild-windows-64-0.15.18.tgz",
"integrity": "sha512-qinug1iTTaIIrCorAUjR0fcBk24fjzEedFYhhispP8Oc7SFvs+XeW3YpAKiKp8dRpizl4YYAhxMjlftAMJiaUw==",
"cpu": [
"x64"
],
@@ -53,7 +53,7 @@
"license": "MIT",
"optional": true,
"os": [
"linux"
"win32"
],
"engines": {
"node": ">=12"
-3
View File
@@ -1,3 +0,0 @@
# esbuild
This is the Linux 64-bit binary for esbuild, a JavaScript bundler and minifier. See https://github.com/evanw/esbuild for details.
+3
View File
@@ -0,0 +1,3 @@
# esbuild
This is the Windows 64-bit binary for esbuild, a JavaScript bundler and minifier. See https://github.com/evanw/esbuild for details.
+14
View File
@@ -0,0 +1,14 @@
#!/usr/bin/env node
// Unfortunately even though npm shims "bin" commands on Windows with auto-
// generated forwarding scripts, it doesn't strip the ".exe" from the file name
// first. So it's possible to publish executables via npm on all platforms
// except Windows. I consider this a npm bug.
//
// My workaround is to add this script as another layer of indirection. It'll
// be slower because node has to boot up just to shell out to the actual exe,
// but Windows is somewhat of a second-class platform to npm so it's the best
// I can do I think.
const esbuild_exe = require.resolve('esbuild-windows-64/esbuild.exe');
const child_process = require('child_process');
child_process.spawnSync(esbuild_exe, process.argv.slice(2), { stdio: 'inherit' });
Binary file not shown.
@@ -1,7 +1,7 @@
{
"name": "esbuild-linux-64",
"name": "esbuild-windows-64",
"version": "0.15.18",
"description": "The Linux 64-bit binary for esbuild, a JavaScript bundler.",
"description": "The Windows 64-bit binary for esbuild, a JavaScript bundler.",
"repository": "https://github.com/evanw/esbuild",
"license": "MIT",
"preferUnplugged": true,
@@ -9,7 +9,7 @@
"node": ">=12"
},
"os": [
"linux"
"win32"
],
"cpu": [
"x64"
BIN
View File
Binary file not shown.
+1 -1
View File
@@ -1 +1 @@
5fbf12469d224a93954efecb5886e8a6
be0c7dc3573b2470ab6a8ec4c48845b6
+256 -169
View File
@@ -1,125 +1,168 @@
@import url('https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600&display=swap');
:root {
--bg-color: #0b0f1a;
--card-bg: rgba(30, 41, 59, 0.7);
--input-bg: rgba(51, 65, 85, 0.5);
--text-color: #f8fafc;
--text-muted: #94a3b8;
--primary-color: #3b82f6;
--primary-hover: #2563eb;
--accent-color: #6366f1;
--border-color: rgba(75, 85, 99, 0.4);
--success-color: #10b981;
--glass-border: rgba(255, 255, 255, 0.1);
--bg: #111113;
--surface: #1a1a1e;
--surface-2: #222226;
--border: rgba(255, 255, 255, 0.07);
--border-hover: rgba(255, 255, 255, 0.13);
--text: #e8e8ea;
--text-2: #8a8a90;
--text-3: #555560;
--accent: #e8622a;
--accent-dim: rgba(232, 98, 42, 0.15);
--accent-hover: #f07040;
--green: #3dba6e;
--green-dim: rgba(61, 186, 110, 0.12);
--red: #e05252;
--red-dim: rgba(224, 82, 82, 0.12);
--radius: 10px;
--radius-sm: 7px;
color-scheme: dark;
}
* { box-sizing: border-box; }
body {
background: radial-gradient(circle at top right, #1e293b, #0b0f1a);
color: var(--text-color);
background: var(--bg);
color: var(--text);
font-family: 'Inter', system-ui, -apple-system, sans-serif;
font-size: 14px;
margin: 0;
padding: 30px;
padding: 0;
min-height: 100vh;
letter-spacing: -0.01em;
line-height: 1.5;
-webkit-font-smoothing: antialiased;
}
/* ── Layout ── */
.container {
max-width: 720px;
max-width: 680px;
margin: 0 auto;
padding: 32px 28px 60px;
}
/* ── Header ── */
h1 {
font-size: 28px;
font-weight: 700;
background: linear-gradient(135deg, #60a5fa, #a78bfa);
-webkit-background-clip: text;
background-clip: text;
-webkit-text-fill-color: transparent;
margin-bottom: 30px;
font-size: 20px;
font-weight: 600;
color: var(--text);
margin: 0;
letter-spacing: -0.02em;
}
.flex-between {
display: flex;
justify-content: space-between;
align-items: center;
}
.flex-row {
display: flex;
gap: 10px;
align-items: center;
flex-wrap: nowrap;
}
/* ── Section cards ── */
.section {
background: var(--card-bg);
backdrop-filter: blur(12px);
-webkit-backdrop-filter: blur(12px);
padding: 24px;
border-radius: 16px;
margin-bottom: 24px;
border: 1px solid var(--glass-border);
box-shadow: 0 10px 15px -3px rgba(0, 0, 0, 0.2);
transition: transform 0.2s ease;
background: var(--surface);
border: 1px solid var(--border);
border-radius: var(--radius);
padding: 20px 22px;
margin-bottom: 10px;
transition: border-color 0.15s ease;
}
.section:hover {
border-color: rgba(255, 255, 255, 0.2);
border-color: var(--border-hover);
}
/* ── Labels ── */
label {
display: block;
color: var(--text-muted);
font-size: 13px;
font-weight: 600;
text-transform: uppercase;
letter-spacing: 0.05em;
margin-bottom: 10px;
font-weight: 500;
color: var(--text);
margin-bottom: 9px;
text-transform: none;
letter-spacing: normal;
}
input[type="checkbox"] {
accent-color: var(--primary-color);
width: 18px;
height: 18px;
cursor: pointer;
.hint {
font-size: 12px;
color: var(--text-2);
margin-top: 7px;
line-height: 1.5;
}
/* ── Inputs ── */
input[type="text"],
input[type="password"],
select,
textarea {
width: 100%;
background: var(--input-bg);
border: 1px solid var(--border-color);
color: white;
padding: 12px 14px;
border-radius: 10px;
font-size: 14px;
transition: all 0.2s ease;
box-sizing: border-box;
}
select {
appearance: none;
background: var(--surface-2);
border: 1px solid var(--border);
color: var(--text);
padding: 9px 12px;
border-radius: var(--radius-sm);
font-size: 13.5px;
font-family: inherit;
transition: border-color 0.15s ease, box-shadow 0.15s ease;
outline: none;
-webkit-appearance: none;
background-image: url("data:image/svg+xml;charset=UTF-8,%3csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24' fill='none' stroke='%2394a3b8' stroke-width='2' stroke-linecap='round' stroke-linejoin='round'%3e%3cpolyline points='6 9 12 15 18 9'%3e%3c/polyline%3e%3c/svg%3e");
background-repeat: no-repeat;
background-position: right 14px center;
background-size: 16px;
padding-right: 40px;
}
select option {
background: var(--bg-color);
color: white;
}
textarea {
resize: vertical;
min-height: 80px;
appearance: none;
}
input:focus,
select:focus,
textarea:focus {
outline: none;
border-color: var(--primary-color);
background: rgba(51, 65, 85, 0.8);
box-shadow: 0 0 0 3px rgba(59, 130, 246, 0.2);
border-color: var(--accent);
box-shadow: 0 0 0 3px var(--accent-dim);
}
input::placeholder,
textarea::placeholder {
color: var(--text-3);
}
/* Custom select arrow */
select {
background-image: url("data:image/svg+xml;charset=UTF-8,%3csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 16 16' fill='none' stroke='%23555560' stroke-width='1.5' stroke-linecap='round' stroke-linejoin='round'%3e%3cpolyline points='4 6 8 10 12 6'%3e%3c/polyline%3e%3c/svg%3e");
background-repeat: no-repeat;
background-position: right 11px center;
background-size: 14px;
padding-right: 34px;
cursor: pointer;
}
select option {
background: var(--surface-2);
color: var(--text);
}
textarea {
resize: vertical;
min-height: 80px;
line-height: 1.6;
}
input[type="checkbox"] {
accent-color: var(--accent);
width: 16px;
height: 16px;
cursor: pointer;
border-radius: 4px;
flex-shrink: 0;
}
/* ── Form control width ── */
.form-control {
max-width: 480px;
width: 100%;
}
/* ── Input wrapper (for eye-btn) ── */
.input-wrapper {
position: relative;
flex: 1;
@@ -127,10 +170,7 @@ textarea:focus {
}
.input-wrapper input {
width: 100%;
padding-right: 44px;
box-sizing: border-box;
display: block;
padding-right: 40px;
}
.eye-btn {
@@ -142,35 +182,96 @@ textarea:focus {
border: none !important;
box-shadow: none !important;
cursor: pointer;
padding: 4px !important;
font-size: 16px;
opacity: 0.5;
transition: opacity 0.2s;
padding: 2px !important;
font-size: 15px;
opacity: 0.35;
transition: opacity 0.15s;
line-height: 1;
display: flex;
align-items: center;
justify-content: center;
}
.eye-btn:hover {
opacity: 1;
transform: translateY(-50%) scale(1.1);
opacity: 0.8;
transform: translateY(-50%);
box-shadow: none !important;
}
/* ── Buttons ── */
button {
background: var(--accent);
color: #fff;
border: none;
padding: 9px 16px;
border-radius: var(--radius-sm);
cursor: pointer;
font-family: inherit;
font-weight: 500;
font-size: 13.5px;
transition: background 0.15s ease, transform 0.1s ease;
white-space: nowrap;
flex-shrink: 0;
}
button:hover {
background: var(--accent-hover);
transform: none;
box-shadow: none;
}
button:active {
transform: scale(0.97);
}
.btn-link {
background: transparent !important;
border: none !important;
box-shadow: none !important;
color: var(--red) !important;
padding: 0 !important;
font-size: 12px !important;
text-decoration: none;
opacity: 0.75;
min-height: 0;
}
.btn-link:hover {
opacity: 1;
transform: none;
background: transparent !important;
}
.btn-danger {
background: transparent !important;
border: 1px solid var(--red) !important;
color: var(--red) !important;
box-shadow: none !important;
font-size: 13px !important;
}
.btn-danger:hover {
background: var(--red-dim) !important;
opacity: 1 !important;
}
/* ── Save status toast ── */
.save-status {
position: fixed;
bottom: 30px;
right: 30px;
background: var(--success-color);
color: white;
padding: 12px 24px;
border-radius: 12px;
font-weight: 600;
box-shadow: 0 10px 15px -3px rgba(0, 0, 0, 0.2);
transform: translateY(100px);
bottom: 24px;
right: 24px;
background: var(--surface-2);
border: 1px solid var(--border-hover);
color: var(--green);
padding: 10px 18px;
border-radius: var(--radius);
font-weight: 500;
font-size: 13px;
box-shadow: 0 8px 24px rgba(0,0,0,0.4);
transform: translateY(16px);
opacity: 0;
transition: all 0.3s cubic-bezier(0.4, 0, 0.2, 1);
transition: all 0.2s cubic-bezier(0.4, 0, 0.2, 1);
z-index: 1000;
pointer-events: none;
}
.save-status.show {
@@ -178,98 +279,84 @@ textarea:focus {
opacity: 1;
}
button {
background-color: var(--primary-color);
color: white;
border: none;
padding: 10px 20px;
border-radius: 10px;
cursor: pointer;
font-weight: 600;
font-size: 14px;
transition: all 0.2s cubic-bezier(0.4, 0, 0.2, 1);
box-shadow: 0 4px 6px -1px rgba(0, 0, 0, 0.2);
white-space: nowrap;
flex-shrink: 0;
}
button:hover {
transform: translateY(-1px);
box-shadow: 0 10px 15px -3px rgba(0, 0, 0, 0.3);
background-color: var(--primary-hover);
}
button:active {
transform: translateY(0);
}
button[style*="background: transparent"] {
background: transparent !important;
box-shadow: none !important;
text-decoration: underline;
opacity: 0.7;
}
button[style*="background: transparent"]:hover {
opacity: 1;
}
.flex-row {
display: flex;
gap: 12px;
align-items: center;
flex-wrap: nowrap;
}
.flex-between {
display: flex;
justify-content: space-between;
align-items: center;
}
/* ── History ── */
.history-list {
max-height: 250px;
max-height: 240px;
overflow-y: auto;
margin-top: 10px;
padding-right: 5px;
margin-top: 4px;
}
.history-item {
background: rgba(255, 255, 255, 0.03);
padding: 14px;
border-radius: 12px;
margin-bottom: 10px;
border: 1px solid rgba(255, 255, 255, 0.05);
transition: all 0.2s ease;
background: var(--surface-2);
border: 1px solid var(--border);
padding: 11px 14px;
border-radius: var(--radius-sm);
margin-bottom: 6px;
transition: border-color 0.15s;
}
.history-item:hover {
background: rgba(255, 255, 255, 0.06);
transform: translateX(2px);
border-color: var(--border-hover);
transform: none;
}
.history-time {
color: var(--primary-color);
color: var(--text-2);
font-size: 11px;
font-weight: 700;
margin-bottom: 6px;
font-weight: 500;
margin-bottom: 4px;
display: block;
}
/* Scrollbar excellence */
::-webkit-scrollbar {
width: 6px;
/* ── Inline status badges (online/offline) ── */
#connectionStatus {
border-radius: var(--radius-sm) !important;
font-size: 13px !important;
font-weight: 500;
}
::-webkit-scrollbar-track {
background: transparent;
/* Installed whisper card */
#whisperInstalled > div {
border-radius: var(--radius-sm) !important;
}
/* Install button override */
#installBtn {
background: var(--accent) !important;
border-radius: var(--radius-sm) !important;
font-size: 14px !important;
padding: 11px 20px !important;
width: 100%;
}
#installBtn:hover {
background: var(--accent-hover) !important;
}
/* ── Offline Whisper section separator ── */
#offlineWhisperSection {
border-top: 1px solid var(--border) !important;
margin-top: 20px !important;
padding-top: 20px !important;
}
/* ── Section group header divider ── */
.section-group-label {
font-size: 11px;
font-weight: 600;
text-transform: uppercase;
letter-spacing: 0.07em;
color: var(--text-3);
margin: 22px 0 8px;
}
/* ── Scrollbar ── */
::-webkit-scrollbar { width: 5px; }
::-webkit-scrollbar-track { background: transparent; }
::-webkit-scrollbar-thumb {
background: var(--border-color);
background: var(--surface-2);
border-radius: 10px;
}
::-webkit-scrollbar-thumb:hover {
background: var(--text-muted);
}
::-webkit-scrollbar-thumb:hover { background: var(--border-hover); }
+1 -82
View File
@@ -246,85 +246,4 @@ export function OnFileDropOff() :void
export function CanResolveFilePaths(): boolean;
// Resolves file paths for an array of files
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>;
export function ResolveFilePaths(files: File[]): void
-56
View File
@@ -239,60 +239,4 @@ 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);
}
+4 -2
View File
@@ -7,6 +7,7 @@ import (
"fmt"
"os"
"sync"
"sync/atomic"
"math"
"unsafe"
@@ -215,7 +216,8 @@ func (r *AudioRecorder) Stop() error {
if r.outputFile != nil {
// Rewrite header with correct data size
r.outputFile.Seek(0, 0)
if err := r.writeWAVHeader(r.dataSize); err != nil {
finalSize := atomic.LoadUint32(&r.dataSize)
if err := r.writeWAVHeader(finalSize); err != nil {
logger.Error("Failed to update WAV header: %v", err)
}
r.outputFile.Close()
@@ -256,7 +258,7 @@ func (r *AudioRecorder) Cleanup() {
func (r *AudioRecorder) onAudioData(_, inputSamples []byte, _ uint32) {
if r.outputFile != nil && len(inputSamples) > 0 {
n, _ := r.outputFile.Write(inputSamples)
r.dataSize += uint32(n)
atomic.AddUint32(&r.dataSize, uint32(n))
// Calculate volume if callback is set
if r.OnVolume != nil {
+25 -4
View File
@@ -29,6 +29,7 @@ type HistoryItem struct {
// Config represents the complete application configuration.
type Config struct {
Version int `json:"version"`
APIKey string `json:"api_key"`
Shortcut string `json:"shortcut"`
WhisperModel string `json:"whisper_model"`
@@ -39,6 +40,9 @@ type Config struct {
History []HistoryItem `json:"history"`
}
const CurrentConfigVersion = 1
const MaxHistoryItems = 100
// DefaultConfig returns a new configuration with sensible default values.
func DefaultConfig() *Config {
return &Config{
@@ -70,12 +74,21 @@ func Load(configPath string) (*Config, error) {
return nil, err
}
// Apply defaults for any missing fields
// Apply defaults and migrations
cfg.migrate()
cfg.applyDefaults()
return &cfg, nil
}
// migrate handles version-based configuration upgrades
func (c *Config) migrate() {
if c.Version < CurrentConfigVersion {
// Migration logic for future versions goes here
c.Version = CurrentConfigVersion
}
}
// Save writes the configuration to the specified file path.
// If configPath is empty, it uses the default configuration path.
func Save(c *Config, configPath string) error {
@@ -134,12 +147,20 @@ func (c *Config) applyDefaults() {
}
}
// AddHistoryItem adds a new transcription to the history.
// AddHistoryItem adds a new transcription to the history, enforcing a maximum limit.
func (c *Config) AddHistoryItem(text, timestamp string) {
c.History = append(c.History, HistoryItem{
newItem := HistoryItem{
Text: text,
Timestamp: timestamp,
})
}
// 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]
}
}
// ClearHistory removes all history items.
+8 -3
View File
@@ -26,7 +26,7 @@ const (
const (
DefaultWhisperModel = "whisper-large-v3-turbo"
DefaultAIModel = "llama-3.3-70b-versatile"
HTTPTimeout = 60 * time.Second
HTTPTimeout = 300 * time.Second
RefinementTemp = 0.3 // Temperature for text refinement (lower = more deterministic)
)
@@ -118,15 +118,20 @@ func (c *Client) TranscribeAudio(audioFilePath, language string) (string, error)
// RefineText uses an LLM to clean up and correct transcribed text.
// If the AI model is set to "None" or the API key is missing, returns the original text.
func (c *Client) RefineText(text string) (string, error) {
func (c *Client) RefineText(text string, activeContext string) (string, error) {
if c.apiKey == "" || c.aiModel == "None" {
return text, nil
}
systemPrompt := c.aiPrompt
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)
}
payload := map[string]interface{}{
"model": c.aiModel,
"messages": []map[string]string{
{"role": "system", "content": c.aiPrompt},
{"role": "system", "content": systemPrompt},
{"role": "user", "content": text},
},
"temperature": RefinementTemp,
+8 -4
View File
@@ -261,7 +261,7 @@ func (m *Manager) Uninstall() error {
}
// Transcribe runs local whisper transcription
func (m *Manager) Transcribe(audioPath string) (string, error) {
func (m *Manager) Transcribe(audioPath string, language string) (string, error) {
if !m.IsInstalled() {
return "", fmt.Errorf("whisper is not installed")
}
@@ -280,11 +280,15 @@ func (m *Manager) Transcribe(audioPath string) (string, error) {
modelPath = filepath.Join(m.installDir, modelFilename)
}
logger.Info("Running whisper: %s -m %s -f %s", binaryPath, modelPath, audioPath)
if language == "" {
language = "auto"
}
// Run whisper with simple arguments: ./main -m model.bin -f audio.wav
logger.Info("Running whisper: %s -m %s -l %s -f %s", binaryPath, modelPath, language, audioPath)
// 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, "-f", audioPath)
cmd := exec.Command(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)
+8 -3
View File
@@ -67,8 +67,10 @@ func onReady(app App) {
statusMenuItem = systray.AddMenuItem("Status: Ready", "Current application status")
statusMenuItem.Disable()
triggerCountItem = systray.AddMenuItem("Shortcut detected: 0 times", "Troubleshooting counter")
triggerCountItem.Disable()
if runtime.GOOS != "windows" {
triggerCountItem = systray.AddMenuItem("Shortcut detected: 0 times", "Troubleshooting counter")
triggerCountItem.Disable()
}
systray.AddSeparator()
@@ -187,9 +189,12 @@ func icoToPNG(data []byte) ([]byte, error) {
return nil, fmt.Errorf("no PNG image found in ico")
}
var lastStatus string
// UpdateStatus updates the status text displayed in the tray menu.
func UpdateStatus(status string) {
if statusMenuItem != nil {
if statusMenuItem != nil && status != lastStatus {
lastStatus = status
statusMenuItem.SetTitle("Status: " + status)
systray.SetTooltip(trayLabel + " - " + status)
+15 -12
View File
@@ -5,7 +5,6 @@ import (
_ "embed"
"os"
"os/exec"
"path/filepath"
"syscall"
"wis-free-v3/internal/logger"
)
@@ -41,17 +40,21 @@ func sendKey(key byte) {
// IsPlaying checks if media is currently playing using PowerShell SMTC query
func IsPlaying() bool {
// Write script to temp file only if it doesn't exist
tempDir := os.TempDir()
scriptPath := filepath.Join(tempDir, "wis_check_media_v2.ps1")
if _, err := os.Stat(scriptPath); os.IsNotExist(err) {
err = os.WriteFile(scriptPath, checkMediaScript, 0644)
if err != nil {
logger.Error("Failed to write media check script: %v", err)
return false
}
// Create a secure temp file for the script to prevent symlink attacks
f, err := os.CreateTemp("", "wis_check_media_*.ps1")
if err != nil {
logger.Error("Failed to create secure media check script: %v", err)
return false
}
scriptPath := f.Name()
defer os.Remove(scriptPath) // Clean up immediately after execution
if _, err := f.Write(checkMediaScript); err != nil {
f.Close()
logger.Error("Failed to write media check script: %v", err)
return false
}
f.Close()
cmd := exec.Command("powershell.exe",
"-NoProfile",
@@ -67,7 +70,7 @@ func IsPlaying() bool {
CreationFlags: 0x08000000 | 0x00000200, // CREATE_NO_WINDOW | DETACHED_PROCESS
}
err := cmd.Run()
err = cmd.Run()
// Exit code 0 = not playing, Exit code 1 = playing
if err == nil {
+3 -1
View File
@@ -33,7 +33,9 @@ func AddToStartup() error {
}
defer key.Close()
if err := key.SetStringValue(appName, exePath); err != nil {
// Quote the path to handle spaces correctly and prevent execution hijacking
quotedPath := fmt.Sprintf("\"%s\"", exePath)
if err := key.SetStringValue(appName, quotedPath); err != nil {
return fmt.Errorf("failed to set registry value: %w", err)
}
+17 -1
View File
@@ -5,6 +5,22 @@ package main
// secondInstanceWake is unused on Windows (second-instance UX not wired here).
var secondInstanceWake = make(chan struct{}, 8)
func tryNotifyRunningInstanceToShow() {}
// secondInstanceCommand is unused on Windows (second-instance UX not wired here).
var secondInstanceCommand = make(chan byte, 16)
const (
instanceCmdShow byte = 1
instanceCmdStart byte = 2
instanceCmdStop byte = 3
instanceCmdToggle byte = 4
)
func tryNotifyRunningInstanceToShow() bool {
return false
}
func tryNotifyRunningInstanceAction(action string) bool {
return false
}
func runSecondInstanceListener() {}
BIN
View File
Binary file not shown.