Remove settings preview page

This commit is contained in:
jahruz67
2026-07-14 17:01:58 -07:00
parent eea198d882
commit 1e9862ffa1
13 changed files with 73 additions and 694 deletions
+2 -6
View File
@@ -39,17 +39,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 +152,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. |
+36 -76
View File
@@ -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
@@ -468,37 +468,25 @@ func (a *App) SaveSettings(settings map[string]interface{}) string {
} }
} }
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)"
// Show notification for hotkey registration failures }
go func() { if modOnly && runtime.GOOS != "windows" {
time.Sleep(500 * time.Millisecond) return "Modifier-only shortcuts (like ctrl+win) are only supported on Windows"
if a.overlay != nil { }
a.overlay.Show("Shortcut registration failed. Use the command shown in Settings for a desktop shortcut.")
} a.config.Shortcut = val
logger.Info("HOTKEY SETUP: Portal failed. Add a custom GNOME/KDE shortcut with: wisp-open --action=toggle") 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 {
@@ -652,37 +640,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)
// Always show an error notification when portal registration fails
// so the user knows to use the fallback method
go func() {
// Show notification immediately via tray if available
if err != nil {
logger.Error("Portal hotkey error (will show notification): %v", err)
}
// Also try to show overlay if window is visible
time.Sleep(500 * time.Millisecond)
if a.overlay != nil {
a.overlay.Show("Shortcut registration failed. Use the command shown in Settings for a desktop shortcut.")
}
// Log a clear instruction for the fallback method
logger.Info("HOTKEY SETUP: Portal failed. Add a custom GNOME/KDE shortcut with: wisp-open --action=toggle")
}()
})
}
a.hotkeyListener.Start() a.hotkeyListener.Start()
} else { } else {
logger.Info("Linux portal hotkey disabled via WISFREE_USE_PORTAL_HOTKEY=0; 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!")
@@ -690,16 +660,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 {
+13 -5
View File
@@ -39,7 +39,7 @@
<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> <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,7 +50,7 @@
<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>
@@ -61,11 +61,11 @@
<div id="linuxYdotoolStatus"></div> <div id="linuxYdotoolStatus"></div>
</div> </div>
<!-- Linux fallback shortcut command (collapsible) --> <!-- Linux system shortcut command (collapsible) -->
<div class="section" id="linuxFallbackSection" style="display: none;"> <div class="section" id="linuxFallbackSection" style="display: none;">
<details class="fallback-details"> <details class="fallback-details">
<summary class="fallback-summary">Fallback Method</summary> <summary class="fallback-summary">Linux System Shortcut</summary>
<p class="hint" style="margin-top: 12px;">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" 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="form-control" style="margin-top: 12px;">
<div class="flex-row"> <div class="flex-row">
<div class="input-wrapper"> <div class="input-wrapper">
@@ -209,6 +209,7 @@
<script type="module"> <script type="module">
let apiKeyVisible = false; let apiKeyVisible = false;
let linuxShortcutMode = false;
// Toggle API key visibility // Toggle API key visibility
window.toggleApiKey = function () { window.toggleApiKey = function () {
@@ -221,6 +222,10 @@
// 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');
@@ -397,11 +402,14 @@
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 shortcutSection = document.getElementById('shortcutSection');
const ydotoolSection = document.getElementById('linuxYdotoolSection'); const ydotoolSection = document.getElementById('linuxYdotoolSection');
const fallbackSection = document.getElementById('linuxFallbackSection'); const fallbackSection = document.getElementById('linuxFallbackSection');
const cmdInput = document.getElementById('linuxPressCommand'); const cmdInput = document.getElementById('linuxPressCommand');
if (shortcutSection) shortcutSection.style.display = 'none';
if (ydotoolSection) ydotoolSection.style.display = 'block'; if (ydotoolSection) ydotoolSection.style.display = 'block';
if (fallbackSection) fallbackSection.style.display = 'block'; if (fallbackSection) fallbackSection.style.display = 'block';
+5 -1
View File
@@ -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 || '';
} }
-1
View File
@@ -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
-2
View File
@@ -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=
+3 -4
View File
@@ -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()
} }
+1 -17
View File
@@ -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
} }
-549
View File
@@ -1,549 +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
ParentWindow string // Required: empty string or window handle for the parent window
Details map[string]dbus.Variant
}
shortcutsArg := []portalShortcut{
{
ID: wisfreeGlobalShortcutID,
ParentWindow: "", // Empty string since we have no focused window handle
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
// NOTE: BindShortcuts expects shortcuts as a(ssa{sv}) - array of structs with (string, string, dict)
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)
}
// portalExtractIDs walks an Activated/Deactivated signal body argument and
// returns the shortcut IDs it references. The XDG GlobalShortcuts spec
// defines Activated/Deactivated with a single argument of type a(su):
// an array of (string id, uint state) structs. We are defensive about
// variant wrapping and about older drafts that may send a single struct
// (su) or even just a bare string id.
func portalExtractIDs(bodyArg interface{}) []string {
val := unwrapVariant(bodyArg)
switch v := val.(type) {
case []interface{}:
var ids []string
for _, item := range v {
ids = append(ids, portalExtractIDs(item)...)
}
return ids
case [][]interface{}:
// Each element is a (id, state) struct serialized as []interface{}.
var ids []string
for _, tuple := range v {
if len(tuple) > 0 {
if id, ok := unwrapVariant(tuple[0]).(string); ok {
ids = append(ids, id)
}
}
}
return ids
case []map[string]interface{}:
var ids []string
for _, m := range v {
if s, ok := m["id"].(string); ok {
ids = append(ids, s)
}
}
return ids
case map[string]interface{}:
if s, ok := v["id"].(string); ok {
return []string{s}
}
return nil
case string:
return []string{v}
default:
return nil
}
}
// portalSignalMatchesID reports whether any of the signal's body arguments
// reference the given shortcut ID. It scans every body argument because the
// exact argument index varies across portal implementations.
func portalSignalMatchesID(body []interface{}, wantID string) bool {
for _, arg := range body {
for _, id := range portalExtractIDs(arg) {
if id == wantID {
return true
}
}
}
return false
}
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)
// Note: we intentionally do NOT filter on `sender` here. A D-Bus signal's
// SENDER header is always the portal's *unique* connection name (:1.x), not
// the well-known org.freedesktop.portal.Desktop. Filtering the match-rule
// `sender` key on a well-known name is unreliable across dbus-daemon/libdbus
// versions and silently drops every signal. The interface filter plus the
// shortcut-ID body check below is sufficient and matches the reference
// implementations (e.g. Ghostty subscribes with sender=null).
rule := fmt.Sprintf(
"type='signal',interface='%s'",
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 (interface=%s)", ifaceGlobalShortcuts)
for {
select {
case <-hk.portalStop:
return
case sig, ok := <-ch:
if !ok || sig == nil {
return
}
if len(sig.Body) < 1 {
continue
}
// Diagnostic: confirm the daemon is actually delivering
// GlobalShortcuts signals to us (regardless of whether the ID
// matches our shortcut).
logger.Debug("wis-free-v3 hotkey: received signal %s (args=%d)", sig.Name, len(sig.Body))
// The XDG GlobalShortcuts Activated/Deactivated signals carry
// (session_handle o, shortcut_id s, timestamp t, options a{sv}).
// Older drafts used a single a(su) argument. We match on our
// unique shortcut ID across *all* body arguments rather than a
// specific index, which is what the old code got wrong (it
// expected sig.Body[1] to be a string and silently dropped every
// signal whenever the signature didn't match).
if !portalSignalMatchesID(sig.Body, 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
}
+8 -28
View File
@@ -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")
}
} }
} }
+1 -1
View File
@@ -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
+1 -1
View File
@@ -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 {
+3 -3
View File
@@ -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"