diff --git a/app.go b/app.go index 8b8757b..c31cec4 100644 --- a/app.go +++ b/app.go @@ -6,6 +6,7 @@ import ( "os" "path/filepath" "runtime" + "sync/atomic" "strings" "time" @@ -31,6 +32,7 @@ type App struct { config *config.Config overlay platform.Overlay recordingPath string + recording int32 isQuitting bool wasMediaPlaying bool 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 - 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 @@ -103,8 +144,27 @@ func (a *App) Quit() { 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 func (a *App) StartRecording() { + if !atomic.CompareAndSwapInt32(&a.recording, 0, 1) { + return + } logger.Info("StartRecording triggered") // 1. Show overlay immediately (most visible feedback) @@ -120,6 +180,7 @@ func (a *App) StartRecording() { if a.overlay != nil { a.overlay.Hide() } + atomic.StoreInt32(&a.recording, 0) return } @@ -141,6 +202,7 @@ func (a *App) StartRecording() { if a.overlay != nil { a.overlay.Hide() } + atomic.StoreInt32(&a.recording, 0) return } } @@ -160,6 +222,9 @@ func (a *App) StartRecording() { // StopRecording stops the audio recording and triggers transcription func (a *App) StopRecording() { + if !atomic.CompareAndSwapInt32(&a.recording, 1, 0) { + return + } logger.Info("StopRecording triggered") // Resume media if it was playing before @@ -169,12 +234,15 @@ func (a *App) StopRecording() { } if a.audioRecorder == nil { + atomic.StoreInt32(&a.recording, 0) return } err := a.audioRecorder.Stop() if err != nil { 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 } @@ -329,7 +397,7 @@ func (a *App) SaveSettings(settings map[string]interface{}) string { a.hotkeyListener.UpdateShortcut(val) } else { // Should not happen if app started correctly, but just in case - a.hotkeyListener = hotkey.NewListener(val, a.StartRecording, a.StopRecording) + a.hotkeyListener = hotkey.NewListener(val, a.ToggleRecording, func() {}) a.hotkeyListener.Start() } } @@ -480,8 +548,8 @@ func (a *App) startupHeadless() { logger.Error("Failed to ensure desktop file: %v", err) } - // Initialize Hotkey Listener - a.hotkeyListener = hotkey.NewListener(a.config.Shortcut, a.StartRecording, a.StopRecording) + // Initialize Hotkey Listener (toggle mode: keydown toggles, keyup ignored) + a.hotkeyListener = hotkey.NewListener(a.config.Shortcut, a.ToggleRecording, func() {}) a.hotkeyListener.Start() logger.Info("Components initialized successfully!") diff --git a/build/bin/wis-free-v3.exe b/build/bin/wis-free-v3 old mode 100644 new mode 100755 similarity index 63% rename from build/bin/wis-free-v3.exe rename to build/bin/wis-free-v3 index 534b0a4..58874e5 Binary files a/build/bin/wis-free-v3.exe and b/build/bin/wis-free-v3 differ diff --git a/frontend/dist/assets/index-BNpC-7X0.js b/frontend/dist/assets/index-BNpC-7X0.js deleted file mode 100644 index b5292de..0000000 --- a/frontend/dist/assets/index-BNpC-7X0.js +++ /dev/null @@ -1,3 +0,0 @@ -(function(){let e=document.createElement(`link`).relList;if(e&&e.supports&&e.supports(`modulepreload`))return;for(let e of document.querySelectorAll(`link[rel="modulepreload"]`))n(e);new MutationObserver(e=>{for(let t of e)if(t.type===`childList`)for(let e of t.addedNodes)e.tagName===`LINK`&&e.rel===`modulepreload`&&n(e)}).observe(document,{childList:!0,subtree:!0});function t(e){let t={};return e.integrity&&(t.integrity=e.integrity),e.referrerPolicy&&(t.referrerPolicy=e.referrerPolicy),e.crossOrigin===`use-credentials`?t.credentials=`include`:e.crossOrigin===`anonymous`?t.credentials=`omit`:t.credentials=`same-origin`,t}function n(e){if(e.ep)return;e.ep=!0;let n=t(e);fetch(e.href,n)}})();var e=!1;window.toggleApiKey=function(){let t=document.getElementById(`apiKey`),n=document.getElementById(`eyeBtn`);e=!e,t.type=e?`text`:`password`,n.textContent=e?`🙈`:`👁️`},window.recordShortcut=function(){let e=document.getElementById(`recordBtn`),t=document.getElementById(`shortcutInput`),n=e=>e.metaKey||e.key===`Meta`||e.key===`OS`||e.code===`MetaLeft`||e.code===`MetaRight`;e.textContent=`Recording...`,e.disabled=!0,t.value=``;let r=``,i=e=>{e.preventDefault(),e.stopPropagation();let i=[];e.ctrlKey&&i.push(`ctrl`),e.altKey&&i.push(`alt`),e.shiftKey&&i.push(`shift`),n(e)&&i.push(`win`);let a=e.key.toLowerCase();a===`control`&&(a=`ctrl`),a===`alt`&&(a=`alt`),a===`shift`&&(a=`shift`),(a===`meta`||a===`os`)&&(a=`win`),(e.code===`MetaLeft`||e.code===`MetaRight`)&&(a=`win`),a===` `&&(a=`space`);let s=new Set(i);s.add(a);let c=Array.from(s).filter(e=>e!==a);c.push(a),r=c.join(`+`),t.value=r,[`ctrl`,`alt`,`shift`,`win`].includes(a)||o()},a=e=>{e.preventDefault(),e.stopPropagation(),r&&(r.split(`+`).every(e=>[`ctrl`,`alt`,`shift`,`win`].includes(e))&&r.includes(`+`)?setTimeout(o,100):!e.ctrlKey&&!e.altKey&&!e.shiftKey&&!n(e)&&o())},o=()=>{if(!r){e.textContent=`Record`,e.disabled=!1,s();return}window.go.main.App.SaveSettings({shortcut:r}).then(t=>{t&&t.indexOf(`Invalid shortcut`)!==-1&&alert(t),e.textContent=`Record`,e.disabled=!1,s()})},s=()=>{window.removeEventListener(`keydown`,i,!0),window.removeEventListener(`keyup`,a,!0)};window.addEventListener(`keydown`,i,!0),window.addEventListener(`keyup`,a,!0)};function t(e){let t=document.getElementById(`historyList`);if(!e||e.length===0){t.innerHTML=`
No history yet.
`;return}t.innerHTML=``,[...e].reverse().forEach(e=>{let n=document.createElement(`div`);n.className=`history-item`;let r=document.createElement(`span`);r.className=`history-time`,r.textContent=new Date(e.timestamp).toLocaleString(void 0,{month:`short`,day:`numeric`,hour:`2-digit`,minute:`2-digit`});let i=document.createElement(`div`);i.textContent=e.text,n.appendChild(r),n.appendChild(i),t.appendChild(n)})}async function n(){try{t((await window.go.main.App.GetSettings()).history||[])}catch(e){console.error(`Failed to refresh history:`,e)}}async function r(){try{let e=await window.go.main.App.GetSettings();document.getElementById(`apiKey`).value=e.api_key||``,document.getElementById(`shortcutInput`).value=e.shortcut||`alt+z`,document.getElementById(`whisperModel`).value=e.whisper_model||`whisper-large-v3-turbo`,document.getElementById(`aiModel`).value=e.ai_model||`llama-3.3-70b-versatile`,document.getElementById(`aiPrompt`).value=e.ai_prompt||``,document.getElementById(`language`).value=e.language||`en`,document.getElementById(`startupToggle`).checked=e.startup||!1;let n=e.app_version&&String(e.app_version).trim()||`dev`;document.getElementById(`appVersion`).textContent=n===`dev`?`dev`:`v`+n,t(e.history||[])}catch(e){console.error(`Failed to load settings:`,e)}}async function i(){try{let e=await window.go.main.App.GetMicrophones(),t=document.getElementById(`micDevice`);e.forEach(e=>{if(e.index!==-1){let n=document.createElement(`option`);n.value=e.index,n.textContent=e.name,t.appendChild(n)}})}catch(e){console.error(`Failed to load mics:`,e)}}window.saveApiKey=async function(){let e=document.getElementById(`apiKey`).value.trim();await window.go.main.App.SaveSettings({api_key:e}),a()};function a(e=`Settings Saved!`){let t=document.getElementById(`saveStatus`);t.textContent=e,t.classList.add(`show`),setTimeout(()=>t.classList.remove(`show`),2e3)}window.saveWhisperModel=async function(){await window.go.main.App.SaveSettings({whisper_model:document.getElementById(`whisperModel`).value}),a()},window.saveAiModel=async function(){await window.go.main.App.SaveSettings({ai_model:document.getElementById(`aiModel`).value}),a()},window.saveLanguage=async function(){await window.go.main.App.SaveSettings({language:document.getElementById(`language`).value}),a(`Language Saved!`)},window.saveMicDevice=async function(){let e=document.getElementById(`micDevice`).value;await window.go.main.App.SaveSettings({microphone_device:e===`default`?null:parseInt(e)}),a()},window.savePrompt=async function(){await window.go.main.App.SaveSettings({ai_prompt:document.getElementById(`aiPrompt`).value.trim()}),a(`Prompt Saved!`)},window.toggleStartup=async function(){await window.go.main.App.ToggleStartup(document.getElementById(`startupToggle`).checked)},window.clearHistory=async function(){confirm(`Clear all history?`)&&(await window.go.main.App.ClearHistory(),t([]))};async function o(){try{let e=await window.go.main.App.CheckOnline(),t=document.getElementById(`connectionStatus`);if(e)t.innerHTML=`🟢 Online - Cloud transcription available`,t.style.background=`rgba(34, 197, 94, 0.1)`,t.style.color=`#22c55e`;else{t.innerHTML=`🔴 Offline - Install local Whisper for transcription`,t.style.background=`rgba(239, 68, 68, 0.1)`,t.style.color=`#ef4444`;let e=document.getElementById(`aiModel`);document.getElementById(`whisperModel`);for(let t of e.options)t.value!==`None`&&!t.value.startsWith(`local-`)&&(t.disabled=!0,t.text.includes(`(offline)`)||(t.text+=` (offline)`))}}catch(e){console.error(`Connection check failed:`,e)}}async function s(){try{let e=await window.go.main.App.GetWhisperInfo(),t=document.getElementById(`whisperModel`),n=t.querySelector(`option[value^="local-"]`);if(n&&n.remove(),e.installed){document.getElementById(`whisperNotInstalled`).style.display=`none`,document.getElementById(`whisperInstalled`).style.display=`block`,document.getElementById(`installedModel`).textContent=e.model+` model`;let n=document.createElement(`option`);n.value=`local-`+e.model,n.textContent=`🖥️ Local - `+e.model+` (offline)`,n.style.fontWeight=`bold`,t.insertBefore(n,t.firstChild)}else document.getElementById(`whisperNotInstalled`).style.display=`block`,document.getElementById(`whisperInstalled`).style.display=`none`}catch(e){console.error(`Whisper status check failed:`,e)}}window.installWhisper=async function(){let e=document.getElementById(`whisperModelSelect`).value,t=document.getElementById(`installBtn`);t.disabled=!0,t.textContent=`Starting installation...`;try{let t=await window.go.main.App.InstallWhisper(e);alert(t+` - -A terminal window will open showing the installation progress. This may take several minutes depending on your internet speed.`),setTimeout(s,5e3)}catch(e){alert(`Installation failed: `+e)}t.disabled=!1,t.textContent=`🚀 Install Offline Whisper`},window.uninstallWhisper=async function(){if(confirm(`Are you sure you want to uninstall offline Whisper? This will delete the downloaded model.`))try{let e=await window.go.main.App.UninstallWhisper();alert(e),s()}catch(e){alert(`Uninstall failed: `+e)}};async function c(){try{if(navigator.userAgent.toLowerCase().includes(`linux`)){let e=document.getElementById(`offlineWhisperSection`);e&&(e.style.display=`none`);let t=document.getElementById(`startupLabel`);t&&(t.innerText=`Run on Linux OS Startup`)}}catch(e){console.error(`Failed to get environment:`,e)}if(await r(),await i(),await o(),await s(),window.runtime&&typeof window.runtime.EventsOn==`function`&&window.runtime.EventsOn(`history:updated`,function(){n()}),navigator.userAgent.toLowerCase().includes(`linux`)){let e=document.getElementById(`whisperModel`);e&&Array.from(e.options).forEach(e=>{e.value.startsWith(`local-`)&&e.remove()})}}c(); \ No newline at end of file diff --git a/frontend/dist/assets/index-BsntdVbh.css b/frontend/dist/assets/index-BsntdVbh.css deleted file mode 100644 index a74adec..0000000 --- a/frontend/dist/assets/index-BsntdVbh.css +++ /dev/null @@ -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)} diff --git a/frontend/dist/assets/index.c31e3052.js b/frontend/dist/assets/index.c31e3052.js new file mode 100644 index 0000000..3464b02 --- /dev/null +++ b/frontend/dist/assets/index.c31e3052.js @@ -0,0 +1,3 @@ +(function(){const t=document.createElement("link").relList;if(t&&t.supports&&t.supports("modulepreload"))return;for(const n of document.querySelectorAll('link[rel="modulepreload"]'))i(n);new MutationObserver(n=>{for(const s of n)if(s.type==="childList")for(const r of s.addedNodes)r.tagName==="LINK"&&r.rel==="modulepreload"&&i(r)}).observe(document,{childList:!0,subtree:!0});function a(n){const s={};return n.integrity&&(s.integrity=n.integrity),n.referrerpolicy&&(s.referrerPolicy=n.referrerpolicy),n.crossorigin==="use-credentials"?s.credentials="include":n.crossorigin==="anonymous"?s.credentials="omit":s.credentials="same-origin",s}function i(n){if(n.ep)return;n.ep=!0;const s=a(n);fetch(n.href,s)}})();let u=!1;window.toggleApiKey=function(){const e=document.getElementById("apiKey"),t=document.getElementById("eyeBtn");u=!u,e.type=u?"text":"password",t.textContent=u?"\u{1F648}":"\u{1F441}\uFE0F"};window.recordShortcut=function(){const e=document.getElementById("recordBtn"),t=document.getElementById("shortcutInput"),a=o=>o.metaKey||o.key==="Meta"||o.key==="OS"||o.code==="MetaLeft"||o.code==="MetaRight";e.textContent="Recording...",e.disabled=!0,t.value="";let i="";const n=o=>{o.preventDefault(),o.stopPropagation();const c=[];o.ctrlKey&&c.push("ctrl"),o.altKey&&c.push("alt"),o.shiftKey&&c.push("shift"),a(o)&&c.push("win");let l=o.key.toLowerCase();l==="control"&&(l="ctrl"),l==="alt"&&(l="alt"),l==="shift"&&(l="shift"),(l==="meta"||l==="os")&&(l="win"),(o.code==="MetaLeft"||o.code==="MetaRight")&&(l="win"),l===" "&&(l="space");const y=new Set(c);y.add(l);const w=Array.from(y).filter(g=>g!==l);w.push(l),i=w.join("+"),t.value=i,["ctrl","alt","shift","win"].includes(l)||r()},s=o=>{o.preventDefault(),o.stopPropagation(),i&&(i.split("+").every(l=>["ctrl","alt","shift","win"].includes(l))&&i.includes("+")?setTimeout(r,100):!o.ctrlKey&&!o.altKey&&!o.shiftKey&&!a(o)&&r())},r=()=>{if(!i){e.textContent="Record",e.disabled=!1,f();return}window.go.main.App.SaveSettings({shortcut:i}).then(o=>{o&&o.indexOf("Invalid shortcut")!==-1&&alert(o),e.textContent="Record",e.disabled=!1,f()})},f=()=>{window.removeEventListener("keydown",n,!0),window.removeEventListener("keyup",s,!0)};window.addEventListener("keydown",n,!0),window.addEventListener("keyup",s,!0)};function m(e){const t=document.getElementById("historyList");if(!e||e.length===0){t.innerHTML='
No history yet.
';return}t.innerHTML="",[...e].reverse().forEach(a=>{const i=document.createElement("div");i.className="history-item";const n=document.createElement("span");n.className="history-time",n.textContent=new Date(a.timestamp).toLocaleString(void 0,{month:"short",day:"numeric",hour:"2-digit",minute:"2-digit"});const s=document.createElement("div");s.textContent=a.text,i.appendChild(n),i.appendChild(s),t.appendChild(i)})}async function h(){try{const e=await window.go.main.App.GetSettings();m(e.history||[])}catch(e){console.error("Failed to refresh history:",e)}}async function v(){try{const e=await window.go.main.App.GetSettings();document.getElementById("apiKey").value=e.api_key||"",document.getElementById("shortcutInput").value=e.shortcut||"alt+z",document.getElementById("whisperModel").value=e.whisper_model||"whisper-large-v3-turbo",document.getElementById("aiModel").value=e.ai_model||"llama-3.3-70b-versatile",document.getElementById("aiPrompt").value=e.ai_prompt||"",document.getElementById("language").value=e.language||"en",document.getElementById("startupToggle").checked=e.startup||!1;const t=e.app_version&&String(e.app_version).trim()||"dev";document.getElementById("appVersion").textContent=t==="dev"?"dev":"v"+t,m(e.history||[])}catch(e){console.error("Failed to load settings:",e)}}async function E(){try{const e=await window.go.main.App.GetMicrophones(),t=document.getElementById("micDevice");e.forEach(a=>{if(a.index!==-1){const i=document.createElement("option");i.value=a.index,i.textContent=a.name,t.appendChild(i)}})}catch(e){console.error("Failed to load mics:",e)}}window.saveApiKey=async function(){const e=document.getElementById("apiKey").value.trim();await window.go.main.App.SaveSettings({api_key:e}),d()};function d(e="Settings Saved!"){const t=document.getElementById("saveStatus");t.textContent=e,t.classList.add("show"),setTimeout(()=>t.classList.remove("show"),2e3)}window.saveWhisperModel=async function(){await window.go.main.App.SaveSettings({whisper_model:document.getElementById("whisperModel").value}),d()};window.saveAiModel=async function(){await window.go.main.App.SaveSettings({ai_model:document.getElementById("aiModel").value}),d()};window.saveLanguage=async function(){await window.go.main.App.SaveSettings({language:document.getElementById("language").value}),d("Language Saved!")};window.saveMicDevice=async function(){const e=document.getElementById("micDevice").value;await window.go.main.App.SaveSettings({microphone_device:e==="default"?null:parseInt(e)}),d()};window.savePrompt=async function(){await window.go.main.App.SaveSettings({ai_prompt:document.getElementById("aiPrompt").value.trim()}),d("Prompt Saved!")};window.toggleStartup=async function(){await window.go.main.App.ToggleStartup(document.getElementById("startupToggle").checked)};window.clearHistory=async function(){confirm("Clear all history?")&&(await window.go.main.App.ClearHistory(),m([]))};async function I(){try{const e=await window.go.main.App.CheckOnline(),t=document.getElementById("connectionStatus");if(e)t.innerHTML="\u{1F7E2} Online - Cloud transcription available",t.style.background="rgba(34, 197, 94, 0.1)",t.style.color="#22c55e";else{t.innerHTML="\u{1F534} Offline - Install local Whisper for transcription",t.style.background="rgba(239, 68, 68, 0.1)",t.style.color="#ef4444";const a=document.getElementById("aiModel"),i=document.getElementById("whisperModel");for(let n of a.options)n.value!=="None"&&!n.value.startsWith("local-")&&(n.disabled=!0,n.text.includes("(offline)")||(n.text+=" (offline)"))}}catch(e){console.error("Connection check failed:",e)}}async function p(){try{const e=await window.go.main.App.GetWhisperInfo(),t=document.getElementById("whisperModel"),a=t.querySelector('option[value^="local-"]');if(a&&a.remove(),e.installed){document.getElementById("whisperNotInstalled").style.display="none",document.getElementById("whisperInstalled").style.display="block",document.getElementById("installedModel").textContent=e.model+" model";const i=document.createElement("option");i.value="local-"+e.model,i.textContent="\u{1F5A5}\uFE0F Local - "+e.model+" (offline)",i.style.fontWeight="bold",t.insertBefore(i,t.firstChild)}else document.getElementById("whisperNotInstalled").style.display="block",document.getElementById("whisperInstalled").style.display="none"}catch(e){console.error("Whisper status check failed:",e)}}window.installWhisper=async function(){const e=document.getElementById("whisperModelSelect").value,t=document.getElementById("installBtn");t.disabled=!0,t.textContent="Starting installation...";try{const a=await window.go.main.App.InstallWhisper(e);alert(a+` + +A terminal window will open showing the installation progress. This may take several minutes depending on your internet speed.`),setTimeout(p,5e3)}catch(a){alert("Installation failed: "+a)}t.disabled=!1,t.textContent="\u{1F680} Install Offline Whisper"};window.uninstallWhisper=async function(){if(!!confirm("Are you sure you want to uninstall offline Whisper? This will delete the downloaded model."))try{const e=await window.go.main.App.UninstallWhisper();alert(e),p()}catch(e){alert("Uninstall failed: "+e)}};async function S(){try{if(navigator.userAgent.toLowerCase().includes("linux")){const t=document.getElementById("offlineWhisperSection");t&&(t.style.display="none");const a=document.getElementById("startupLabel");a&&(a.innerText="Run on Linux OS Startup")}}catch(e){console.error("Failed to get environment:",e)}if(await v(),await E(),await I(),await p(),window.runtime&&typeof window.runtime.EventsOn=="function"&&window.runtime.EventsOn("history:updated",function(){h()}),navigator.userAgent.toLowerCase().includes("linux")){const e=document.getElementById("whisperModel");e&&Array.from(e.options).forEach(t=>{t.value.startsWith("local-")&&t.remove()})}}S(); diff --git a/frontend/dist/assets/index.d8635aeb.css b/frontend/dist/assets/index.d8635aeb.css new file mode 100644 index 0000000..31c34a4 --- /dev/null +++ b/frontend/dist/assets/index.d8635aeb.css @@ -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)} diff --git a/frontend/dist/index.html b/frontend/dist/index.html index bbfc781..981065e 100644 --- a/frontend/dist/index.html +++ b/frontend/dist/index.html @@ -5,8 +5,9 @@ wis-free-v3 Settings - - + + + @@ -182,6 +183,7 @@ + \ No newline at end of file diff --git a/frontend/wailsjs/go/main/App.d.ts b/frontend/wailsjs/go/main/App.d.ts index 052c502..4ab79d7 100755 --- a/frontend/wailsjs/go/main/App.d.ts +++ b/frontend/wailsjs/go/main/App.d.ts @@ -32,6 +32,10 @@ export function StartRecording():Promise; export function StopRecording():Promise; +export function ToggleRecording():Promise; + export function ToggleStartup(arg1:boolean):Promise; export function UninstallWhisper():Promise; + +export function Version():Promise; diff --git a/frontend/wailsjs/go/main/App.js b/frontend/wailsjs/go/main/App.js index fe9679c..35a9de2 100755 --- a/frontend/wailsjs/go/main/App.js +++ b/frontend/wailsjs/go/main/App.js @@ -62,6 +62,10 @@ export function StopRecording() { return window['go']['main']['App']['StopRecording'](); } +export function ToggleRecording() { + return window['go']['main']['App']['ToggleRecording'](); +} + export function ToggleStartup(arg1) { return window['go']['main']['App']['ToggleStartup'](arg1); } @@ -69,3 +73,7 @@ export function ToggleStartup(arg1) { export function UninstallWhisper() { return window['go']['main']['App']['UninstallWhisper'](); } + +export function Version() { + return window['go']['main']['App']['Version'](); +} diff --git a/frontend/wailsjs/runtime/runtime.d.ts b/frontend/wailsjs/runtime/runtime.d.ts index 4445dac..3bbea84 100644 --- a/frontend/wailsjs/runtime/runtime.d.ts +++ b/frontend/wailsjs/runtime/runtime.d.ts @@ -246,4 +246,85 @@ export function OnFileDropOff() :void export function CanResolveFilePaths(): boolean; // Resolves file paths for an array of files -export function ResolveFilePaths(files: File[]): void \ No newline at end of file +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; + +// [CleanupNotifications](https://wails.io/docs/reference/runtime/notification#cleanupnotifications) +// Cleans up notification resources and releases any held connections. +export function CleanupNotifications(): Promise; + +// [IsNotificationAvailable](https://wails.io/docs/reference/runtime/notification#isnotificationavailable) +// Checks if notifications are available on the current platform. +export function IsNotificationAvailable(): Promise; + +// [RequestNotificationAuthorization](https://wails.io/docs/reference/runtime/notification#requestnotificationauthorization) +// Requests notification authorization from the user (macOS only). +export function RequestNotificationAuthorization(): Promise; + +// [CheckNotificationAuthorization](https://wails.io/docs/reference/runtime/notification#checknotificationauthorization) +// Checks the current notification authorization status (macOS only). +export function CheckNotificationAuthorization(): Promise; + +// [SendNotification](https://wails.io/docs/reference/runtime/notification#sendnotification) +// Sends a basic notification with the given options. +export function SendNotification(options: NotificationOptions): Promise; + +// [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; + +// [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; + +// [RemoveNotificationCategory](https://wails.io/docs/reference/runtime/notification#removenotificationcategory) +// Removes a previously registered notification category. +export function RemoveNotificationCategory(categoryId: string): Promise; + +// [RemoveAllPendingNotifications](https://wails.io/docs/reference/runtime/notification#removeallpendingnotifications) +// Removes all pending notifications from the notification center. +export function RemoveAllPendingNotifications(): Promise; + +// [RemovePendingNotification](https://wails.io/docs/reference/runtime/notification#removependingnotification) +// Removes a specific pending notification by its identifier. +export function RemovePendingNotification(identifier: string): Promise; + +// [RemoveAllDeliveredNotifications](https://wails.io/docs/reference/runtime/notification#removealldeliverednotifications) +// Removes all delivered notifications from the notification center. +export function RemoveAllDeliveredNotifications(): Promise; + +// [RemoveDeliveredNotification](https://wails.io/docs/reference/runtime/notification#removedeliverednotification) +// Removes a specific delivered notification by its identifier. +export function RemoveDeliveredNotification(identifier: string): Promise; + +// [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; \ No newline at end of file diff --git a/frontend/wailsjs/runtime/runtime.js b/frontend/wailsjs/runtime/runtime.js index 7cb89d7..556621e 100644 --- a/frontend/wailsjs/runtime/runtime.js +++ b/frontend/wailsjs/runtime/runtime.js @@ -239,4 +239,60 @@ export function CanResolveFilePaths() { export function ResolveFilePaths(files) { return window.runtime.ResolveFilePaths(files); +} + +export function InitializeNotifications() { + return window.runtime.InitializeNotifications(); +} + +export function CleanupNotifications() { + return window.runtime.CleanupNotifications(); +} + +export function IsNotificationAvailable() { + return window.runtime.IsNotificationAvailable(); +} + +export function RequestNotificationAuthorization() { + return window.runtime.RequestNotificationAuthorization(); +} + +export function CheckNotificationAuthorization() { + return window.runtime.CheckNotificationAuthorization(); +} + +export function SendNotification(options) { + return window.runtime.SendNotification(options); +} + +export function SendNotificationWithActions(options) { + return window.runtime.SendNotificationWithActions(options); +} + +export function RegisterNotificationCategory(category) { + return window.runtime.RegisterNotificationCategory(category); +} + +export function RemoveNotificationCategory(categoryId) { + return window.runtime.RemoveNotificationCategory(categoryId); +} + +export function RemoveAllPendingNotifications() { + return window.runtime.RemoveAllPendingNotifications(); +} + +export function RemovePendingNotification(identifier) { + return window.runtime.RemovePendingNotification(identifier); +} + +export function RemoveAllDeliveredNotifications() { + return window.runtime.RemoveAllDeliveredNotifications(); +} + +export function RemoveDeliveredNotification(identifier) { + return window.runtime.RemoveDeliveredNotification(identifier); +} + +export function RemoveNotification(identifier) { + return window.runtime.RemoveNotification(identifier); } \ No newline at end of file diff --git a/internal/hotkey/hotkey.go b/internal/hotkey/hotkey.go index ebc0faf..784aa1d 100644 --- a/internal/hotkey/hotkey.go +++ b/internal/hotkey/hotkey.go @@ -154,11 +154,11 @@ func (l *Listener) eventLoop(hk *xhk.Hotkey) { // If Wayland doesn't send Deactivated (Keyup), this acts as a Toggle fallback. select { case <-hk.Keyup(): - // Out-of-order X11 auto-repeat. Ignore both. + // Out-of-order auto-repeat. Ignore both. case <-time.After(20 * time.Millisecond): // Genuine second press. Toggle off. - logger.Info("Shortcut activated again: stopping recording (Wayland toggle fallback)") - go l.stopCallback() + logger.Info("Shortcut activated again: toggling recording (Wayland toggle fallback)") + go l.startCallback() isRecording = false } } diff --git a/internal/hotkey/modifiers_linux.go b/internal/hotkey/modifiers_linux.go index 52a2c3e..34d87ad 100644 --- a/internal/hotkey/modifiers_linux.go +++ b/internal/hotkey/modifiers_linux.go @@ -9,6 +9,7 @@ var ModMap = map[string]xhk.Modifier{ "control": xhk.ModCtrl, "shift": xhk.ModShift, "alt": xhk.Mod1, + "option": xhk.Mod1, "win": xhk.Mod4, "windows": xhk.Mod4, "meta": xhk.Mod4, diff --git a/internal/linux/startup.go b/internal/linux/startup.go index 1e82904..56e94db 100644 --- a/internal/linux/startup.go +++ b/internal/linux/startup.go @@ -14,19 +14,24 @@ const appName = "wis-free-v3" const baseDesktopFileTemplate = `[Desktop Entry] Type=Application Name=WIS Free V3 +Comment=Voice Recording and Transcription +%s %s Icon=%s StartupWMClass=%s Terminal=false Categories=Utility; +Keywords=voice;recorder;transcription; ` const autostartDesktopFileTemplate = `[Desktop Entry] Type=Application Name=WIS Free V3 +Comment=Voice Recording and Transcription %s -Icon=wis-free-v3 -StartupWMClass=wis-free-v3 +%s +Icon=%s +StartupWMClass=%s Terminal=false Categories=Utility; 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) } - 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) } @@ -113,6 +118,11 @@ func desktopExecField(exePath string) string { 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 { autostartPath, err := getAutostartPath() if err != nil { @@ -124,7 +134,17 @@ func AddToStartup() error { 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 { return fmt.Errorf("failed to write autostart file: %w", err) } diff --git a/internal/ui/tray/tray.go b/internal/ui/tray/tray.go index dc3c865..7350b90 100644 --- a/internal/ui/tray/tray.go +++ b/internal/ui/tray/tray.go @@ -46,21 +46,6 @@ var statusMenuItem *systray.MenuItem var triggerCountItem *systray.MenuItem 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 { v := app.Version() if v != "" && v != "dev" { diff --git a/internal/ui/tray/tray_start_linux.go b/internal/ui/tray/tray_start_linux.go new file mode 100644 index 0000000..8f5a6d5 --- /dev/null +++ b/internal/ui/tray/tray_start_linux.go @@ -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, + ) +} diff --git a/internal/ui/tray/tray_start_nonlinux.go b/internal/ui/tray/tray_start_nonlinux.go new file mode 100644 index 0000000..6c0828f --- /dev/null +++ b/internal/ui/tray/tray_start_nonlinux.go @@ -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, + ) +} diff --git a/internal/xhotkey/hotkey_linux.c b/internal/xhotkey/hotkey_linux.c deleted file mode 100644 index 6a54322..0000000 --- a/internal/xhotkey/hotkey_linux.c +++ /dev/null @@ -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 - -//go:build linux - -#include -#include -#include -#include -#include -#include - -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; -} \ No newline at end of file diff --git a/internal/xhotkey/hotkey_linux_dispatch_nocgo.go b/internal/xhotkey/hotkey_linux.go similarity index 70% rename from internal/xhotkey/hotkey_linux_dispatch_nocgo.go rename to internal/xhotkey/hotkey_linux.go index 91161a2..13c53c7 100644 --- a/internal/xhotkey/hotkey_linux_dispatch_nocgo.go +++ b/internal/xhotkey/hotkey_linux.go @@ -1,4 +1,4 @@ -//go:build linux && !cgo +//go:build linux package hotkey @@ -14,10 +14,6 @@ func (hk *Hotkey) unregister() error { hk.mu.Unlock() 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.mu.Unlock() return hk.cleanupPortal() diff --git a/internal/xhotkey/hotkey_linux_dispatch_cgo.go b/internal/xhotkey/hotkey_linux_dispatch_cgo.go deleted file mode 100644 index a9cb8ff..0000000 --- a/internal/xhotkey/hotkey_linux_dispatch_cgo.go +++ /dev/null @@ -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") - } -} diff --git a/internal/xhotkey/hotkey_linux_platform.go b/internal/xhotkey/hotkey_linux_platform.go index 0dcc980..b36d0ed 100644 --- a/internal/xhotkey/hotkey_linux_platform.go +++ b/internal/xhotkey/hotkey_linux_platform.go @@ -3,7 +3,6 @@ package hotkey import ( - "context" "sync" "github.com/godbus/dbus/v5" @@ -11,7 +10,6 @@ import ( const ( linuxHKNone = iota - linuxHKX11 linuxHKPortal ) @@ -20,11 +18,6 @@ type platformHotkey struct { registered bool backend int - // X11 (CGO) - ctx context.Context - cancel context.CancelFunc - canceled chan struct{} - // Wayland / XDG portal global shortcuts portalStop chan struct{} portalDone chan struct{} diff --git a/internal/xhotkey/hotkey_linux_portal.go b/internal/xhotkey/hotkey_linux_portal.go index 3517ce4..90934d9 100644 --- a/internal/xhotkey/hotkey_linux_portal.go +++ b/internal/xhotkey/hotkey_linux_portal.go @@ -23,20 +23,13 @@ const ( ifaceSession = "org.freedesktop.portal.Session" wisfreeGlobalShortcutID = "com.wisfree.push-to-record" envForcePortal = "WISFREE_USE_PORTAL_HOTKEY" - envForceX11 = "WISFREE_USE_X11_HOTKEY" ) func usePortalBackend() bool { - if os.Getenv(envForceX11) == "1" { + if os.Getenv(envForcePortal) == "0" { return false } - if os.Getenv(envForcePortal) == "1" { - return true - } - if os.Getenv("WAYLAND_DISPLAY") != "" { - return true - } - return strings.EqualFold(os.Getenv("XDG_SESSION_TYPE"), "wayland") + return true } func randomPortalToken() string { @@ -162,7 +155,7 @@ func portalWaitRequest(conn *dbus.Conn, reqPath dbus.ObjectPath) (uint32, map[st if sig == nil || sig.Path != reqPath { continue } - if sig.Name != "Response" { + if !strings.HasSuffix(sig.Name, ".Response") { continue } if len(sig.Body) < 2 { @@ -208,6 +201,9 @@ func (hk *Hotkey) registerPortal() error { 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 @@ -270,7 +266,7 @@ func (hk *Hotkey) registerPortal() error { 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 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 { val := sc.Value() @@ -323,8 +319,8 @@ func (hk *Hotkey) portalSignalLoop() { ch := make(chan *dbus.Signal, 32) conn.Signal(ch) rule := fmt.Sprintf( - "type='signal',interface='%s',path='%s'", - ifaceGlobalShortcuts, string(portalObjectPath), + "type='signal',interface='%s'", + ifaceGlobalShortcuts, ) if err := conn.BusObject().Call("org.freedesktop.DBus.AddMatch", 0, rule).Store(); err != nil { log.Printf("wis-free-v3 hotkey: AddMatch GlobalShortcuts: %v", err) @@ -340,9 +336,6 @@ func (hk *Hotkey) portalSignalLoop() { if !ok || sig == nil { return } - if sig.Path != portalObjectPath { - continue - } if len(sig.Body) < 2 { continue } diff --git a/internal/xhotkey/hotkey_linux_x11.go b/internal/xhotkey/hotkey_linux_x11.go deleted file mode 100644 index 0e49985..0000000 --- a/internal/xhotkey/hotkey_linux_x11.go +++ /dev/null @@ -1,80 +0,0 @@ -//go:build linux && cgo - -package hotkey - -/* -#cgo LDFLAGS: -lX11 - -#include - -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{} -} diff --git a/main.go b/main.go index ff25859..182ba3f 100644 --- a/main.go +++ b/main.go @@ -8,6 +8,7 @@ import ( "path/filepath" "runtime" "strconv" + "strings" "wis-free-v3/internal/logger" "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`. var AppVersion = "dev" +var initialAction string + func main() { // 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 // goroutine locks itself in tray.Start instead (see internal/ui/tray/tray.go). 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 if !acquireInstanceLock() { // Another copy is already running (often left in the tray). Without this, // we would exit silently and the user sees "nothing happens" when clicking // the launcher again. - tryNotifyRunningInstanceToShow() + _ = tryNotifyRunningInstanceToShow() os.Exit(0) } runSecondInstanceListener() diff --git a/main_instance_unix.go b/main_instance_unix.go index 24278f2..fb6a0bc 100644 --- a/main_instance_unix.go +++ b/main_instance_unix.go @@ -7,6 +7,7 @@ import ( "net" "os" "path/filepath" + "strings" "time" ) @@ -15,6 +16,17 @@ const instanceSockName = "instance.sock" // secondInstanceWake is closed when a second process asks the running app to show its window. 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) { home, err := os.UserHomeDir() if err != nil { @@ -23,17 +35,40 @@ func instanceSocketPath() (string, error) { return filepath.Join(home, configDir, instanceSockName), nil } -func tryNotifyRunningInstanceToShow() { +func tryNotifyRunningInstance(cmd byte) bool { path, err := instanceSocketPath() if err != nil { - return + return false } c, err := net.DialTimeout("unix", path, 500*time.Millisecond) if err != nil { - return + return false } 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() { @@ -54,6 +89,20 @@ func runSecondInstanceListener() { } go func(conn net.Conn) { 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) select { case secondInstanceWake <- struct{}{}: