mirror of
https://github.com/jahruz67/wisp-open.git
synced 2026-08-08 18:14:08 +00:00
Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
b62d41d646 | ||
|
|
62e679dc56 | ||
|
|
83ef777dc0 | ||
|
|
53a1559f2f | ||
|
|
9c9ad5ef87 | ||
|
|
f658ab0de3 | ||
|
|
4155c6d423 | ||
|
|
644b3e5a22 | ||
|
|
a4634f19d5 | ||
|
|
6c9ac3804a | ||
|
|
1e9862ffa1 | ||
|
|
eea198d882 | ||
|
|
030982d355 | ||
|
|
cf2f73cd98 | ||
|
|
ed6d00c730 | ||
|
|
858f62c916 | ||
|
|
af55f41eb7 | ||
|
|
52f5297808 | ||
|
|
e137cca619 | ||
|
|
5e4a4d7bb8 |
@@ -33,17 +33,28 @@ jobs:
|
|||||||
libgtk-3-dev \
|
libgtk-3-dev \
|
||||||
libwebkit2gtk-4.1-dev \
|
libwebkit2gtk-4.1-dev \
|
||||||
libasound2-dev \
|
libasound2-dev \
|
||||||
libayatana-appindicator3-dev
|
libayatana-appindicator3-dev \
|
||||||
|
dpkg-dev \
|
||||||
|
rpm
|
||||||
|
|
||||||
- name: Install frontend dependencies
|
- name: Install frontend dependencies
|
||||||
run: npm ci
|
run: npm install
|
||||||
working-directory: frontend
|
working-directory: frontend
|
||||||
|
|
||||||
- name: Build app
|
- name: Build app
|
||||||
run: bash scripts/build-linux.sh --no-install
|
run: bash scripts/build-linux.sh --no-install
|
||||||
|
|
||||||
- name: Upload binary
|
- name: Package .deb and .rpm
|
||||||
|
run: bash scripts/package-linux.sh
|
||||||
|
|
||||||
|
- name: Upload .deb package
|
||||||
uses: actions/upload-artifact@v4
|
uses: actions/upload-artifact@v4
|
||||||
with:
|
with:
|
||||||
name: wis-free-v3-linux-amd64
|
name: wis-free-v3-linux-deb
|
||||||
path: build/bin/wis-free-v3
|
path: dist/packages/*.deb
|
||||||
|
|
||||||
|
- name: Upload .rpm package
|
||||||
|
uses: actions/upload-artifact@v4
|
||||||
|
with:
|
||||||
|
name: wis-free-v3-linux-rpm
|
||||||
|
path: dist/packages/*.rpm
|
||||||
|
|||||||
@@ -31,7 +31,9 @@ Install the RPM with the distribution package manager:
|
|||||||
sudo dnf install ./wis-free-v3-<version>-1.x86_64.rpm
|
sudo dnf install ./wis-free-v3-<version>-1.x86_64.rpm
|
||||||
```
|
```
|
||||||
|
|
||||||
For openSUSE, use `sudo zypper install ./wis-free-v3-<version>.x86_64.rpm`.
|
Uninstall it from a terminal with `sudo dnf remove wis-free-v3`. The RPM also ships desktop and AppStream metadata so graphical software centers such as GNOME Software can show the package as an app and offer install/uninstall actions for the local RPM.
|
||||||
|
|
||||||
|
For openSUSE, use `sudo zypper install ./wis-free-v3-<version>.x86_64.rpm` and `sudo zypper remove wis-free-v3`.
|
||||||
|
|
||||||
After installing, launch **WIS Free V3** from the application launcher or run `wis-free-v3` in a terminal. A reboot is not required.
|
After installing, launch **WIS Free V3** from the application launcher or run `wis-free-v3` in a terminal. A reboot is not required.
|
||||||
|
|
||||||
@@ -39,17 +41,13 @@ After installing, launch **WIS Free V3** from the application launcher or run `w
|
|||||||
|
|
||||||
### Global shortcut
|
### Global shortcut
|
||||||
|
|
||||||
On current GNOME, KDE Plasma, and other desktops that implement the XDG GlobalShortcuts portal, set the shortcut in WIS Free V3 Settings. The desktop may ask you to approve or change the shortcut.
|
Linux uses your desktop environment's custom shortcut feature as the primary global shortcut method. Copy the **Linux System Shortcut** command shown in Settings and bind it to your preferred key combination:
|
||||||
|
|
||||||
If the portal is unavailable on your desktop, use the **Fallback System Shortcut** command shown in Settings:
|
|
||||||
|
|
||||||
- GNOME: **Settings -> Keyboard -> Custom Shortcuts**
|
- GNOME: **Settings -> Keyboard -> Custom Shortcuts**
|
||||||
- KDE Plasma: **System Settings -> Shortcuts -> Command/URL**
|
- KDE Plasma: **System Settings -> Shortcuts -> Command/URL**
|
||||||
|
|
||||||
That command toggles recording: one press starts and the next press stops. It is shell-quoted automatically, including when the app is installed in a path containing spaces. It also works from a cold start because it uses `--action=toggle`.
|
That command toggles recording: one press starts and the next press stops. It is shell-quoted automatically, including when the app is installed in a path containing spaces. It also works from a cold start because it uses `--action=toggle`.
|
||||||
|
|
||||||
Set `WISFREE_USE_PORTAL_HOTKEY=0` before launching the app only to deliberately disable portal shortcuts and use the fallback command instead.
|
|
||||||
|
|
||||||
### Direct typing with ydotool
|
### Direct typing with ydotool
|
||||||
|
|
||||||
WIS Free V3 uses `ydotool` to type transcriptions into the active application. This works on both Wayland and X11, but it needs the persistent `ydotoold` service.
|
WIS Free V3 uses `ydotool` to type transcriptions into the active application. This works on both Wayland and X11, but it needs the persistent `ydotoold` service.
|
||||||
@@ -156,7 +154,7 @@ With no flag, the script builds both formats and therefore requires both `dpkg-d
|
|||||||
| Symptom | What to check |
|
| Symptom | What to check |
|
||||||
| --- | --- |
|
| --- | --- |
|
||||||
| The package installed but the app will not launch | Install it with `apt install ./file.deb` or `dnf install ./file.rpm` so runtime libraries are resolved. Launch `wis-free-v3` from a terminal once to see any loader error. |
|
| The package installed but the app will not launch | Install it with `apt install ./file.deb` or `dnf install ./file.rpm` so runtime libraries are resolved. Launch `wis-free-v3` from a terminal once to see any loader error. |
|
||||||
| The shortcut does nothing | Configure the portal shortcut in Settings. If your desktop rejects it, use the fallback command displayed there. |
|
| The shortcut does nothing | Copy the Linux System Shortcut command from Settings and bind it in your desktop's custom shortcut settings. |
|
||||||
| Transcription completes but text is not inserted | Open Settings and complete the ydotool setup. Check `systemctl --user status ydotool.service`. |
|
| Transcription completes but text is not inserted | Open Settings and complete the ydotool setup. Check `systemctl --user status ydotool.service`. |
|
||||||
| `ydotoold socket not found` | Enable the user service, then log out/in once only if the service still cannot access `/dev/uinput`. |
|
| `ydotoold socket not found` | Enable the user service, then log out/in once only if the service still cannot access `/dev/uinput`. |
|
||||||
| The app runs but no tray icon appears on GNOME | Install and enable the AppIndicator extension. |
|
| The app runs but no tray icon appears on GNOME | Install and enable the AppIndicator extension. |
|
||||||
|
|||||||
@@ -25,19 +25,19 @@ import (
|
|||||||
|
|
||||||
// App struct
|
// App struct
|
||||||
type App struct {
|
type App struct {
|
||||||
ctx context.Context
|
ctx context.Context
|
||||||
audioRecorder *recorder.AudioRecorder
|
audioRecorder *recorder.AudioRecorder
|
||||||
hotkeyListener *hotkey.Listener
|
hotkeyListener *hotkey.Listener
|
||||||
transcriber *transcriber.Client
|
transcriber *transcriber.Client
|
||||||
config *config.Config
|
config *config.Config
|
||||||
overlay platform.Overlay
|
overlay platform.Overlay
|
||||||
recordingPath string
|
recordingPath string
|
||||||
recording int32
|
recording int32
|
||||||
isQuitting bool
|
isQuitting bool
|
||||||
wasMediaPlaying bool
|
wasMediaPlaying bool
|
||||||
whisperManager *whisper.Manager
|
whisperManager *whisper.Manager
|
||||||
tempDir string
|
tempDir string
|
||||||
transcribing int32 // atomic: 1 = transcription in progress, prevents concurrent
|
transcribing int32 // atomic: 1 = transcription in progress, prevents concurrent
|
||||||
}
|
}
|
||||||
|
|
||||||
// NewApp creates a new App application struct
|
// NewApp creates a new App application struct
|
||||||
@@ -344,8 +344,12 @@ func (a *App) processRecording(recordingPath string) {
|
|||||||
isLocal := strings.HasPrefix(a.config.WhisperModel, "local-")
|
isLocal := strings.HasPrefix(a.config.WhisperModel, "local-")
|
||||||
|
|
||||||
// IDIOT-PROOFING: Validate API key before attempting cloud transcription
|
// IDIOT-PROOFING: Validate API key before attempting cloud transcription
|
||||||
if !isLocal && !strings.HasPrefix(a.config.APIKey, "gsk_") {
|
if !isLocal {
|
||||||
err = fmt.Errorf("invalid API key - must start with gsk_")
|
// Get the appropriate API key for the whisper model
|
||||||
|
apiKey := a.config.GetAPIKey(transcriber.GetProviderForModel(a.config.WhisperModel))
|
||||||
|
if apiKey == "" {
|
||||||
|
err = fmt.Errorf("API key is missing for the selected provider")
|
||||||
|
}
|
||||||
} else if isLocal {
|
} else if isLocal {
|
||||||
// Use local whisper.cpp
|
// Use local whisper.cpp
|
||||||
if a.whisperManager == nil {
|
if a.whisperManager == nil {
|
||||||
@@ -424,16 +428,25 @@ func (a *App) processRecording(recordingPath string) {
|
|||||||
// GetSettings returns the current configuration
|
// GetSettings returns the current configuration
|
||||||
func (a *App) GetSettings() map[string]interface{} {
|
func (a *App) GetSettings() map[string]interface{} {
|
||||||
conf := make(map[string]interface{})
|
conf := make(map[string]interface{})
|
||||||
// Mask the API key: only reveal the last 4 characters so the user can verify
|
// Mask the API keys: only reveal the last 4 characters so the user can verify
|
||||||
// which key is configured without exposing the full secret to the frontend.
|
// which key is configured without exposing the full secret to the frontend.
|
||||||
if a.config.APIKey != "" {
|
if a.config.GroqAPIKey != "" {
|
||||||
key := a.config.APIKey
|
key := a.config.GroqAPIKey
|
||||||
if len(key) > 4 {
|
if len(key) > 4 {
|
||||||
key = "****" + key[len(key)-4:]
|
key = "****" + key[len(key)-4:]
|
||||||
}
|
}
|
||||||
conf["api_key"] = key
|
conf["groq_api_key"] = key
|
||||||
} else {
|
} else {
|
||||||
conf["api_key"] = ""
|
conf["groq_api_key"] = ""
|
||||||
|
}
|
||||||
|
if a.config.MistralAPIKey != "" {
|
||||||
|
key := a.config.MistralAPIKey
|
||||||
|
if len(key) > 4 {
|
||||||
|
key = "****" + key[len(key)-4:]
|
||||||
|
}
|
||||||
|
conf["mistral_api_key"] = key
|
||||||
|
} else {
|
||||||
|
conf["mistral_api_key"] = ""
|
||||||
}
|
}
|
||||||
conf["shortcut"] = a.config.Shortcut
|
conf["shortcut"] = a.config.Shortcut
|
||||||
conf["whisper_model"] = a.config.WhisperModel
|
conf["whisper_model"] = a.config.WhisperModel
|
||||||
@@ -459,38 +472,40 @@ func (a *App) GetSettings() map[string]interface{} {
|
|||||||
|
|
||||||
// SaveSettings updates the configuration
|
// SaveSettings updates the configuration
|
||||||
func (a *App) SaveSettings(settings map[string]interface{}) string {
|
func (a *App) SaveSettings(settings map[string]interface{}) string {
|
||||||
if val, ok := settings["api_key"].(string); ok {
|
if val, ok := settings["groq_api_key"].(string); ok {
|
||||||
// Only update the API key if it's not the masked value returned by GetSettings.
|
// Only update the API key if it's not the masked value returned by GetSettings.
|
||||||
// GetSettings masks the key as "****abcd" so the frontend can show the last 4 chars.
|
// GetSettings masks the key as "****abcd" so the frontend can show the last 4 chars.
|
||||||
// If the user didn't change it and sent back the masked value, preserve the real key.
|
// If the user didn't change it and sent back the masked value, preserve the real key.
|
||||||
if !strings.HasPrefix(val, "****") {
|
if !strings.HasPrefix(val, "****") {
|
||||||
a.config.APIKey = val
|
a.config.GroqAPIKey = val
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if val, ok := settings["mistral_api_key"].(string); ok {
|
||||||
|
// Only update the API key if it's not the masked value returned by GetSettings.
|
||||||
|
if !strings.HasPrefix(val, "****") {
|
||||||
|
a.config.MistralAPIKey = val
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if val, ok := settings["shortcut"].(string); ok {
|
if val, ok := settings["shortcut"].(string); ok {
|
||||||
_, _, modOnly, ok := hotkey.ParseShortcut(val)
|
if runtime.GOOS == "linux" {
|
||||||
if !ok {
|
logger.Info("Ignoring in-app shortcut recording on Linux; use the desktop custom-shortcut command shown in Settings")
|
||||||
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
|
|
||||||
// Hot-swap the listener on every platform. Linux uses the XDG
|
|
||||||
// GlobalShortcuts portal when the desktop implements it and otherwise
|
|
||||||
// exposes the custom-command fallback in Settings.
|
|
||||||
if a.hotkeyListener != nil {
|
|
||||||
a.hotkeyListener.UpdateShortcut(val)
|
|
||||||
} else {
|
} else {
|
||||||
a.hotkeyListener = hotkey.NewListener(val, a.StartRecording, a.StopRecording)
|
_, _, modOnly, ok := hotkey.ParseShortcut(val)
|
||||||
if runtime.GOOS != "windows" {
|
if !ok {
|
||||||
a.hotkeyListener.SetRegistrationErrorCallback(func(err error) {
|
logger.Error("Invalid shortcut: %s (rejected)", val)
|
||||||
logger.Error("Linux hotkey registration failed: %v", err)
|
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
|
||||||
|
if a.hotkeyListener != nil {
|
||||||
|
a.hotkeyListener.UpdateShortcut(val)
|
||||||
|
} else {
|
||||||
|
a.hotkeyListener = hotkey.NewListener(val, a.StartRecording, a.StopRecording)
|
||||||
|
a.hotkeyListener.Start()
|
||||||
}
|
}
|
||||||
a.hotkeyListener.Start()
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if val, ok := settings["whisper_model"].(string); ok {
|
if val, ok := settings["whisper_model"].(string); ok {
|
||||||
@@ -524,10 +539,11 @@ func (a *App) SaveSettings(settings map[string]interface{}) string {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Re-init transcriber with new settings.
|
// Re-init transcriber with new settings.
|
||||||
// Note: a.config.APIKey is already updated by the api_key field above.
|
// Note: a.config.GroqAPIKey is already updated by the api_key field above.
|
||||||
// Since GetSettings masks the key, we only update if it's not still the masked value.
|
// Since GetSettings masks the key, we only update if it's not still the masked value.
|
||||||
a.transcriber = transcriber.NewClient(
|
a.transcriber = transcriber.NewClient(
|
||||||
a.config.APIKey,
|
a.config.GroqAPIKey,
|
||||||
|
a.config.MistralAPIKey,
|
||||||
a.config.WhisperModel,
|
a.config.WhisperModel,
|
||||||
a.config.AIModel,
|
a.config.AIModel,
|
||||||
a.config.AIPrompt,
|
a.config.AIPrompt,
|
||||||
@@ -617,7 +633,8 @@ func (a *App) startupHeadless() {
|
|||||||
|
|
||||||
// Initialize transcriber
|
// Initialize transcriber
|
||||||
a.transcriber = transcriber.NewClient(
|
a.transcriber = transcriber.NewClient(
|
||||||
a.config.APIKey,
|
a.config.GroqAPIKey,
|
||||||
|
a.config.MistralAPIKey,
|
||||||
a.config.WhisperModel,
|
a.config.WhisperModel,
|
||||||
a.config.AIModel,
|
a.config.AIModel,
|
||||||
a.config.AIPrompt,
|
a.config.AIPrompt,
|
||||||
@@ -644,28 +661,19 @@ func (a *App) startupHeadless() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Ensure desktop integration for Wayland portals
|
// Ensure desktop integration for Linux custom shortcuts and tray metadata.
|
||||||
if err := platform.EnsureDesktopFile(tray.DefaultIconBytes()); err != nil {
|
if err := platform.EnsureDesktopFile(tray.DefaultIconBytes()); err != nil {
|
||||||
logger.Error("Failed to ensure desktop file: %v", err)
|
logger.Error("Failed to ensure desktop file: %v", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Initialize Hotkey Listener
|
// Linux uses the desktop custom-shortcut command shown in Settings instead
|
||||||
if shouldStartBuiltInHotkeyListener() {
|
// of an in-app global hotkey backend. Other platforms keep their native
|
||||||
|
// listeners.
|
||||||
|
if runtime.GOOS != "linux" {
|
||||||
a.hotkeyListener = hotkey.NewListener(a.config.Shortcut, a.StartRecording, a.StopRecording)
|
a.hotkeyListener = hotkey.NewListener(a.config.Shortcut, a.StartRecording, a.StopRecording)
|
||||||
if runtime.GOOS != "windows" {
|
|
||||||
a.hotkeyListener.SetRegistrationErrorCallback(func(err error) {
|
|
||||||
logger.Error("Linux hotkey registration failed: %v", err)
|
|
||||||
go func() {
|
|
||||||
time.Sleep(2 * time.Second)
|
|
||||||
if a.overlay != nil {
|
|
||||||
a.overlay.Show("Shortcut registration failed. Add a custom system shortcut with the command shown in Settings.")
|
|
||||||
}
|
|
||||||
}()
|
|
||||||
})
|
|
||||||
}
|
|
||||||
a.hotkeyListener.Start()
|
a.hotkeyListener.Start()
|
||||||
} else {
|
} else {
|
||||||
logger.Info("Linux portal hotkey disabled; use the command shown in Settings for a desktop shortcut")
|
logger.Info("Linux shortcut setup uses the command shown in Settings; configure it in GNOME/KDE custom shortcuts")
|
||||||
}
|
}
|
||||||
|
|
||||||
logger.Info("Components initialized successfully!")
|
logger.Info("Components initialized successfully!")
|
||||||
@@ -673,16 +681,6 @@ func (a *App) startupHeadless() {
|
|||||||
logger.Info("Basic app components loaded, continuing startup...")
|
logger.Info("Basic app components loaded, continuing startup...")
|
||||||
}
|
}
|
||||||
|
|
||||||
func shouldStartBuiltInHotkeyListener() bool {
|
|
||||||
if runtime.GOOS != "linux" {
|
|
||||||
return true
|
|
||||||
}
|
|
||||||
// The portal is the safest cross-desktop implementation on modern Linux.
|
|
||||||
// WISFREE_USE_PORTAL_HOTKEY=0 remains an escape hatch for desktops with a
|
|
||||||
// broken portal; Settings always provides a custom-shortcut fallback.
|
|
||||||
return os.Getenv("WISFREE_USE_PORTAL_HOTKEY") != "0"
|
|
||||||
}
|
|
||||||
|
|
||||||
// Shutdown cleans up resources
|
// Shutdown cleans up resources
|
||||||
func (a *App) Shutdown(ctx context.Context) {
|
func (a *App) Shutdown(ctx context.Context) {
|
||||||
if a.hotkeyListener != nil {
|
if a.hotkeyListener != nil {
|
||||||
|
|||||||
+149
-56
@@ -24,22 +24,51 @@
|
|||||||
|
|
||||||
<div class="section-group-label">Authentication</div>
|
<div class="section-group-label">Authentication</div>
|
||||||
|
|
||||||
<!-- API Key -->
|
<!-- API Keys Dropdown Section -->
|
||||||
<div class="section">
|
<div class="section">
|
||||||
<label>Groq API Key</label>
|
<details class="api-keys-details">
|
||||||
<div class="form-control">
|
<summary class="api-keys-summary">
|
||||||
<div class="flex-row">
|
<span>API Keys</span>
|
||||||
<div class="input-wrapper">
|
<span id="apiKeysStatus" style="color: var(--text-2); font-size: 12px; margin-left: 8px;">
|
||||||
<input type="password" id="apiKey" placeholder="gsk_...">
|
<span id="groqKeyStatus">No Groq key</span> | <span id="mistralKeyStatus">No Mistral key</span>
|
||||||
<button class="eye-btn" onclick="toggleApiKey()" id="eyeBtn">👁️</button>
|
</span>
|
||||||
|
</summary>
|
||||||
|
|
||||||
|
<div class="api-keys-content">
|
||||||
|
<!-- Groq API Key -->
|
||||||
|
<div class="api-key-section">
|
||||||
|
<label style="margin-top: 12px; margin-bottom: 8px; display: block;">Groq API Key</label>
|
||||||
|
<div class="form-control">
|
||||||
|
<div class="flex-row">
|
||||||
|
<div class="input-wrapper">
|
||||||
|
<input type="password" id="groqApiKey" placeholder="gsk_...">
|
||||||
|
<button class="eye-btn" onclick="toggleGroqApiKey()" id="groqEyeBtn">👁️</button>
|
||||||
|
</div>
|
||||||
|
<button onclick="saveGroqApiKey()">Save</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<p class="hint">Get your free key at <a href="#" onclick="window.runtime.BrowserOpenURL('https://console.groq.com/keys'); return false;" style="color: var(--accent);">console.groq.com/keys</a></p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Mistral API Key -->
|
||||||
|
<div class="api-key-section">
|
||||||
|
<label style="margin-top: 12px; margin-bottom: 8px; display: block;">Mistral API Key</label>
|
||||||
|
<div class="form-control">
|
||||||
|
<div class="flex-row">
|
||||||
|
<div class="input-wrapper">
|
||||||
|
<input type="password" id="mistralApiKey" placeholder="mx_...">
|
||||||
|
<button class="eye-btn" onclick="toggleMistralApiKey()" id="mistralEyeBtn">👁️</button>
|
||||||
|
</div>
|
||||||
|
<button onclick="saveMistralApiKey()">Save</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<p class="hint">Get your key at <a href="#" onclick="window.runtime.BrowserOpenURL('https://console.mistral.ai/api-keys'); return false;" style="color: var(--accent);">console.mistral.ai/api-keys</a></p>
|
||||||
</div>
|
</div>
|
||||||
<button onclick="saveApiKey()">Save</button>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</details>
|
||||||
<p class="hint">Get your free key at <a href="#" onclick="window.runtime.BrowserOpenURL('https://console.groq.com/keys'); return false;" style="color: var(--accent);">console.groq.com/keys</a></p>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Global Hotkey -->
|
<!-- Global Hotkey (hidden on Linux because Linux uses the system shortcut command below) -->
|
||||||
<div class="section" id="shortcutSection">
|
<div class="section" id="shortcutSection">
|
||||||
<label>Global Hotkey</label>
|
<label>Global Hotkey</label>
|
||||||
<div class="form-control">
|
<div class="form-control">
|
||||||
@@ -50,26 +79,33 @@
|
|||||||
<button id="recordBtn" onclick="recordShortcut()">Record</button>
|
<button id="recordBtn" onclick="recordShortcut()">Record</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<p class="hint">Uses the desktop GlobalShortcuts portal when available. Press the key combination you want to use to start/stop recording (e.g. alt+z, ctrl+shift+space). Modifier-only combos like ctrl+alt are supported only on Windows.</p>
|
<p class="hint">Windows and macOS can listen for this shortcut directly. Modifier-only combos like ctrl+alt are supported only on Windows.</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="section-group-label">Input</div>
|
<div class="section-group-label">Input</div>
|
||||||
|
|
||||||
<!-- Linux fallback shortcut command -->
|
<!-- Linux ydotool status / Direct typing setup -->
|
||||||
<div class="section" id="linuxPressSection" style="display: none;">
|
<div class="section" id="linuxYdotoolSection" style="display: none;">
|
||||||
<label>Fallback System Shortcut (Linux)</label>
|
<label>Direct Typing (ydotool)</label>
|
||||||
<div class="form-control">
|
<div id="linuxYdotoolStatus"></div>
|
||||||
<div class="flex-row">
|
</div>
|
||||||
<div class="input-wrapper">
|
|
||||||
<input type="text" id="linuxPressCommand" readonly>
|
<!-- Linux system shortcut command (collapsible) -->
|
||||||
|
<div class="section" id="linuxFallbackSection" style="display: none;">
|
||||||
|
<details class="fallback-details">
|
||||||
|
<summary class="fallback-summary">Linux System Shortcut</summary>
|
||||||
|
<p class="hint" style="margin-top: 12px;">This is the main Linux shortcut method. Copy this command into your desktop's custom shortcut settings. One press starts recording, and the next press stops.</p>
|
||||||
|
<div class="form-control" style="margin-top: 12px;">
|
||||||
|
<div class="flex-row">
|
||||||
|
<div class="input-wrapper">
|
||||||
|
<input type="text" id="linuxPressCommand" readonly>
|
||||||
|
</div>
|
||||||
|
<button onclick="copyLinuxPressCommand()">Copy</button>
|
||||||
</div>
|
</div>
|
||||||
<button onclick="copyLinuxPressCommand()">Copy</button>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
<p class="hint" style="margin-top: 10px;">GNOME: Settings > Keyboard > Custom Shortcuts > Add a shortcut with the copied command.</p>
|
||||||
<p class="hint">Use this if your desktop does not support the GlobalShortcuts portal. The command toggles recording: one press starts, the next stops.</p>
|
<p class="hint">KDE: System Settings > Shortcuts > Command/URL > Add a shortcut with the copied command.</p>
|
||||||
<p class="hint">GNOME: Settings -> Keyboard -> Custom Shortcuts -> Add a shortcut with the copied command.</p>
|
</details>
|
||||||
<p class="hint">KDE: System Settings -> Shortcuts -> Command/URL -> Add a shortcut with the copied command.</p>
|
|
||||||
<div id="linuxYdotoolStatus" style="display: none; margin-top: 12px; padding: 10px 12px; border-radius: 7px; font-size: 12px; line-height: 1.45;"></div>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Microphone -->
|
<!-- Microphone -->
|
||||||
@@ -86,8 +122,9 @@
|
|||||||
<div class="section">
|
<div class="section">
|
||||||
<label>Whisper Model</label>
|
<label>Whisper Model</label>
|
||||||
<select id="whisperModel" onchange="saveWhisperModel()" class="form-control">
|
<select id="whisperModel" onchange="saveWhisperModel()" class="form-control">
|
||||||
<option value="whisper-large-v3-turbo">whisper-large-v3-turbo (Recommended)</option>
|
<option value="whisper-large-v3-turbo">Whisper Large v3 Turbo (Groq)</option>
|
||||||
<option value="whisper-large-v3">whisper-large-v3</option>
|
<option value="whisper-large-v3">Whisper Large v3 (Groq)</option>
|
||||||
|
<option value="voxtral-small-latest">Voxtral Small (Mistral)</option>
|
||||||
</select>
|
</select>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -109,12 +146,14 @@
|
|||||||
<label>AI Model</label>
|
<label>AI Model</label>
|
||||||
<select id="aiModel" onchange="saveAiModel()" class="form-control">
|
<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-high">GPT OSS 120B High (Groq)</option>
|
||||||
<option value="openai/gpt-oss-120b">openai/gpt-oss-120b (low reasoning)</option>
|
<option value="openai/gpt-oss-120b">GPT OSS 120B (Groq)</option>
|
||||||
<option value="openai/gpt-oss-20b-high">openai/gpt-oss-20b (high reasoning)</option>
|
<option value="openai/gpt-oss-20b-high">GPT OSS 20B High (Groq)</option>
|
||||||
<option value="openai/gpt-oss-20b">openai/gpt-oss-20b (low reasoning)</option>
|
<option value="openai/gpt-oss-20b">GPT OSS 20B (Groq)</option>
|
||||||
<option value="llama-3.3-70b-versatile">llama-3.3-70b-versatile</option>
|
<option value="llama-3.3-70b-versatile">Llama 3.3 70B Versatile (Groq)</option>
|
||||||
<option value="llama-3.1-8b-instant">llama-3.1-8b-instant (faster)</option>
|
<option value="llama-3.1-8b-instant">Llama 3.1 8B Instant (Groq)</option>
|
||||||
|
<option value="mistral-small-latest">Mistral Small (Mistral)</option>
|
||||||
|
<option value="mistral-medium-latest">Mistral Medium (Mistral)</option>
|
||||||
</select>
|
</select>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -182,7 +221,7 @@
|
|||||||
|
|
||||||
<div id="whisperInstalled" style="display: none;">
|
<div id="whisperInstalled" style="display: none;">
|
||||||
<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="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="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;">
|
<div style="font-size: 12px; color: var(--text-2); margin-top: 4px;">
|
||||||
Model: <span id="installedModel">—</span>
|
Model: <span id="installedModel">—</span>
|
||||||
</div>
|
</div>
|
||||||
@@ -201,19 +240,62 @@
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<script type="module">
|
<script type="module">
|
||||||
let apiKeyVisible = false;
|
let groqApiKeyVisible = false;
|
||||||
|
let mistralApiKeyVisible = false;
|
||||||
|
let linuxShortcutMode = false;
|
||||||
|
|
||||||
// Toggle API key visibility
|
// Toggle Groq API key visibility
|
||||||
window.toggleApiKey = function () {
|
window.toggleGroqApiKey = function () {
|
||||||
const input = document.getElementById('apiKey');
|
const input = document.getElementById('groqApiKey');
|
||||||
const btn = document.getElementById('eyeBtn');
|
const btn = document.getElementById('groqEyeBtn');
|
||||||
apiKeyVisible = !apiKeyVisible;
|
groqApiKeyVisible = !groqApiKeyVisible;
|
||||||
input.type = apiKeyVisible ? 'text' : 'password';
|
input.type = groqApiKeyVisible ? 'text' : 'password';
|
||||||
btn.textContent = apiKeyVisible ? '🙈' : '👁️';
|
btn.textContent = groqApiKeyVisible ? '👁️' : '👁️';
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// Toggle Mistral API key visibility
|
||||||
|
window.toggleMistralApiKey = function () {
|
||||||
|
const input = document.getElementById('mistralApiKey');
|
||||||
|
const btn = document.getElementById('mistralEyeBtn');
|
||||||
|
mistralApiKeyVisible = !mistralApiKeyVisible;
|
||||||
|
input.type = mistralApiKeyVisible ? 'text' : 'password';
|
||||||
|
btn.textContent = mistralApiKeyVisible ? '👁️' : '👁️';
|
||||||
|
};
|
||||||
|
|
||||||
|
// Save Groq API key
|
||||||
|
window.saveGroqApiKey = async function () {
|
||||||
|
const key = document.getElementById('groqApiKey').value.trim();
|
||||||
|
await window.go.main.App.SaveSettings({ groq_api_key: key });
|
||||||
|
showSaveStatus();
|
||||||
|
updateApiKeyStatus();
|
||||||
|
};
|
||||||
|
|
||||||
|
// Save Mistral API key
|
||||||
|
window.saveMistralApiKey = async function () {
|
||||||
|
const key = document.getElementById('mistralApiKey').value.trim();
|
||||||
|
await window.go.main.App.SaveSettings({ mistral_api_key: key });
|
||||||
|
showSaveStatus();
|
||||||
|
updateApiKeyStatus();
|
||||||
|
};
|
||||||
|
|
||||||
|
// Update API key status display
|
||||||
|
function updateApiKeyStatus() {
|
||||||
|
const groqKey = document.getElementById('groqApiKey').value;
|
||||||
|
const mistralKey = document.getElementById('mistralApiKey').value;
|
||||||
|
|
||||||
|
const groqStatus = document.getElementById('groqKeyStatus');
|
||||||
|
const mistralStatus = document.getElementById('mistralKeyStatus');
|
||||||
|
|
||||||
|
groqStatus.textContent = groqKey ? (groqKey.length > 4 ? 'Groq key set' : 'Groq key') : 'No Groq key';
|
||||||
|
mistralStatus.textContent = mistralKey ? (mistralKey.length > 4 ? 'Mistral key set' : 'Mistral key') : 'No Mistral key';
|
||||||
|
}
|
||||||
|
|
||||||
// Record shortcut
|
// Record shortcut
|
||||||
window.recordShortcut = function () {
|
window.recordShortcut = function () {
|
||||||
|
if (linuxShortcutMode) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
const btn = document.getElementById('recordBtn');
|
const btn = document.getElementById('recordBtn');
|
||||||
const input = document.getElementById('shortcutInput');
|
const input = document.getElementById('shortcutInput');
|
||||||
|
|
||||||
@@ -385,16 +467,32 @@
|
|||||||
try {
|
try {
|
||||||
const settings = await window.go.main.App.GetSettings();
|
const settings = await window.go.main.App.GetSettings();
|
||||||
|
|
||||||
document.getElementById('apiKey').value = settings.api_key || '';
|
document.getElementById('groqApiKey').value = settings.groq_api_key || '';
|
||||||
|
document.getElementById('mistralApiKey').value = settings.mistral_api_key || '';
|
||||||
const shortcutInput = document.getElementById('shortcutInput');
|
const shortcutInput = document.getElementById('shortcutInput');
|
||||||
if (shortcutInput) {
|
if (shortcutInput) {
|
||||||
shortcutInput.value = settings.shortcut || 'alt+z';
|
shortcutInput.value = settings.shortcut || 'alt+z';
|
||||||
}
|
}
|
||||||
|
linuxShortcutMode = !!settings.linux_press_mode;
|
||||||
if (settings.linux_press_mode) {
|
if (settings.linux_press_mode) {
|
||||||
const section = document.getElementById('linuxPressSection');
|
const shortcutSection = document.getElementById('shortcutSection');
|
||||||
|
const ydotoolSection = document.getElementById('linuxYdotoolSection');
|
||||||
|
const fallbackSection = document.getElementById('linuxFallbackSection');
|
||||||
const cmdInput = document.getElementById('linuxPressCommand');
|
const cmdInput = document.getElementById('linuxPressCommand');
|
||||||
if (section) section.style.display = 'block';
|
|
||||||
if (cmdInput) cmdInput.value = settings.linux_press_command || '';
|
if (shortcutSection) shortcutSection.style.display = 'none';
|
||||||
|
if (ydotoolSection) ydotoolSection.style.display = 'block';
|
||||||
|
if (fallbackSection) fallbackSection.style.display = 'block';
|
||||||
|
|
||||||
|
if (cmdInput) {
|
||||||
|
// Show just the executable name with quotes and the toggle flag
|
||||||
|
const fullCommand = settings.linux_press_command || '';
|
||||||
|
const match = fullCommand.match(/^'[^']*' (--action=toggle)$/);
|
||||||
|
const togglePart = match ? "'wisp-open' " + match[1] : "'wisp-open' --action=toggle";
|
||||||
|
cmdInput.value = togglePart;
|
||||||
|
window._linuxFullCommand = togglePart;
|
||||||
|
}
|
||||||
|
|
||||||
renderLinuxYdotoolStatus(settings.linux_ydotool_status);
|
renderLinuxYdotoolStatus(settings.linux_ydotool_status);
|
||||||
}
|
}
|
||||||
document.getElementById('whisperModel').value = settings.whisper_model || 'whisper-large-v3-turbo';
|
document.getElementById('whisperModel').value = settings.whisper_model || 'whisper-large-v3-turbo';
|
||||||
@@ -407,6 +505,7 @@
|
|||||||
document.getElementById('appVersion').textContent = ver === 'dev' ? 'dev' : ('v' + ver);
|
document.getElementById('appVersion').textContent = ver === 'dev' ? 'dev' : ('v' + ver);
|
||||||
|
|
||||||
renderHistoryList(settings.history || []);
|
renderHistoryList(settings.history || []);
|
||||||
|
updateApiKeyStatus();
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error("Failed to load settings:", err);
|
console.error("Failed to load settings:", err);
|
||||||
}
|
}
|
||||||
@@ -430,13 +529,6 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Save functions
|
|
||||||
window.saveApiKey = async function () {
|
|
||||||
const key = document.getElementById('apiKey').value.trim();
|
|
||||||
await window.go.main.App.SaveSettings({ api_key: key });
|
|
||||||
showSaveStatus();
|
|
||||||
};
|
|
||||||
|
|
||||||
function showSaveStatus(message = 'Saved') {
|
function showSaveStatus(message = 'Saved') {
|
||||||
const el = document.getElementById('saveStatus');
|
const el = document.getElementById('saveStatus');
|
||||||
el.textContent = message;
|
el.textContent = message;
|
||||||
@@ -474,7 +566,8 @@
|
|||||||
const cmdInput = document.getElementById('linuxPressCommand');
|
const cmdInput = document.getElementById('linuxPressCommand');
|
||||||
if (!cmdInput) return;
|
if (!cmdInput) return;
|
||||||
|
|
||||||
const command = cmdInput.value || '';
|
// Reconstruct the full command from the backend settings
|
||||||
|
const command = window._linuxFullCommand || cmdInput.value || '';
|
||||||
try {
|
try {
|
||||||
if (navigator.clipboard && navigator.clipboard.writeText) {
|
if (navigator.clipboard && navigator.clipboard.writeText) {
|
||||||
await navigator.clipboard.writeText(command);
|
await navigator.clipboard.writeText(command);
|
||||||
@@ -512,7 +605,7 @@
|
|||||||
statusEl.style.color = 'var(--green)';
|
statusEl.style.color = 'var(--green)';
|
||||||
statusEl.style.border = '1px solid rgba(61, 186, 110, 0.2)';
|
statusEl.style.border = '1px solid rgba(61, 186, 110, 0.2)';
|
||||||
} else {
|
} else {
|
||||||
statusEl.innerHTML = '● Offline — install local Whisper for transcription';
|
statusEl.innerHTML = '● Offline \u2014 install local Whisper for transcription';
|
||||||
statusEl.style.background = 'var(--red-dim)';
|
statusEl.style.background = 'var(--red-dim)';
|
||||||
statusEl.style.color = 'var(--red)';
|
statusEl.style.color = 'var(--red)';
|
||||||
statusEl.style.border = '1px solid rgba(224, 82, 82, 0.2)';
|
statusEl.style.border = '1px solid rgba(224, 82, 82, 0.2)';
|
||||||
@@ -552,7 +645,7 @@
|
|||||||
// Add local option to whisper dropdown
|
// Add local option to whisper dropdown
|
||||||
const localOption = document.createElement('option');
|
const localOption = document.createElement('option');
|
||||||
localOption.value = 'local-' + info.model;
|
localOption.value = 'local-' + info.model;
|
||||||
localOption.textContent = '⬡ Local — ' + info.model + ' (offline)';
|
localOption.textContent = '↑ Local \u2014 ' + info.model + ' (offline)';
|
||||||
localOption.style.fontWeight = 'bold';
|
localOption.style.fontWeight = 'bold';
|
||||||
whisperSelect.insertBefore(localOption, whisperSelect.firstChild);
|
whisperSelect.insertBefore(localOption, whisperSelect.firstChild);
|
||||||
} else {
|
} else {
|
||||||
|
|||||||
@@ -8,6 +8,6 @@
|
|||||||
"preview": "vite preview"
|
"preview": "vite preview"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"vite": "^3.0.7"
|
"vite": "^3.2.11"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -3,6 +3,7 @@
|
|||||||
// Global state
|
// Global state
|
||||||
let currentSettings = {};
|
let currentSettings = {};
|
||||||
let apiKeyVisible = true;
|
let apiKeyVisible = true;
|
||||||
|
let linuxShortcutMode = false;
|
||||||
|
|
||||||
// Initialize
|
// Initialize
|
||||||
document.addEventListener('DOMContentLoaded', async () => {
|
document.addEventListener('DOMContentLoaded', async () => {
|
||||||
@@ -29,9 +30,12 @@ async function loadSettings() {
|
|||||||
document.getElementById('aiPrompt').value = settings.ai_prompt || '';
|
document.getElementById('aiPrompt').value = settings.ai_prompt || '';
|
||||||
document.getElementById('startupToggle').checked = settings.startup || false;
|
document.getElementById('startupToggle').checked = settings.startup || false;
|
||||||
|
|
||||||
|
linuxShortcutMode = !!settings.linux_press_mode;
|
||||||
if (settings.linux_press_mode) {
|
if (settings.linux_press_mode) {
|
||||||
const section = document.getElementById('linuxPressSection');
|
const shortcutSection = document.getElementById('shortcutSection');
|
||||||
|
const section = document.getElementById('linuxPressSection') || document.getElementById('linuxFallbackSection');
|
||||||
const cmdInput = document.getElementById('linuxPressCommand');
|
const cmdInput = document.getElementById('linuxPressCommand');
|
||||||
|
if (shortcutSection) shortcutSection.style.display = 'none';
|
||||||
if (section) section.style.display = 'block';
|
if (section) section.style.display = 'block';
|
||||||
if (cmdInput) cmdInput.value = settings.linux_press_command || '';
|
if (cmdInput) cmdInput.value = settings.linux_press_command || '';
|
||||||
}
|
}
|
||||||
|
|||||||
+133
-14
@@ -18,6 +18,8 @@
|
|||||||
--red-dim: rgba(224, 82, 82, 0.12);
|
--red-dim: rgba(224, 82, 82, 0.12);
|
||||||
--radius: 10px;
|
--radius: 10px;
|
||||||
--radius-sm: 7px;
|
--radius-sm: 7px;
|
||||||
|
--mistral: #8a5cf0;
|
||||||
|
--mistral-dim: rgba(138, 92, 240, 0.15);
|
||||||
color-scheme: dark;
|
color-scheme: dark;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -35,14 +37,14 @@ body {
|
|||||||
-webkit-font-smoothing: antialiased;
|
-webkit-font-smoothing: antialiased;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* ── Layout ── */
|
/* Layout */
|
||||||
.container {
|
.container {
|
||||||
max-width: 680px;
|
max-width: 680px;
|
||||||
margin: 0 auto;
|
margin: 0 auto;
|
||||||
padding: 32px 28px 60px;
|
padding: 32px 28px 60px;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* ── Header ── */
|
/* Header */
|
||||||
h1 {
|
h1 {
|
||||||
font-size: 20px;
|
font-size: 20px;
|
||||||
font-weight: 600;
|
font-weight: 600;
|
||||||
@@ -64,7 +66,7 @@ h1 {
|
|||||||
flex-wrap: nowrap;
|
flex-wrap: nowrap;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* ── Section cards ── */
|
/* Section cards */
|
||||||
.section {
|
.section {
|
||||||
background: var(--surface);
|
background: var(--surface);
|
||||||
border: 1px solid var(--border);
|
border: 1px solid var(--border);
|
||||||
@@ -78,7 +80,7 @@ h1 {
|
|||||||
border-color: var(--border-hover);
|
border-color: var(--border-hover);
|
||||||
}
|
}
|
||||||
|
|
||||||
/* ── Labels ── */
|
/* Labels */
|
||||||
label {
|
label {
|
||||||
display: block;
|
display: block;
|
||||||
font-size: 13px;
|
font-size: 13px;
|
||||||
@@ -96,7 +98,7 @@ label {
|
|||||||
line-height: 1.5;
|
line-height: 1.5;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* ── Inputs ── */
|
/* Inputs */
|
||||||
input[type="text"],
|
input[type="text"],
|
||||||
input[type="password"],
|
input[type="password"],
|
||||||
select,
|
select,
|
||||||
@@ -157,12 +159,12 @@ input[type="checkbox"] {
|
|||||||
flex-shrink: 0;
|
flex-shrink: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* ── Form control width ── */
|
/* Form control width */
|
||||||
.form-control {
|
.form-control {
|
||||||
width: 100%;
|
width: 100%;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* ── Input wrapper (for eye-btn) ── */
|
/* Input wrapper (for eye-btn) */
|
||||||
.input-wrapper {
|
.input-wrapper {
|
||||||
position: relative;
|
position: relative;
|
||||||
flex: 1;
|
flex: 1;
|
||||||
@@ -197,7 +199,7 @@ input[type="checkbox"] {
|
|||||||
box-shadow: none !important;
|
box-shadow: none !important;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* ── Buttons ── */
|
/* Buttons */
|
||||||
button {
|
button {
|
||||||
background: var(--accent);
|
background: var(--accent);
|
||||||
color: #fff;
|
color: #fff;
|
||||||
@@ -254,7 +256,7 @@ button:active {
|
|||||||
opacity: 1 !important;
|
opacity: 1 !important;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* ── Save status toast ── */
|
/* Save status toast */
|
||||||
.save-status {
|
.save-status {
|
||||||
position: fixed;
|
position: fixed;
|
||||||
bottom: 24px;
|
bottom: 24px;
|
||||||
@@ -279,7 +281,7 @@ button:active {
|
|||||||
opacity: 1;
|
opacity: 1;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* ── History ── */
|
/* History */
|
||||||
.history-list {
|
.history-list {
|
||||||
max-height: 240px;
|
max-height: 240px;
|
||||||
overflow-y: auto;
|
overflow-y: auto;
|
||||||
@@ -308,7 +310,7 @@ button:active {
|
|||||||
display: block;
|
display: block;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* ── Inline status badges (online/offline) ── */
|
/* Inline status badges (online/offline) */
|
||||||
#connectionStatus {
|
#connectionStatus {
|
||||||
border-radius: var(--radius-sm) !important;
|
border-radius: var(--radius-sm) !important;
|
||||||
font-size: 13px !important;
|
font-size: 13px !important;
|
||||||
@@ -333,16 +335,133 @@ button:active {
|
|||||||
background: var(--accent-hover) !important;
|
background: var(--accent-hover) !important;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* ── Offline Whisper section separator ── */
|
/* Offline Whisper section separator */
|
||||||
#offlineWhisperSection {
|
#offlineWhisperSection {
|
||||||
border-top: 1px solid var(--border) !important;
|
border-top: 1px solid var(--border) !important;
|
||||||
margin-top: 20px !important;
|
margin-top: 20px !important;
|
||||||
padding-top: 20px !important;
|
padding-top: 20px !important;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* API Keys Dropdown Section */
|
||||||
|
.api-keys-details {
|
||||||
|
color: var(--text);
|
||||||
|
}
|
||||||
|
|
||||||
|
.api-keys-summary {
|
||||||
|
cursor: pointer;
|
||||||
|
font-weight: 500;
|
||||||
|
font-size: 13px;
|
||||||
|
color: var(--text);
|
||||||
|
padding: 2px 0;
|
||||||
|
user-select: none;
|
||||||
|
list-style: none;
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
align-items: center;
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
/* ── Section group header divider ── */
|
.api-keys-summary::-webkit-details-marker {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.api-keys-summary::after {
|
||||||
|
content: '\u25b6';
|
||||||
|
font-size: 10px;
|
||||||
|
color: var(--text-3);
|
||||||
|
transition: transform 0.15s ease;
|
||||||
|
display: inline-block;
|
||||||
|
margin-left: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.api-keys-details[open] > .api-keys-summary::after {
|
||||||
|
transform: rotate(90deg);
|
||||||
|
}
|
||||||
|
|
||||||
|
.api-keys-summary:hover {
|
||||||
|
color: var(--text);
|
||||||
|
}
|
||||||
|
|
||||||
|
.api-keys-content {
|
||||||
|
margin-top: 12px;
|
||||||
|
padding-top: 12px;
|
||||||
|
border-top: 1px solid var(--border);
|
||||||
|
}
|
||||||
|
|
||||||
|
.api-key-section {
|
||||||
|
margin-bottom: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.api-key-section:last-child {
|
||||||
|
margin-bottom: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Provider badges for model options */
|
||||||
|
select option[data-provider]::before {
|
||||||
|
content: attr(data-provider);
|
||||||
|
display: inline-block;
|
||||||
|
padding: 2px 6px;
|
||||||
|
border-radius: 4px;
|
||||||
|
font-size: 11px;
|
||||||
|
font-weight: 500;
|
||||||
|
margin-right: 8px;
|
||||||
|
opacity: 0.8;
|
||||||
|
}
|
||||||
|
|
||||||
|
select option[data-provider="groq"]::before {
|
||||||
|
background: var(--accent-dim);
|
||||||
|
color: var(--accent);
|
||||||
|
}
|
||||||
|
|
||||||
|
select option[data-provider="mistral"]::before {
|
||||||
|
background: var(--mistral-dim);
|
||||||
|
color: var(--mistral);
|
||||||
|
}
|
||||||
|
|
||||||
|
select option[data-provider="local"]::before {
|
||||||
|
background: var(--green-dim);
|
||||||
|
color: var(--green);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Collapsible fallback details */
|
||||||
|
.fallback-details {
|
||||||
|
color: var(--text);
|
||||||
|
}
|
||||||
|
|
||||||
|
.fallback-summary {
|
||||||
|
cursor: pointer;
|
||||||
|
font-weight: 500;
|
||||||
|
font-size: 13px;
|
||||||
|
color: var(--text-2);
|
||||||
|
padding: 2px 0;
|
||||||
|
user-select: none;
|
||||||
|
list-style: none;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 6px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.fallback-summary::-webkit-details-marker {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.fallback-summary::before {
|
||||||
|
content: '\u25b6';
|
||||||
|
font-size: 10px;
|
||||||
|
color: var(--text-3);
|
||||||
|
transition: transform 0.15s ease;
|
||||||
|
display: inline-block;
|
||||||
|
}
|
||||||
|
|
||||||
|
.fallback-details[open] > .fallback-summary::before {
|
||||||
|
transform: rotate(90deg);
|
||||||
|
}
|
||||||
|
|
||||||
|
.fallback-summary:hover {
|
||||||
|
color: var(--text);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Section group header divider */
|
||||||
.section-group-label {
|
.section-group-label {
|
||||||
font-size: 11px;
|
font-size: 11px;
|
||||||
font-weight: 600;
|
font-weight: 600;
|
||||||
@@ -352,7 +471,7 @@ button:active {
|
|||||||
margin: 22px 0 8px;
|
margin: 22px 0 8px;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* ── Scrollbar ── */
|
/* Scrollbar */
|
||||||
::-webkit-scrollbar { width: 5px; }
|
::-webkit-scrollbar { width: 5px; }
|
||||||
::-webkit-scrollbar-track { background: transparent; }
|
::-webkit-scrollbar-track { background: transparent; }
|
||||||
::-webkit-scrollbar-thumb {
|
::-webkit-scrollbar-thumb {
|
||||||
|
|||||||
@@ -6,7 +6,6 @@ require (
|
|||||||
github.com/gen2brain/malgo v0.11.24
|
github.com/gen2brain/malgo v0.11.24
|
||||||
github.com/getlantern/systray v1.2.2
|
github.com/getlantern/systray v1.2.2
|
||||||
github.com/go-vgo/robotgo v0.110.8
|
github.com/go-vgo/robotgo v0.110.8
|
||||||
github.com/godbus/dbus/v5 v5.1.0
|
|
||||||
github.com/wailsapp/wails/v2 v2.11.0
|
github.com/wailsapp/wails/v2 v2.11.0
|
||||||
golang.design/x/mainthread v0.3.0
|
golang.design/x/mainthread v0.3.0
|
||||||
golang.org/x/sys v0.33.0
|
golang.org/x/sys v0.33.0
|
||||||
|
|||||||
@@ -34,8 +34,6 @@ github.com/go-stack/stack v1.8.0 h1:5SgMzNM5HxrEjV0ww2lTmX6E2Izsfxas4+YHWRs3Lsk=
|
|||||||
github.com/go-stack/stack v1.8.0/go.mod h1:v0f6uXyyMGvRgIKkXu+yp6POWl0qKG85gN/melR3HDY=
|
github.com/go-stack/stack v1.8.0/go.mod h1:v0f6uXyyMGvRgIKkXu+yp6POWl0qKG85gN/melR3HDY=
|
||||||
github.com/go-vgo/robotgo v0.110.8 h1:tWoUyqlZgDJ61bQju3WGSb/NIIfNV4TkYL3GFeWcHio=
|
github.com/go-vgo/robotgo v0.110.8 h1:tWoUyqlZgDJ61bQju3WGSb/NIIfNV4TkYL3GFeWcHio=
|
||||||
github.com/go-vgo/robotgo v0.110.8/go.mod h1:45w33PzprtFncpw4cAt9SzMtSY9XnVfotu+RrCVN8JE=
|
github.com/go-vgo/robotgo v0.110.8/go.mod h1:45w33PzprtFncpw4cAt9SzMtSY9XnVfotu+RrCVN8JE=
|
||||||
github.com/godbus/dbus/v5 v5.1.0 h1:4KLkAxT3aOY8Li4FRJe/KvhoNFFxo0m6fNuFUO8QJUk=
|
|
||||||
github.com/godbus/dbus/v5 v5.1.0/go.mod h1:xhWf0FNVPg57R7Z0UbKHbJfkEywrmjJnf7w5xrFpKfA=
|
|
||||||
github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8=
|
github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8=
|
||||||
github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU=
|
github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU=
|
||||||
github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
|
github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
|
||||||
|
|||||||
@@ -31,7 +31,8 @@ type HistoryItem struct {
|
|||||||
// Config represents the complete application configuration.
|
// Config represents the complete application configuration.
|
||||||
type Config struct {
|
type Config struct {
|
||||||
Version int `json:"version"`
|
Version int `json:"version"`
|
||||||
APIKey string `json:"api_key"`
|
GroqAPIKey string `json:"groq_api_key"`
|
||||||
|
MistralAPIKey string `json:"mistral_api_key"`
|
||||||
Shortcut string `json:"shortcut"`
|
Shortcut string `json:"shortcut"`
|
||||||
WhisperModel string `json:"whisper_model"`
|
WhisperModel string `json:"whisper_model"`
|
||||||
AIModel string `json:"ai_model"`
|
AIModel string `json:"ai_model"`
|
||||||
@@ -50,7 +51,8 @@ var saveMu sync.Mutex
|
|||||||
// DefaultConfig returns a new configuration with sensible default values.
|
// DefaultConfig returns a new configuration with sensible default values.
|
||||||
func DefaultConfig() *Config {
|
func DefaultConfig() *Config {
|
||||||
return &Config{
|
return &Config{
|
||||||
APIKey: "",
|
GroqAPIKey: "",
|
||||||
|
MistralAPIKey: "",
|
||||||
Shortcut: DefaultShortcut,
|
Shortcut: DefaultShortcut,
|
||||||
WhisperModel: DefaultWhisperModel,
|
WhisperModel: DefaultWhisperModel,
|
||||||
AIModel: DefaultAIModel,
|
AIModel: DefaultAIModel,
|
||||||
@@ -197,3 +199,25 @@ func (c *Config) AddHistoryItem(text, timestamp string) {
|
|||||||
func (c *Config) ClearHistory() {
|
func (c *Config) ClearHistory() {
|
||||||
c.History = []HistoryItem{}
|
c.History = []HistoryItem{}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// GetAPIKey returns the API key for a specific provider
|
||||||
|
func (c *Config) GetAPIKey(provider string) string {
|
||||||
|
switch provider {
|
||||||
|
case "groq":
|
||||||
|
return c.GroqAPIKey
|
||||||
|
case "mistral":
|
||||||
|
return c.MistralAPIKey
|
||||||
|
default:
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// SetAPIKey sets the API key for a specific provider
|
||||||
|
func (c *Config) SetAPIKey(provider, key string) {
|
||||||
|
switch provider {
|
||||||
|
case "groq":
|
||||||
|
c.GroqAPIKey = key
|
||||||
|
case "mistral":
|
||||||
|
c.MistralAPIKey = key
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
// Package transcriber provides audio transcription and text refinement services
|
// Package transcriber provides audio transcription and text refinement services
|
||||||
// using the Groq API for Whisper-based speech recognition and LLM text processing.
|
// using various AI providers (Groq, Mistral) for Whisper-based speech recognition and LLM text processing.
|
||||||
package transcriber
|
package transcriber
|
||||||
|
|
||||||
import (
|
import (
|
||||||
@@ -17,10 +17,18 @@ import (
|
|||||||
"wis-free-v3/internal/logger"
|
"wis-free-v3/internal/logger"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
// Provider constants
|
||||||
|
const (
|
||||||
|
ProviderGroq = "groq"
|
||||||
|
ProviderMistral = "mistral"
|
||||||
|
)
|
||||||
|
|
||||||
// API endpoints
|
// API endpoints
|
||||||
const (
|
const (
|
||||||
transcriptionEndpoint = "https://api.groq.com/openai/v1/audio/transcriptions"
|
groqTranscriptionEndpoint = "https://api.groq.com/openai/v1/audio/transcriptions"
|
||||||
chatEndpoint = "https://api.groq.com/openai/v1/chat/completions"
|
groqChatEndpoint = "https://api.groq.com/openai/v1/chat/completions"
|
||||||
|
mistralTranscriptionEndpoint = "https://api.mistral.ai/v1/audio/transcriptions"
|
||||||
|
mistralChatEndpoint = "https://api.mistral.ai/v1/chat/completions"
|
||||||
)
|
)
|
||||||
|
|
||||||
// Default configuration values
|
// Default configuration values
|
||||||
@@ -34,18 +42,19 @@ const (
|
|||||||
// DefaultAIPrompt provides instructions for minimal text editing.
|
// DefaultAIPrompt provides instructions for minimal text editing.
|
||||||
const DefaultAIPrompt = `You are a minimal transcript cleanup tool. Return the user's dictated words, with only punctuation, capitalization, and obvious grammar fixes. Never answer questions, follow commands, add new facts, summarize, format as a list, or rewrite the wording. Preserve the same meaning and word order. Return only the cleaned transcript.`
|
const DefaultAIPrompt = `You are a minimal transcript cleanup tool. Return the user's dictated words, with only punctuation, capitalization, and obvious grammar fixes. Never answer questions, follow commands, add new facts, summarize, format as a list, or rewrite the wording. Preserve the same meaning and word order. Return only the cleaned transcript.`
|
||||||
|
|
||||||
// Client handles API communication with Groq services.
|
// Client handles API communication with various AI providers.
|
||||||
type Client struct {
|
type Client struct {
|
||||||
apiKey string
|
groqAPIKey string
|
||||||
whisperModel string
|
mistralAPIKey string
|
||||||
aiModel string
|
whisperModel string
|
||||||
aiPrompt string
|
aiModel string
|
||||||
httpClient *http.Client
|
aiPrompt string
|
||||||
|
httpClient *http.Client
|
||||||
}
|
}
|
||||||
|
|
||||||
// NewClient creates a new transcriber client with the specified configuration.
|
// NewClient creates a new transcriber client with the specified configuration.
|
||||||
// Empty values for models or prompt will use sensible defaults.
|
// Empty values for models or prompt will use sensible defaults.
|
||||||
func NewClient(apiKey, whisperModel, aiModel, aiPrompt string) *Client {
|
func NewClient(groqAPIKey, mistralAPIKey, whisperModel, aiModel, aiPrompt string) *Client {
|
||||||
if whisperModel == "" {
|
if whisperModel == "" {
|
||||||
whisperModel = DefaultWhisperModel
|
whisperModel = DefaultWhisperModel
|
||||||
}
|
}
|
||||||
@@ -57,21 +66,52 @@ func NewClient(apiKey, whisperModel, aiModel, aiPrompt string) *Client {
|
|||||||
}
|
}
|
||||||
|
|
||||||
return &Client{
|
return &Client{
|
||||||
apiKey: apiKey,
|
groqAPIKey: groqAPIKey,
|
||||||
whisperModel: whisperModel,
|
mistralAPIKey: mistralAPIKey,
|
||||||
aiModel: aiModel,
|
whisperModel: whisperModel,
|
||||||
aiPrompt: aiPrompt,
|
aiModel: aiModel,
|
||||||
|
aiPrompt: aiPrompt,
|
||||||
httpClient: &http.Client{
|
httpClient: &http.Client{
|
||||||
Timeout: HTTPTimeout,
|
Timeout: HTTPTimeout,
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// GetProviderForModel returns the provider for a given model name
|
||||||
|
func GetProviderForModel(model string) string {
|
||||||
|
// Mistral models
|
||||||
|
if strings.HasPrefix(model, "voxtral") || strings.HasPrefix(model, "voitrex") ||
|
||||||
|
strings.HasPrefix(model, "mistral-") ||
|
||||||
|
strings.Contains(model, "mistral") {
|
||||||
|
return ProviderMistral
|
||||||
|
}
|
||||||
|
// Groq models (default)
|
||||||
|
return ProviderGroq
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetAPIKeyForModel returns the appropriate API key for a given model
|
||||||
|
func (c *Client) GetAPIKeyForModel(model string) string {
|
||||||
|
provider := GetProviderForModel(model)
|
||||||
|
if provider == ProviderMistral {
|
||||||
|
return c.mistralAPIKey
|
||||||
|
}
|
||||||
|
return c.groqAPIKey
|
||||||
|
}
|
||||||
|
|
||||||
// TranscribeAudio converts an audio file to text using Whisper.
|
// TranscribeAudio converts an audio file to text using Whisper.
|
||||||
// Returns the transcribed text or an error if the operation fails.
|
// Returns the transcribed text or an error if the operation fails.
|
||||||
func (c *Client) TranscribeAudio(audioFilePath, language string) (string, error) {
|
func (c *Client) TranscribeAudio(audioFilePath, language string) (string, error) {
|
||||||
if c.apiKey == "" {
|
// Check if using local whisper
|
||||||
return "", fmt.Errorf("API key is missing - please configure it in Settings")
|
isLocal := strings.HasPrefix(c.whisperModel, "local-")
|
||||||
|
if isLocal {
|
||||||
|
return "", fmt.Errorf("local whisper should be handled separately")
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get the appropriate API key for the model
|
||||||
|
apiKey := c.GetAPIKeyForModel(c.whisperModel)
|
||||||
|
if apiKey == "" {
|
||||||
|
provider := GetProviderForModel(c.whisperModel)
|
||||||
|
return "", fmt.Errorf("API key is missing for %s provider - please configure it in Settings", provider)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Validate file exists and get size for logging
|
// Validate file exists and get size for logging
|
||||||
@@ -87,12 +127,19 @@ func (c *Client) TranscribeAudio(audioFilePath, language string) (string, error)
|
|||||||
return "", err
|
return "", err
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Determine which endpoint to use based on the model
|
||||||
|
provider := GetProviderForModel(c.whisperModel)
|
||||||
|
endpoint := groqTranscriptionEndpoint
|
||||||
|
if provider == ProviderMistral {
|
||||||
|
endpoint = mistralTranscriptionEndpoint
|
||||||
|
}
|
||||||
|
|
||||||
// Create and send request
|
// Create and send request
|
||||||
req, err := http.NewRequest(http.MethodPost, transcriptionEndpoint, body)
|
req, err := http.NewRequest(http.MethodPost, endpoint, body)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return "", fmt.Errorf("failed to create request: %w", err)
|
return "", fmt.Errorf("failed to create request: %w", err)
|
||||||
}
|
}
|
||||||
req.Header.Set("Authorization", "Bearer "+c.apiKey)
|
req.Header.Set("Authorization", "Bearer "+apiKey)
|
||||||
req.Header.Set("Content-Type", contentType)
|
req.Header.Set("Content-Type", contentType)
|
||||||
|
|
||||||
resp, err := c.httpClient.Do(req)
|
resp, err := c.httpClient.Do(req)
|
||||||
@@ -103,7 +150,7 @@ func (c *Client) TranscribeAudio(audioFilePath, language string) (string, error)
|
|||||||
|
|
||||||
// Handle response
|
// Handle response
|
||||||
if resp.StatusCode != http.StatusOK {
|
if resp.StatusCode != http.StatusOK {
|
||||||
return "", c.handleAPIError(resp, "transcription")
|
return "", c.handleAPIError(resp, "transcription", provider)
|
||||||
}
|
}
|
||||||
|
|
||||||
var result struct {
|
var result struct {
|
||||||
@@ -120,7 +167,15 @@ func (c *Client) TranscribeAudio(audioFilePath, language string) (string, error)
|
|||||||
// RefineText uses an LLM to clean up and correct transcribed text.
|
// 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.
|
// If the AI model is set to "None" or the API key is missing, returns the original text.
|
||||||
func (c *Client) RefineText(text string, activeContext string) (string, error) {
|
func (c *Client) RefineText(text string, activeContext string) (string, error) {
|
||||||
if c.apiKey == "" || c.aiModel == "None" {
|
if c.aiModel == "None" {
|
||||||
|
return text, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get the appropriate API key for the AI model
|
||||||
|
apiKey := c.GetAPIKeyForModel(c.aiModel)
|
||||||
|
if apiKey == "" {
|
||||||
|
provider := GetProviderForModel(c.aiModel)
|
||||||
|
logger.Error("API key missing for %s provider - skipping refinement", provider)
|
||||||
return text, nil
|
return text, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -165,12 +220,19 @@ func (c *Client) RefineText(text string, activeContext string) (string, error) {
|
|||||||
return text, nil // Return original text on error
|
return text, nil // Return original text on error
|
||||||
}
|
}
|
||||||
|
|
||||||
req, err := http.NewRequest(http.MethodPost, chatEndpoint, bytes.NewBuffer(payloadBytes))
|
// Determine which endpoint to use based on the AI model
|
||||||
|
provider := GetProviderForModel(c.aiModel)
|
||||||
|
endpoint := groqChatEndpoint
|
||||||
|
if provider == ProviderMistral {
|
||||||
|
endpoint = mistralChatEndpoint
|
||||||
|
}
|
||||||
|
|
||||||
|
req, err := http.NewRequest(http.MethodPost, endpoint, bytes.NewBuffer(payloadBytes))
|
||||||
if err != nil {
|
if err != nil {
|
||||||
logger.Error("Failed to create refinement request: %v", err)
|
logger.Error("Failed to create refinement request: %v", err)
|
||||||
return text, nil
|
return text, nil
|
||||||
}
|
}
|
||||||
req.Header.Set("Authorization", "Bearer "+c.apiKey)
|
req.Header.Set("Authorization", "Bearer "+apiKey)
|
||||||
req.Header.Set("Content-Type", "application/json")
|
req.Header.Set("Content-Type", "application/json")
|
||||||
|
|
||||||
resp, err := c.httpClient.Do(req)
|
resp, err := c.httpClient.Do(req)
|
||||||
@@ -181,7 +243,7 @@ func (c *Client) RefineText(text string, activeContext string) (string, error) {
|
|||||||
defer resp.Body.Close()
|
defer resp.Body.Close()
|
||||||
|
|
||||||
if resp.StatusCode != http.StatusOK {
|
if resp.StatusCode != http.StatusOK {
|
||||||
c.handleAPIError(resp, "refinement")
|
c.handleAPIError(resp, "refinement", provider)
|
||||||
return text, nil
|
return text, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -325,13 +387,13 @@ func (c *Client) prepareAudioRequest(audioFilePath, language string) (*bytes.Buf
|
|||||||
|
|
||||||
// handleAPIError logs and formats API error responses.
|
// handleAPIError logs and formats API error responses.
|
||||||
// Truncates the body to avoid leaking secrets if the API echoes request data.
|
// Truncates the body to avoid leaking secrets if the API echoes request data.
|
||||||
func (c *Client) handleAPIError(resp *http.Response, operation string) error {
|
func (c *Client) handleAPIError(resp *http.Response, operation string, provider string) error {
|
||||||
bodyBytes, _ := io.ReadAll(resp.Body)
|
bodyBytes, _ := io.ReadAll(resp.Body)
|
||||||
const maxLogLen = 200
|
const maxLogLen = 200
|
||||||
bodyStr := string(bodyBytes)
|
bodyStr := string(bodyBytes)
|
||||||
if len(bodyStr) > maxLogLen {
|
if len(bodyStr) > maxLogLen {
|
||||||
bodyStr = bodyStr[:maxLogLen] + "...(truncated)"
|
bodyStr = bodyStr[:maxLogLen] + "...(truncated)"
|
||||||
}
|
}
|
||||||
logger.Error("API %s error: status=%d body=%s", operation, resp.StatusCode, bodyStr)
|
logger.Error("API %s error (%s): status=%d body=%s", operation, provider, resp.StatusCode, bodyStr)
|
||||||
return fmt.Errorf("%s failed with status %d", operation, resp.StatusCode)
|
return fmt.Errorf("%s failed with status %d", operation, resp.StatusCode)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -5,16 +5,15 @@ package hotkey
|
|||||||
import "errors"
|
import "errors"
|
||||||
|
|
||||||
func (hk *Hotkey) register() error {
|
func (hk *Hotkey) register() error {
|
||||||
return hk.registerPortal()
|
return errors.New("Linux in-app global hotkeys are disabled; use the desktop custom-shortcut command shown in Settings")
|
||||||
}
|
}
|
||||||
|
|
||||||
func (hk *Hotkey) unregister() error {
|
func (hk *Hotkey) unregister() error {
|
||||||
hk.mu.Lock()
|
hk.mu.Lock()
|
||||||
|
defer hk.mu.Unlock()
|
||||||
if !hk.registered {
|
if !hk.registered {
|
||||||
hk.mu.Unlock()
|
|
||||||
return errors.New("hotkey is not registered.")
|
return errors.New("hotkey is not registered.")
|
||||||
}
|
}
|
||||||
hk.registered = false
|
hk.registered = false
|
||||||
hk.mu.Unlock()
|
return nil
|
||||||
return hk.cleanupPortal()
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,25 +2,9 @@
|
|||||||
|
|
||||||
package hotkey
|
package hotkey
|
||||||
|
|
||||||
import (
|
import "sync"
|
||||||
"sync"
|
|
||||||
|
|
||||||
"github.com/godbus/dbus/v5"
|
|
||||||
)
|
|
||||||
|
|
||||||
const (
|
|
||||||
linuxHKNone = iota
|
|
||||||
linuxHKPortal
|
|
||||||
)
|
|
||||||
|
|
||||||
type platformHotkey struct {
|
type platformHotkey struct {
|
||||||
mu sync.Mutex
|
mu sync.Mutex
|
||||||
registered bool
|
registered bool
|
||||||
backend int
|
|
||||||
|
|
||||||
// Wayland / XDG portal global shortcuts
|
|
||||||
portalStop chan struct{}
|
|
||||||
portalDone chan struct{}
|
|
||||||
portalConn *dbus.Conn
|
|
||||||
sessionPath dbus.ObjectPath
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,471 +0,0 @@
|
|||||||
//go:build linux
|
|
||||||
|
|
||||||
// ============================================================
|
|
||||||
// LINUX-ONLY FILE — Portal-based global hotkey implementation
|
|
||||||
// for Linux using the org.freedesktop.portal.GlobalShortcuts
|
|
||||||
// D-Bus API (Wayland/desktop-agnostic).
|
|
||||||
// The Windows equivalent is in hotkey_windows.go
|
|
||||||
// ============================================================
|
|
||||||
|
|
||||||
package hotkey
|
|
||||||
|
|
||||||
import (
|
|
||||||
"crypto/rand"
|
|
||||||
"encoding/hex"
|
|
||||||
"errors"
|
|
||||||
"fmt"
|
|
||||||
"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"
|
|
||||||
ifaceGlobalShortcuts = "org.freedesktop.portal.GlobalShortcuts"
|
|
||||||
ifaceRequest = "org.freedesktop.portal.Request"
|
|
||||||
ifaceSession = "org.freedesktop.portal.Session"
|
|
||||||
wisfreeGlobalShortcutID = "com.wisfree.push-to-record"
|
|
||||||
envForcePortal = "WISFREE_USE_PORTAL_HOTKEY"
|
|
||||||
)
|
|
||||||
|
|
||||||
func usePortalBackend() bool {
|
|
||||||
if os.Getenv(envForcePortal) == "0" {
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
return true
|
|
||||||
}
|
|
||||||
|
|
||||||
func randomPortalToken() string {
|
|
||||||
b := make([]byte, 8)
|
|
||||||
if _, err := rand.Read(b); err != nil {
|
|
||||||
return "tok" + fmt.Sprintf("%d", time.Now().UnixNano())
|
|
||||||
}
|
|
||||||
return hex.EncodeToString(b)
|
|
||||||
}
|
|
||||||
|
|
||||||
func triggerForPortalSpec(mods []Modifier, key Key) string {
|
|
||||||
var ctrl, shift, alt, super bool
|
|
||||||
for _, m := range mods {
|
|
||||||
switch m {
|
|
||||||
case ModCtrl:
|
|
||||||
ctrl = true
|
|
||||||
case ModShift:
|
|
||||||
shift = true
|
|
||||||
case Mod1:
|
|
||||||
alt = true
|
|
||||||
case Mod4:
|
|
||||||
super = true
|
|
||||||
}
|
|
||||||
}
|
|
||||||
keyName := portalKeySpecName(key)
|
|
||||||
if keyName == "" {
|
|
||||||
return ""
|
|
||||||
}
|
|
||||||
var parts []string
|
|
||||||
if ctrl {
|
|
||||||
parts = append(parts, "Control")
|
|
||||||
}
|
|
||||||
if alt {
|
|
||||||
parts = append(parts, "Alt")
|
|
||||||
}
|
|
||||||
if shift {
|
|
||||||
parts = append(parts, "Shift")
|
|
||||||
}
|
|
||||||
if super {
|
|
||||||
parts = append(parts, "Super")
|
|
||||||
}
|
|
||||||
if len(parts) == 0 {
|
|
||||||
return keyName
|
|
||||||
}
|
|
||||||
return strings.Join(parts, "+") + "+" + keyName
|
|
||||||
}
|
|
||||||
|
|
||||||
func portalKeySpecName(key Key) string {
|
|
||||||
if key >= KeyA && key <= KeyZ {
|
|
||||||
return string(rune(key))
|
|
||||||
}
|
|
||||||
if key >= Key0 && key <= Key9 {
|
|
||||||
return string(rune(key))
|
|
||||||
}
|
|
||||||
switch key {
|
|
||||||
case KeySpace:
|
|
||||||
return "Space"
|
|
||||||
case KeyReturn:
|
|
||||||
return "Return"
|
|
||||||
case KeyEscape:
|
|
||||||
return "Escape"
|
|
||||||
case KeyDelete:
|
|
||||||
return "Delete"
|
|
||||||
case KeyTab:
|
|
||||||
return "Tab"
|
|
||||||
case KeyLeft:
|
|
||||||
return "Left"
|
|
||||||
case KeyRight:
|
|
||||||
return "Right"
|
|
||||||
case KeyUp:
|
|
||||||
return "Up"
|
|
||||||
case KeyDown:
|
|
||||||
return "Down"
|
|
||||||
case KeyF1:
|
|
||||||
return "F1"
|
|
||||||
case KeyF2:
|
|
||||||
return "F2"
|
|
||||||
case KeyF3:
|
|
||||||
return "F3"
|
|
||||||
case KeyF4:
|
|
||||||
return "F4"
|
|
||||||
case KeyF5:
|
|
||||||
return "F5"
|
|
||||||
case KeyF6:
|
|
||||||
return "F6"
|
|
||||||
case KeyF7:
|
|
||||||
return "F7"
|
|
||||||
case KeyF8:
|
|
||||||
return "F8"
|
|
||||||
case KeyF9:
|
|
||||||
return "F9"
|
|
||||||
case KeyF10:
|
|
||||||
return "F10"
|
|
||||||
case KeyF11:
|
|
||||||
return "F11"
|
|
||||||
case KeyF12:
|
|
||||||
return "F12"
|
|
||||||
default:
|
|
||||||
return ""
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
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,
|
|
||||||
)
|
|
||||||
if err := conn.BusObject().Call("org.freedesktop.DBus.AddMatch", 0, rule).Store(); err != nil {
|
|
||||||
return 0, nil, err
|
|
||||||
}
|
|
||||||
defer func() {
|
|
||||||
_ = conn.BusObject().Call("org.freedesktop.DBus.RemoveMatch", 0, rule).Store()
|
|
||||||
}()
|
|
||||||
|
|
||||||
timeout := time.NewTimer(45 * time.Second)
|
|
||||||
defer timeout.Stop()
|
|
||||||
|
|
||||||
for {
|
|
||||||
select {
|
|
||||||
case sig := <-ch:
|
|
||||||
if sig == nil || sig.Path != reqPath {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
if !strings.HasSuffix(sig.Name, ".Response") {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
if len(sig.Body) < 2 {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
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
|
|
||||||
}
|
|
||||||
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")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func variantToObjectPath(v dbus.Variant) (dbus.ObjectPath, bool) {
|
|
||||||
val := unwrapVariant(v)
|
|
||||||
switch x := val.(type) {
|
|
||||||
case dbus.ObjectPath:
|
|
||||||
return x, true
|
|
||||||
case string:
|
|
||||||
return dbus.ObjectPath(x), true
|
|
||||||
default:
|
|
||||||
return "", false
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// registerPortal binds a global shortcut via org.freedesktop.portal.GlobalShortcuts (Wayland / desktop-agnostic).
|
|
||||||
func (hk *Hotkey) registerPortal() error {
|
|
||||||
trigger := triggerForPortalSpec(hk.mods, hk.key)
|
|
||||||
if trigger == "" {
|
|
||||||
return fmt.Errorf("unsupported key for portal global shortcuts")
|
|
||||||
}
|
|
||||||
|
|
||||||
conn, err := dbus.SessionBus()
|
|
||||||
if err != nil {
|
|
||||||
return fmt.Errorf("dbus session: %w", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
portal := conn.Object(portalBusName, portalObjectPath)
|
|
||||||
|
|
||||||
createOpts := map[string]dbus.Variant{
|
|
||||||
"handle_token": dbus.MakeVariant(randomPortalToken()),
|
|
||||||
"session_handle_token": dbus.MakeVariant(randomPortalToken()),
|
|
||||||
// Required by xdg-desktop-portal on some desktops (e.g. Fedora/GNOME).
|
|
||||||
// This should match the app's .desktop file id when possible.
|
|
||||||
"app_id": dbus.MakeVariant("wis-free-v3"),
|
|
||||||
}
|
|
||||||
|
|
||||||
var createReqPath dbus.ObjectPath
|
|
||||||
if err := portal.Call(ifaceGlobalShortcuts+".CreateSession", 0, createOpts).Store(&createReqPath); err != nil {
|
|
||||||
_ = conn.Close()
|
|
||||||
return fmt.Errorf("CreateSession: %w", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
code, results, err := portalWaitRequest(conn, createReqPath)
|
|
||||||
if err != nil {
|
|
||||||
_ = conn.Close()
|
|
||||||
return fmt.Errorf("CreateSession wait: %w", err)
|
|
||||||
}
|
|
||||||
if code != 0 {
|
|
||||||
_ = conn.Close()
|
|
||||||
return fmt.Errorf("CreateSession rejected (code %d)", code)
|
|
||||||
}
|
|
||||||
v, ok := results["session_handle"]
|
|
||||||
if !ok {
|
|
||||||
_ = conn.Close()
|
|
||||||
return errors.New("CreateSession: missing session_handle")
|
|
||||||
}
|
|
||||||
sessPath, okp := variantToObjectPath(v)
|
|
||||||
if !okp || sessPath == "" {
|
|
||||||
_ = conn.Close()
|
|
||||||
return errors.New("CreateSession: invalid session_handle")
|
|
||||||
}
|
|
||||||
|
|
||||||
type portalShortcut struct {
|
|
||||||
ID string
|
|
||||||
Details map[string]dbus.Variant
|
|
||||||
}
|
|
||||||
shortcutsArg := []portalShortcut{
|
|
||||||
{
|
|
||||||
ID: wisfreeGlobalShortcutID,
|
|
||||||
Details: map[string]dbus.Variant{
|
|
||||||
"description": dbus.MakeVariant("Hold to dictate; release to transcribe (WIS Free)"),
|
|
||||||
"preferred_trigger": dbus.MakeVariant(trigger),
|
|
||||||
},
|
|
||||||
},
|
|
||||||
}
|
|
||||||
|
|
||||||
bindOpts := map[string]dbus.Variant{
|
|
||||||
"handle_token": dbus.MakeVariant(randomPortalToken()),
|
|
||||||
}
|
|
||||||
|
|
||||||
var bindReqPath dbus.ObjectPath
|
|
||||||
if err := portal.Call(ifaceGlobalShortcuts+".BindShortcuts", 0, sessPath, shortcutsArg, "", bindOpts).Store(&bindReqPath); err != nil {
|
|
||||||
_ = conn.Object(portalBusName, sessPath).Call(ifaceSession+".Close", 0).Store()
|
|
||||||
_ = conn.Close()
|
|
||||||
return fmt.Errorf("BindShortcuts: %w", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
code, results, err = portalWaitRequest(conn, bindReqPath)
|
|
||||||
if err != nil {
|
|
||||||
_ = conn.Object(portalBusName, sessPath).Call(ifaceSession+".Close", 0).Store()
|
|
||||||
_ = conn.Close()
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
if code != 0 {
|
|
||||||
_ = conn.Object(portalBusName, sessPath).Call(ifaceSession+".Close", 0).Store()
|
|
||||||
_ = conn.Close()
|
|
||||||
return fmt.Errorf("BindShortcuts rejected (code %d); install a desktop with GlobalShortcuts portal support (e.g. recent KDE Plasma or GNOME)", code)
|
|
||||||
}
|
|
||||||
if sc, ok := results["shortcuts"]; ok {
|
|
||||||
val := sc.Value()
|
|
||||||
isEmpty := false
|
|
||||||
switch v := val.(type) {
|
|
||||||
case []interface{}:
|
|
||||||
isEmpty = len(v) == 0
|
|
||||||
case [][]interface{}:
|
|
||||||
isEmpty = len(v) == 0
|
|
||||||
case []map[string]interface{}:
|
|
||||||
isEmpty = len(v) == 0
|
|
||||||
}
|
|
||||||
if isEmpty {
|
|
||||||
_ = conn.Object(portalBusName, sessPath).Call(ifaceSession+".Close", 0).Store()
|
|
||||||
_ = conn.Close()
|
|
||||||
return fmt.Errorf("BindShortcuts returned empty shortcut list (desktop declined the binding)")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
hk.mu.Lock()
|
|
||||||
if hk.registered {
|
|
||||||
hk.mu.Unlock()
|
|
||||||
_ = conn.Object(portalBusName, sessPath).Call(ifaceSession+".Close", 0).Store()
|
|
||||||
_ = conn.Close()
|
|
||||||
return errors.New("hotkey already registered.")
|
|
||||||
}
|
|
||||||
hk.backend = linuxHKPortal
|
|
||||||
hk.registered = true
|
|
||||||
hk.portalConn = conn
|
|
||||||
hk.sessionPath = sessPath
|
|
||||||
hk.portalStop = make(chan struct{})
|
|
||||||
hk.portalDone = make(chan struct{})
|
|
||||||
hk.mu.Unlock()
|
|
||||||
|
|
||||||
go hk.portalSignalLoop()
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func (hk *Hotkey) sendPortalEvent(name string, ch chan<- Event) {
|
|
||||||
hk.mu.Lock()
|
|
||||||
stopCh := hk.portalStop
|
|
||||||
registered := hk.registered
|
|
||||||
hk.mu.Unlock()
|
|
||||||
if !registered || stopCh == nil {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
defer func() {
|
|
||||||
if r := recover(); r != nil {
|
|
||||||
logger.Debug("sendPortalEvent(%s): recovered from panic: %v", name, r)
|
|
||||||
}
|
|
||||||
}()
|
|
||||||
|
|
||||||
select {
|
|
||||||
case ch <- Event{}:
|
|
||||||
case <-stopCh:
|
|
||||||
case <-time.After(2 * time.Second):
|
|
||||||
logger.Error("Timed out delivering Linux portal hotkey %s event", name)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// safeSendKeydown sends a keydown event to the hotkey channel.
|
|
||||||
func (hk *Hotkey) safeSendKeydown() {
|
|
||||||
hk.sendPortalEvent("keydown", hk.keydownIn)
|
|
||||||
}
|
|
||||||
|
|
||||||
// safeSendKeyup sends a keyup event to the hotkey channel.
|
|
||||||
func (hk *Hotkey) safeSendKeyup() {
|
|
||||||
hk.sendPortalEvent("keyup", hk.keyupIn)
|
|
||||||
}
|
|
||||||
|
|
||||||
func (hk *Hotkey) portalSignalLoop() {
|
|
||||||
defer close(hk.portalDone)
|
|
||||||
|
|
||||||
hk.mu.Lock()
|
|
||||||
conn := hk.portalConn
|
|
||||||
hk.mu.Unlock()
|
|
||||||
if conn == nil {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
ch := make(chan *dbus.Signal, 32)
|
|
||||||
conn.Signal(ch)
|
|
||||||
defer conn.RemoveSignal(ch)
|
|
||||||
|
|
||||||
rule := fmt.Sprintf(
|
|
||||||
"type='signal',sender='%s',interface='%s'",
|
|
||||||
portalBusName, ifaceGlobalShortcuts,
|
|
||||||
)
|
|
||||||
if err := conn.BusObject().Call("org.freedesktop.DBus.AddMatch", 0, rule).Store(); err != nil {
|
|
||||||
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:
|
|
||||||
return
|
|
||||||
case sig, ok := <-ch:
|
|
||||||
if !ok || sig == nil {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
if len(sig.Body) < 2 {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
|
|
||||||
// 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
|
|
||||||
}
|
|
||||||
|
|
||||||
logger.Info("Matched global shortcut signal: name=%s", sig.Name)
|
|
||||||
|
|
||||||
switch sig.Name {
|
|
||||||
case ifaceGlobalShortcuts + ".Activated":
|
|
||||||
hk.safeSendKeydown()
|
|
||||||
case ifaceGlobalShortcuts + ".Deactivated":
|
|
||||||
hk.safeSendKeyup()
|
|
||||||
default:
|
|
||||||
// Fallback for different bus routing names just in case
|
|
||||||
if strings.HasSuffix(sig.Name, ".Activated") {
|
|
||||||
hk.safeSendKeydown()
|
|
||||||
} else if strings.HasSuffix(sig.Name, ".Deactivated") {
|
|
||||||
hk.safeSendKeyup()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// cleanupPortal stops the portal listener and closes the session (unlock before call).
|
|
||||||
func (hk *Hotkey) cleanupPortal() error {
|
|
||||||
hk.mu.Lock()
|
|
||||||
stopCh := hk.portalStop
|
|
||||||
doneCh := hk.portalDone
|
|
||||||
conn := hk.portalConn
|
|
||||||
sess := hk.sessionPath
|
|
||||||
hk.portalStop = nil
|
|
||||||
hk.portalDone = nil
|
|
||||||
hk.portalConn = nil
|
|
||||||
hk.sessionPath = ""
|
|
||||||
hk.backend = linuxHKNone
|
|
||||||
hk.mu.Unlock()
|
|
||||||
|
|
||||||
if stopCh != nil {
|
|
||||||
close(stopCh)
|
|
||||||
}
|
|
||||||
if doneCh != nil {
|
|
||||||
<-doneCh
|
|
||||||
}
|
|
||||||
if conn != nil && sess != "" {
|
|
||||||
_ = conn.Object(portalBusName, sess).Call(ifaceSession+".Close", 0).Store()
|
|
||||||
_ = conn.Close()
|
|
||||||
}
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
@@ -1,41 +1,21 @@
|
|||||||
// Copyright 2021 The golang.design Initiative Authors.
|
|
||||||
// All rights reserved. Use of this source code is governed
|
|
||||||
// by a MIT license that can be found in the LICENSE file.
|
|
||||||
//
|
|
||||||
// Written by Changkun Ou <changkun.de>
|
|
||||||
|
|
||||||
//go:build linux && cgo
|
//go:build linux && cgo
|
||||||
|
|
||||||
package hotkey_test
|
package hotkey_test
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"context"
|
"strings"
|
||||||
"fmt"
|
|
||||||
"testing"
|
"testing"
|
||||||
"time"
|
|
||||||
|
|
||||||
"wis-free-v3/internal/xhotkey"
|
"wis-free-v3/internal/xhotkey"
|
||||||
)
|
)
|
||||||
|
|
||||||
// TestHotkey should always run success.
|
func TestHotkeyLinuxUsesDesktopShortcutFallback(t *testing.T) {
|
||||||
// This is a test to run and for manually testing, registered combination:
|
hk := hotkey.New([]hotkey.Modifier{hotkey.ModCtrl, hotkey.Mod2, hotkey.Mod4}, hotkey.KeyA)
|
||||||
// Ctrl+Alt+A (Ctrl+Mod2+Mod4+A on Linux)
|
err := hk.Register()
|
||||||
func TestHotkey(t *testing.T) {
|
if err == nil {
|
||||||
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
|
t.Fatal("expected Linux in-app hotkey registration to be disabled")
|
||||||
defer cancel()
|
|
||||||
|
|
||||||
hk := hotkey.New([]hotkey.Modifier{
|
|
||||||
hotkey.ModCtrl, hotkey.Mod2, hotkey.Mod4}, hotkey.KeyA)
|
|
||||||
if err := hk.Register(); err != nil {
|
|
||||||
t.Errorf("failed to register hotkey: %v", err)
|
|
||||||
return
|
|
||||||
}
|
}
|
||||||
for {
|
if !strings.Contains(err.Error(), "desktop custom-shortcut command") {
|
||||||
select {
|
t.Fatalf("expected desktop shortcut fallback error, got %v", err)
|
||||||
case <-ctx.Done():
|
|
||||||
return
|
|
||||||
case <-hk.Keydown():
|
|
||||||
fmt.Println("triggered")
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,7 +2,7 @@
|
|||||||
|
|
||||||
package hotkey
|
package hotkey
|
||||||
|
|
||||||
// Modifier represents a modifier (X11 bitmask; also used when mapping to portal shortcut spec).
|
// Modifier represents a modifier (X11 bitmask value used by non-Linux native backends and shortcut parsing).
|
||||||
type Modifier uint32
|
type Modifier uint32
|
||||||
|
|
||||||
// See /usr/include/X11/X.h
|
// See /usr/include/X11/X.h
|
||||||
|
|||||||
@@ -14,7 +14,7 @@ import (
|
|||||||
"wis-free-v3/internal/xhotkey"
|
"wis-free-v3/internal/xhotkey"
|
||||||
)
|
)
|
||||||
|
|
||||||
// Without CGO on Darwin, registration is unsupported (panic). Linux without CGO uses the portal backend instead.
|
// Without CGO on Darwin, registration is unsupported (panic). Linux without CGO returns the Linux unsupported-backend error.
|
||||||
func TestHotkey(t *testing.T) {
|
func TestHotkey(t *testing.T) {
|
||||||
defer func() {
|
defer func() {
|
||||||
if r := recover(); r != nil {
|
if r := recover(); r != nil {
|
||||||
|
|||||||
@@ -351,8 +351,8 @@ if [ $MISSING_DEPS -eq 1 ]; then
|
|||||||
echo ""
|
echo ""
|
||||||
echo "It looks like you are missing some required libraries."
|
echo "It looks like you are missing some required libraries."
|
||||||
echo ""
|
echo ""
|
||||||
echo "Wayland note: global hotkeys use the XDG GlobalShortcuts portal (xdg-desktop-portal"
|
echo "Wayland note: Linux global shortcuts use your desktop's custom shortcut settings"
|
||||||
echo "with a supporting compositor, e.g. KDE Plasma or GNOME)."
|
echo "with the command shown in the app Settings window."
|
||||||
echo ""
|
echo ""
|
||||||
|
|
||||||
DEBIAN_DEPS="build-essential pkg-config libgtk-3-dev libwebkit2gtk-4.0-dev libasound2-dev libayatana-appindicator3-dev"
|
DEBIAN_DEPS="build-essential pkg-config libgtk-3-dev libwebkit2gtk-4.0-dev libasound2-dev libayatana-appindicator3-dev"
|
||||||
@@ -363,7 +363,7 @@ if [ $MISSING_DEPS -eq 1 ]; then
|
|||||||
FEDORA_DEPS="gcc gcc-c++ make pkgconf-pkg-config gtk3-devel webkit2gtk4.1-devel alsa-lib-devel libayatana-appindicator-gtk3-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)
|
# Same as FEDORA_DEPS but classic libappindicator (some spins/repos lack Ayatana -devel)
|
||||||
FEDORA_DEPS_ALT="gcc gcc-c++ make pkgconf-pkg-config gtk3-devel webkit2gtk4.1-devel alsa-lib-devel libappindicator-gtk3-devel"
|
FEDORA_DEPS_ALT="gcc gcc-c++ make pkgconf-pkg-config gtk3-devel webkit2gtk4.1-devel alsa-lib-devel libappindicator-gtk3-devel"
|
||||||
FEDORA_RUNTIME_OPT="playerctl xdg-desktop-portal ydotool"
|
FEDORA_RUNTIME_OPT="playerctl ydotool"
|
||||||
ARCH_DEPS="base-devel pkgconf gtk3 webkit2gtk alsa-lib libayatana-appindicator"
|
ARCH_DEPS="base-devel pkgconf gtk3 webkit2gtk alsa-lib libayatana-appindicator"
|
||||||
ARCH_RUNTIME_OPT="playerctl ydotool"
|
ARCH_RUNTIME_OPT="playerctl ydotool"
|
||||||
|
|
||||||
|
|||||||
@@ -196,6 +196,7 @@ rm -rf "$WORK_DIR"
|
|||||||
mkdir -p "$WORK_DIR/root/usr/bin"
|
mkdir -p "$WORK_DIR/root/usr/bin"
|
||||||
mkdir -p "$WORK_DIR/root/usr/share/applications"
|
mkdir -p "$WORK_DIR/root/usr/share/applications"
|
||||||
mkdir -p "$WORK_DIR/root/usr/share/pixmaps"
|
mkdir -p "$WORK_DIR/root/usr/share/pixmaps"
|
||||||
|
mkdir -p "$WORK_DIR/root/usr/share/metainfo"
|
||||||
mkdir -p "$WORK_DIR/root/usr/share/doc/$APP_NAME"
|
mkdir -p "$WORK_DIR/root/usr/share/doc/$APP_NAME"
|
||||||
mkdir -p "$DIST_DIR"
|
mkdir -p "$DIST_DIR"
|
||||||
|
|
||||||
@@ -221,6 +222,32 @@ Icon=$APP_NAME
|
|||||||
StartupWMClass=$APP_NAME
|
StartupWMClass=$APP_NAME
|
||||||
Terminal=false
|
Terminal=false
|
||||||
Categories=Utility;Audio;
|
Categories=Utility;Audio;
|
||||||
|
Keywords=dictation;speech;voice;transcription;whisper;
|
||||||
|
EOF
|
||||||
|
|
||||||
|
cat > "$WORK_DIR/root/usr/share/metainfo/$APP_NAME.metainfo.xml" <<EOF
|
||||||
|
<?xml version="1.0" encoding="UTF-8"?>
|
||||||
|
<component type="desktop-application">
|
||||||
|
<id>$APP_NAME.desktop</id>
|
||||||
|
<metadata_license>MIT</metadata_license>
|
||||||
|
<project_license>$LICENSE</project_license>
|
||||||
|
<name>$DISPLAY_NAME</name>
|
||||||
|
<summary>$COMMENT</summary>
|
||||||
|
<description>
|
||||||
|
<p>WIS Free V3 is a desktop voice dictation app built with Go and Wails.</p>
|
||||||
|
<p>It records audio from a global shortcut, transcribes it, optionally refines the text, and types the result into the focused application.</p>
|
||||||
|
</description>
|
||||||
|
<launchable type="desktop-id">$APP_NAME.desktop</launchable>
|
||||||
|
<provides>
|
||||||
|
<binary>$APP_NAME</binary>
|
||||||
|
</provides>
|
||||||
|
<categories>
|
||||||
|
<category>Utility</category>
|
||||||
|
<category>Audio</category>
|
||||||
|
</categories>
|
||||||
|
<content_rating type="oars-1.1" />
|
||||||
|
<url type="homepage">https://github.com/Daishir0/wisp-open</url>
|
||||||
|
</component>
|
||||||
EOF
|
EOF
|
||||||
|
|
||||||
if [ -f README.md ]; then
|
if [ -f README.md ]; then
|
||||||
@@ -250,6 +277,30 @@ Description: $COMMENT
|
|||||||
On Wayland, install ydotool for direct keyboard injection.
|
On Wayland, install ydotool for direct keyboard injection.
|
||||||
EOF
|
EOF
|
||||||
|
|
||||||
|
cat > "$DEB_ROOT/DEBIAN/postinst" <<'EOF'
|
||||||
|
#!/usr/bin/env sh
|
||||||
|
set -e
|
||||||
|
if command -v update-desktop-database >/dev/null 2>&1; then
|
||||||
|
update-desktop-database /usr/share/applications >/dev/null 2>&1 || true
|
||||||
|
fi
|
||||||
|
if command -v appstreamcli >/dev/null 2>&1; then
|
||||||
|
appstreamcli refresh-cache --force >/dev/null 2>&1 || true
|
||||||
|
fi
|
||||||
|
exit 0
|
||||||
|
EOF
|
||||||
|
cat > "$DEB_ROOT/DEBIAN/postrm" <<'EOF'
|
||||||
|
#!/usr/bin/env sh
|
||||||
|
set -e
|
||||||
|
if command -v update-desktop-database >/dev/null 2>&1; then
|
||||||
|
update-desktop-database /usr/share/applications >/dev/null 2>&1 || true
|
||||||
|
fi
|
||||||
|
if command -v appstreamcli >/dev/null 2>&1; then
|
||||||
|
appstreamcli refresh-cache --force >/dev/null 2>&1 || true
|
||||||
|
fi
|
||||||
|
exit 0
|
||||||
|
EOF
|
||||||
|
chmod 0755 "$DEB_ROOT/DEBIAN/postinst" "$DEB_ROOT/DEBIAN/postrm"
|
||||||
|
|
||||||
dpkg-deb --build "$DEB_ROOT" "$DIST_DIR/${APP_NAME}_${PKG_VERSION}-${PKG_RELEASE}_${ARCH_DEB}.deb"
|
dpkg-deb --build "$DEB_ROOT" "$DIST_DIR/${APP_NAME}_${PKG_VERSION}-${PKG_RELEASE}_${ARCH_DEB}.deb"
|
||||||
fi
|
fi
|
||||||
|
|
||||||
@@ -269,7 +320,10 @@ Summary: $COMMENT
|
|||||||
License: $LICENSE
|
License: $LICENSE
|
||||||
Requires: gtk3
|
Requires: gtk3
|
||||||
Requires: alsa-lib
|
Requires: alsa-lib
|
||||||
|
Requires: webkit2gtk4.1
|
||||||
|
Requires: libayatana-appindicator-gtk3
|
||||||
Recommends: ydotool
|
Recommends: ydotool
|
||||||
|
Provides: application() application($APP_NAME.desktop)
|
||||||
|
|
||||||
%description
|
%description
|
||||||
WIS Free V3 is a desktop voice dictation app built with Go and Wails.
|
WIS Free V3 is a desktop voice dictation app built with Go and Wails.
|
||||||
@@ -280,10 +334,27 @@ rm -rf %{buildroot}
|
|||||||
mkdir -p %{buildroot}
|
mkdir -p %{buildroot}
|
||||||
cp -a "$RPM_PAYLOAD"/. %{buildroot}/
|
cp -a "$RPM_PAYLOAD"/. %{buildroot}/
|
||||||
|
|
||||||
|
%post
|
||||||
|
if command -v update-desktop-database >/dev/null 2>&1; then
|
||||||
|
update-desktop-database /usr/share/applications >/dev/null 2>&1 || :
|
||||||
|
fi
|
||||||
|
if command -v appstreamcli >/dev/null 2>&1; then
|
||||||
|
appstreamcli refresh-cache --force >/dev/null 2>&1 || :
|
||||||
|
fi
|
||||||
|
|
||||||
|
%postun
|
||||||
|
if command -v update-desktop-database >/dev/null 2>&1; then
|
||||||
|
update-desktop-database /usr/share/applications >/dev/null 2>&1 || :
|
||||||
|
fi
|
||||||
|
if command -v appstreamcli >/dev/null 2>&1; then
|
||||||
|
appstreamcli refresh-cache --force >/dev/null 2>&1 || :
|
||||||
|
fi
|
||||||
|
|
||||||
%files
|
%files
|
||||||
%attr(0755,root,root) /usr/bin/$APP_NAME
|
%attr(0755,root,root) /usr/bin/$APP_NAME
|
||||||
/usr/share/applications/$APP_NAME.desktop
|
/usr/share/applications/$APP_NAME.desktop
|
||||||
/usr/share/pixmaps/$APP_NAME.png
|
/usr/share/pixmaps/$APP_NAME.png
|
||||||
|
/usr/share/metainfo/$APP_NAME.metainfo.xml
|
||||||
/usr/share/doc/$APP_NAME/README.md
|
/usr/share/doc/$APP_NAME/README.md
|
||||||
EOF
|
EOF
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user