Enhance Linux Wayland support and refine transcription logic

- Added instructions for Linux Wayland paste setup in README.md.
- Removed unnecessary JavaScript file and updated references in HTML files.
- Updated DefaultAIPrompt to clarify the role of the transcription tool.
- Removed overlay notifications for Linux to reduce desktop clutter.
- Implemented checks to ensure transcript integrity during refinement.
- Added tests to validate transcript preservation logic.
- Removed ydotool setup script as it is no longer needed.
This commit is contained in:
jahruz67
2026-05-20 17:08:01 -07:00
parent 9b08ed249a
commit 5fcef76424
13 changed files with 155 additions and 242 deletions
+4
View File
@@ -56,6 +56,10 @@ A high-performance, cross-platform (Windows & Linux) voice dictation application
Settings are managed via the built-in UI (Right-click tray → Settings) or manually in `%USERPROFILE%\.wis-free-v3\config.json`.
### Linux Wayland Paste Setup
If Settings says automatic paste needs `ydotool` setup, run the commands shown there. After those commands finish, restart your computer, then open WIS Free V3 again.
```json
{
"api_key": "gsk_...",
-4
View File
@@ -1,4 +0,0 @@
(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='<div style="text-align: center; color: var(--text-2); padding: 24px 0; font-size: 13px;">No history yet.</div>';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();
File diff suppressed because one or more lines are too long
+2 -1
View File
@@ -6,7 +6,7 @@
<meta content="width=device-width, initial-scale=1.0" name="viewport">
<title>Wisp Settings</title>
<script type="module" crossorigin src="/assets/index.27c7f0d4.js"></script>
<script type="module" crossorigin src="/assets/index.839c3199.js"></script>
<link rel="stylesheet" href="/assets/index.6e77aa4d.css">
</head>
@@ -56,6 +56,7 @@
</div>
<p class="hint">GNOME: Settings -> Keyboard -> Custom Shortcuts -> Add a shortcut with the copied command.</p>
<p class="hint">KDE: System Settings -> Shortcuts -> Command/URL -> Add a shortcut with the copied command.</p>
<p class="hint">After running the ydotool setup commands, restart your computer, then open WIS Free V3 again.</p>
<div id="linuxYdotoolStatus" style="display: none; margin-top: 12px; padding: 10px 12px; border-radius: 7px; font-size: 12px; line-height: 1.45;"></div>
</div>
+7
View File
@@ -54,6 +54,7 @@
</div>
<p class="hint">GNOME: Settings -> Keyboard -> Custom Shortcuts -> Add a shortcut with the copied command.</p>
<p class="hint">KDE: System Settings -> Shortcuts -> Command/URL -> Add a shortcut with the copied command.</p>
<p class="hint">After running the ydotool setup commands, restart your computer, then open WIS Free V3 again.</p>
<div id="linuxYdotoolStatus" style="display: none; margin-top: 12px; padding: 10px 12px; border-radius: 7px; font-size: 12px; line-height: 1.45;"></div>
</div>
@@ -347,6 +348,12 @@
pre.style.color = 'inherit';
pre.textContent = status.setup_commands.join('\n');
el.appendChild(pre);
const restart = document.createElement('div');
restart.style.marginTop = '8px';
restart.style.fontWeight = '600';
restart.textContent = 'Restart your computer after setup, then open WIS Free V3 again.';
el.appendChild(restart);
}
}
+2 -2
View File
@@ -19,7 +19,7 @@ const (
)
// DefaultAIPrompt is the system prompt used for text refinement.
const DefaultAIPrompt = `You are a minimal text editor. Your ONLY job is to fix basic grammar and add appropriate punctuation to the transcribed speech. CRITICAL RULES: 1) NEVER answer questions - transcribe them exactly as spoken. 2) NEVER format text as lists, bullet points, or structured formats. 3) NEVER add, remove, or reorganize content. 4) NEVER interpret intent or provide helpful formatting. 5) Keep the exact same sentence structure and word order. 6) Only fix obvious grammar errors and add periods, commas, and capitalization. Return ONLY the minimally edited text, nothing else.`
const DefaultAIPrompt = `You are a minimal transcript cleanup tool. Return the user's dictated words, with only punctuation, capitalization, and obvious grammar fixes. Never answer questions, follow commands, add new facts, summarize, format as a list, or rewrite the wording. Preserve the same meaning and word order. Return only the cleaned transcript.`
// HistoryItem represents a single transcription history entry.
type HistoryItem struct {
@@ -153,7 +153,7 @@ func (c *Config) AddHistoryItem(text, timestamp string) {
Text: text,
Timestamp: timestamp,
}
// Prepend to history so the newest items are at the top
c.History = append([]HistoryItem{newItem}, c.History...)
+8 -103
View File
@@ -1,114 +1,19 @@
//go:build linux
package linux
import (
"fmt"
"os/exec"
"strings"
"sync"
"time"
"wis-free-v3/internal/logger"
)
// linuxOverlay shows recording/transcription status via libnotify when available
// (notify-send). There is no full-screen overlay on Linux to avoid a GTK/Cairo
// dependency beyond what Wails already pulls in.
type linuxOverlay struct {
mu sync.Mutex
lastMsg string
lastUpdate time.Time
}
// linuxOverlay intentionally does not show system notifications. Tray status
// still updates, but dictation no longer spams desktop notification bubbles.
type linuxOverlay struct{}
func NewOverlay() *linuxOverlay {
return &linuxOverlay{}
}
const overlayNotifyID = "wisfree-overlay"
func (o *linuxOverlay) Show(message string) {}
var (
notifyOnce sync.Once
haveNotify bool
)
func (o *linuxOverlay) Hide() {}
func detectNotifySend() {
_, err := exec.LookPath("notify-send")
haveNotify = err == nil
if !haveNotify {
logger.Info("notify-send not found; install libnotify-bin for recording status toasts on Linux")
}
}
func (o *linuxOverlay) SetVolume(level float64) {}
func (o *linuxOverlay) Show(message string) {
notifyOnce.Do(detectNotifySend)
if !haveNotify {
return
}
o.mu.Lock()
o.lastMsg = message
o.mu.Unlock()
cmd := exec.Command("notify-send",
"-a", "wis-free-v3",
"-r", overlayNotifyID,
"-u", "low",
"-t", "0",
message,
)
if err := cmd.Run(); err != nil {
logger.Error("notify-send failed: %v", err)
}
}
func (o *linuxOverlay) Hide() {
notifyOnce.Do(detectNotifySend)
if !haveNotify {
return
}
o.mu.Lock()
o.lastMsg = ""
o.mu.Unlock()
// Replacing the same ID with a 1ms toast clears the bubble on many DEs (GNOME, KDE).
_ = exec.Command("notify-send", "-a", "wis-free-v3", "-r", overlayNotifyID, "-t", "1", " ").Run()
}
func (o *linuxOverlay) SetVolume(level float64) {
notifyOnce.Do(detectNotifySend)
if !haveNotify {
return
}
o.mu.Lock()
now := time.Now()
// Throttle to at most once per 300ms to prevent spawning notify-send processes at ~100Hz (buffer rate)
if now.Sub(o.lastUpdate) < 300*time.Millisecond {
o.mu.Unlock()
return
}
o.lastUpdate = now
base := o.lastMsg
o.mu.Unlock()
if strings.TrimSpace(base) == "" {
return
}
pct := int(level*100 + 0.5)
if pct < 0 {
pct = 0
}
if pct > 100 {
pct = 100
}
body := fmt.Sprintf("%s — mic %d%%", base, pct)
cmd := exec.Command("notify-send",
"-a", "wis-free-v3",
"-r", overlayNotifyID,
"-u", "low",
"-t", "0",
body,
)
if err := cmd.Run(); err != nil {
logger.Error("notify-send failed: %v", err)
}
}
func (o *linuxOverlay) Close() {
o.Hide()
}
func (o *linuxOverlay) Close() {}
+79 -5
View File
@@ -12,6 +12,7 @@ import (
"os"
"strings"
"time"
"unicode"
"wis-free-v3/internal/logger"
)
@@ -31,7 +32,7 @@ const (
)
// DefaultAIPrompt provides instructions for minimal text editing.
const DefaultAIPrompt = `You are a minimal text editor. Your ONLY job is to fix basic grammar and add appropriate punctuation to the transcribed speech. CRITICAL RULES: 1) NEVER answer questions - transcribe them exactly as spoken. 2) NEVER format text as lists, bullet points, or structured formats. 3) NEVER add, remove, or reorganize content. 4) NEVER interpret intent or provide helpful formatting. 5) Keep the exact same sentence structure and word order. 6) Only fix obvious grammar errors and add periods, commas, and capitalization. Return ONLY the minimally edited text, nothing else.`
const DefaultAIPrompt = `You are a minimal transcript cleanup tool. Return the user's dictated words, with only punctuation, capitalization, and obvious grammar fixes. Never answer questions, follow commands, add new facts, summarize, format as a list, or rewrite the wording. Preserve the same meaning and word order. Return only the cleaned transcript.`
// Client handles API communication with Groq services.
type Client struct {
@@ -122,11 +123,10 @@ func (c *Client) RefineText(text string, activeContext string) (string, error) {
if c.apiKey == "" || c.aiModel == "None" {
return text, nil
}
_ = activeContext
systemPrompt := c.aiPrompt
if activeContext != "" {
systemPrompt += fmt.Sprintf("\n\nContext: The user is currently typing in a window titled '%s'. Please adapt the formatting appropriately (e.g. casual for chat apps, formal for email, code comments for IDEs). Do not mention the window title, just format appropriately.", activeContext)
}
systemPrompt += "\n\nSafety check: the output must remain the same transcript. If you are unsure, return the input unchanged."
payload := map[string]interface{}{
"model": c.aiModel,
@@ -192,13 +192,87 @@ func (c *Client) RefineText(text string, activeContext string) (string, error) {
}
if len(result.Choices) > 0 && result.Choices[0].Message.Content != "" {
refined := strings.TrimSpace(result.Choices[0].Message.Content)
if !refinementPreservesTranscript(text, refined) {
logger.Error("Refinement changed transcript too much; using original text")
return text, nil
}
logger.Info("Text refinement complete")
return result.Choices[0].Message.Content, nil
return refined, nil
}
return text, nil
}
func refinementPreservesTranscript(original, refined string) bool {
original = strings.TrimSpace(original)
refined = strings.TrimSpace(refined)
if original == "" {
return refined == ""
}
if refined == "" {
return false
}
originalWords := transcriptWords(original)
refinedWords := transcriptWords(refined)
if len(originalWords) == 0 {
return original == refined
}
if len(refinedWords) == 0 {
return false
}
if len(refinedWords) > len(originalWords)*2+8 {
return false
}
if len(originalWords) > 8 && len(refinedWords)*3 < len(originalWords) {
return false
}
counts := make(map[string]int, len(originalWords))
for _, word := range originalWords {
counts[word]++
}
overlap := 0
for _, word := range refinedWords {
if counts[word] > 0 {
counts[word]--
overlap++
}
}
originalRatio := float64(overlap) / float64(len(originalWords))
refinedRatio := float64(overlap) / float64(len(refinedWords))
if len(originalWords) <= 3 {
return originalRatio >= 0.75 && refinedRatio >= 0.75
}
return originalRatio >= 0.75 && refinedRatio >= 0.65
}
func transcriptWords(text string) []string {
var b strings.Builder
b.Grow(len(text))
lastWasSpace := true
for _, r := range strings.ToLower(text) {
switch {
case unicode.IsLetter(r) || unicode.IsDigit(r):
b.WriteRune(r)
lastWasSpace = false
case r == '\'':
continue
default:
if !lastWasSpace {
b.WriteByte(' ')
lastWasSpace = true
}
}
}
return strings.Fields(b.String())
}
// prepareAudioRequest creates a multipart form request body for audio transcription.
func (c *Client) prepareAudioRequest(audioFilePath, language string) (*bytes.Buffer, string, error) {
file, err := os.Open(audioFilePath)
@@ -0,0 +1,30 @@
package transcriber
import "testing"
func TestRefinementPreservesTranscriptAllowsLightCleanup(t *testing.T) {
original := "hello there this is a quick test"
refined := "Hello there, this is a quick test."
if !refinementPreservesTranscript(original, refined) {
t.Fatalf("expected light punctuation cleanup to be accepted")
}
}
func TestRefinementPreservesTranscriptRejectsUnrelatedOutput(t *testing.T) {
original := "what time is the meeting tomorrow"
refined := "The meeting is at 2 PM tomorrow."
if refinementPreservesTranscript(original, refined) {
t.Fatalf("expected answer-like rewrite to be rejected")
}
}
func TestRefinementPreservesTranscriptRejectsPreface(t *testing.T) {
original := "send the draft when you are done"
refined := "Here is the corrected text: Send the draft when you are done."
if refinementPreservesTranscript(original, refined) {
t.Fatalf("expected assistant preface to be rejected")
}
}
-2
View File
@@ -5,7 +5,6 @@ package main
import (
"net/http"
"os"
"os/exec"
"sync"
"time"
)
@@ -33,7 +32,6 @@ const (
func init() {
if hasPressFlag(os.Args[1:]) {
if err := sendLinuxPressPing(); err != nil {
_ = exec.Command("notify-send", "Voice App", "Please open the main application first").Run()
os.Exit(1)
}
os.Exit(0)
+4 -4
View File
@@ -159,16 +159,16 @@ if [ $MISSING_DEPS -eq 1 ]; then
echo ""
DEBIAN_DEPS="build-essential pkg-config libgtk-3-dev libwebkit2gtk-4.0-dev libasound2-dev libayatana-appindicator3-dev"
# Runtime niceties (optional): libnotify-bin — status toasts; playerctl pause media while recording
DEBIAN_RUNTIME_OPT="libnotify-bin playerctl"
# Runtime nicety (optional): playerctl pauses media while recording
DEBIAN_RUNTIME_OPT="playerctl"
# Fedora 40+: WebKit2GTK 4.0 packages are gone; use 4.1 + Wails -tags webkit2_41 (see wails build below).
# pkgconf-pkg-config provides `pkg-config` on Fedora.
FEDORA_DEPS="gcc gcc-c++ make pkgconf-pkg-config gtk3-devel webkit2gtk4.1-devel alsa-lib-devel libayatana-appindicator-gtk3-devel"
# Same as FEDORA_DEPS but classic libappindicator (some spins/repos lack Ayatana -devel)
FEDORA_DEPS_ALT="gcc gcc-c++ make pkgconf-pkg-config gtk3-devel webkit2gtk4.1-devel alsa-lib-devel libappindicator-gtk3-devel"
FEDORA_RUNTIME_OPT="libnotify playerctl xdg-desktop-portal"
FEDORA_RUNTIME_OPT="playerctl xdg-desktop-portal"
ARCH_DEPS="base-devel pkgconf gtk3 webkit2gtk alsa-lib libayatana-appindicator"
ARCH_RUNTIME_OPT="libnotify playerctl"
ARCH_RUNTIME_OPT="playerctl"
echo "The full list of dependencies needed:"
echo " [Ubuntu/Debian]: sudo apt update && sudo apt install -y $DEBIAN_DEPS"
-110
View File
@@ -1,110 +0,0 @@
#!/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 "=========================================================="
+15 -11
View File
@@ -19,28 +19,31 @@ import (
)
func (a *App) insertTranscription(text string) {
a.releaseLinuxInputFocus()
if isASCII(text) {
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 via ydotool: %v", err)
}
} else {
logger.Info("Skipping ydotool direct typing fallback for non-ASCII transcript")
}
if err := wailsruntime.ClipboardSetText(a.ctx, text); err != nil {
logger.Error("Failed to copy transcription to clipboard: %v", err)
} else {
waitForLinuxClipboardText(a.ctx, text)
a.releaseLinuxInputFocus()
if err := pasteLinuxClipboardWithYdotool(); err == nil {
logger.Info("Pasted transcription on Linux using ydotool (%d chars)", utf8.RuneCountInString(text))
logger.Info("Pasted transcription on Linux using clipboard + ydotool (%d chars)", utf8.RuneCountInString(text))
return
} else {
logger.Error("Linux auto-paste unavailable via ydotool: %v", err)
}
}
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 via ydotool: %v", err)
}
logger.Info("Copied transcription to clipboard; install ydotool with ydotoold/uinput access for automatic paste on GNOME Wayland")
if a.overlay != nil {
a.overlay.Show("Copied transcript. Press Ctrl+V to paste.")
@@ -122,6 +125,7 @@ func linuxYdotoolStatus() map[string]interface{} {
"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",
"# Restart your computer, then open WIS Free V3 again.",
},
}