mirror of
https://github.com/jahruz67/wisp-open.git
synced 2026-08-08 18:14:08 +00:00
Compare commits
15
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
02d26ce79b | ||
|
|
c4739dc38c | ||
|
|
7840918118 | ||
|
|
7fe25ae125 | ||
|
|
9d4d279a87 | ||
|
|
89af7f9841 | ||
|
|
e8745f926c | ||
|
|
fa147ccb81 | ||
|
|
12f314043e | ||
|
|
fb555ea533 | ||
|
|
00f4912cc3 | ||
|
|
b6ae530876 | ||
|
|
b0f4ea3e6f | ||
|
|
e645102d3a | ||
|
|
ccf4d6184c |
@@ -6,8 +6,8 @@ import (
|
||||
"os"
|
||||
"path/filepath"
|
||||
"runtime"
|
||||
"sync/atomic"
|
||||
"strings"
|
||||
"sync/atomic"
|
||||
"time"
|
||||
|
||||
"wis-free-v3/internal/audio/recorder"
|
||||
@@ -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":
|
||||
@@ -75,6 +90,7 @@ func (a *App) startup(ctx context.Context) {
|
||||
|
||||
// Initialize components
|
||||
a.startupHeadless()
|
||||
a.startLinuxPressDaemon()
|
||||
|
||||
// When the user launches the app again while it is already running (tray-only),
|
||||
// the second process signals us here so the settings window becomes visible.
|
||||
@@ -144,14 +160,16 @@ func (a *App) Quit() {
|
||||
wailsruntime.Quit(a.ctx)
|
||||
}
|
||||
|
||||
var lastToggle time.Time
|
||||
var lastToggleUnixNano int64
|
||||
|
||||
func (a *App) ToggleRecording() {
|
||||
// Debounce toggle calls to prevent rapid firing from double-binds or Wayland glitches
|
||||
if time.Since(lastToggle) < 500*time.Millisecond {
|
||||
now := time.Now().UnixNano()
|
||||
last := atomic.LoadInt64(&lastToggleUnixNano)
|
||||
if last != 0 && time.Duration(now-last) < 250*time.Millisecond {
|
||||
return
|
||||
}
|
||||
lastToggle = time.Now()
|
||||
atomic.StoreInt64(&lastToggleUnixNano, now)
|
||||
|
||||
if atomic.LoadInt32(&a.recording) == 1 {
|
||||
a.StopRecording()
|
||||
@@ -184,13 +202,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
|
||||
@@ -246,13 +272,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 {
|
||||
@@ -261,12 +289,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
|
||||
|
||||
@@ -288,12 +330,14 @@ 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)
|
||||
@@ -306,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)
|
||||
|
||||
// Paste
|
||||
a.pasteText()
|
||||
// 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")
|
||||
}
|
||||
}()
|
||||
}
|
||||
}
|
||||
|
||||
// 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")
|
||||
|
||||
@@ -373,6 +437,12 @@ func (a *App) GetSettings() map[string]interface{} {
|
||||
conf["history"] = a.config.History
|
||||
conf["startup"] = platform.IsInStartup()
|
||||
conf["app_version"] = AppVersion
|
||||
if runtime.GOOS == "linux" {
|
||||
if exePath, err := os.Executable(); err == nil {
|
||||
conf["linux_press_command"] = exePath + " --press"
|
||||
}
|
||||
conf["linux_press_mode"] = true
|
||||
}
|
||||
return conf
|
||||
}
|
||||
|
||||
@@ -381,24 +451,37 @@ func (a *App) SaveSettings(settings map[string]interface{}) string {
|
||||
if val, ok := settings["api_key"].(string); ok {
|
||||
a.config.APIKey = val
|
||||
}
|
||||
if val, ok := settings["shortcut"].(string); ok {
|
||||
_, _, modOnly, ok := hotkey.ParseShortcut(val)
|
||||
if !ok {
|
||||
logger.Error("Invalid shortcut: %s (rejected)", val)
|
||||
return "Invalid shortcut - use modifiers plus a key (e.g. ctrl+k), or modifier-only on Windows (e.g. ctrl+win)"
|
||||
}
|
||||
if modOnly && runtime.GOOS != "windows" {
|
||||
return "Modifier-only shortcuts (like ctrl+win) are only supported on Windows"
|
||||
}
|
||||
if runtime.GOOS != "linux" {
|
||||
if val, ok := settings["shortcut"].(string); ok {
|
||||
_, _, modOnly, ok := hotkey.ParseShortcut(val)
|
||||
if !ok {
|
||||
logger.Error("Invalid shortcut: %s (rejected)", val)
|
||||
return "Invalid shortcut - use modifiers plus a key (e.g. ctrl+k), or modifier-only on Windows (e.g. ctrl+win)"
|
||||
}
|
||||
if modOnly && runtime.GOOS != "windows" {
|
||||
return "Modifier-only shortcuts (like ctrl+win) are only supported on Windows"
|
||||
}
|
||||
|
||||
a.config.Shortcut = val
|
||||
// Update existing listener with new shortcut (hot-swap)
|
||||
if a.hotkeyListener != nil {
|
||||
a.hotkeyListener.UpdateShortcut(val)
|
||||
} else {
|
||||
// Should not happen if app started correctly, but just in case
|
||||
a.hotkeyListener = hotkey.NewListener(val, a.ToggleRecording, func() {})
|
||||
a.hotkeyListener.Start()
|
||||
a.config.Shortcut = val
|
||||
// Update existing listener with new shortcut (hot-swap)
|
||||
if a.hotkeyListener != nil {
|
||||
a.hotkeyListener.UpdateShortcut(val)
|
||||
} else {
|
||||
// Should not happen if app started correctly, but just in case
|
||||
a.hotkeyListener = hotkey.NewListener(val, a.StartRecording, a.StopRecording)
|
||||
if runtime.GOOS != "windows" {
|
||||
a.hotkeyListener.SetRegistrationErrorCallback(func(err error) {
|
||||
logger.Error("Linux hotkey registration failed: %v", err)
|
||||
go func() {
|
||||
time.Sleep(2 * time.Second)
|
||||
if a.overlay != nil {
|
||||
a.overlay.Show("Shortcut registration failed. Please add a custom system shortcut calling 'wis-free-v3 --action=toggle' as a fallback.")
|
||||
}
|
||||
}()
|
||||
})
|
||||
}
|
||||
a.hotkeyListener.Start()
|
||||
}
|
||||
}
|
||||
}
|
||||
if val, ok := settings["whisper_model"].(string); ok {
|
||||
@@ -548,8 +631,19 @@ 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
|
||||
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.")
|
||||
}
|
||||
}()
|
||||
})
|
||||
}
|
||||
a.hotkeyListener.Start()
|
||||
|
||||
logger.Info("Components initialized successfully!")
|
||||
|
||||
Binary file not shown.
|
Before Width: | Height: | Size: 130 KiB After Width: | Height: | Size: 373 B |
Executable → Regular
BIN
Binary file not shown.
+1
File diff suppressed because one or more lines are too long
-3
File diff suppressed because one or more lines are too long
-1
@@ -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)}
|
||||
+3
File diff suppressed because one or more lines are too long
Vendored
+70
-67
@@ -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,28 +38,36 @@
|
||||
<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>
|
||||
|
||||
<!-- Shortcut -->
|
||||
<div class="section">
|
||||
<label>Shortcut (Hold to Record)</label>
|
||||
<div class="section-group-label">Input</div>
|
||||
|
||||
<!-- Linux Wayland Hotkey Command -->
|
||||
<div class="section" id="linuxPressSection" style="display: none;">
|
||||
<label>System Hotkey Command (Linux)</label>
|
||||
<div class="form-control">
|
||||
<div class="flex-row">
|
||||
<div class="input-wrapper">
|
||||
<input type="text" id="shortcutInput" placeholder="Click Record to set..." readonly>
|
||||
<input type="text" id="linuxPressCommand" readonly>
|
||||
</div>
|
||||
<button onclick="recordShortcut()" id="recordBtn">Record</button>
|
||||
<button onclick="copyLinuxPressCommand()">Copy</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">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>
|
||||
</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 +79,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 +104,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>
|
||||
|
||||
+89
-93
@@ -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,28 +36,36 @@
|
||||
<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>
|
||||
|
||||
<!-- Shortcut -->
|
||||
<div class="section">
|
||||
<label>Shortcut (Hold to Record)</label>
|
||||
<div class="section-group-label">Input</div>
|
||||
|
||||
<!-- Linux Wayland Hotkey Command -->
|
||||
<div class="section" id="linuxPressSection" style="display: none;">
|
||||
<label>System Hotkey Command (Linux)</label>
|
||||
<div class="form-control">
|
||||
<div class="flex-row">
|
||||
<div class="input-wrapper">
|
||||
<input type="text" id="shortcutInput" placeholder="Click Record to set..." readonly>
|
||||
<input type="text" id="linuxPressCommand" readonly>
|
||||
</div>
|
||||
<button onclick="recordShortcut()" id="recordBtn">Record</button>
|
||||
<button onclick="copyLinuxPressCommand()">Copy</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">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>
|
||||
</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 +77,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 +102,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 +295,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 +374,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 +393,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 +404,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 +412,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 +425,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 +470,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 +501,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 +521,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 +544,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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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' });
|
||||
Generated
Vendored
Executable → Regular
BIN
Binary file not shown.
Generated
Vendored
+3
-3
@@ -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
Binary file not shown.
@@ -1 +1 @@
|
||||
5fbf12469d224a93954efecb5886e8a6
|
||||
be0c7dc3573b2470ab6a8ec4c48845b6
|
||||
+25
-16
@@ -24,12 +24,18 @@ async function loadSettings() {
|
||||
|
||||
// Populate fields
|
||||
document.getElementById('apiKey').value = settings.api_key || '';
|
||||
document.getElementById('shortcutInput').value = settings.shortcut || 'alt+z';
|
||||
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 || '';
|
||||
document.getElementById('startupToggle').checked = settings.startup || false;
|
||||
|
||||
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 || '';
|
||||
}
|
||||
|
||||
// Load history
|
||||
renderHistory(settings.history || []);
|
||||
|
||||
@@ -123,25 +129,28 @@ async function saveApiKey() {
|
||||
showToast('API Key saved');
|
||||
}
|
||||
|
||||
// Save Shortcut
|
||||
async function saveShortcut() {
|
||||
const shortcut = document.getElementById('shortcutInput').value.trim().toLowerCase();
|
||||
|
||||
if (!shortcut) {
|
||||
alert('Please enter a shortcut');
|
||||
return;
|
||||
async function copyLinuxPressCommand() {
|
||||
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');
|
||||
}
|
||||
showToast('Command copied');
|
||||
} catch (err) {
|
||||
console.error('Failed to copy Linux command:', err);
|
||||
}
|
||||
|
||||
const parts = shortcut.split('+');
|
||||
if (parts.length < 2) {
|
||||
alert('Shortcut must have a modifier + key (e.g., ctrl+x)');
|
||||
return;
|
||||
}
|
||||
|
||||
await saveSetting('shortcut', shortcut);
|
||||
showToast('Shortcut saved: ' + shortcut);
|
||||
}
|
||||
|
||||
window.copyLinuxPressCommand = copyLinuxPressCommand;
|
||||
|
||||
// Save Whisper Model
|
||||
async function saveWhisperModel() {
|
||||
const model = document.getElementById('whisperModel').value;
|
||||
|
||||
+256
-169
@@ -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
@@ -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
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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 {
|
||||
|
||||
@@ -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 {
|
||||
@@ -89,7 +102,7 @@ func Save(c *Config, configPath string) error {
|
||||
|
||||
// Ensure the directory exists
|
||||
dir := filepath.Dir(configPath)
|
||||
if err := os.MkdirAll(dir, 0755); err != nil {
|
||||
if err := os.MkdirAll(dir, 0700); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
@@ -98,7 +111,7 @@ func Save(c *Config, configPath string) error {
|
||||
return err
|
||||
}
|
||||
|
||||
return os.WriteFile(configPath, data, 0644)
|
||||
return os.WriteFile(configPath, data, 0600)
|
||||
}
|
||||
|
||||
// GetConfigPath returns the default configuration file path.
|
||||
@@ -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.
|
||||
|
||||
@@ -13,13 +13,14 @@ import (
|
||||
// Listener handles global hotkey events and triggers callbacks when the
|
||||
// configured shortcut is pressed and released.
|
||||
type Listener struct {
|
||||
startCallback func()
|
||||
stopCallback func()
|
||||
isListening bool
|
||||
shortcut string
|
||||
hk *xhk.Hotkey
|
||||
stopModPoll chan struct{}
|
||||
mu sync.RWMutex
|
||||
startCallback func()
|
||||
stopCallback func()
|
||||
registrationErrorCallback func(error)
|
||||
isListening bool
|
||||
shortcut string
|
||||
hk *xhk.Hotkey
|
||||
stopModPoll chan struct{}
|
||||
mu sync.RWMutex
|
||||
}
|
||||
|
||||
// NewListener creates a new hotkey listener with the specified shortcut and callbacks.
|
||||
@@ -34,6 +35,13 @@ func NewListener(shortcut string, onStart, onStop func()) *Listener {
|
||||
}
|
||||
}
|
||||
|
||||
// SetRegistrationErrorCallback sets a callback to be invoked if hotkey registration fails.
|
||||
func (l *Listener) SetRegistrationErrorCallback(cb func(error)) {
|
||||
l.mu.Lock()
|
||||
l.registrationErrorCallback = cb
|
||||
l.mu.Unlock()
|
||||
}
|
||||
|
||||
// UpdateShortcut changes the shortcut without stopping the listener.
|
||||
// This allows hot-swapping the shortcut while the application is running.
|
||||
func (l *Listener) UpdateShortcut(shortcut string) {
|
||||
@@ -90,11 +98,15 @@ func (l *Listener) Start() {
|
||||
if err := hkToRegister.Register(); err != nil {
|
||||
logger.Error("Failed to register hotkey %s: %v", shortcutToRegister, err)
|
||||
l.mu.Lock()
|
||||
cb := l.registrationErrorCallback
|
||||
if l.hk == hkToRegister {
|
||||
l.hk = nil
|
||||
l.isListening = false
|
||||
}
|
||||
l.mu.Unlock()
|
||||
if cb != nil {
|
||||
cb(err)
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
@@ -158,7 +170,11 @@ func (l *Listener) eventLoop(hk *xhk.Hotkey) {
|
||||
case <-time.After(20 * time.Millisecond):
|
||||
// Genuine second press. Toggle off.
|
||||
logger.Info("Shortcut activated again: toggling recording (Wayland toggle fallback)")
|
||||
go l.startCallback()
|
||||
if l.stopCallback != nil {
|
||||
go l.stopCallback()
|
||||
} else {
|
||||
go l.startCallback()
|
||||
}
|
||||
isRecording = false
|
||||
}
|
||||
}
|
||||
|
||||
@@ -6,6 +6,7 @@ import (
|
||||
"os/exec"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"wis-free-v3/internal/logger"
|
||||
)
|
||||
@@ -14,8 +15,9 @@ import (
|
||||
// (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
|
||||
mu sync.Mutex
|
||||
lastMsg string
|
||||
lastUpdate time.Time
|
||||
}
|
||||
|
||||
func NewOverlay() *linuxOverlay {
|
||||
@@ -75,6 +77,13 @@ func (o *linuxOverlay) SetVolume(level float64) {
|
||||
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) == "" {
|
||||
|
||||
@@ -67,9 +67,8 @@ func Close() {
|
||||
}
|
||||
|
||||
// Info logs an informational message with timestamp.
|
||||
// Modified to do nothing so only errors are logged.
|
||||
func Info(format string, args ...interface{}) {
|
||||
// Do nothing
|
||||
log("INFO", format, args...)
|
||||
}
|
||||
|
||||
// Error logs an error message with timestamp.
|
||||
@@ -77,7 +76,7 @@ func Error(format string, args ...interface{}) {
|
||||
log("ERROR", format, args...)
|
||||
}
|
||||
|
||||
// log writes a formatted log message to the log file.
|
||||
// log writes a formatted log message to the log file and console.
|
||||
func log(level, format string, args ...interface{}) {
|
||||
logMutex.Lock()
|
||||
defer logMutex.Unlock()
|
||||
@@ -95,6 +94,13 @@ func log(level, format string, args ...interface{}) {
|
||||
message := fmt.Sprintf(format, args...)
|
||||
logLine := fmt.Sprintf("[%s] %s: %s\n", timestamp, level, message)
|
||||
|
||||
// Print to console for real-time terminal output
|
||||
if level == "ERROR" {
|
||||
fmt.Fprint(os.Stderr, logLine)
|
||||
} else {
|
||||
fmt.Fprint(os.Stdout, logLine)
|
||||
}
|
||||
|
||||
if logFile != nil {
|
||||
logFile.WriteString(logLine)
|
||||
logFile.Sync()
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -74,7 +74,12 @@ func (m *Manager) IsInstalled() bool {
|
||||
return false
|
||||
}
|
||||
|
||||
modelPath := filepath.Join(m.installDir, Models[info.Model].Filename)
|
||||
modelInfo, ok := Models[info.Model]
|
||||
if !ok || modelInfo.Filename == "" {
|
||||
return false
|
||||
}
|
||||
|
||||
modelPath := filepath.Join(m.installDir, modelInfo.Filename)
|
||||
if _, err := os.Stat(modelPath); os.IsNotExist(err) {
|
||||
return false
|
||||
}
|
||||
@@ -256,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")
|
||||
}
|
||||
@@ -275,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)
|
||||
@@ -445,4 +454,3 @@ func downloadFile(url, dest string, progress chan<- DownloadProgress) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
|
||||
|
||||
Binary file not shown.
|
Before Width: | Height: | Size: 130 KiB After Width: | Height: | Size: 373 B |
@@ -7,9 +7,13 @@ import (
|
||||
_ "embed"
|
||||
"encoding/binary"
|
||||
"fmt"
|
||||
"image"
|
||||
"image/color"
|
||||
"image/png"
|
||||
"os"
|
||||
"runtime"
|
||||
"strings"
|
||||
"sync"
|
||||
|
||||
"wis-free-v3/internal/config"
|
||||
"wis-free-v3/internal/logger"
|
||||
@@ -45,6 +49,7 @@ var trayLabel = "wis-free-v3"
|
||||
var statusMenuItem *systray.MenuItem
|
||||
var triggerCountItem *systray.MenuItem
|
||||
var triggerCount int
|
||||
var iconsInitOnce sync.Once
|
||||
|
||||
func appDisplayName(app App) string {
|
||||
v := app.Version()
|
||||
@@ -56,6 +61,7 @@ func appDisplayName(app App) string {
|
||||
|
||||
// onReady is called when the system tray is ready to be configured.
|
||||
func onReady(app App) {
|
||||
initDynamicIcons()
|
||||
trayLabel = appDisplayName(app)
|
||||
|
||||
// Configure tray icon and tooltip
|
||||
@@ -67,8 +73,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()
|
||||
|
||||
@@ -140,6 +148,7 @@ func buildTooltip(app App) string {
|
||||
}
|
||||
|
||||
func getDefaultIcon() []byte {
|
||||
initDynamicIcons()
|
||||
if runtime.GOOS == "linux" {
|
||||
return iconPNGData
|
||||
}
|
||||
@@ -187,12 +196,16 @@ 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)
|
||||
|
||||
initDynamicIcons()
|
||||
if strings.Contains(status, "Recording") {
|
||||
systray.SetIcon(iconRecordingData)
|
||||
} else if strings.Contains(status, "Transcribing") {
|
||||
@@ -215,3 +228,76 @@ func IncrementTriggerCount() {
|
||||
func onExit() {
|
||||
logger.Info("System tray terminated")
|
||||
}
|
||||
|
||||
func initDynamicIcons() {
|
||||
iconsInitOnce.Do(func() {
|
||||
white := color.RGBA{R: 255, G: 255, B: 255, A: 255}
|
||||
red := color.RGBA{R: 255, G: 59, B: 48, A: 255}
|
||||
yellow := color.RGBA{R: 255, G: 204, B: 0, A: 255}
|
||||
|
||||
iconPNGData = createMicPNG(white)
|
||||
iconRecordingData = createMicPNG(red)
|
||||
iconTranscribingData = createMicPNG(yellow)
|
||||
})
|
||||
}
|
||||
|
||||
func createMicPNG(c color.Color) []byte {
|
||||
img := image.NewRGBA(image.Rect(0, 0, 64, 64))
|
||||
// All pixels are transparent by default (new RGBA starts with 0 alpha).
|
||||
|
||||
// Let's draw the microphone parts
|
||||
for y := 0; y < 64; y++ {
|
||||
for x := 0; x < 64; 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 {
|
||||
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
|
||||
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 {
|
||||
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)
|
||||
distSq := dx*dx + dy*dy
|
||||
if y >= 25 && y <= 40 && distSq >= 144 && distSq <= 225 { // 12^2 to 15^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 {
|
||||
drawPixel = true
|
||||
}
|
||||
|
||||
// 4. Base (Horizontal line at Y=50 to 52, X=20 to 44)
|
||||
if x >= 20 && x <= 44 && y >= 50 && y <= 52 {
|
||||
drawPixel = true
|
||||
}
|
||||
|
||||
if drawPixel {
|
||||
img.Set(x, y, c)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
var buf bytes.Buffer
|
||||
_ = png.Encode(&buf, img)
|
||||
return buf.Bytes()
|
||||
}
|
||||
|
||||
+15
-12
@@ -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 {
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
|
||||
@@ -7,14 +7,25 @@ import (
|
||||
"encoding/hex"
|
||||
"errors"
|
||||
"fmt"
|
||||
"log"
|
||||
"os"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/godbus/dbus/v5"
|
||||
"wis-free-v3/internal/logger"
|
||||
)
|
||||
|
||||
func unwrapVariant(val interface{}) interface{} {
|
||||
for {
|
||||
if v, ok := val.(dbus.Variant); ok {
|
||||
val = v.Value()
|
||||
} else {
|
||||
break
|
||||
}
|
||||
}
|
||||
return val
|
||||
}
|
||||
|
||||
const (
|
||||
portalBusName = "org.freedesktop.portal.Desktop"
|
||||
portalObjectPath = "/org/freedesktop/portal/desktop"
|
||||
@@ -135,6 +146,7 @@ func portalKeySpecName(key Key) string {
|
||||
func portalWaitRequest(conn *dbus.Conn, reqPath dbus.ObjectPath) (uint32, map[string]dbus.Variant, error) {
|
||||
ch := make(chan *dbus.Signal, 8)
|
||||
conn.Signal(ch)
|
||||
defer conn.RemoveSignal(ch)
|
||||
rule := fmt.Sprintf(
|
||||
"type='signal',path='%s',interface='%s',member='Response'",
|
||||
string(reqPath), ifaceRequest,
|
||||
@@ -161,11 +173,31 @@ func portalWaitRequest(conn *dbus.Conn, reqPath dbus.ObjectPath) (uint32, map[st
|
||||
if len(sig.Body) < 2 {
|
||||
continue
|
||||
}
|
||||
code, ok := sig.Body[0].(uint32)
|
||||
if !ok {
|
||||
rawCode := unwrapVariant(sig.Body[0])
|
||||
var code uint32
|
||||
switch x := rawCode.(type) {
|
||||
case uint32:
|
||||
code = x
|
||||
case int:
|
||||
code = uint32(x)
|
||||
case int32:
|
||||
code = uint32(x)
|
||||
case uint8:
|
||||
code = uint32(x)
|
||||
default:
|
||||
continue
|
||||
}
|
||||
results, _ := sig.Body[1].(map[string]dbus.Variant)
|
||||
rawResults := unwrapVariant(sig.Body[1])
|
||||
var results map[string]dbus.Variant
|
||||
switch resMap := rawResults.(type) {
|
||||
case map[string]dbus.Variant:
|
||||
results = resMap
|
||||
case map[string]interface{}:
|
||||
results = make(map[string]dbus.Variant)
|
||||
for k, val := range resMap {
|
||||
results[k] = dbus.MakeVariant(val)
|
||||
}
|
||||
}
|
||||
return code, results, nil
|
||||
case <-timeout.C:
|
||||
return 0, nil, fmt.Errorf("portal request timed out")
|
||||
@@ -174,7 +206,8 @@ func portalWaitRequest(conn *dbus.Conn, reqPath dbus.ObjectPath) (uint32, map[st
|
||||
}
|
||||
|
||||
func variantToObjectPath(v dbus.Variant) (dbus.ObjectPath, bool) {
|
||||
switch x := v.Value().(type) {
|
||||
val := unwrapVariant(v)
|
||||
switch x := val.(type) {
|
||||
case dbus.ObjectPath:
|
||||
return x, true
|
||||
case string:
|
||||
@@ -310,7 +343,6 @@ func (hk *Hotkey) portalSignalLoop() {
|
||||
|
||||
hk.mu.Lock()
|
||||
conn := hk.portalConn
|
||||
sess := hk.sessionPath
|
||||
hk.mu.Unlock()
|
||||
if conn == nil {
|
||||
return
|
||||
@@ -318,16 +350,20 @@ func (hk *Hotkey) portalSignalLoop() {
|
||||
|
||||
ch := make(chan *dbus.Signal, 32)
|
||||
conn.Signal(ch)
|
||||
defer conn.RemoveSignal(ch)
|
||||
|
||||
rule := fmt.Sprintf(
|
||||
"type='signal',interface='%s'",
|
||||
ifaceGlobalShortcuts,
|
||||
"type='signal',sender='%s',interface='%s'",
|
||||
portalBusName, ifaceGlobalShortcuts,
|
||||
)
|
||||
if err := conn.BusObject().Call("org.freedesktop.DBus.AddMatch", 0, rule).Store(); err != nil {
|
||||
log.Printf("wis-free-v3 hotkey: AddMatch GlobalShortcuts: %v", err)
|
||||
logger.Error("wis-free-v3 hotkey: AddMatch GlobalShortcuts: %v", err)
|
||||
return
|
||||
}
|
||||
defer func() { _ = conn.BusObject().Call("org.freedesktop.DBus.RemoveMatch", 0, rule).Store() }()
|
||||
|
||||
logger.Info("Listening for global shortcut signals (sender=%s, interface=%s)", portalBusName, ifaceGlobalShortcuts)
|
||||
|
||||
for {
|
||||
select {
|
||||
case <-hk.portalStop:
|
||||
@@ -339,27 +375,29 @@ func (hk *Hotkey) portalSignalLoop() {
|
||||
if len(sig.Body) < 2 {
|
||||
continue
|
||||
}
|
||||
sessVar, ok := sig.Body[0].(dbus.ObjectPath)
|
||||
if !ok {
|
||||
if s, ok := sig.Body[0].(string); ok {
|
||||
sessVar = dbus.ObjectPath(s)
|
||||
} else {
|
||||
continue
|
||||
}
|
||||
}
|
||||
if sessVar != sess {
|
||||
continue
|
||||
}
|
||||
id, ok := sig.Body[1].(string)
|
||||
|
||||
// Bypass strict session path checking to avoid mismatch bugs.
|
||||
// The shortcut ID is unique to our application.
|
||||
rawID := unwrapVariant(sig.Body[1])
|
||||
id, ok := rawID.(string)
|
||||
if !ok || id != wisfreeGlobalShortcutID {
|
||||
continue
|
||||
}
|
||||
name := sig.Name
|
||||
switch {
|
||||
case name == "Activated" || strings.HasSuffix(name, ".Activated"):
|
||||
|
||||
logger.Info("Matched global shortcut signal: name=%s", sig.Name)
|
||||
|
||||
switch sig.Name {
|
||||
case ifaceGlobalShortcuts + ".Activated":
|
||||
go func() { hk.keydownIn <- Event{} }()
|
||||
case name == "Deactivated" || strings.HasSuffix(name, ".Deactivated"):
|
||||
case ifaceGlobalShortcuts + ".Deactivated":
|
||||
go func() { hk.keyupIn <- Event{} }()
|
||||
default:
|
||||
// Fallback for different bus routing names just in case
|
||||
if strings.HasSuffix(sig.Name, ".Activated") {
|
||||
go func() { hk.keydownIn <- Event{} }()
|
||||
} else if strings.HasSuffix(sig.Name, ".Deactivated") {
|
||||
go func() { hk.keyupIn <- Event{} }()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,83 @@
|
||||
//go:build linux
|
||||
|
||||
package main
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"os"
|
||||
"os/exec"
|
||||
"sync"
|
||||
"time"
|
||||
)
|
||||
|
||||
const linuxPressAddr = "127.0.0.1:9876"
|
||||
|
||||
var (
|
||||
linuxPressMu sync.Mutex
|
||||
linuxPressTimer *time.Timer
|
||||
linuxPressRecording bool
|
||||
)
|
||||
|
||||
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)
|
||||
}
|
||||
}
|
||||
|
||||
func hasPressFlag(args []string) bool {
|
||||
for _, arg := range args {
|
||||
if arg == "--press" {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func sendLinuxPressPing() error {
|
||||
client := &http.Client{Timeout: 300 * time.Millisecond}
|
||||
resp, err := client.Get("http://" + linuxPressAddr + "/press")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
if resp.StatusCode < 200 || resp.StatusCode >= 300 {
|
||||
return os.ErrNotExist
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
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()
|
||||
}
|
||||
})
|
||||
|
||||
w.WriteHeader(http.StatusNoContent)
|
||||
})
|
||||
|
||||
go func() {
|
||||
_ = http.ListenAndServe(linuxPressAddr, mux)
|
||||
}()
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
//go:build !linux
|
||||
|
||||
package main
|
||||
|
||||
func (a *App) startLinuxPressDaemon() {}
|
||||
@@ -16,6 +16,7 @@ import (
|
||||
"github.com/wailsapp/wails/v2"
|
||||
"github.com/wailsapp/wails/v2/pkg/options"
|
||||
"github.com/wailsapp/wails/v2/pkg/options/assetserver"
|
||||
"github.com/wailsapp/wails/v2/pkg/options/linux"
|
||||
)
|
||||
|
||||
// Application constants
|
||||
@@ -86,6 +87,9 @@ func main() {
|
||||
OnBeforeClose: app.beforeClose,
|
||||
StartHidden: true,
|
||||
Bind: []interface{}{app},
|
||||
Linux: &linux.Options{
|
||||
ProgramName: "wis-free-v3",
|
||||
},
|
||||
})
|
||||
|
||||
if err != nil {
|
||||
@@ -93,6 +97,7 @@ func main() {
|
||||
}
|
||||
|
||||
// Clean up resources on exit
|
||||
cleanupSecondInstanceListener()
|
||||
releaseInstanceLock()
|
||||
}
|
||||
|
||||
|
||||
+18
-1
@@ -5,6 +5,23 @@ 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() {}
|
||||
func cleanupSecondInstanceListener() {}
|
||||
|
||||
@@ -112,3 +112,9 @@ func runSecondInstanceListener() {
|
||||
}
|
||||
}()
|
||||
}
|
||||
|
||||
func cleanupSecondInstanceListener() {
|
||||
if path, err := instanceSocketPath(); err == nil {
|
||||
_ = os.Remove(path)
|
||||
}
|
||||
}
|
||||
|
||||
+6
-12
@@ -140,10 +140,6 @@ if command_exists pkg-config; then
|
||||
echo "[WARNING] Missing Wails dependencies (GTK3 / WebKit2GTK)."
|
||||
MISSING_DEPS=1
|
||||
fi
|
||||
if ! pkg-config --exists x11 xtst xcb xkbcommon-x11; then
|
||||
echo "[WARNING] Missing gohook dependencies (X11 / Xtst / Xcb / Xkbcommon)."
|
||||
MISSING_DEPS=1
|
||||
fi
|
||||
if ! pkg-config --exists alsa; then
|
||||
echo "[WARNING] Missing audio dependencies (ALSA)."
|
||||
MISSING_DEPS=1
|
||||
@@ -158,22 +154,20 @@ if [ $MISSING_DEPS -eq 1 ]; then
|
||||
echo ""
|
||||
echo "It looks like you are missing some required libraries."
|
||||
echo ""
|
||||
echo "Wayland note: global hotkeys use the XDG GlobalShortcuts portal when WAYLAND_DISPLAY"
|
||||
echo "or XDG_SESSION_TYPE=wayland is set (xdg-desktop-portal + a supporting compositor, e.g. KDE Plasma)."
|
||||
echo "Override: WISFREE_USE_X11_HOTKEY=1 forces X11 grabs (needs XWayland);"
|
||||
echo "WISFREE_USE_PORTAL_HOTKEY=1 forces the portal on X11 sessions for testing."
|
||||
echo "Wayland note: global hotkeys use the XDG GlobalShortcuts portal (xdg-desktop-portal"
|
||||
echo "with a supporting compositor, e.g. KDE Plasma or GNOME)."
|
||||
echo ""
|
||||
|
||||
DEBIAN_DEPS="build-essential pkg-config libgtk-3-dev libwebkit2gtk-4.0-dev libx11-dev libx11-xcb-dev libxtst-dev libasound2-dev libayatana-appindicator3-dev libxkbcommon-x11-dev"
|
||||
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"
|
||||
# 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 libX11-devel libxcb-devel libXtst-devel alsa-lib-devel libayatana-appindicator-gtk3-devel libxkbcommon-x11-devel"
|
||||
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 libX11-devel libxcb-devel libXtst-devel alsa-lib-devel libappindicator-gtk3-devel libxkbcommon-x11-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"
|
||||
ARCH_DEPS="base-devel pkgconf gtk3 webkit2gtk libx11 libxtst alsa-lib libayatana-appindicator libxkbcommon-x11"
|
||||
ARCH_DEPS="base-devel pkgconf gtk3 webkit2gtk alsa-lib libayatana-appindicator"
|
||||
ARCH_RUNTIME_OPT="libnotify playerctl"
|
||||
|
||||
echo "The full list of dependencies needed:"
|
||||
|
||||
Reference in New Issue
Block a user