From 9b08ed249aec45f8231a9e8281a5c8405be8c92f Mon Sep 17 00:00:00 2001 From: jahruz67 Date: Wed, 20 May 2026 16:12:55 -0700 Subject: [PATCH] feat: implement ydotool support for Linux with auto-paste functionality and add setup script --- app.go | 1 + frontend/dist/assets/index.27c7f0d4.js | 4 + frontend/dist/assets/index.3a064eef.js | 3 - frontend/dist/index.html | 3 +- frontend/index.html | 33 +++++ scripts/setup-ydotool.sh | 110 +++++++++++++++++ text_insert_linux.go | 160 +++++++++++++++++-------- text_insert_status_nonlinux.go | 13 ++ 8 files changed, 274 insertions(+), 53 deletions(-) create mode 100644 frontend/dist/assets/index.27c7f0d4.js delete mode 100644 frontend/dist/assets/index.3a064eef.js create mode 100644 scripts/setup-ydotool.sh create mode 100644 text_insert_status_nonlinux.go diff --git a/app.go b/app.go index fc629b1..7e9777e 100644 --- a/app.go +++ b/app.go @@ -408,6 +408,7 @@ func (a *App) GetSettings() map[string]interface{} { conf["linux_press_command"] = exePath + " --press" } conf["linux_press_mode"] = true + conf["linux_ydotool_status"] = linuxYdotoolStatus() } return conf } diff --git a/frontend/dist/assets/index.27c7f0d4.js b/frontend/dist/assets/index.27c7f0d4.js new file mode 100644 index 0000000..e723dd6 --- /dev/null +++ b/frontend/dist/assets/index.27c7f0d4.js @@ -0,0 +1,4 @@ +(function(){const t=document.createElement("link").relList;if(t&&t.supports&&t.supports("modulepreload"))return;for(const i of document.querySelectorAll('link[rel="modulepreload"]'))n(i);new MutationObserver(i=>{for(const l of i)if(l.type==="childList")for(const r of l.addedNodes)r.tagName==="LINK"&&r.rel==="modulepreload"&&n(r)}).observe(document,{childList:!0,subtree:!0});function o(i){const l={};return i.integrity&&(l.integrity=i.integrity),i.referrerpolicy&&(l.referrerPolicy=i.referrerpolicy),i.crossorigin==="use-credentials"?l.credentials="include":i.crossorigin==="anonymous"?l.credentials="omit":l.credentials="same-origin",l}function n(i){if(i.ep)return;i.ep=!0;const l=o(i);fetch(i.href,l)}})();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"),o=a=>a.metaKey||a.key==="Meta"||a.key==="OS"||a.code==="MetaLeft"||a.code==="MetaRight";e.textContent="Recording...",e.disabled=!0,t.value="";let n="";const i=a=>{a.preventDefault(),a.stopPropagation();const c=[];a.ctrlKey&&c.push("ctrl"),a.altKey&&c.push("alt"),a.shiftKey&&c.push("shift"),o(a)&&c.push("win");let s=a.key.toLowerCase();s==="control"&&(s="ctrl"),s==="alt"&&(s="alt"),s==="shift"&&(s="shift"),(s==="meta"||s==="os")&&(s="win"),(a.code==="MetaLeft"||a.code==="MetaRight")&&(s="win"),s===" "&&(s="space");const f=new Set(c);f.add(s);const g=Array.from(f).filter(w=>w!==s);g.push(s),n=g.join("+"),t.value=n,["ctrl","alt","shift","win"].includes(s)||r()},l=a=>{a.preventDefault(),a.stopPropagation(),n&&(n.split("+").every(s=>["ctrl","alt","shift","win"].includes(s))&&n.includes("+")?setTimeout(r,100):!a.ctrlKey&&!a.altKey&&!a.shiftKey&&!o(a)&&r())},r=()=>{if(!n){e.textContent="Record",e.disabled=!1,y();return}window.go.main.App.SaveSettings({shortcut:n}).then(a=>{a&&a.indexOf("Invalid shortcut")!==-1&&alert(a),e.textContent="Record",e.disabled=!1,y()})},y=()=>{window.removeEventListener("keydown",i,!0),window.removeEventListener("keyup",l,!0)};window.addEventListener("keydown",i,!0),window.addEventListener("keyup",l,!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(o=>{const n=document.createElement("div");n.className="history-item";const i=document.createElement("span");i.className="history-time",i.textContent=new Date(o.timestamp).toLocaleString(void 0,{month:"short",day:"numeric",hour:"2-digit",minute:"2-digit"});const l=document.createElement("div");l.textContent=o.text,n.appendChild(i),n.appendChild(l),t.appendChild(n)})}function h(e){const t=document.getElementById("linuxYdotoolStatus");if(!t||!e)return;const o=!!e.ready;t.style.display="block",t.style.background=o?"var(--green-dim)":"var(--red-dim)",t.style.color=o?"var(--green)":"var(--red)",t.style.border=o?"1px solid rgba(61, 186, 110, 0.25)":"1px solid rgba(224, 82, 82, 0.25)",t.innerHTML="";const n=document.createElement("div");n.style.fontWeight="600",n.textContent=o?"Automatic paste ready":"Automatic paste needs ydotool setup",t.appendChild(n);const i=document.createElement("div");if(i.style.marginTop="4px",i.textContent=e.message||"",t.appendChild(i),!o&&Array.isArray(e.setup_commands)&&e.setup_commands.length){const l=document.createElement("pre");l.style.whiteSpace="pre-wrap",l.style.margin="8px 0 0",l.style.color="inherit",l.textContent=e.setup_commands.join(` +`),t.appendChild(l)}}async function v(){try{const e=await window.go.main.App.GetSettings();m(e.history||[])}catch(e){console.error("Failed to refresh history:",e)}}async function E(){try{const e=await window.go.main.App.GetSettings();document.getElementById("apiKey").value=e.api_key||"";const t=document.getElementById("shortcutInput");if(t&&(t.value=e.shortcut||"alt+z"),e.linux_press_mode){const n=document.getElementById("linuxPressSection"),i=document.getElementById("linuxPressCommand");n&&(n.style.display="block"),i&&(i.value=e.linux_press_command||""),h(e.linux_ydotool_status)}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 o=e.app_version&&String(e.app_version).trim()||"dev";document.getElementById("appVersion").textContent=o==="dev"?"dev":"v"+o,m(e.history||[])}catch(e){console.error("Failed to load settings:",e)}}async function I(){try{const e=await window.go.main.App.GetMicrophones(),t=document.getElementById("micDevice");e.forEach(o=>{if(o.index!==-1){const n=document.createElement("option");n.value=o.index,n.textContent=o.name,t.appendChild(n)}})}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="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()};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.copyLinuxPressCommand=async function(){const e=document.getElementById("linuxPressCommand");if(!e)return;const t=e.value||"";try{navigator.clipboard&&navigator.clipboard.writeText?await navigator.clipboard.writeText(t):(e.focus(),e.select(),document.execCommand("copy")),d("Command copied")}catch(o){console.error("Failed to copy Linux command:",o)}};window.toggleStartup=async function(){await window.go.main.App.ToggleStartup(document.getElementById("startupToggle").checked)};window.clearHistory=async function(){confirm("Clear all transcription history?")&&(await window.go.main.App.ClearHistory(),m([]))};async function x(){try{const e=await window.go.main.App.CheckOnline(),t=document.getElementById("connectionStatus");if(e)t.innerHTML="\u25CF Online",t.style.background="var(--green-dim)",t.style.color="var(--green)",t.style.border="1px solid rgba(61, 186, 110, 0.2)";else{t.innerHTML="\u25CF Offline \u2014 install local Whisper for transcription",t.style.background="var(--red-dim)",t.style.color="var(--red)",t.style.border="1px solid rgba(224, 82, 82, 0.2)";const o=document.getElementById("aiModel");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 p(){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 n=document.createElement("option");n.value="local-"+e.model,n.textContent="\u2B21 Local \u2014 "+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(){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(p,5e3)}catch(o){alert("Installation failed: "+o)}t.disabled=!1,t.textContent="Install Offline Whisper"};window.uninstallWhisper=async function(){if(!!confirm("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 o=document.getElementById("startupLabel");o&&(o.innerText="Run on Startup")}}catch(e){console.error("Failed to get environment:",e)}if(await E(),await I(),await x(),await p(),window.runtime&&typeof window.runtime.EventsOn=="function"&&window.runtime.EventsOn("history:updated",function(){v()}),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.3a064eef.js b/frontend/dist/assets/index.3a064eef.js deleted file mode 100644 index 39f9add..0000000 --- a/frontend/dist/assets/index.3a064eef.js +++ /dev/null @@ -1,3 +0,0 @@ -(function(){const t=document.createElement("link").relList;if(t&&t.supports&&t.supports("modulepreload"))return;for(const a of document.querySelectorAll('link[rel="modulepreload"]'))n(a);new MutationObserver(a=>{for(const l of a)if(l.type==="childList")for(const r of l.addedNodes)r.tagName==="LINK"&&r.rel==="modulepreload"&&n(r)}).observe(document,{childList:!0,subtree:!0});function o(a){const l={};return a.integrity&&(l.integrity=a.integrity),a.referrerpolicy&&(l.referrerPolicy=a.referrerpolicy),a.crossorigin==="use-credentials"?l.credentials="include":a.crossorigin==="anonymous"?l.credentials="omit":l.credentials="same-origin",l}function n(a){if(a.ep)return;a.ep=!0;const l=o(a);fetch(a.href,l)}})();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"),o=i=>i.metaKey||i.key==="Meta"||i.key==="OS"||i.code==="MetaLeft"||i.code==="MetaRight";e.textContent="Recording...",e.disabled=!0,t.value="";let n="";const a=i=>{i.preventDefault(),i.stopPropagation();const d=[];i.ctrlKey&&d.push("ctrl"),i.altKey&&d.push("alt"),i.shiftKey&&d.push("shift"),o(i)&&d.push("win");let s=i.key.toLowerCase();s==="control"&&(s="ctrl"),s==="alt"&&(s="alt"),s==="shift"&&(s="shift"),(s==="meta"||s==="os")&&(s="win"),(i.code==="MetaLeft"||i.code==="MetaRight")&&(s="win"),s===" "&&(s="space");const f=new Set(d);f.add(s);const w=Array.from(f).filter(g=>g!==s);w.push(s),n=w.join("+"),t.value=n,["ctrl","alt","shift","win"].includes(s)||r()},l=i=>{i.preventDefault(),i.stopPropagation(),n&&(n.split("+").every(s=>["ctrl","alt","shift","win"].includes(s))&&n.includes("+")?setTimeout(r,100):!i.ctrlKey&&!i.altKey&&!i.shiftKey&&!o(i)&&r())},r=()=>{if(!n){e.textContent="Record",e.disabled=!1,y();return}window.go.main.App.SaveSettings({shortcut:n}).then(i=>{i&&i.indexOf("Invalid shortcut")!==-1&&alert(i),e.textContent="Record",e.disabled=!1,y()})},y=()=>{window.removeEventListener("keydown",a,!0),window.removeEventListener("keyup",l,!0)};window.addEventListener("keydown",a,!0),window.addEventListener("keyup",l,!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(o=>{const n=document.createElement("div");n.className="history-item";const a=document.createElement("span");a.className="history-time",a.textContent=new Date(o.timestamp).toLocaleString(void 0,{month:"short",day:"numeric",hour:"2-digit",minute:"2-digit"});const l=document.createElement("div");l.textContent=o.text,n.appendChild(a),n.appendChild(l),t.appendChild(n)})}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||"";const t=document.getElementById("shortcutInput");if(t&&(t.value=e.shortcut||"alt+z"),e.linux_press_mode){const n=document.getElementById("linuxPressSection"),a=document.getElementById("linuxPressCommand");n&&(n.style.display="block"),a&&(a.value=e.linux_press_command||"")}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 o=e.app_version&&String(e.app_version).trim()||"dev";document.getElementById("appVersion").textContent=o==="dev"?"dev":"v"+o,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(o=>{if(o.index!==-1){const n=document.createElement("option");n.value=o.index,n.textContent=o.name,t.appendChild(n)}})}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}),c()};function c(e="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}),c()};window.saveAiModel=async function(){await window.go.main.App.SaveSettings({ai_model:document.getElementById("aiModel").value}),c()};window.saveLanguage=async function(){await window.go.main.App.SaveSettings({language:document.getElementById("language").value}),c()};window.saveMicDevice=async function(){const e=document.getElementById("micDevice").value;await window.go.main.App.SaveSettings({microphone_device:e==="default"?null:parseInt(e)}),c()};window.savePrompt=async function(){await window.go.main.App.SaveSettings({ai_prompt:document.getElementById("aiPrompt").value.trim()}),c("Prompt saved")};window.copyLinuxPressCommand=async function(){const e=document.getElementById("linuxPressCommand");if(!e)return;const t=e.value||"";try{navigator.clipboard&&navigator.clipboard.writeText?await navigator.clipboard.writeText(t):(e.focus(),e.select(),document.execCommand("copy")),c("Command copied")}catch(o){console.error("Failed to copy Linux command:",o)}};window.toggleStartup=async function(){await window.go.main.App.ToggleStartup(document.getElementById("startupToggle").checked)};window.clearHistory=async function(){confirm("Clear all transcription 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="\u25CF Online",t.style.background="var(--green-dim)",t.style.color="var(--green)",t.style.border="1px solid rgba(61, 186, 110, 0.2)";else{t.innerHTML="\u25CF Offline \u2014 install local Whisper for transcription",t.style.background="var(--red-dim)",t.style.color="var(--red)",t.style.border="1px solid rgba(224, 82, 82, 0.2)";const o=document.getElementById("aiModel");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 p(){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 n=document.createElement("option");n.value="local-"+e.model,n.textContent="\u2B21 Local \u2014 "+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(){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(p,5e3)}catch(o){alert("Installation failed: "+o)}t.disabled=!1,t.textContent="Install Offline Whisper"};window.uninstallWhisper=async function(){if(!!confirm("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 x(){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 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()})}}x(); diff --git a/frontend/dist/index.html b/frontend/dist/index.html index 3abae24..1ea1435 100644 --- a/frontend/dist/index.html +++ b/frontend/dist/index.html @@ -6,7 +6,7 @@ Wisp Settings - + @@ -56,6 +56,7 @@

GNOME: Settings -> Keyboard -> Custom Shortcuts -> Add a shortcut with the copied command.

KDE: System Settings -> Shortcuts -> Command/URL -> Add a shortcut with the copied command.

+ diff --git a/frontend/index.html b/frontend/index.html index f8ac21c..ed25ab4 100644 --- a/frontend/index.html +++ b/frontend/index.html @@ -54,6 +54,7 @@

GNOME: Settings -> Keyboard -> Custom Shortcuts -> Add a shortcut with the copied command.

KDE: System Settings -> Shortcuts -> Command/URL -> Add a shortcut with the copied command.

+ @@ -318,6 +319,37 @@ }); } + function renderLinuxYdotoolStatus(status) { + const el = document.getElementById('linuxYdotoolStatus'); + if (!el || !status) return; + + const ready = !!status.ready; + el.style.display = 'block'; + el.style.background = ready ? 'var(--green-dim)' : 'var(--red-dim)'; + el.style.color = ready ? 'var(--green)' : 'var(--red)'; + el.style.border = ready ? '1px solid rgba(61, 186, 110, 0.25)' : '1px solid rgba(224, 82, 82, 0.25)'; + el.innerHTML = ''; + + const title = document.createElement('div'); + title.style.fontWeight = '600'; + title.textContent = ready ? 'Automatic paste ready' : 'Automatic paste needs ydotool setup'; + el.appendChild(title); + + const message = document.createElement('div'); + message.style.marginTop = '4px'; + message.textContent = status.message || ''; + el.appendChild(message); + + if (!ready && Array.isArray(status.setup_commands) && status.setup_commands.length) { + const pre = document.createElement('pre'); + pre.style.whiteSpace = 'pre-wrap'; + pre.style.margin = '8px 0 0'; + pre.style.color = 'inherit'; + pre.textContent = status.setup_commands.join('\n'); + el.appendChild(pre); + } + } + async function refreshHistoryFromBackend() { try { const settings = await window.go.main.App.GetSettings(); @@ -342,6 +374,7 @@ const cmdInput = document.getElementById('linuxPressCommand'); if (section) section.style.display = 'block'; if (cmdInput) cmdInput.value = settings.linux_press_command || ''; + renderLinuxYdotoolStatus(settings.linux_ydotool_status); } document.getElementById('whisperModel').value = settings.whisper_model || 'whisper-large-v3-turbo'; document.getElementById('aiModel').value = settings.ai_model || 'llama-3.3-70b-versatile'; diff --git a/scripts/setup-ydotool.sh b/scripts/setup-ydotool.sh new file mode 100644 index 0000000..f8cfab7 --- /dev/null +++ b/scripts/setup-ydotool.sh @@ -0,0 +1,110 @@ +#!/bin/bash + +# Exit on error +set -e + +echo "==========================================================" +echo " ydotool Wayland Auto-Configuration Utility" +echo "==========================================================" +echo "" + +# Function to check if a command exists +command_exists() { + command -v "$1" >/dev/null 2>&1 +} + +# 1. Package Installation +echo "[1/4] Checking and installing ydotool..." +if ! command_exists ydotool; then + if command_exists dnf; then + echo "Fedora detected. Installing ydotool via dnf..." + sudo dnf install -y ydotool + elif command_exists apt-get; then + echo "Debian/Ubuntu detected. Installing ydotool via apt..." + sudo apt-get update && sudo apt-get install -y ydotool + elif command_exists pacman; then + echo "Arch Linux detected. Installing ydotool via pacman..." + sudo pacman -S --noconfirm ydotool + else + echo "ERROR: Unsupported package manager. Please install 'ydotool' manually, then run this script again." + exit 1 + fi +else + echo "ydotool is already installed." +fi + +# 2. Configure Non-Root /dev/uinput Access via udev +echo "[2/4] Configuring non-root udev rule for /dev/uinput..." +sudo tee /etc/udev/rules.d/80-uinput.rules << 'EOF' > /dev/null +KERNEL=="uinput", SUBSYSTEM=="misc", TAG+="uaccess", OPTIONS+="static_node=uinput" +EOF + +echo "Reloading udev rules..." +sudo udevadm control --reload-rules && sudo udevadm trigger + +# 3. Locate the Executable and Setup Systemd User Service +echo "[3/4] Designing systemd user-level service..." + +# Dynamically locate the binary path to support varying distributions (/usr/bin vs /usr/local/bin) +YDOTOOLD_PATH=$(command -v ydotoold || true) +if [ -z "$YDOTOOLD_PATH" ]; then + YDOTOOLD_PATH="/usr/bin/ydotoold" +fi + +mkdir -p "$HOME/.config/systemd/user" +tee "$HOME/.config/systemd/user/ydotool.service" << EOF > /dev/null +[Unit] +Description=ydotoold key emulation daemon for non-root user + +[Service] +Type=simple +ExecStart=$YDOTOOLD_PATH +Restart=always + +[Install] +WantedBy=default.target +EOF + +# 4. Activate User-Level Daemon +echo "[4/4] Starting ydotool daemon for user: $USER (UID: $(id -u))..." + +# Disable 'exit on error' temporarily in case systemd user-manager lacks session initialization +set +e +systemctl --user daemon-reload +systemctl --user enable ydotool.service +systemctl --user start ydotool.service +set -e + +# 5. Post-Configuration Diagnostics +echo "" +echo "==========================================================" +echo " Post-Installation Diagnostics" +echo "==========================================================" +echo "Giving the daemon a moment to spin up..." +sleep 2 + +# Check if daemon is active +if systemctl --user is-active --quiet ydotool.service; then + echo "[-] Daemon Status: Active (Running)" +else + echo "[!] Daemon Status: Warning! The daemon failed to start." + echo " Check logs using: journalctl --user -u ydotool.service" +fi + +# Check for socket existence +SOCKET_PATH="/run/user/$(id -u)/.ydotool_socket" +if [ -S "$SOCKET_PATH" ]; then + echo "[-] Socket Location: Found at $SOCKET_PATH" + echo "" + echo "SUCCESS: Configuration completed." + echo "You can now run your Go/Wails application, and paste operations" + echo "will route seamlessly through ydotool." +else + echo "[!] Socket Location: Missing at $SOCKET_PATH" + echo "" + echo "WARNING: The setup ran but the communications socket is absent." + echo "This is typically caused by /dev/uinput permissions not applying yet." + echo "Please log out of your desktop session and log back in, or run:" + echo " systemctl --user restart ydotool.service" +fi +echo "==========================================================" diff --git a/text_insert_linux.go b/text_insert_linux.go index 4cab0a8..7c507aa 100644 --- a/text_insert_linux.go +++ b/text_insert_linux.go @@ -6,7 +6,9 @@ import ( "context" "errors" "fmt" + "os" "os/exec" + "path/filepath" "strings" "time" "unicode/utf8" @@ -21,19 +23,22 @@ func (a *App) insertTranscription(text string) { logger.Error("Failed to copy transcription to clipboard: %v", err) } else { waitForLinuxClipboardText(a.ctx, text) - if typer, err := pasteLinuxClipboardWithoutXTest(); err == nil { - logger.Info("Pasted transcription on Linux using %s (%d chars)", typer, utf8.RuneCountInString(text)) + a.releaseLinuxInputFocus() + if err := pasteLinuxClipboardWithYdotool(); err == nil { + logger.Info("Pasted transcription on Linux using ydotool (%d chars)", utf8.RuneCountInString(text)) return } else { - logger.Error("Linux auto-paste unavailable without XTEST: %v", err) + logger.Error("Linux auto-paste unavailable via ydotool: %v", err) } } - if typer, err := typeLinuxTextWithoutXTest(text); err == nil { - logger.Info("Typed transcription on Linux using %s (%d chars)", typer, utf8.RuneCountInString(text)) + if !isASCII(text) { + logger.Info("Skipping ydotool direct typing fallback for non-ASCII transcript") + } else if err := typeLinuxTextWithYdotool(text); err == nil { + logger.Info("Typed transcription on Linux using ydotool (%d chars)", utf8.RuneCountInString(text)) return } else { - logger.Error("Linux auto-type unavailable without XTEST: %v", err) + logger.Error("Linux auto-type unavailable via ydotool: %v", err) } logger.Info("Copied transcription to clipboard; install ydotool with ydotoold/uinput access for automatic paste on GNOME Wayland") @@ -42,6 +47,24 @@ func (a *App) insertTranscription(text string) { } } +func (a *App) releaseLinuxInputFocus() { + if a.ctx == nil { + time.Sleep(150 * time.Millisecond) + return + } + wailsruntime.WindowHide(a.ctx) + time.Sleep(250 * time.Millisecond) +} + +func isASCII(text string) bool { + for _, r := range text { + if r > 127 { + return false + } + } + return true +} + func waitForLinuxClipboardText(ctx context.Context, text string) { deadline := time.Now().Add(700 * time.Millisecond) for time.Now().Before(deadline) { @@ -53,63 +76,102 @@ func waitForLinuxClipboardText(ctx context.Context, text string) { } } -func pasteLinuxClipboardWithoutXTest() (string, error) { +func pasteLinuxClipboardWithYdotool() error { time.Sleep(150 * time.Millisecond) - var errs []string - - if path, err := exec.LookPath("ydotool"); err == nil { - if err := runLinuxInputCommand(path, []string{"key", "-d", "20", "29:1", "47:1", "47:0", "29:0"}, "", 3*time.Second); err == nil { - return "ydotool", nil - } else { - errs = append(errs, "ydotool: "+err.Error()) - } + path, socketPath, err := getYdotoolCommand() + if err != nil { + return err } - if path, err := exec.LookPath("wtype"); err == nil { - if err := runLinuxInputCommand(path, []string{"-M", "ctrl", "v", "-m", "ctrl"}, "", 3*time.Second); err == nil { - return "wtype", nil - } else { - errs = append(errs, "wtype: "+err.Error()) - } - } - - if len(errs) == 0 { - return "", errors.New("install ydotool with ydotoold/uinput access for GNOME Wayland auto-paste") - } - return "", errors.New(strings.Join(errs, "; ")) + return runLinuxInputCommand(path, []string{"key", "-d", "20", "29:1", "47:1", "47:0", "29:0"}, "", 3*time.Second, socketPath) } -func typeLinuxTextWithoutXTest(text string) (string, error) { - var errs []string - - if path, err := exec.LookPath("ydotool"); err == nil { - if err := runLinuxInputCommand(path, []string{"type", "--file", "-"}, text, linuxTextTyperTimeout(text)); err == nil { - return "ydotool", nil - } else { - errs = append(errs, "ydotool: "+err.Error()) - } +func typeLinuxTextWithYdotool(text string) error { + path, socketPath, err := getYdotoolCommand() + if err != nil { + return err } - if path, err := exec.LookPath("wtype"); err == nil { - if err := runLinuxInputCommand(path, []string{"-"}, text, linuxTextTyperTimeout(text)); err == nil { - return "wtype", nil - } else { - errs = append(errs, "wtype: "+err.Error()) - } - } - - if len(errs) == 0 { - return "", errors.New("install ydotool with ydotoold/uinput access for GNOME Wayland auto-typing") - } - return "", errors.New(strings.Join(errs, "; ")) + return runLinuxInputCommand(path, []string{"type", "--file", "-"}, text, linuxTextTyperTimeout(text), socketPath) } -func runLinuxInputCommand(path string, args []string, stdin string, timeout time.Duration) error { +func getYdotoolCommand() (string, string, error) { + path, err := exec.LookPath("ydotool") + if err != nil { + return "", "", errors.New("ydotool not found; install ydotool and start the ydotool user service") + } + + socketPath, err := getYdotoolSocketPath() + if err != nil { + return "", "", err + } + + return path, socketPath, nil +} + +func linuxYdotoolStatus() map[string]interface{} { + status := map[string]interface{}{ + "ready": false, + "installed": false, + "socket": false, + "socket_path": "", + "message": "", + "setup_commands": []string{ + "sudo dnf install ydotool", + "echo 'KERNEL==\"uinput\", SUBSYSTEM==\"misc\", TAG+=\"uaccess\", OPTIONS+=\"static_node=uinput\"' | sudo tee /etc/udev/rules.d/80-uinput.rules", + "sudo udevadm control --reload-rules && sudo udevadm trigger", + "systemctl --user enable --now ydotool.service", + }, + } + + path, err := exec.LookPath("ydotool") + if err != nil { + status["message"] = "ydotool is not installed." + return status + } + status["installed"] = true + + socketPath, err := getYdotoolSocketPath() + if err != nil { + status["message"] = err.Error() + return status + } + status["socket"] = true + status["socket_path"] = socketPath + + if err := runLinuxInputCommand(path, []string{"key", "-d", "1", "0"}, "", 800*time.Millisecond, socketPath); err != nil { + status["message"] = "ydotool is installed, but the daemon test failed: " + err.Error() + return status + } + + status["ready"] = true + status["message"] = "ydotool is ready for automatic paste." + return status +} + +func getYdotoolSocketPath() (string, error) { + if socketPath := strings.TrimSpace(os.Getenv("YDOTOOL_SOCKET")); socketPath != "" { + if _, err := os.Stat(socketPath); err == nil { + return socketPath, nil + } + return "", fmt.Errorf("YDOTOOL_SOCKET is set but not accessible: %s", socketPath) + } + + socketPath := filepath.Join("/run/user", fmt.Sprintf("%d", os.Getuid()), ".ydotool_socket") + if _, err := os.Stat(socketPath); err != nil { + return "", fmt.Errorf("ydotoold socket not found at %s; run `systemctl --user start ydotool.service` after configuring /dev/uinput permissions", socketPath) + } + + return socketPath, nil +} + +func runLinuxInputCommand(path string, args []string, stdin string, timeout time.Duration, socketPath string) error { ctx, cancel := context.WithTimeout(context.Background(), timeout) defer cancel() cmd := exec.CommandContext(ctx, path, args...) + cmd.Env = append(os.Environ(), "YDOTOOL_SOCKET="+socketPath) if stdin != "" { cmd.Stdin = strings.NewReader(stdin) } diff --git a/text_insert_status_nonlinux.go b/text_insert_status_nonlinux.go new file mode 100644 index 0000000..8c23e2f --- /dev/null +++ b/text_insert_status_nonlinux.go @@ -0,0 +1,13 @@ +//go:build !linux + +package main + +func linuxYdotoolStatus() map[string]interface{} { + return map[string]interface{}{ + "ready": false, + "installed": false, + "socket": false, + "socket_path": "", + "message": "ydotool is only used on Linux.", + } +}