This commit is contained in:
Your Name
2026-05-09 13:51:20 -07:00
parent 6d1fe33c60
commit f111695a45
25 changed files with 376 additions and 299 deletions
+72 -4
View File
@@ -6,6 +6,7 @@ import (
"os" "os"
"path/filepath" "path/filepath"
"runtime" "runtime"
"sync/atomic"
"strings" "strings"
"time" "time"
@@ -31,6 +32,7 @@ type App struct {
config *config.Config config *config.Config
overlay platform.Overlay overlay platform.Overlay
recordingPath string recordingPath string
recording int32
isQuitting bool isQuitting bool
wasMediaPlaying bool wasMediaPlaying bool
whisperManager *whisper.Manager whisperManager *whisper.Manager
@@ -82,8 +84,47 @@ func (a *App) startup(ctx context.Context) {
} }
}() }()
// Accept explicit commands from helper invocations (Linux desktop shortcuts).
go func() {
for cmd := range secondInstanceCommand {
switch cmd {
case instanceCmdShow:
wailsruntime.WindowShow(ctx)
case instanceCmdStart:
a.StartRecording()
case instanceCmdStop:
a.StopRecording()
case instanceCmdToggle:
a.ToggleRecording()
default:
}
}
}()
// Start system tray in a goroutine // Start system tray in a goroutine
go tray.Start(a) if runtime.GOOS == "linux" {
tray.Start(a)
} else {
go tray.Start(a)
}
if initialAction != "" {
action := initialAction
initialAction = ""
go func() {
switch action {
case "show":
wailsruntime.WindowShow(ctx)
case "start":
a.StartRecording()
case "stop":
a.StopRecording()
case "toggle":
a.ToggleRecording()
default:
}
}()
}
} }
// beforeClose is called when the window is about to close // beforeClose is called when the window is about to close
@@ -103,8 +144,27 @@ func (a *App) Quit() {
wailsruntime.Quit(a.ctx) wailsruntime.Quit(a.ctx)
} }
var lastToggle time.Time
func (a *App) ToggleRecording() {
// Debounce toggle calls to prevent rapid firing from double-binds or Wayland glitches
if time.Since(lastToggle) < 500*time.Millisecond {
return
}
lastToggle = time.Now()
if atomic.LoadInt32(&a.recording) == 1 {
a.StopRecording()
return
}
a.StartRecording()
}
// StartRecording starts the audio recording // StartRecording starts the audio recording
func (a *App) StartRecording() { func (a *App) StartRecording() {
if !atomic.CompareAndSwapInt32(&a.recording, 0, 1) {
return
}
logger.Info("StartRecording triggered") logger.Info("StartRecording triggered")
// 1. Show overlay immediately (most visible feedback) // 1. Show overlay immediately (most visible feedback)
@@ -120,6 +180,7 @@ func (a *App) StartRecording() {
if a.overlay != nil { if a.overlay != nil {
a.overlay.Hide() a.overlay.Hide()
} }
atomic.StoreInt32(&a.recording, 0)
return return
} }
@@ -141,6 +202,7 @@ func (a *App) StartRecording() {
if a.overlay != nil { if a.overlay != nil {
a.overlay.Hide() a.overlay.Hide()
} }
atomic.StoreInt32(&a.recording, 0)
return return
} }
} }
@@ -160,6 +222,9 @@ func (a *App) StartRecording() {
// StopRecording stops the audio recording and triggers transcription // StopRecording stops the audio recording and triggers transcription
func (a *App) StopRecording() { func (a *App) StopRecording() {
if !atomic.CompareAndSwapInt32(&a.recording, 1, 0) {
return
}
logger.Info("StopRecording triggered") logger.Info("StopRecording triggered")
// Resume media if it was playing before // Resume media if it was playing before
@@ -169,12 +234,15 @@ func (a *App) StopRecording() {
} }
if a.audioRecorder == nil { if a.audioRecorder == nil {
atomic.StoreInt32(&a.recording, 0)
return return
} }
err := a.audioRecorder.Stop() err := a.audioRecorder.Stop()
if err != nil { if err != nil {
logger.Error("Failed to stop recording: %v", err) logger.Error("Failed to stop recording: %v", err)
// Attempt to keep state consistent: if stop failed, we are likely still recording.
atomic.StoreInt32(&a.recording, 1)
return return
} }
@@ -329,7 +397,7 @@ func (a *App) SaveSettings(settings map[string]interface{}) string {
a.hotkeyListener.UpdateShortcut(val) a.hotkeyListener.UpdateShortcut(val)
} else { } else {
// Should not happen if app started correctly, but just in case // Should not happen if app started correctly, but just in case
a.hotkeyListener = hotkey.NewListener(val, a.StartRecording, a.StopRecording) a.hotkeyListener = hotkey.NewListener(val, a.ToggleRecording, func() {})
a.hotkeyListener.Start() a.hotkeyListener.Start()
} }
} }
@@ -480,8 +548,8 @@ func (a *App) startupHeadless() {
logger.Error("Failed to ensure desktop file: %v", err) logger.Error("Failed to ensure desktop file: %v", err)
} }
// Initialize Hotkey Listener // Initialize Hotkey Listener (toggle mode: keydown toggles, keyup ignored)
a.hotkeyListener = hotkey.NewListener(a.config.Shortcut, a.StartRecording, a.StopRecording) a.hotkeyListener = hotkey.NewListener(a.config.Shortcut, a.ToggleRecording, func() {})
a.hotkeyListener.Start() a.hotkeyListener.Start()
logger.Info("Components initialized successfully!") logger.Info("Components initialized successfully!")
BIN
View File
Binary file not shown.
File diff suppressed because one or more lines are too long
-1
View File
@@ -1 +0,0 @@
:root{--bg-color:#0b0f1a;--card-bg:#1e293bb3;--input-bg:#33415580;--text-color:#f8fafc;--text-muted:#94a3b8;--primary-color:#3b82f6;--primary-hover:#2563eb;--accent-color:#6366f1;--border-color:#4b556366;--success-color:#10b981;--glass-border:#ffffff1a;--lightningcss-light: ;--lightningcss-dark:initial;color-scheme:dark}body{color:var(--text-color);letter-spacing:-.01em;background:radial-gradient(circle at 100% 0,#1e293b,#0b0f1a);min-height:100vh;margin:0;padding:30px;font-family:Inter,system-ui,-apple-system,sans-serif}.container{max-width:720px;margin:0 auto}h1{background:linear-gradient(135deg,#60a5fa,#a78bfa);-webkit-text-fill-color:transparent;-webkit-background-clip:text;background-clip:text;margin-bottom:30px;font-size:28px;font-weight:700}.section{background:var(--card-bg);-webkit-backdrop-filter:blur(12px);border:1px solid var(--glass-border);border-radius:16px;margin-bottom:24px;padding:24px;transition:transform .2s;box-shadow:0 10px 15px -3px #0003}.section:hover{border-color:#fff3}label{color:var(--text-muted);text-transform:uppercase;letter-spacing:.05em;margin-bottom:10px;font-size:13px;font-weight:600;display:block}input[type=checkbox]{accent-color:var(--primary-color);cursor:pointer;width:18px;height:18px}input[type=text],input[type=password],select,textarea{background:var(--input-bg);border:1px solid var(--border-color);color:#fff;box-sizing:border-box;border-radius:10px;width:100%;padding:12px 14px;font-size:14px;transition:all .2s}select{appearance:none;background-image:url("data:image/svg+xml;charset=UTF-8,%3csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24' fill='none' stroke='%2394a3b8' stroke-width='2' stroke-linecap='round' stroke-linejoin='round'%3e%3cpolyline points='6 9 12 15 18 9'%3e%3c/polyline%3e%3c/svg%3e");background-position:right 14px center;background-repeat:no-repeat;background-size:16px;padding-right:40px}select option{background:var(--bg-color);color:#fff}textarea{resize:vertical;min-height:80px}input:focus,select:focus,textarea:focus{border-color:var(--primary-color);background:#334155cc;outline:none;box-shadow:0 0 0 3px #3b82f633}.form-control{width:100%;max-width:480px}.input-wrapper{flex:1;min-width:0;position:relative}.input-wrapper input{box-sizing:border-box;width:100%;padding-right:44px;display:block}.eye-btn{cursor:pointer;opacity:.5;justify-content:center;align-items:center;font-size:16px;line-height:1;transition:opacity .2s;display:flex;position:absolute;top:50%;right:8px;transform:translateY(-50%);box-shadow:none!important;background:0 0!important;border:none!important;padding:4px!important}.eye-btn:hover{opacity:1;transform:translateY(-50%)scale(1.1)}.save-status{background:var(--success-color);color:#fff;opacity:0;z-index:1000;border-radius:12px;padding:12px 24px;font-weight:600;transition:all .3s cubic-bezier(.4,0,.2,1);position:fixed;bottom:30px;right:30px;transform:translateY(100px);box-shadow:0 10px 15px -3px #0003}.save-status.show{opacity:1;transform:translateY(0)}button{background-color:var(--primary-color);color:#fff;cursor:pointer;white-space:nowrap;border:none;border-radius:10px;flex-shrink:0;padding:10px 20px;font-size:14px;font-weight:600;transition:all .2s cubic-bezier(.4,0,.2,1);box-shadow:0 4px 6px -1px #0003}button:hover{background-color:var(--primary-hover);transform:translateY(-1px);box-shadow:0 10px 15px -3px #0000004d}button:active{transform:translateY(0)}button[style*="background: transparent"]{opacity:.7;text-decoration:underline;box-shadow:none!important;background:0 0!important}button[style*="background: transparent"]:hover{opacity:1}.flex-row{flex-wrap:nowrap;align-items:center;gap:12px;display:flex}.flex-between{justify-content:space-between;align-items:center;display:flex}.history-list{max-height:250px;margin-top:10px;padding-right:5px;overflow-y:auto}.history-item{background:#ffffff08;border:1px solid #ffffff0d;border-radius:12px;margin-bottom:10px;padding:14px;transition:all .2s}.history-item:hover{background:#ffffff0f;transform:translate(2px)}.history-time{color:var(--primary-color);margin-bottom:6px;font-size:11px;font-weight:700;display:block}::-webkit-scrollbar{width:6px}::-webkit-scrollbar-track{background:0 0}::-webkit-scrollbar-thumb{background:var(--border-color);border-radius:10px}::-webkit-scrollbar-thumb:hover{background:var(--text-muted)}
File diff suppressed because one or more lines are too long
+1
View File
@@ -0,0 +1 @@
:root{--bg-color: #0b0f1a;--card-bg: rgba(30, 41, 59, .7);--input-bg: rgba(51, 65, 85, .5);--text-color: #f8fafc;--text-muted: #94a3b8;--primary-color: #3b82f6;--primary-hover: #2563eb;--accent-color: #6366f1;--border-color: rgba(75, 85, 99, .4);--success-color: #10b981;--glass-border: rgba(255, 255, 255, .1);color-scheme:dark}body{background:radial-gradient(circle at top right,#1e293b,#0b0f1a);color:var(--text-color);font-family:Inter,system-ui,-apple-system,sans-serif;margin:0;padding:30px;min-height:100vh;letter-spacing:-.01em}.container{max-width:720px;margin:0 auto}h1{font-size:28px;font-weight:700;background:linear-gradient(135deg,#60a5fa,#a78bfa);-webkit-background-clip:text;background-clip:text;-webkit-text-fill-color:transparent;margin-bottom:30px}.section{background:var(--card-bg);backdrop-filter:blur(12px);-webkit-backdrop-filter:blur(12px);padding:24px;border-radius:16px;margin-bottom:24px;border:1px solid var(--glass-border);box-shadow:0 10px 15px -3px #0003;transition:transform .2s ease}.section:hover{border-color:#fff3}label{display:block;color:var(--text-muted);font-size:13px;font-weight:600;text-transform:uppercase;letter-spacing:.05em;margin-bottom:10px}input[type=checkbox]{accent-color:var(--primary-color);width:18px;height:18px;cursor:pointer}input[type=text],input[type=password],select,textarea{width:100%;background:var(--input-bg);border:1px solid var(--border-color);color:#fff;padding:12px 14px;border-radius:10px;font-size:14px;transition:all .2s ease;box-sizing:border-box}select{appearance:none;-webkit-appearance:none;background-image:url("data:image/svg+xml;charset=UTF-8,%3csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24' fill='none' stroke='%2394a3b8' stroke-width='2' stroke-linecap='round' stroke-linejoin='round'%3e%3cpolyline points='6 9 12 15 18 9'%3e%3c/polyline%3e%3c/svg%3e");background-repeat:no-repeat;background-position:right 14px center;background-size:16px;padding-right:40px}select option{background:var(--bg-color);color:#fff}textarea{resize:vertical;min-height:80px}input:focus,select:focus,textarea:focus{outline:none;border-color:var(--primary-color);background:rgba(51,65,85,.8);box-shadow:0 0 0 3px #3b82f633}.form-control{max-width:480px;width:100%}.input-wrapper{position:relative;flex:1;min-width:0}.input-wrapper input{width:100%;padding-right:44px;box-sizing:border-box;display:block}.eye-btn{position:absolute;right:8px;top:50%;transform:translateY(-50%);background:transparent!important;border:none!important;box-shadow:none!important;cursor:pointer;padding:4px!important;font-size:16px;opacity:.5;transition:opacity .2s;line-height:1;display:flex;align-items:center;justify-content:center}.eye-btn:hover{opacity:1;transform:translateY(-50%) scale(1.1)}.save-status{position:fixed;bottom:30px;right:30px;background:var(--success-color);color:#fff;padding:12px 24px;border-radius:12px;font-weight:600;box-shadow:0 10px 15px -3px #0003;transform:translateY(100px);opacity:0;transition:all .3s cubic-bezier(.4,0,.2,1);z-index:1000}.save-status.show{transform:translateY(0);opacity:1}button{background-color:var(--primary-color);color:#fff;border:none;padding:10px 20px;border-radius:10px;cursor:pointer;font-weight:600;font-size:14px;transition:all .2s cubic-bezier(.4,0,.2,1);box-shadow:0 4px 6px -1px #0003;white-space:nowrap;flex-shrink:0}button:hover{transform:translateY(-1px);box-shadow:0 10px 15px -3px #0000004d;background-color:var(--primary-hover)}button:active{transform:translateY(0)}button[style*="background: transparent"]{background:transparent!important;box-shadow:none!important;text-decoration:underline;opacity:.7}button[style*="background: transparent"]:hover{opacity:1}.flex-row{display:flex;gap:12px;align-items:center;flex-wrap:nowrap}.flex-between{display:flex;justify-content:space-between;align-items:center}.history-list{max-height:250px;overflow-y:auto;margin-top:10px;padding-right:5px}.history-item{background:rgba(255,255,255,.03);padding:14px;border-radius:12px;margin-bottom:10px;border:1px solid rgba(255,255,255,.05);transition:all .2s ease}.history-item:hover{background:rgba(255,255,255,.06);transform:translate(2px)}.history-time{color:var(--primary-color);font-size:11px;font-weight:700;margin-bottom:6px;display:block}::-webkit-scrollbar{width:6px}::-webkit-scrollbar-track{background:transparent}::-webkit-scrollbar-thumb{background:var(--border-color);border-radius:10px}::-webkit-scrollbar-thumb:hover{background:var(--text-muted)}
+4 -2
View File
@@ -5,8 +5,9 @@
<meta charset="UTF-8"> <meta charset="UTF-8">
<meta content="width=device-width, initial-scale=1.0" name="viewport"> <meta content="width=device-width, initial-scale=1.0" name="viewport">
<title>wis-free-v3 Settings</title> <title>wis-free-v3 Settings</title>
<script type="module" crossorigin src="/assets/index-BNpC-7X0.js"></script>
<link rel="stylesheet" crossorigin href="/assets/index-BsntdVbh.css"> <script type="module" crossorigin src="/assets/index.c31e3052.js"></script>
<link rel="stylesheet" href="/assets/index.d8635aeb.css">
</head> </head>
<body> <body>
@@ -182,6 +183,7 @@
</div> </div>
</div> </div>
</body> </body>
</html> </html>
+4
View File
@@ -32,6 +32,10 @@ export function StartRecording():Promise<void>;
export function StopRecording():Promise<void>; export function StopRecording():Promise<void>;
export function ToggleRecording():Promise<void>;
export function ToggleStartup(arg1:boolean):Promise<string>; export function ToggleStartup(arg1:boolean):Promise<string>;
export function UninstallWhisper():Promise<string>; export function UninstallWhisper():Promise<string>;
export function Version():Promise<string>;
+8
View File
@@ -62,6 +62,10 @@ export function StopRecording() {
return window['go']['main']['App']['StopRecording'](); return window['go']['main']['App']['StopRecording']();
} }
export function ToggleRecording() {
return window['go']['main']['App']['ToggleRecording']();
}
export function ToggleStartup(arg1) { export function ToggleStartup(arg1) {
return window['go']['main']['App']['ToggleStartup'](arg1); return window['go']['main']['App']['ToggleStartup'](arg1);
} }
@@ -69,3 +73,7 @@ export function ToggleStartup(arg1) {
export function UninstallWhisper() { export function UninstallWhisper() {
return window['go']['main']['App']['UninstallWhisper'](); return window['go']['main']['App']['UninstallWhisper']();
} }
export function Version() {
return window['go']['main']['App']['Version']();
}
+81
View File
@@ -247,3 +247,84 @@ export function CanResolveFilePaths(): boolean;
// Resolves file paths for an array of files // Resolves file paths for an array of files
export function ResolveFilePaths(files: File[]): void export function ResolveFilePaths(files: File[]): void
// Notification types
export interface NotificationOptions {
id: string;
title: string;
subtitle?: string; // macOS and Linux only
body?: string;
categoryId?: string;
data?: { [key: string]: any };
}
export interface NotificationAction {
id?: string;
title?: string;
destructive?: boolean; // macOS-specific
}
export interface NotificationCategory {
id?: string;
actions?: NotificationAction[];
hasReplyField?: boolean;
replyPlaceholder?: string;
replyButtonTitle?: string;
}
// [InitializeNotifications](https://wails.io/docs/reference/runtime/notification#initializenotifications)
// Initializes the notification service for the application.
// This must be called before sending any notifications.
export function InitializeNotifications(): Promise<void>;
// [CleanupNotifications](https://wails.io/docs/reference/runtime/notification#cleanupnotifications)
// Cleans up notification resources and releases any held connections.
export function CleanupNotifications(): Promise<void>;
// [IsNotificationAvailable](https://wails.io/docs/reference/runtime/notification#isnotificationavailable)
// Checks if notifications are available on the current platform.
export function IsNotificationAvailable(): Promise<boolean>;
// [RequestNotificationAuthorization](https://wails.io/docs/reference/runtime/notification#requestnotificationauthorization)
// Requests notification authorization from the user (macOS only).
export function RequestNotificationAuthorization(): Promise<boolean>;
// [CheckNotificationAuthorization](https://wails.io/docs/reference/runtime/notification#checknotificationauthorization)
// Checks the current notification authorization status (macOS only).
export function CheckNotificationAuthorization(): Promise<boolean>;
// [SendNotification](https://wails.io/docs/reference/runtime/notification#sendnotification)
// Sends a basic notification with the given options.
export function SendNotification(options: NotificationOptions): Promise<void>;
// [SendNotificationWithActions](https://wails.io/docs/reference/runtime/notification#sendnotificationwithactions)
// Sends a notification with action buttons. Requires a registered category.
export function SendNotificationWithActions(options: NotificationOptions): Promise<void>;
// [RegisterNotificationCategory](https://wails.io/docs/reference/runtime/notification#registernotificationcategory)
// Registers a notification category that can be used with SendNotificationWithActions.
export function RegisterNotificationCategory(category: NotificationCategory): Promise<void>;
// [RemoveNotificationCategory](https://wails.io/docs/reference/runtime/notification#removenotificationcategory)
// Removes a previously registered notification category.
export function RemoveNotificationCategory(categoryId: string): Promise<void>;
// [RemoveAllPendingNotifications](https://wails.io/docs/reference/runtime/notification#removeallpendingnotifications)
// Removes all pending notifications from the notification center.
export function RemoveAllPendingNotifications(): Promise<void>;
// [RemovePendingNotification](https://wails.io/docs/reference/runtime/notification#removependingnotification)
// Removes a specific pending notification by its identifier.
export function RemovePendingNotification(identifier: string): Promise<void>;
// [RemoveAllDeliveredNotifications](https://wails.io/docs/reference/runtime/notification#removealldeliverednotifications)
// Removes all delivered notifications from the notification center.
export function RemoveAllDeliveredNotifications(): Promise<void>;
// [RemoveDeliveredNotification](https://wails.io/docs/reference/runtime/notification#removedeliverednotification)
// Removes a specific delivered notification by its identifier.
export function RemoveDeliveredNotification(identifier: string): Promise<void>;
// [RemoveNotification](https://wails.io/docs/reference/runtime/notification#removenotification)
// Removes a notification by its identifier (cross-platform convenience function).
export function RemoveNotification(identifier: string): Promise<void>;
+56
View File
@@ -240,3 +240,59 @@ export function CanResolveFilePaths() {
export function ResolveFilePaths(files) { export function ResolveFilePaths(files) {
return window.runtime.ResolveFilePaths(files); return window.runtime.ResolveFilePaths(files);
} }
export function InitializeNotifications() {
return window.runtime.InitializeNotifications();
}
export function CleanupNotifications() {
return window.runtime.CleanupNotifications();
}
export function IsNotificationAvailable() {
return window.runtime.IsNotificationAvailable();
}
export function RequestNotificationAuthorization() {
return window.runtime.RequestNotificationAuthorization();
}
export function CheckNotificationAuthorization() {
return window.runtime.CheckNotificationAuthorization();
}
export function SendNotification(options) {
return window.runtime.SendNotification(options);
}
export function SendNotificationWithActions(options) {
return window.runtime.SendNotificationWithActions(options);
}
export function RegisterNotificationCategory(category) {
return window.runtime.RegisterNotificationCategory(category);
}
export function RemoveNotificationCategory(categoryId) {
return window.runtime.RemoveNotificationCategory(categoryId);
}
export function RemoveAllPendingNotifications() {
return window.runtime.RemoveAllPendingNotifications();
}
export function RemovePendingNotification(identifier) {
return window.runtime.RemovePendingNotification(identifier);
}
export function RemoveAllDeliveredNotifications() {
return window.runtime.RemoveAllDeliveredNotifications();
}
export function RemoveDeliveredNotification(identifier) {
return window.runtime.RemoveDeliveredNotification(identifier);
}
export function RemoveNotification(identifier) {
return window.runtime.RemoveNotification(identifier);
}
+3 -3
View File
@@ -154,11 +154,11 @@ func (l *Listener) eventLoop(hk *xhk.Hotkey) {
// If Wayland doesn't send Deactivated (Keyup), this acts as a Toggle fallback. // If Wayland doesn't send Deactivated (Keyup), this acts as a Toggle fallback.
select { select {
case <-hk.Keyup(): case <-hk.Keyup():
// Out-of-order X11 auto-repeat. Ignore both. // Out-of-order auto-repeat. Ignore both.
case <-time.After(20 * time.Millisecond): case <-time.After(20 * time.Millisecond):
// Genuine second press. Toggle off. // Genuine second press. Toggle off.
logger.Info("Shortcut activated again: stopping recording (Wayland toggle fallback)") logger.Info("Shortcut activated again: toggling recording (Wayland toggle fallback)")
go l.stopCallback() go l.startCallback()
isRecording = false isRecording = false
} }
} }
+1
View File
@@ -9,6 +9,7 @@ var ModMap = map[string]xhk.Modifier{
"control": xhk.ModCtrl, "control": xhk.ModCtrl,
"shift": xhk.ModShift, "shift": xhk.ModShift,
"alt": xhk.Mod1, "alt": xhk.Mod1,
"option": xhk.Mod1,
"win": xhk.Mod4, "win": xhk.Mod4,
"windows": xhk.Mod4, "windows": xhk.Mod4,
"meta": xhk.Mod4, "meta": xhk.Mod4,
+24 -4
View File
@@ -14,19 +14,24 @@ const appName = "wis-free-v3"
const baseDesktopFileTemplate = `[Desktop Entry] const baseDesktopFileTemplate = `[Desktop Entry]
Type=Application Type=Application
Name=WIS Free V3 Name=WIS Free V3
Comment=Voice Recording and Transcription
%s
%s %s
Icon=%s Icon=%s
StartupWMClass=%s StartupWMClass=%s
Terminal=false Terminal=false
Categories=Utility; Categories=Utility;
Keywords=voice;recorder;transcription;
` `
const autostartDesktopFileTemplate = `[Desktop Entry] const autostartDesktopFileTemplate = `[Desktop Entry]
Type=Application Type=Application
Name=WIS Free V3 Name=WIS Free V3
Comment=Voice Recording and Transcription
%s %s
Icon=wis-free-v3 %s
StartupWMClass=wis-free-v3 Icon=%s
StartupWMClass=%s
Terminal=false Terminal=false
Categories=Utility; Categories=Utility;
X-GNOME-Autostart-enabled=true X-GNOME-Autostart-enabled=true
@@ -60,7 +65,7 @@ func EnsureDesktopFile(iconBytes []byte) error {
return fmt.Errorf("failed to write icon to pixmaps: %w", err) return fmt.Errorf("failed to write icon to pixmaps: %w", err)
} }
content := fmt.Sprintf(baseDesktopFileTemplate, desktopExecField(exePath), appName, appName) content := fmt.Sprintf(baseDesktopFileTemplate, desktopExecField(exePath), desktopTryExecField(exePath), iconPath, appName)
return os.WriteFile(desktopPath, []byte(content), 0644) return os.WriteFile(desktopPath, []byte(content), 0644)
} }
@@ -113,6 +118,11 @@ func desktopExecField(exePath string) string {
return `Exec="` + escaped + `"` return `Exec="` + escaped + `"`
} }
// desktopTryExecField returns one line: TryExec=/path (desktop entries do not accept quoting for TryExec)
func desktopTryExecField(exePath string) string {
return "TryExec=" + exePath
}
func AddToStartup() error { func AddToStartup() error {
autostartPath, err := getAutostartPath() autostartPath, err := getAutostartPath()
if err != nil { if err != nil {
@@ -124,7 +134,17 @@ func AddToStartup() error {
return fmt.Errorf("failed to get executable path: %w", err) return fmt.Errorf("failed to get executable path: %w", err)
} }
content := fmt.Sprintf(autostartDesktopFileTemplate, desktopExecField(exePath)) // Prefer a concrete icon file path if we already created one in EnsureDesktopFile.
// Fall back to the icon name (theme lookup) if it doesn't exist.
iconValue := appName
if home, err := os.UserHomeDir(); err == nil {
iconPath := filepath.Join(home, ".local", "share", "pixmaps", appName+".png")
if _, statErr := os.Stat(iconPath); statErr == nil {
iconValue = iconPath
}
}
content := fmt.Sprintf(autostartDesktopFileTemplate, desktopExecField(exePath), desktopTryExecField(exePath), iconValue, appName)
if err := os.WriteFile(autostartPath, []byte(content), 0644); err != nil { if err := os.WriteFile(autostartPath, []byte(content), 0644); err != nil {
return fmt.Errorf("failed to write autostart file: %w", err) return fmt.Errorf("failed to write autostart file: %w", err)
} }
-15
View File
@@ -46,21 +46,6 @@ var statusMenuItem *systray.MenuItem
var triggerCountItem *systray.MenuItem var triggerCountItem *systray.MenuItem
var triggerCount int var triggerCount int
// Start initializes and runs the system tray.
// This function blocks until the tray is terminated.
func Start(app App) {
// Pin this goroutine to one OS thread for the whole lifetime of systray.Run.
// getlantern/systray creates the notify icon and runs the Win32 message pump on
// whichever thread calls Run; if the goroutine migrates between threads, callbacks
// and the tray context menu break until restart (often seen after sleep/resume).
runtime.LockOSThread()
defer runtime.UnlockOSThread()
systray.Run(
func() { onReady(app) },
onExit,
)
}
func appDisplayName(app App) string { func appDisplayName(app App) string {
v := app.Version() v := app.Version()
if v != "" && v != "dev" { if v != "" && v != "dev" {
+15
View File
@@ -0,0 +1,15 @@
//go:build linux
package tray
import "github.com/getlantern/systray"
// On Linux, Wails already runs the GTK main loop on the main thread.
// Calling systray.Run() would start a second gtk_main() and may abort.
// Instead, we only register the tray and let the existing GTK loop drive it.
func Start(app App) {
systray.Register(
func() { onReady(app) },
onExit,
)
}
+24
View File
@@ -0,0 +1,24 @@
//go:build !linux
package tray
import (
"runtime"
"github.com/getlantern/systray"
)
// Start initializes and runs the system tray.
// This function blocks until the tray is terminated.
func Start(app App) {
// Pin this goroutine to one OS thread for the whole lifetime of systray.Run.
// getlantern/systray creates the notify icon and runs the Win32 message pump on
// whichever thread calls Run; if the goroutine migrates between threads, callbacks
// and the tray context menu break until restart (often seen after sleep/resume).
runtime.LockOSThread()
defer runtime.UnlockOSThread()
systray.Run(
func() { onReady(app) },
onExit,
)
}
-111
View File
@@ -1,111 +0,0 @@
// 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
#include <stdint.h>
#include <stdio.h>
#include <X11/Xlib.h>
#include <X11/Xutil.h>
#include <X11/XKBlib.h>
#include <unistd.h>
extern void hotkeyDown(uintptr_t hkhandle);
extern void hotkeyUp(uintptr_t hkhandle);
extern int checkCancel(uintptr_t hkhandle);
// Effective modifier masks for XGrabKey when NumLock / CapsLock are on.
// See: https://stackoverflow.com/questions/4037230/how-to-handle-global-hotkeys-with-x11-xlib
static unsigned int mod_masks[4];
static void init_mod_masks(unsigned int mod) {
mod_masks[0] = mod;
mod_masks[1] = mod | Mod2Mask;
mod_masks[2] = mod | LockMask;
mod_masks[3] = mod | Mod2Mask | LockMask;
}
static void grab_all(Display* d, int keycode, unsigned int mod, Window w) {
init_mod_masks(mod);
for (int i = 0; i < 4; i++) {
XGrabKey(d, keycode, mod_masks[i], w, False, GrabModeAsync, GrabModeAsync);
}
}
static void ungrab_all(Display* d, int keycode, unsigned int mod, Window w) {
init_mod_masks(mod);
for (int i = 0; i < 4; i++) {
XUngrabKey(d, keycode, mod_masks[i], w);
}
}
int displayTest() {
Display* d = NULL;
for (int i = 0; i < 42; i++) {
d = XOpenDisplay(0);
if (d == NULL) continue;
break;
}
if (d == NULL) {
return -1;
}
XCloseDisplay(d);
return 0;
}
// waitHotkey blocks until the hotkey is triggered.
int waitHotkey(uintptr_t hkhandle, unsigned int mod, int key) {
Display* d = NULL;
for (int i = 0; i < 42; i++) {
d = XOpenDisplay(0);
if (d == NULL) continue;
break;
}
if (d == NULL) {
return -1;
}
// Optional: Ask X server to only send one release at the physical end of auto-repeat.
Bool supported;
XkbSetDetectableAutoRepeat(d, True, &supported);
int keycode = XKeysymToKeycode(d, key);
if (keycode == 0) {
XCloseDisplay(d);
return -1;
}
Window root = DefaultRootWindow(d);
grab_all(d, keycode, mod, root);
XEvent ev;
while(1) {
if (checkCancel(hkhandle) == 1) {
break;
}
if (XPending(d) > 0) {
XNextEvent(d, &ev);
switch(ev.type) {
case KeyPress:
if (ev.xkey.keycode == (unsigned)keycode) {
hotkeyDown(hkhandle);
}
continue;
case KeyRelease:
if (ev.xkey.keycode == (unsigned)keycode) {
hotkeyUp(hkhandle);
}
continue;
}
} else {
usleep(10000); // Poll every 10ms for snappy responsiveness without high CPU
}
}
ungrab_all(d, keycode, mod, root);
XCloseDisplay(d);
return 0;
}
@@ -1,4 +1,4 @@
//go:build linux && !cgo //go:build linux
package hotkey package hotkey
@@ -14,10 +14,6 @@ func (hk *Hotkey) unregister() error {
hk.mu.Unlock() hk.mu.Unlock()
return errors.New("hotkey is not registered.") return errors.New("hotkey is not registered.")
} }
if hk.backend != linuxHKPortal {
hk.mu.Unlock()
return errors.New("hotkey: invalid backend state")
}
hk.registered = false hk.registered = false
hk.mu.Unlock() hk.mu.Unlock()
return hk.cleanupPortal() return hk.cleanupPortal()
@@ -1,42 +0,0 @@
//go:build linux && cgo
package hotkey
import (
"errors"
"log"
)
func (hk *Hotkey) register() error {
if usePortalBackend() {
if err := hk.registerPortal(); err == nil {
return nil
} else {
log.Printf("wis-free-v3: Wayland global-shortcuts portal unavailable (%v); trying X11", err)
}
}
return hk.registerX11()
}
func (hk *Hotkey) unregister() error {
hk.mu.Lock()
if !hk.registered {
hk.mu.Unlock()
return errors.New("hotkey is not registered.")
}
switch hk.backend {
case linuxHKPortal:
hk.registered = false
hk.mu.Unlock()
return hk.cleanupPortal()
case linuxHKX11:
hk.cancel()
hk.registered = false
hk.mu.Unlock()
<-hk.canceled
return nil
default:
hk.mu.Unlock()
return errors.New("hotkey: invalid backend state")
}
}
@@ -3,7 +3,6 @@
package hotkey package hotkey
import ( import (
"context"
"sync" "sync"
"github.com/godbus/dbus/v5" "github.com/godbus/dbus/v5"
@@ -11,7 +10,6 @@ import (
const ( const (
linuxHKNone = iota linuxHKNone = iota
linuxHKX11
linuxHKPortal linuxHKPortal
) )
@@ -20,11 +18,6 @@ type platformHotkey struct {
registered bool registered bool
backend int backend int
// X11 (CGO)
ctx context.Context
cancel context.CancelFunc
canceled chan struct{}
// Wayland / XDG portal global shortcuts // Wayland / XDG portal global shortcuts
portalStop chan struct{} portalStop chan struct{}
portalDone chan struct{} portalDone chan struct{}
+9 -16
View File
@@ -23,20 +23,13 @@ const (
ifaceSession = "org.freedesktop.portal.Session" ifaceSession = "org.freedesktop.portal.Session"
wisfreeGlobalShortcutID = "com.wisfree.push-to-record" wisfreeGlobalShortcutID = "com.wisfree.push-to-record"
envForcePortal = "WISFREE_USE_PORTAL_HOTKEY" envForcePortal = "WISFREE_USE_PORTAL_HOTKEY"
envForceX11 = "WISFREE_USE_X11_HOTKEY"
) )
func usePortalBackend() bool { func usePortalBackend() bool {
if os.Getenv(envForceX11) == "1" { if os.Getenv(envForcePortal) == "0" {
return false return false
} }
if os.Getenv(envForcePortal) == "1" { return true
return true
}
if os.Getenv("WAYLAND_DISPLAY") != "" {
return true
}
return strings.EqualFold(os.Getenv("XDG_SESSION_TYPE"), "wayland")
} }
func randomPortalToken() string { func randomPortalToken() string {
@@ -162,7 +155,7 @@ func portalWaitRequest(conn *dbus.Conn, reqPath dbus.ObjectPath) (uint32, map[st
if sig == nil || sig.Path != reqPath { if sig == nil || sig.Path != reqPath {
continue continue
} }
if sig.Name != "Response" { if !strings.HasSuffix(sig.Name, ".Response") {
continue continue
} }
if len(sig.Body) < 2 { if len(sig.Body) < 2 {
@@ -208,6 +201,9 @@ func (hk *Hotkey) registerPortal() error {
createOpts := map[string]dbus.Variant{ createOpts := map[string]dbus.Variant{
"handle_token": dbus.MakeVariant(randomPortalToken()), "handle_token": dbus.MakeVariant(randomPortalToken()),
"session_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 var createReqPath dbus.ObjectPath
@@ -270,7 +266,7 @@ func (hk *Hotkey) registerPortal() error {
if code != 0 { if code != 0 {
_ = conn.Object(portalBusName, sessPath).Call(ifaceSession+".Close", 0).Store() _ = conn.Object(portalBusName, sessPath).Call(ifaceSession+".Close", 0).Store()
_ = conn.Close() _ = conn.Close()
return fmt.Errorf("BindShortcuts rejected (code %d); install a desktop with GlobalShortcuts portal support (e.g. recent KDE Plasma) or set %s=1 to force X11 hotkeys under XWayland", code, envForceX11) 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 { if sc, ok := results["shortcuts"]; ok {
val := sc.Value() val := sc.Value()
@@ -323,8 +319,8 @@ func (hk *Hotkey) portalSignalLoop() {
ch := make(chan *dbus.Signal, 32) ch := make(chan *dbus.Signal, 32)
conn.Signal(ch) conn.Signal(ch)
rule := fmt.Sprintf( rule := fmt.Sprintf(
"type='signal',interface='%s',path='%s'", "type='signal',interface='%s'",
ifaceGlobalShortcuts, string(portalObjectPath), ifaceGlobalShortcuts,
) )
if err := conn.BusObject().Call("org.freedesktop.DBus.AddMatch", 0, rule).Store(); err != nil { if err := conn.BusObject().Call("org.freedesktop.DBus.AddMatch", 0, rule).Store(); err != nil {
log.Printf("wis-free-v3 hotkey: AddMatch GlobalShortcuts: %v", err) log.Printf("wis-free-v3 hotkey: AddMatch GlobalShortcuts: %v", err)
@@ -340,9 +336,6 @@ func (hk *Hotkey) portalSignalLoop() {
if !ok || sig == nil { if !ok || sig == nil {
return return
} }
if sig.Path != portalObjectPath {
continue
}
if len(sig.Body) < 2 { if len(sig.Body) < 2 {
continue continue
} }
-80
View File
@@ -1,80 +0,0 @@
//go:build linux && cgo
package hotkey
/*
#cgo LDFLAGS: -lX11
#include <stdint.h>
int displayTest();
int waitHotkey(uintptr_t hkhandle, unsigned int mod, int key);
*/
import "C"
import (
"context"
"errors"
"fmt"
"runtime"
"runtime/cgo"
)
func (hk *Hotkey) registerX11() error {
if C.displayTest() != 0 {
return fmt.Errorf("X11 display not available (is DISPLAY set?)")
}
hk.mu.Lock()
if hk.registered {
hk.mu.Unlock()
return errors.New("hotkey already registered.")
}
hk.backend = linuxHKX11
hk.registered = true
hk.ctx, hk.cancel = context.WithCancel(context.Background())
hk.canceled = make(chan struct{})
hk.mu.Unlock()
go hk.handleX11()
return nil
}
func (hk *Hotkey) handleX11() {
runtime.LockOSThread()
defer runtime.UnlockOSThread()
var mod Modifier
for _, m := range hk.mods {
mod = mod | m
}
h := cgo.NewHandle(hk)
defer h.Delete()
for {
_ = C.waitHotkey(C.uintptr_t(h), C.uint(mod), C.int(hk.key))
close(hk.canceled)
return
}
}
//export checkCancel
func checkCancel(h uintptr) C.int {
hk := cgo.Handle(h).Value().(*Hotkey)
select {
case <-hk.ctx.Done():
return 1
default:
return 0
}
}
//export hotkeyDown
func hotkeyDown(h uintptr) {
hk := cgo.Handle(h).Value().(*Hotkey)
hk.keydownIn <- Event{}
}
//export hotkeyUp
func hotkeyUp(h uintptr) {
hk := cgo.Handle(h).Value().(*Hotkey)
hk.keyupIn <- Event{}
}
+16 -1
View File
@@ -8,6 +8,7 @@ import (
"path/filepath" "path/filepath"
"runtime" "runtime"
"strconv" "strconv"
"strings"
"wis-free-v3/internal/logger" "wis-free-v3/internal/logger"
"wis-free-v3/internal/platform" "wis-free-v3/internal/platform"
@@ -40,18 +41,32 @@ var instanceLock *os.File
// from scripts/VERSION. Default is used for plain `go build` / `wails build`. // from scripts/VERSION. Default is used for plain `go build` / `wails build`.
var AppVersion = "dev" var AppVersion = "dev"
var initialAction string
func main() { func main() {
// systray's package init calls runtime.LockOSThread() on the program's startup // systray's package init calls runtime.LockOSThread() on the program's startup
// thread. Undo that so the main goroutine is not permanently bound; the tray // thread. Undo that so the main goroutine is not permanently bound; the tray
// goroutine locks itself in tray.Start instead (see internal/ui/tray/tray.go). // goroutine locks itself in tray.Start instead (see internal/ui/tray/tray.go).
runtime.UnlockOSThread() runtime.UnlockOSThread()
// If we are being used as a helper command (e.g. GNOME custom shortcut),
// signal the running instance and exit.
for _, arg := range os.Args[1:] {
if strings.HasPrefix(arg, "--action=") {
action := strings.TrimPrefix(arg, "--action=")
initialAction = action
if tryNotifyRunningInstanceAction(action) {
os.Exit(0)
}
}
}
// Ensure only one instance of the application is running // Ensure only one instance of the application is running
if !acquireInstanceLock() { if !acquireInstanceLock() {
// Another copy is already running (often left in the tray). Without this, // Another copy is already running (often left in the tray). Without this,
// we would exit silently and the user sees "nothing happens" when clicking // we would exit silently and the user sees "nothing happens" when clicking
// the launcher again. // the launcher again.
tryNotifyRunningInstanceToShow() _ = tryNotifyRunningInstanceToShow()
os.Exit(0) os.Exit(0)
} }
runSecondInstanceListener() runSecondInstanceListener()
+53 -4
View File
@@ -7,6 +7,7 @@ import (
"net" "net"
"os" "os"
"path/filepath" "path/filepath"
"strings"
"time" "time"
) )
@@ -15,6 +16,17 @@ const instanceSockName = "instance.sock"
// secondInstanceWake is closed when a second process asks the running app to show its window. // secondInstanceWake is closed when a second process asks the running app to show its window.
var secondInstanceWake = make(chan struct{}, 8) var secondInstanceWake = make(chan struct{}, 8)
// secondInstanceCommand receives commands from a spawned second process (e.g. desktop shortcut).
// Commands are 1-byte values written to the unix socket.
var secondInstanceCommand = make(chan byte, 16)
const (
instanceCmdShow byte = 1
instanceCmdStart byte = 2
instanceCmdStop byte = 3
instanceCmdToggle byte = 4
)
func instanceSocketPath() (string, error) { func instanceSocketPath() (string, error) {
home, err := os.UserHomeDir() home, err := os.UserHomeDir()
if err != nil { if err != nil {
@@ -23,17 +35,40 @@ func instanceSocketPath() (string, error) {
return filepath.Join(home, configDir, instanceSockName), nil return filepath.Join(home, configDir, instanceSockName), nil
} }
func tryNotifyRunningInstanceToShow() { func tryNotifyRunningInstance(cmd byte) bool {
path, err := instanceSocketPath() path, err := instanceSocketPath()
if err != nil { if err != nil {
return return false
} }
c, err := net.DialTimeout("unix", path, 500*time.Millisecond) c, err := net.DialTimeout("unix", path, 500*time.Millisecond)
if err != nil { if err != nil {
return return false
} }
defer c.Close() defer c.Close()
_, _ = c.Write([]byte{1}) if cmd == 0 {
cmd = instanceCmdShow
}
_, _ = c.Write([]byte{cmd})
return true
}
func tryNotifyRunningInstanceToShow() bool {
return tryNotifyRunningInstance(instanceCmdShow)
}
func tryNotifyRunningInstanceAction(action string) bool {
switch strings.ToLower(strings.TrimSpace(action)) {
case "show":
return tryNotifyRunningInstance(instanceCmdShow)
case "start":
return tryNotifyRunningInstance(instanceCmdStart)
case "stop":
return tryNotifyRunningInstance(instanceCmdStop)
case "toggle":
return tryNotifyRunningInstance(instanceCmdToggle)
default:
return false
}
} }
func runSecondInstanceListener() { func runSecondInstanceListener() {
@@ -54,6 +89,20 @@ func runSecondInstanceListener() {
} }
go func(conn net.Conn) { go func(conn net.Conn) {
defer conn.Close() defer conn.Close()
buf := make([]byte, 1)
n, _ := conn.Read(buf)
if n == 1 {
select {
case secondInstanceCommand <- buf[0]:
default:
}
} else {
// Backwards-compatible: any connection with no payload = show window.
select {
case secondInstanceCommand <- instanceCmdShow:
default:
}
}
_, _ = io.Copy(io.Discard, conn) _, _ = io.Copy(io.Discard, conn)
select { select {
case secondInstanceWake <- struct{}{}: case secondInstanceWake <- struct{}{}: