diff --git a/app.go b/app.go index 98ae68c..e33cf9f 100644 --- a/app.go +++ b/app.go @@ -5,10 +5,10 @@ import ( "fmt" "os" "path/filepath" + "runtime" "strings" "time" - "wis-free-v3/internal/audio/recorder" "wis-free-v3/internal/config" "wis-free-v3/internal/hotkey" @@ -19,7 +19,7 @@ import ( "wis-free-v3/internal/ui/tray" "github.com/go-vgo/robotgo" - "github.com/wailsapp/wails/v2/pkg/runtime" + wailsruntime "github.com/wailsapp/wails/v2/pkg/runtime" ) // App struct @@ -46,10 +46,15 @@ func (a *App) GetConfig() *config.Config { return a.config } +// Version returns the application version (see scripts/VERSION and build scripts). +func (a *App) Version() string { + return AppVersion +} + // ShowSettings shows the settings window func (a *App) ShowSettings() { if a.ctx != nil { - runtime.WindowShow(a.ctx) + wailsruntime.WindowShow(a.ctx) } } @@ -57,6 +62,15 @@ func (a *App) ShowSettings() { func (a *App) startup(ctx context.Context) { a.ctx = ctx + title := appTitle + switch AppVersion { + case "", "dev": + title = appTitle + " (dev)" + default: + title = appTitle + " v" + AppVersion + } + wailsruntime.WindowSetTitle(ctx, title) + // Initialize components a.startupHeadless() @@ -69,7 +83,7 @@ func (a *App) beforeClose(ctx context.Context) (prevent bool) { if a.isQuitting { return false } - runtime.WindowHide(ctx) + wailsruntime.WindowHide(ctx) return true } @@ -78,7 +92,7 @@ func (a *App) Quit() { a.isQuitting = true logger.Info("Application quitting...") logger.Close() - runtime.Quit(a.ctx) + wailsruntime.Quit(a.ctx) } // StartRecording starts the audio recording @@ -235,10 +249,14 @@ func (a *App) processRecording() { if len(a.config.History) > 50 { a.config.History = a.config.History[:50] } - config.Save(a.config, "") + if err := config.Save(a.config, ""); err != nil { + logger.Error("Failed to save config after history update: %v", err) + } else if a.ctx != nil { + wailsruntime.EventsEmit(a.ctx, "history:updated") + } // Copy to clipboard - runtime.ClipboardSetText(a.ctx, refinedText) + wailsruntime.ClipboardSetText(a.ctx, refinedText) // Paste a.pasteText() @@ -276,6 +294,7 @@ func (a *App) GetSettings() map[string]interface{} { conf["microphone_device"] = a.config.MicrophoneDevice conf["history"] = a.config.History conf["startup"] = platform.IsInStartup() + conf["app_version"] = AppVersion return conf } @@ -285,11 +304,13 @@ func (a *App) SaveSettings(settings map[string]interface{}) string { a.config.APIKey = val } if val, ok := settings["shortcut"].(string); ok { - // Validate shortcut before applying - _, _, ok := hotkey.ParseShortcut(val) + _, _, modOnly, ok := hotkey.ParseShortcut(val) if !ok { logger.Error("Invalid shortcut: %s (rejected)", val) - return "Invalid shortcut - must have at least one modifier and a regular key" + return "Invalid shortcut - use modifiers plus a key (e.g. ctrl+k), or modifier-only on Windows (e.g. ctrl+win)" + } + if modOnly && runtime.GOOS != "windows" { + return "Modifier-only shortcuts (like ctrl+win) are only supported on Windows" } a.config.Shortcut = val diff --git a/build/bin/wis-free-v3 b/build/bin/wis-free-v3.exe old mode 100755 new mode 100644 similarity index 59% rename from build/bin/wis-free-v3 rename to build/bin/wis-free-v3.exe index 67a4faa..167f6a8 Binary files a/build/bin/wis-free-v3 and b/build/bin/wis-free-v3.exe differ diff --git a/frontend/dist/assets/index-BNpC-7X0.js b/frontend/dist/assets/index-BNpC-7X0.js new file mode 100644 index 0000000..b5292de --- /dev/null +++ b/frontend/dist/assets/index-BNpC-7X0.js @@ -0,0 +1,3 @@ +(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 new file mode 100644 index 0000000..a74adec --- /dev/null +++ b/frontend/dist/assets/index-BsntdVbh.css @@ -0,0 +1 @@ +: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.c6252eaf.js b/frontend/dist/assets/index.c6252eaf.js deleted file mode 100644 index 1892917..0000000 --- a/frontend/dist/assets/index.c6252eaf.js +++ /dev/null @@ -1,3 +0,0 @@ -(function(){const t=document.createElement("link").relList;if(t&&t.supports&&t.supports("modulepreload"))return;for(const n of document.querySelectorAll('link[rel="modulepreload"]'))a(n);new MutationObserver(n=>{for(const i of n)if(i.type==="childList")for(const r of i.addedNodes)r.tagName==="LINK"&&r.rel==="modulepreload"&&a(r)}).observe(document,{childList:!0,subtree:!0});function o(n){const i={};return n.integrity&&(i.integrity=n.integrity),n.referrerpolicy&&(i.referrerPolicy=n.referrerpolicy),n.crossorigin==="use-credentials"?i.credentials="include":n.crossorigin==="anonymous"?i.credentials="omit":i.credentials="same-origin",i}function a(n){if(n.ep)return;n.ep=!0;const i=o(n);fetch(n.href,i)}})();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");e.textContent="Recording...",e.disabled=!0,t.value="";let o="";const a=s=>{s.preventDefault(),s.stopPropagation();const c=[];s.ctrlKey&&c.push("ctrl"),s.altKey&&c.push("alt"),s.shiftKey&&c.push("shift"),(s.metaKey||s.key==="Meta"||s.key==="OS")&&c.push("win");let l=s.key.toLowerCase();l==="control"&&(l="ctrl"),l==="alt"&&(l="alt"),l==="shift"&&(l="shift"),(l==="meta"||l==="os")&&(l="win"),l===" "&&(l="space");const p=new Set(c);p.add(l);const y=Array.from(p).filter(f=>f!==l);y.push(l),o=y.join("+"),t.value=o,["ctrl","alt","shift","win"].includes(l)||i()},n=s=>{s.preventDefault(),s.stopPropagation(),o&&(o.split("+").every(l=>["ctrl","alt","shift","win"].includes(l))&&o.includes("+")?setTimeout(i,100):!s.ctrlKey&&!s.altKey&&!s.shiftKey&&!s.metaKey&&i())},i=()=>{if(!o){e.textContent="Record",e.disabled=!1,r();return}window.go.main.App.SaveSettings({shortcut:o}).then(()=>{e.textContent="Record",e.disabled=!1,r()})},r=()=>{window.removeEventListener("keydown",a,!0),window.removeEventListener("keyup",n,!0)};window.addEventListener("keydown",a,!0),window.addEventListener("keyup",n,!0)};async function w(){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.history||[],o=document.getElementById("historyList");t.length>0&&(o.innerHTML="",[...t].reverse().forEach(a=>{const n=document.createElement("div");n.className="history-item";const i=document.createElement("span");i.className="history-time",i.textContent=new Date(a.timestamp).toLocaleString(void 0,{month:"short",day:"numeric",hour:"2-digit",minute:"2-digit"});const r=document.createElement("div");r.textContent=a.text,n.appendChild(i),n.appendChild(r),o.appendChild(n)}))}catch(e){console.error("Failed to load settings:",e)}}async function g(){try{const e=await window.go.main.App.GetMicrophones(),t=document.getElementById("micDevice");e.forEach(o=>{if(o.index!==-1){const a=document.createElement("option");a.value=o.index,a.textContent=o.name,t.appendChild(a)}})}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(),document.getElementById("historyList").innerHTML='
No history yet.
')};async function h(){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 o=document.getElementById("aiModel"),a=document.getElementById("whisperModel");for(let n of o.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 m(){try{const e=await window.go.main.App.GetWhisperInfo(),t=document.getElementById("whisperModel"),o=t.querySelector('option[value^="local-"]');if(o&&o.remove(),e.installed){document.getElementById("whisperNotInstalled").style.display="none",document.getElementById("whisperInstalled").style.display="block",document.getElementById("installedModel").textContent=e.model+" model";const a=document.createElement("option");a.value="local-"+e.model,a.textContent="\u{1F5A5}\uFE0F Local - "+e.model+" (offline)",a.style.fontWeight="bold",t.insertBefore(a,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 o=await window.go.main.App.InstallWhisper(e);alert(o+` - -A terminal window will open showing the installation progress. This may take several minutes depending on your internet speed.`),setTimeout(m,5e3)}catch(o){alert("Installation failed: "+o)}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),m()}catch(e){alert("Uninstall failed: "+e)}};async function v(){try{if(navigator.userAgent.toLowerCase().includes("linux")){const t=document.getElementById("offlineWhisperSection");t&&(t.style.display="none");const o=document.getElementById("startupLabel");o&&(o.innerText="Run on Linux OS Startup")}}catch(e){console.error("Failed to get environment:",e)}if(await w(),await g(),await h(),await m(),navigator.userAgent.toLowerCase().includes("linux")){const e=document.getElementById("whisperModel");e&&Array.from(e.options).forEach(t=>{t.value.startsWith("local-")&&t.remove()})}}v(); diff --git a/frontend/dist/assets/index.d8635aeb.css b/frontend/dist/assets/index.d8635aeb.css deleted file mode 100644 index 31c34a4..0000000 --- a/frontend/dist/assets/index.d8635aeb.css +++ /dev/null @@ -1 +0,0 @@ -:root{--bg-color: #0b0f1a;--card-bg: rgba(30, 41, 59, .7);--input-bg: rgba(51, 65, 85, .5);--text-color: #f8fafc;--text-muted: #94a3b8;--primary-color: #3b82f6;--primary-hover: #2563eb;--accent-color: #6366f1;--border-color: rgba(75, 85, 99, .4);--success-color: #10b981;--glass-border: rgba(255, 255, 255, .1);color-scheme:dark}body{background:radial-gradient(circle at top right,#1e293b,#0b0f1a);color:var(--text-color);font-family:Inter,system-ui,-apple-system,sans-serif;margin:0;padding:30px;min-height:100vh;letter-spacing:-.01em}.container{max-width:720px;margin:0 auto}h1{font-size:28px;font-weight:700;background:linear-gradient(135deg,#60a5fa,#a78bfa);-webkit-background-clip:text;background-clip:text;-webkit-text-fill-color:transparent;margin-bottom:30px}.section{background:var(--card-bg);backdrop-filter:blur(12px);-webkit-backdrop-filter:blur(12px);padding:24px;border-radius:16px;margin-bottom:24px;border:1px solid var(--glass-border);box-shadow:0 10px 15px -3px #0003;transition:transform .2s ease}.section:hover{border-color:#fff3}label{display:block;color:var(--text-muted);font-size:13px;font-weight:600;text-transform:uppercase;letter-spacing:.05em;margin-bottom:10px}input[type=checkbox]{accent-color:var(--primary-color);width:18px;height:18px;cursor:pointer}input[type=text],input[type=password],select,textarea{width:100%;background:var(--input-bg);border:1px solid var(--border-color);color:#fff;padding:12px 14px;border-radius:10px;font-size:14px;transition:all .2s ease;box-sizing:border-box}select{appearance:none;-webkit-appearance:none;background-image:url("data:image/svg+xml;charset=UTF-8,%3csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24' fill='none' stroke='%2394a3b8' stroke-width='2' stroke-linecap='round' stroke-linejoin='round'%3e%3cpolyline points='6 9 12 15 18 9'%3e%3c/polyline%3e%3c/svg%3e");background-repeat:no-repeat;background-position:right 14px center;background-size:16px;padding-right:40px}select option{background:var(--bg-color);color:#fff}textarea{resize:vertical;min-height:80px}input:focus,select:focus,textarea:focus{outline:none;border-color:var(--primary-color);background:rgba(51,65,85,.8);box-shadow:0 0 0 3px #3b82f633}.form-control{max-width:480px;width:100%}.input-wrapper{position:relative;flex:1;min-width:0}.input-wrapper input{width:100%;padding-right:44px;box-sizing:border-box;display:block}.eye-btn{position:absolute;right:8px;top:50%;transform:translateY(-50%);background:transparent!important;border:none!important;box-shadow:none!important;cursor:pointer;padding:4px!important;font-size:16px;opacity:.5;transition:opacity .2s;line-height:1;display:flex;align-items:center;justify-content:center}.eye-btn:hover{opacity:1;transform:translateY(-50%) scale(1.1)}.save-status{position:fixed;bottom:30px;right:30px;background:var(--success-color);color:#fff;padding:12px 24px;border-radius:12px;font-weight:600;box-shadow:0 10px 15px -3px #0003;transform:translateY(100px);opacity:0;transition:all .3s cubic-bezier(.4,0,.2,1);z-index:1000}.save-status.show{transform:translateY(0);opacity:1}button{background-color:var(--primary-color);color:#fff;border:none;padding:10px 20px;border-radius:10px;cursor:pointer;font-weight:600;font-size:14px;transition:all .2s cubic-bezier(.4,0,.2,1);box-shadow:0 4px 6px -1px #0003;white-space:nowrap;flex-shrink:0}button:hover{transform:translateY(-1px);box-shadow:0 10px 15px -3px #0000004d;background-color:var(--primary-hover)}button:active{transform:translateY(0)}button[style*="background: transparent"]{background:transparent!important;box-shadow:none!important;text-decoration:underline;opacity:.7}button[style*="background: transparent"]:hover{opacity:1}.flex-row{display:flex;gap:12px;align-items:center;flex-wrap:nowrap}.flex-between{display:flex;justify-content:space-between;align-items:center}.history-list{max-height:250px;overflow-y:auto;margin-top:10px;padding-right:5px}.history-item{background:rgba(255,255,255,.03);padding:14px;border-radius:12px;margin-bottom:10px;border:1px solid rgba(255,255,255,.05);transition:all .2s ease}.history-item:hover{background:rgba(255,255,255,.06);transform:translate(2px)}.history-time{color:var(--primary-color);font-size:11px;font-weight:700;margin-bottom:6px;display:block}::-webkit-scrollbar{width:6px}::-webkit-scrollbar-track{background:transparent}::-webkit-scrollbar-thumb{background:var(--border-color);border-radius:10px}::-webkit-scrollbar-thumb:hover{background:var(--text-muted)} diff --git a/frontend/dist/index.html b/frontend/dist/index.html index da016d0..bbfc781 100644 --- a/frontend/dist/index.html +++ b/frontend/dist/index.html @@ -5,9 +5,8 @@ wis-free-v3 Settings - - - + + @@ -15,7 +14,7 @@

wis-free-v3 Settings

-
v1.0.0
+
Settings Saved!
@@ -183,7 +182,6 @@ - \ No newline at end of file diff --git a/frontend/index.html b/frontend/index.html index f6f2666..a4c5ec6 100644 --- a/frontend/index.html +++ b/frontend/index.html @@ -13,7 +13,7 @@

wis-free-v3 Settings

-
v1.0.0
+
Settings Saved!
@@ -198,6 +198,14 @@ const btn = document.getElementById('recordBtn'); const input = document.getElementById('shortcutInput'); + // WebView2 on Windows often omits metaKey on key events; e.code still identifies Meta. + const winKeyActive = (e) => + e.metaKey || + e.key === 'Meta' || + e.key === 'OS' || + e.code === 'MetaLeft' || + e.code === 'MetaRight'; + btn.textContent = 'Recording...'; btn.disabled = true; input.value = ''; @@ -212,13 +220,14 @@ if (e.ctrlKey) mods.push('ctrl'); if (e.altKey) mods.push('alt'); if (e.shiftKey) mods.push('shift'); - if (e.metaKey || e.key === 'Meta' || e.key === 'OS') mods.push('win'); + if (winKeyActive(e)) mods.push('win'); let key = e.key.toLowerCase(); if (key === 'control') key = 'ctrl'; if (key === 'alt') key = 'alt'; if (key === 'shift') key = 'shift'; if (key === 'meta' || key === 'os') key = 'win'; + if (e.code === 'MetaLeft' || e.code === 'MetaRight') key = 'win'; if (key === ' ') key = 'space'; const parts = new Set(mods); @@ -247,7 +256,7 @@ if (isModifierOnly && lastShortcut.includes('+')) { // Wait a tiny bit to see if they press another key, but usually release means they are done setTimeout(finish, 100); - } else if (!e.ctrlKey && !e.altKey && !e.shiftKey && !e.metaKey) { + } else if (!e.ctrlKey && !e.altKey && !e.shiftKey && !winKeyActive(e)) { finish(); } } @@ -261,7 +270,10 @@ return; } - window.go.main.App.SaveSettings({ shortcut: lastShortcut }).then(() => { + window.go.main.App.SaveSettings({ shortcut: lastShortcut }).then((result) => { + if (result && result.indexOf('Invalid shortcut') !== -1) { + alert(result); + } btn.textContent = 'Record'; btn.disabled = false; cleanup(); @@ -277,6 +289,41 @@ window.addEventListener('keyup', keyupHandler, true); }; + function renderHistoryList(history) { + const container = document.getElementById('historyList'); + if (!history || history.length === 0) { + container.innerHTML = '
No history yet.
'; + return; + } + container.innerHTML = ''; + [...history].reverse().forEach(item => { + const el = document.createElement('div'); + el.className = 'history-item'; + + const time = document.createElement('span'); + time.className = 'history-time'; + time.textContent = new Date(item.timestamp).toLocaleString(undefined, { + month: 'short', day: 'numeric', hour: '2-digit', minute: '2-digit' + }); + + const text = document.createElement('div'); + text.textContent = item.text; + + el.appendChild(time); + el.appendChild(text); + container.appendChild(el); + }); + } + + async function refreshHistoryFromBackend() { + try { + const settings = await window.go.main.App.GetSettings(); + renderHistoryList(settings.history || []); + } catch (err) { + console.error('Failed to refresh history:', err); + } + } + // Load settings on page load async function loadSettings() { try { @@ -290,29 +337,10 @@ document.getElementById('language').value = settings.language || 'en'; document.getElementById('startupToggle').checked = settings.startup || false; - // History - const history = settings.history || []; - const container = document.getElementById('historyList'); - if (history.length > 0) { - container.innerHTML = ''; - [...history].reverse().forEach(item => { - const el = document.createElement('div'); - el.className = 'history-item'; + const ver = (settings.app_version && String(settings.app_version).trim()) || 'dev'; + document.getElementById('appVersion').textContent = ver === 'dev' ? 'dev' : ('v' + ver); - const time = document.createElement('span'); - time.className = 'history-time'; - time.textContent = new Date(item.timestamp).toLocaleString(undefined, { - month: 'short', day: 'numeric', hour: '2-digit', minute: '2-digit' - }); - - const text = document.createElement('div'); - text.textContent = item.text; - - el.appendChild(time); - el.appendChild(text); - container.appendChild(el); - }); - } + renderHistoryList(settings.history || []); } catch (err) { console.error("Failed to load settings:", err); } @@ -383,7 +411,7 @@ window.clearHistory = async function () { if (confirm('Clear all history?')) { await window.go.main.App.ClearHistory(); - document.getElementById('historyList').innerHTML = '
No history yet.
'; + renderHistoryList([]); } }; @@ -514,6 +542,12 @@ await loadMics(); await checkConnection(); await checkWhisperStatus(); + + if (window.runtime && typeof window.runtime.EventsOn === 'function') { + window.runtime.EventsOn('history:updated', function () { + refreshHistoryFromBackend(); + }); + } // If Linux, ensure Local Whisper option is removed from Whisper Select as a secondary guard if (navigator.userAgent.toLowerCase().includes('linux')) { diff --git a/frontend/wailsjs/runtime/runtime.d.ts b/frontend/wailsjs/runtime/runtime.d.ts index 3bbea84..4445dac 100644 --- a/frontend/wailsjs/runtime/runtime.d.ts +++ b/frontend/wailsjs/runtime/runtime.d.ts @@ -246,85 +246,4 @@ export function OnFileDropOff() :void export function CanResolveFilePaths(): boolean; // Resolves file paths for an array of files -export function ResolveFilePaths(files: File[]): void - -// Notification types -export interface NotificationOptions { - id: string; - title: string; - subtitle?: string; // macOS and Linux only - body?: string; - categoryId?: string; - data?: { [key: string]: any }; -} - -export interface NotificationAction { - id?: string; - title?: string; - destructive?: boolean; // macOS-specific -} - -export interface NotificationCategory { - id?: string; - actions?: NotificationAction[]; - hasReplyField?: boolean; - replyPlaceholder?: string; - replyButtonTitle?: string; -} - -// [InitializeNotifications](https://wails.io/docs/reference/runtime/notification#initializenotifications) -// Initializes the notification service for the application. -// This must be called before sending any notifications. -export function InitializeNotifications(): Promise; - -// [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 +export function ResolveFilePaths(files: File[]): void \ No newline at end of file diff --git a/frontend/wailsjs/runtime/runtime.js b/frontend/wailsjs/runtime/runtime.js index 556621e..7cb89d7 100644 --- a/frontend/wailsjs/runtime/runtime.js +++ b/frontend/wailsjs/runtime/runtime.js @@ -239,60 +239,4 @@ export function CanResolveFilePaths() { export function ResolveFilePaths(files) { return window.runtime.ResolveFilePaths(files); -} - -export function InitializeNotifications() { - return window.runtime.InitializeNotifications(); -} - -export function CleanupNotifications() { - return window.runtime.CleanupNotifications(); -} - -export function IsNotificationAvailable() { - return window.runtime.IsNotificationAvailable(); -} - -export function RequestNotificationAuthorization() { - return window.runtime.RequestNotificationAuthorization(); -} - -export function CheckNotificationAuthorization() { - return window.runtime.CheckNotificationAuthorization(); -} - -export function SendNotification(options) { - return window.runtime.SendNotification(options); -} - -export function SendNotificationWithActions(options) { - return window.runtime.SendNotificationWithActions(options); -} - -export function RegisterNotificationCategory(category) { - return window.runtime.RegisterNotificationCategory(category); -} - -export function RemoveNotificationCategory(categoryId) { - return window.runtime.RemoveNotificationCategory(categoryId); -} - -export function RemoveAllPendingNotifications() { - return window.runtime.RemoveAllPendingNotifications(); -} - -export function RemovePendingNotification(identifier) { - return window.runtime.RemovePendingNotification(identifier); -} - -export function RemoveAllDeliveredNotifications() { - return window.runtime.RemoveAllDeliveredNotifications(); -} - -export function RemoveDeliveredNotification(identifier) { - return window.runtime.RemoveDeliveredNotification(identifier); -} - -export function RemoveNotification(identifier) { - return window.runtime.RemoveNotification(identifier); } \ No newline at end of file diff --git a/internal/hotkey/hotkey.go b/internal/hotkey/hotkey.go index ff536fd..1fd41b8 100644 --- a/internal/hotkey/hotkey.go +++ b/internal/hotkey/hotkey.go @@ -2,10 +2,12 @@ package hotkey import ( + "runtime" "sync" "time" + "wis-free-v3/internal/logger" - "wis-free-v3/internal/xhotkey" + xhk "wis-free-v3/internal/xhotkey" ) // Listener handles global hotkey events and triggers callbacks when the @@ -15,7 +17,8 @@ type Listener struct { stopCallback func() isListening bool shortcut string - hk *hotkey.Hotkey + hk *xhk.Hotkey + stopModPoll chan struct{} mu sync.RWMutex } @@ -37,12 +40,7 @@ func (l *Listener) UpdateShortcut(shortcut string) { l.mu.Lock() l.shortcut = shortcut wasListening := l.isListening - - if l.hk != nil { - l.hk.Unregister() - l.hk = nil - l.isListening = false - } + l.stopListeningLocked() l.mu.Unlock() logger.Info("Hotkey updated: shortcut=%s", shortcut) @@ -55,26 +53,43 @@ func (l *Listener) UpdateShortcut(shortcut string) { // Start begins listening for the configured shortcut in a background goroutine. func (l *Listener) Start() { l.mu.Lock() - defer l.mu.Unlock() - - if l.hk != nil { + if l.isListening { + l.mu.Unlock() return } - key, mods, ok := ParseShortcut(l.shortcut) + key, mods, modOnly, ok := ParseShortcut(l.shortcut) if !ok { logger.Error("Failed to parse shortcut: %s", l.shortcut) + l.mu.Unlock() return } - l.hk = hotkey.New(mods, key) + if modOnly { + if runtime.GOOS != "windows" { + logger.Error("Modifier-only shortcuts like %q are only supported on Windows", l.shortcut) + l.mu.Unlock() + return + } + ch := make(chan struct{}) + l.stopModPoll = ch + l.isListening = true + l.mu.Unlock() + logger.Info("Hotkey listener started (modifier poll), waiting for %s", l.shortcut) + go l.modifierPollLoop(mods, ch) + return + } + + l.hk = xhk.New(mods, key) if err := l.hk.Register(); err != nil { logger.Error("Failed to register hotkey %s: %v", l.shortcut, err) l.hk = nil + l.mu.Unlock() return } l.isListening = true + l.mu.Unlock() logger.Info("Hotkey listener started, waiting for %s", l.shortcut) go l.eventLoop(l.hk) @@ -85,22 +100,26 @@ func (l *Listener) Stop() { l.mu.Lock() defer l.mu.Unlock() - if l.hk == nil { - return - } - - err := l.hk.Unregister() - if err != nil { - logger.Error("Failed to unregister hotkey: %v", err) - } - - l.hk = nil - l.isListening = false + l.stopListeningLocked() logger.Info("Hotkey listener stopped") } +func (l *Listener) stopListeningLocked() { + if l.stopModPoll != nil { + close(l.stopModPoll) + l.stopModPoll = nil + } + if l.hk != nil { + if err := l.hk.Unregister(); err != nil { + logger.Error("Failed to unregister hotkey: %v", err) + } + l.hk = nil + } + l.isListening = false +} + // eventLoop runs the main keyboard event processing loop. -func (l *Listener) eventLoop(hk *hotkey.Hotkey) { +func (l *Listener) eventLoop(hk *xhk.Hotkey) { var isRecording bool for { diff --git a/internal/hotkey/keycodes.go b/internal/hotkey/keycodes.go index 9c9a718..91bfa3b 100644 --- a/internal/hotkey/keycodes.go +++ b/internal/hotkey/keycodes.go @@ -3,96 +3,136 @@ package hotkey import ( "strings" - "wis-free-v3/internal/xhotkey" + xhk "wis-free-v3/internal/xhotkey" ) // KeyMap maps human-readable key names to golang.design/x/hotkey Key instances. -var KeyMap = map[string]hotkey.Key{ - "space": hotkey.KeySpace, - "0": hotkey.Key0, - "1": hotkey.Key1, - "2": hotkey.Key2, - "3": hotkey.Key3, - "4": hotkey.Key4, - "5": hotkey.Key5, - "6": hotkey.Key6, - "7": hotkey.Key7, - "8": hotkey.Key8, - "9": hotkey.Key9, - "a": hotkey.KeyA, - "b": hotkey.KeyB, - "c": hotkey.KeyC, - "d": hotkey.KeyD, - "e": hotkey.KeyE, - "f": hotkey.KeyF, - "g": hotkey.KeyG, - "h": hotkey.KeyH, - "i": hotkey.KeyI, - "j": hotkey.KeyJ, - "k": hotkey.KeyK, - "l": hotkey.KeyL, - "m": hotkey.KeyM, - "n": hotkey.KeyN, - "o": hotkey.KeyO, - "p": hotkey.KeyP, - "q": hotkey.KeyQ, - "r": hotkey.KeyR, - "s": hotkey.KeyS, - "t": hotkey.KeyT, - "u": hotkey.KeyU, - "v": hotkey.KeyV, - "w": hotkey.KeyW, - "x": hotkey.KeyX, - "y": hotkey.KeyY, - "z": hotkey.KeyZ, - "return": hotkey.KeyReturn, - "enter": hotkey.KeyReturn, - "escape": hotkey.KeyEscape, - "esc": hotkey.KeyEscape, - "delete": hotkey.KeyDelete, - "del": hotkey.KeyDelete, - "tab": hotkey.KeyTab, - "left": hotkey.KeyLeft, - "right": hotkey.KeyRight, - "up": hotkey.KeyUp, - "down": hotkey.KeyDown, - "f1": hotkey.KeyF1, - "f2": hotkey.KeyF2, - "f3": hotkey.KeyF3, - "f4": hotkey.KeyF4, - "f5": hotkey.KeyF5, - "f6": hotkey.KeyF6, - "f7": hotkey.KeyF7, - "f8": hotkey.KeyF8, - "f9": hotkey.KeyF9, - "f10": hotkey.KeyF10, - "f11": hotkey.KeyF11, - "f12": hotkey.KeyF12, +var KeyMap = map[string]xhk.Key{ + "space": xhk.KeySpace, + "0": xhk.Key0, + "1": xhk.Key1, + "2": xhk.Key2, + "3": xhk.Key3, + "4": xhk.Key4, + "5": xhk.Key5, + "6": xhk.Key6, + "7": xhk.Key7, + "8": xhk.Key8, + "9": xhk.Key9, + "a": xhk.KeyA, + "b": xhk.KeyB, + "c": xhk.KeyC, + "d": xhk.KeyD, + "e": xhk.KeyE, + "f": xhk.KeyF, + "g": xhk.KeyG, + "h": xhk.KeyH, + "i": xhk.KeyI, + "j": xhk.KeyJ, + "k": xhk.KeyK, + "l": xhk.KeyL, + "m": xhk.KeyM, + "n": xhk.KeyN, + "o": xhk.KeyO, + "p": xhk.KeyP, + "q": xhk.KeyQ, + "r": xhk.KeyR, + "s": xhk.KeyS, + "t": xhk.KeyT, + "u": xhk.KeyU, + "v": xhk.KeyV, + "w": xhk.KeyW, + "x": xhk.KeyX, + "y": xhk.KeyY, + "z": xhk.KeyZ, + "return": xhk.KeyReturn, + "enter": xhk.KeyReturn, + "escape": xhk.KeyEscape, + "esc": xhk.KeyEscape, + "delete": xhk.KeyDelete, + "del": xhk.KeyDelete, + "tab": xhk.KeyTab, + "left": xhk.KeyLeft, + "right": xhk.KeyRight, + "up": xhk.KeyUp, + "down": xhk.KeyDown, + "f1": xhk.KeyF1, + "f2": xhk.KeyF2, + "f3": xhk.KeyF3, + "f4": xhk.KeyF4, + "f5": xhk.KeyF5, + "f6": xhk.KeyF6, + "f7": xhk.KeyF7, + "f8": xhk.KeyF8, + "f9": xhk.KeyF9, + "f10": xhk.KeyF10, + "f11": xhk.KeyF11, + "f12": xhk.KeyF12, } -// ParseShortcut parses a shortcut string and returns the hotkey.Key and a list of modifiers. -// Returns false if parsing fails. -func ParseShortcut(shortcut string) (hotkey.Key, []hotkey.Modifier, bool) { +// ParseShortcut parses a shortcut string and returns the trigger key, modifiers, whether the +// shortcut is modifier-only (no letter/key, e.g. "ctrl+win"), and whether parsing succeeded. +// +// For shortcuts that include a non-modifier key, the trigger key is the rightmost segment in +// KeyMap; every other segment must be in ModMap (so "win+ctrl+k" and "ctrl+win+k" both work). +// +// If no segment matches KeyMap but every segment is a known modifier, modifierOnly is true +// (supported on Windows via key-state polling; RegisterHotKey cannot represent these chords). +func ParseShortcut(shortcut string) (key xhk.Key, mods []xhk.Modifier, modifierOnly bool, ok bool) { parts := strings.Split(strings.ToLower(strings.TrimSpace(shortcut)), "+") if len(parts) == 0 { - return 0, nil, false + return 0, nil, false, false } - // The last part is the trigger key - triggerName := strings.TrimSpace(parts[len(parts)-1]) - k, exists := KeyMap[triggerName] - if !exists { - return 0, nil, false - } - - // The preceding parts are modifiers - var mods []hotkey.Modifier - for i := 0; i < len(parts)-1; i++ { - modName := strings.TrimSpace(parts[i]) - if m, ok := ModMap[modName]; ok { - mods = append(mods, m) + keyIdx := -1 + var triggerKey xhk.Key + for i := len(parts) - 1; i >= 0; i-- { + name := strings.TrimSpace(parts[i]) + if name == "" { + continue + } + if k, exists := KeyMap[name]; exists { + keyIdx = i + triggerKey = k + break } } - return k, mods, true + if keyIdx >= 0 { + mods = nil + for i := 0; i < len(parts); i++ { + if i == keyIdx { + continue + } + modName := strings.TrimSpace(parts[i]) + if modName == "" { + continue + } + m, valid := ModMap[modName] + if !valid { + return 0, nil, false, false + } + mods = append(mods, m) + } + return triggerKey, mods, false, true + } + + mods = nil + for i := 0; i < len(parts); i++ { + modName := strings.TrimSpace(parts[i]) + if modName == "" { + continue + } + m, valid := ModMap[modName] + if !valid { + return 0, nil, false, false + } + mods = append(mods, m) + } + if len(mods) < 2 { + // Single-modifier "chords" would fire on every press of that key alone. + return 0, nil, false, false + } + return 0, mods, true, true } + diff --git a/internal/hotkey/modifier_poll_stub.go b/internal/hotkey/modifier_poll_stub.go new file mode 100644 index 0000000..fcc09d5 --- /dev/null +++ b/internal/hotkey/modifier_poll_stub.go @@ -0,0 +1,10 @@ +//go:build !windows + +package hotkey + +import xhk "wis-free-v3/internal/xhotkey" + +func (l *Listener) modifierPollLoop(required []xhk.Modifier, stop <-chan struct{}) { + // Listener.Start rejects modifier-only shortcuts on non-Windows; never called. + _, _ = required, stop +} diff --git a/internal/hotkey/modifier_poll_windows.go b/internal/hotkey/modifier_poll_windows.go new file mode 100644 index 0000000..bcbda6f --- /dev/null +++ b/internal/hotkey/modifier_poll_windows.go @@ -0,0 +1,85 @@ +//go:build windows + +package hotkey + +import ( + "time" + + "wis-free-v3/internal/logger" + xhk "wis-free-v3/internal/xhotkey" +) + +const ( + vkShift = 0x10 + vkControl = 0x11 + vkMenu = 0x12 + vkLWin = 0x5B + vkRWin = 0x5C +) + +func containsMod(mods []xhk.Modifier, want xhk.Modifier) bool { + for _, m := range mods { + if m == want { + return true + } + } + return false +} + +// modifiersExactMatch reports whether ctrl/shift/alt/win match the required set exactly +// (each required modifier is held, and no extra modifiers among those four are held). +func modifiersExactMatch(required []xhk.Modifier) bool { + needCtrl := containsMod(required, xhk.ModCtrl) + needShift := containsMod(required, xhk.ModShift) + needAlt := containsMod(required, xhk.ModAlt) + needWin := containsMod(required, xhk.ModWin) + + ctrlDown := xhk.AsyncKeyDown(vkControl) + shiftDown := xhk.AsyncKeyDown(vkShift) + altDown := xhk.AsyncKeyDown(vkMenu) + winDown := xhk.AsyncKeyDown(vkLWin) || xhk.AsyncKeyDown(vkRWin) + + return needCtrl == ctrlDown && + needShift == shiftDown && + needAlt == altDown && + needWin == winDown +} + +// modifierPollLoop implements modifier-only shortcuts (e.g. ctrl+win), which Windows +// RegisterHotKey cannot represent. +func (l *Listener) modifierPollLoop(required []xhk.Modifier, stop <-chan struct{}) { + ticker := time.NewTicker(15 * time.Millisecond) + defer ticker.Stop() + + chordHeld := false + isRecording := false + + for { + select { + case <-stop: + if isRecording { + l.stopCallback() + } + logger.Info("Modifier hotkey poll stopped") + return + case <-ticker.C: + on := modifiersExactMatch(required) + switch { + case on && !chordHeld: + chordHeld = true + if !isRecording { + logger.Info("Modifier chord activated: starting recording") + go l.startCallback() + isRecording = true + } + case !on && chordHeld: + chordHeld = false + if isRecording { + logger.Info("Modifier chord released: stopping recording") + go l.stopCallback() + isRecording = false + } + } + } + } +} diff --git a/internal/hotkey/modifiers_darwin.go b/internal/hotkey/modifiers_darwin.go index 326bf31..c926f0b 100644 --- a/internal/hotkey/modifiers_darwin.go +++ b/internal/hotkey/modifiers_darwin.go @@ -2,16 +2,16 @@ package hotkey -import "wis-free-v3/internal/xhotkey" +import xhk "wis-free-v3/internal/xhotkey" -var ModMap = map[string]hotkey.Modifier{ - "ctrl": hotkey.ModCtrl, - "control": hotkey.ModCtrl, - "shift": hotkey.ModShift, - "alt": hotkey.ModOption, - "win": hotkey.ModCmd, - "windows": hotkey.ModCmd, - "meta": hotkey.ModCmd, - "super": hotkey.ModCmd, - "cmd": hotkey.ModCmd, +var ModMap = map[string]xhk.Modifier{ + "ctrl": xhk.ModCtrl, + "control": xhk.ModCtrl, + "shift": xhk.ModShift, + "alt": xhk.ModOption, + "win": xhk.ModCmd, + "windows": xhk.ModCmd, + "meta": xhk.ModCmd, + "super": xhk.ModCmd, + "cmd": xhk.ModCmd, } diff --git a/internal/hotkey/modifiers_linux.go b/internal/hotkey/modifiers_linux.go index 2ead039..52a2c3e 100644 --- a/internal/hotkey/modifiers_linux.go +++ b/internal/hotkey/modifiers_linux.go @@ -2,15 +2,15 @@ package hotkey -import "wis-free-v3/internal/xhotkey" +import xhk "wis-free-v3/internal/xhotkey" -var ModMap = map[string]hotkey.Modifier{ - "ctrl": hotkey.ModCtrl, - "control": hotkey.ModCtrl, - "shift": hotkey.ModShift, - "alt": hotkey.Mod1, - "win": hotkey.Mod4, - "windows": hotkey.Mod4, - "meta": hotkey.Mod4, - "super": hotkey.Mod4, +var ModMap = map[string]xhk.Modifier{ + "ctrl": xhk.ModCtrl, + "control": xhk.ModCtrl, + "shift": xhk.ModShift, + "alt": xhk.Mod1, + "win": xhk.Mod4, + "windows": xhk.Mod4, + "meta": xhk.Mod4, + "super": xhk.Mod4, } diff --git a/internal/hotkey/modifiers_windows.go b/internal/hotkey/modifiers_windows.go index 7a0918e..8771962 100644 --- a/internal/hotkey/modifiers_windows.go +++ b/internal/hotkey/modifiers_windows.go @@ -2,15 +2,15 @@ package hotkey -import "wis-free-v3/internal/xhotkey" +import xhk "wis-free-v3/internal/xhotkey" -var ModMap = map[string]hotkey.Modifier{ - "ctrl": hotkey.ModCtrl, - "control": hotkey.ModCtrl, - "shift": hotkey.ModShift, - "alt": hotkey.ModAlt, - "win": hotkey.ModWin, - "windows": hotkey.ModWin, - "meta": hotkey.ModWin, - "super": hotkey.ModWin, +var ModMap = map[string]xhk.Modifier{ + "ctrl": xhk.ModCtrl, + "control": xhk.ModCtrl, + "shift": xhk.ModShift, + "alt": xhk.ModAlt, + "win": xhk.ModWin, + "windows": xhk.ModWin, + "meta": xhk.ModWin, + "super": xhk.ModWin, } diff --git a/internal/ui/tray/tray.go b/internal/ui/tray/tray.go index ef6ae10..55bb8a6 100644 --- a/internal/ui/tray/tray.go +++ b/internal/ui/tray/tray.go @@ -28,8 +28,12 @@ type App interface { Quit() GetConfig() *config.Config ShowSettings() + Version() string } +// trayLabel is the short product name shown in the tray (includes version when set at link time). +var trayLabel = "wis-free-v3" + // Menu item references for dynamic updates var statusMenuItem *systray.MenuItem @@ -42,11 +46,21 @@ func Start(app App) { ) } +func appDisplayName(app App) string { + v := app.Version() + if v != "" && v != "dev" { + return "wis-free-v3 v" + v + } + return "wis-free-v3" +} + // onReady is called when the system tray is ready to be configured. func onReady(app App) { + trayLabel = appDisplayName(app) + // Configure tray icon and tooltip systray.SetIcon(iconData) - systray.SetTitle("wis-free-v3") + systray.SetTitle(trayLabel) systray.SetTooltip(buildTooltip(app)) // Build menu structure @@ -119,14 +133,14 @@ func buildTooltip(app App) string { if cfg := app.GetConfig(); cfg != nil && cfg.Shortcut != "" { shortcut = cfg.Shortcut } - return "wis-free-v3 - " + shortcut + " to record" + return trayLabel + " - " + shortcut + " to record" } // UpdateStatus updates the status text displayed in the tray menu. func UpdateStatus(status string) { if statusMenuItem != nil { statusMenuItem.SetTitle("Status: " + status) - systray.SetTooltip("wis-free-v3 - " + status) + systray.SetTooltip(trayLabel + " - " + status) if strings.Contains(status, "Recording") { systray.SetIcon(iconRecordingData) diff --git a/internal/xhotkey/asynckey_stub.go b/internal/xhotkey/asynckey_stub.go new file mode 100644 index 0000000..c084466 --- /dev/null +++ b/internal/xhotkey/asynckey_stub.go @@ -0,0 +1,8 @@ +//go:build !windows + +package hotkey + +// AsyncKeyDown is a stub on non-Windows platforms. +func AsyncKeyDown(vk int) bool { + return false +} diff --git a/internal/xhotkey/asynckey_windows.go b/internal/xhotkey/asynckey_windows.go new file mode 100644 index 0000000..e160028 --- /dev/null +++ b/internal/xhotkey/asynckey_windows.go @@ -0,0 +1,10 @@ +//go:build windows + +package hotkey + +import "wis-free-v3/internal/xhotkey/internal/win" + +// AsyncKeyDown reports whether the virtual key is currently held (high bit of GetAsyncKeyState). +func AsyncKeyDown(vk int) bool { + return win.GetAsyncKeyState(vk)&0x8000 != 0 +} diff --git a/main.go b/main.go index b39359c..4423d9d 100644 --- a/main.go +++ b/main.go @@ -35,6 +35,10 @@ var assets embed.FS // Global lock file handle for single instance management var instanceLock *os.File +// AppVersion is set at link time by scripts/build.bat and scripts/build-linux.sh +// from scripts/VERSION. Default is used for plain `go build` / `wails build`. +var AppVersion = "dev" + func main() { // Ensure only one instance of the application is running if !acquireInstanceLock() { diff --git a/scripts/VERSION b/scripts/VERSION new file mode 100644 index 0000000..65ee095 --- /dev/null +++ b/scripts/VERSION @@ -0,0 +1 @@ +1.67.0 diff --git a/scripts/build-linux.sh b/scripts/build-linux.sh index 515fdb0..121ea31 100755 --- a/scripts/build-linux.sh +++ b/scripts/build-linux.sh @@ -15,6 +15,18 @@ echo " wis-free-v3 - Linux Build Script" echo "========================================" echo "" +VERSION_FILE="$(dirname "$0")/VERSION" +APP_VERSION="dev" +if [ -f "$VERSION_FILE" ]; then + APP_VERSION="$(head -n1 "$VERSION_FILE" | tr -d '\r\n')" + APP_VERSION="$(echo "$APP_VERSION" | sed 's/^[[:space:]]*//;s/[[:space:]]*$//')" +fi +if [ -z "$APP_VERSION" ]; then + APP_VERSION="dev" +fi +echo "[0/3] App version: $APP_VERSION" +echo "" + # Function to check if a command exists command_exists() { command -v "$1" >/dev/null 2>&1 @@ -98,7 +110,7 @@ if [ -d "frontend/node_modules/.bin" ]; then chmod +x frontend/node_modules/.bin/* 2>/dev/null || true fi -wails build -platform linux/amd64 -clean +wails build -platform linux/amd64 -clean -ldflags "-X main.AppVersion=${APP_VERSION}" if [ ! -f "$EXECUTABLE" ]; then echo "[ERROR] Build failed. Binary not found at $EXECUTABLE." diff --git a/scripts/build.bat b/scripts/build.bat index 0bb0efc..c7c07b7 100644 --- a/scripts/build.bat +++ b/scripts/build.bat @@ -10,6 +10,15 @@ echo wis-free-v3 - Build Script echo ======================================== echo. +REM App version from scripts\VERSION (single line, e.g. 1.2.3) +set "APP_VERSION=dev" +if exist "%~dp0VERSION" ( + for /f "delims=" %%V in ('powershell -NoProfile -Command "(Get-Content -LiteralPath '%~dp0VERSION' -Raw).Trim()" 2^>nul') do set "APP_VERSION=%%V" +) +if "!APP_VERSION!"=="" set "APP_VERSION=dev" +echo [0/2] App version: !APP_VERSION! +echo. + REM Check if wails is installed where wails >nul 2>&1 if %ERRORLEVEL% NEQ 0 ( @@ -72,7 +81,7 @@ if %ERRORLEVEL% NEQ 0 ( exit /b 1 ) -wails build -clean -ldflags="-linkmode internal" -skipbindings +wails build -clean -ldflags "-linkmode internal -X main.AppVersion=!APP_VERSION!" -skipbindings if %ERRORLEVEL% NEQ 0 ( echo.