From d4a6489ded34c7788d695806fccd1384fd5347a2 Mon Sep 17 00:00:00 2001 From: Nutchanon Ninyawee Date: Fri, 19 Jun 2026 10:33:46 +0700 Subject: [PATCH 1/7] feat(mic-osd): replace recording dot with elapsed/processing timers Remove the pulsing red recording indicator and reclaim its 30px so the waveform bars fill the panel. Show recording duration (MM:SS) while recording/paused and transcription time (X.Xs) while processing, frozen on the brief success/error result. Also force the overlay hidden at startup: if a previous session was SIGKILLed mid-recording, the reused orphaned daemon would otherwise stay stuck visible until the next recording. Co-Authored-By: Claude Opus 4.8 (1M context) Claude-Session: https://claude.ai/code/session_01CZT7DuDus2VDvQKiGoqYcd --- lib/main.py | 8 ++ lib/mic_osd/visualizations/waveform.py | 189 +++++++++++-------------- 2 files changed, 93 insertions(+), 104 deletions(-) diff --git a/lib/main.py b/lib/main.py index e6ad0220..dbf92422 100644 --- a/lib/main.py +++ b/lib/main.py @@ -240,6 +240,14 @@ def _use_notification_status(reason: str): runner = MicOSDRunner() if runner._ensure_daemon(): # Start daemon now self._mic_osd_runner = runner + # Force the overlay hidden at startup. If we reused an + # orphaned daemon that a previous (crashed/SIGKILLed) + # session left visible mid-recording, it would otherwise + # stay stuck on screen until the first new recording. + try: + runner.hide() + except Exception: + pass print("[INIT] Mic-OSD daemon started", flush=True) else: print("[WARN] Failed to start mic-osd daemon", flush=True) diff --git a/lib/mic_osd/visualizations/waveform.py b/lib/mic_osd/visualizations/waveform.py index 6242ad79..3bd50b40 100644 --- a/lib/mic_osd/visualizations/waveform.py +++ b/lib/mic_osd/visualizations/waveform.py @@ -43,10 +43,12 @@ def __init__(self): # State manager for visualizer states (recording, paused, processing, etc.) self.state_manager = StateManager() - # Elapsed time tracking for long-form mode + # Time tracking: recording elapsed (MM:SS) and transcription time (s) self._recording_start_time = None - self._elapsed_seconds = 0.0 - self._show_elapsed_time = False + self._recording_elapsed = 0.0 + self._processing_start_time = None + self._processing_elapsed = 0.0 + self._timer_mode = None # None | 'recording' | 'processing' def update(self, level: float, samples: np.ndarray = None): """Update with new audio samples.""" @@ -87,19 +89,15 @@ def update(self, level: float, samples: np.ndarray = None): self.state_manager.update() def draw(self, cr: cairo.Context, width: int, height: int): - """Draw the bar visualization with recording indicator.""" + """Draw the bar visualization.""" padding = 16 - - # Recording indicator (just the dot) takes up left side - indicator_width = 30 - bars_start_x = padding + indicator_width + + # Bars fill the full width (the red recording indicator dot was removed). + bars_start_x = padding bars_width = width - bars_start_x - padding bars_height = height - (padding * 2) center_y = height / 2 - - # Draw recording indicator (red dot + "Recording...") - self._draw_recording_indicator(cr, padding, center_y) - + # Calculate bar dimensions to fill available space actual_num_bars = self.num_bars bar_gap = 2 @@ -189,137 +187,120 @@ def draw(self, cr: cairo.Context, width: int, height: int): cr.rectangle(x, bar_top, bar_width, bar_h) cr.fill() - # Draw elapsed time (for long-form mode) - self._draw_elapsed_time(cr, width, height) + # Draw recording elapsed / transcription time + self._draw_timer(cr, width, height) def set_state(self, state_str: str): - """Set the visualizer state from a string value.""" + """Set the visualizer state and drive the recording/processing timers.""" self.state_manager.set_state_from_string(state_str) - # Start/stop elapsed time tracking based on state + now = time.time() + prev_mode = self._timer_mode + if state_str == 'recording': - if self._recording_start_time is None: - self._recording_start_time = time.time() - self._show_elapsed_time = True + # A new recording session begins whenever we enter recording from a + # non-recording state (idle, processing, result) — reset the timers. + if prev_mode != 'recording': + self._recording_elapsed = 0.0 + self._processing_elapsed = 0.0 + self._processing_start_time = None + self._recording_start_time = now + elif self._recording_start_time is None: + self._recording_start_time = now + self._timer_mode = 'recording' elif state_str == 'paused': - # Keep showing elapsed time but don't increment + # Freeze the recording timer but keep it on screen. + if self._recording_start_time is not None: + self._recording_elapsed += now - self._recording_start_time + self._recording_start_time = None + self._timer_mode = 'recording' + elif state_str == 'processing': + # Stop the recording timer, start timing the transcription. if self._recording_start_time is not None: - self._elapsed_seconds += time.time() - self._recording_start_time + self._recording_elapsed += now - self._recording_start_time self._recording_start_time = None - self._show_elapsed_time = True + self._processing_start_time = now + self._processing_elapsed = 0.0 + self._timer_mode = 'processing' + elif state_str in ('success', 'error'): + # Freeze the processing time so the brief result display keeps it. + if self._processing_start_time is not None: + self._processing_elapsed = now - self._processing_start_time + self._processing_start_time = None + self._recording_start_time = None + self._timer_mode = 'processing' if self._processing_elapsed > 0 else None else: - # Reset elapsed time for other states self._recording_start_time = None - self._elapsed_seconds = 0.0 - self._show_elapsed_time = False - - def set_elapsed_time(self, seconds: float): - """Set the elapsed time directly (for long-form mode).""" - self._elapsed_seconds = seconds - self._show_elapsed_time = True + self._processing_start_time = None + self._timer_mode = None - def _get_elapsed_seconds(self) -> float: - """Get current elapsed time in seconds.""" - if self._recording_start_time is not None: - return self._elapsed_seconds + (time.time() - self._recording_start_time) - return self._elapsed_seconds + def _timer_text(self) -> str: + """Current timer string, or '' when no timer should be shown.""" + if self._timer_mode == 'recording': + seconds = self._recording_elapsed + if self._recording_start_time is not None: + seconds += time.time() - self._recording_start_time + return self._format_mmss(seconds) + if self._timer_mode == 'processing': + seconds = self._processing_elapsed + if self._processing_start_time is not None: + seconds += time.time() - self._processing_start_time + return self._format_processing(seconds) + return "" - def _format_elapsed_time(self, seconds: float) -> str: + @staticmethod + def _format_mmss(seconds: float) -> str: """Format seconds as MM:SS.""" - minutes = int(seconds) // 60 - secs = int(seconds) % 60 - return f"{minutes:02d}:{secs:02d}" + total = int(seconds) + return f"{total // 60:02d}:{total % 60:02d}" - def _draw_elapsed_time(self, cr: cairo.Context, width: int, height: int): - """Draw elapsed time in the bottom-right corner.""" - if not self._show_elapsed_time: - return + @staticmethod + def _format_processing(seconds: float) -> str: + """Format transcription time: '1.2s' under a minute, else MM:SS.""" + if seconds < 60: + return f"{seconds:.1f}s" + total = int(seconds) + return f"{total // 60:02d}:{total % 60:02d}" - elapsed = self._get_elapsed_seconds() - text = self._format_elapsed_time(elapsed) + def _draw_timer(self, cr: cairo.Context, width: int, height: int): + """Draw the recording/processing timer in the bottom-right corner.""" + text = self._timer_text() + if not text: + return - # Set font (monospace for consistent width) - cr.select_font_face( - "monospace", - cairo.FONT_SLANT_NORMAL, - cairo.FONT_WEIGHT_NORMAL - ) + # Monospace so the digits don't jitter as they tick. + cr.select_font_face("monospace", cairo.FONT_SLANT_NORMAL, cairo.FONT_WEIGHT_NORMAL) cr.set_font_size(11) - # Measure text extents = cr.text_extents(text) text_width = extents.width text_height = extents.height - # Position: bottom-right with padding + # Position: bottom-right with padding. padding = 10 x = width - text_width - padding y = height - padding - # Draw background using theme background color (harmonized with bars) + # Background chip harmonized with the panel. bg_padding = 3 bg_color = theme.background - # Use theme background with slightly higher opacity for better visibility - if len(bg_color) == 4: - bg_alpha = bg_color[3] * 0.9 # Slightly more opaque than main background - else: - bg_alpha = 0.9 + bg_alpha = bg_color[3] * 0.9 if len(bg_color) == 4 else 0.9 cr.set_source_rgba( bg_color[0] if len(bg_color) >= 1 else 0.1, bg_color[1] if len(bg_color) >= 2 else 0.1, bg_color[2] if len(bg_color) >= 3 else 0.15, - bg_alpha + bg_alpha, ) cr.rectangle( x - bg_padding, y - text_height - bg_padding, text_width + bg_padding * 2, - text_height + bg_padding * 2 + text_height + bg_padding * 2, ) cr.fill() - # Draw text using bar colors (interpolated toward right/end for harmony) - # Use the right bar color (green) as it's at the end where timer is - bar_right = theme.bar_right - cr.set_source_rgba( - bar_right[0], - bar_right[1], - bar_right[2], - 0.95 # High opacity for good readability - ) + # Neutral text colour (no red), readable on the dark panel. + text_color = theme.text + cr.set_source_rgba(text_color[0], text_color[1], text_color[2], 0.95) cr.move_to(x, y) cr.show_text(text) - - def _draw_recording_indicator(self, cr: cairo.Context, x: float, center_y: float): - """Draw the state indicator dot with state-appropriate color and animation.""" - dot_radius = 6 - dot_x = x + dot_radius + 4 - dot_y = center_y - - # Get animation value and color from state manager - pulse = self.state_manager.get_animation_value() - dot_color = self.state_manager.get_state_color() - - # Skip drawing if animation has faded out completely (e.g., success state after 2s) - if pulse <= 0: - return - - # Draw glow behind dot - cr.set_source_rgba( - dot_color[0], - dot_color[1], - dot_color[2], - 0.3 * pulse - ) - cr.arc(dot_x, dot_y, dot_radius + 3, 0, 2 * math.pi) - cr.fill() - - # Draw main dot - cr.set_source_rgba( - dot_color[0], - dot_color[1], - dot_color[2], - pulse - ) - cr.arc(dot_x, dot_y, dot_radius, 0, 2 * math.pi) - cr.fill() From 31655b31455ea0dc28560a751043dd89dcbdc2e6 Mon Sep 17 00:00:00 2001 From: Nutchanon Ninyawee Date: Mon, 13 Jul 2026 17:31:28 +0700 Subject: [PATCH 2/7] fix: support X11 sessions, not just Wayland - systemd unit's ExecStartPre hard-required a wayland-* socket before starting, so the service refused to run at all under an X11 session (gnome-session-x11.target etc.). Accept an X11 socket (/tmp/.X11-unix/X*) as an alternative readiness signal. - _copy_text_to_clipboard/_restore_clipboard only tried wl-copy when the binary existed on disk, not when a Wayland compositor was actually reachable. On a hybrid X11/Wayland machine (wl-clipboard installed for the Wayland session, but currently running X11) wl-copy exits 1 and the code gave up instead of falling back to pyperclip, unlike _save_clipboard which already had this fallback. - pyperclip's own determine_clipboard() prefers a GObject-Introspection GTK clipboard over xclip/xsel whenever `gi` is importable, and calls gi.require_version('Gtk', '3.0') to get it. mic_osd's layer-shell probe already pins this process's Gtk namespace to 4.0, so pyperclip's request raised "Namespace Gtk is already loaded with version 4.0" the first time the new clipboard fallback path ran. Force the xclip backend up front so pyperclip never touches `gi`. Hotkey capture (evdev/UInput), paste-key injection (ydotool), and window detection (xdotool/xprop fallback) were already compositor-agnostic and needed no changes. Tested end-to-end on GNOME/X11 (Ubuntu 24.04). Co-Authored-By: Claude Sonnet 5 Claude-Session: https://claude.ai/code/session_01Y87A3sADRj7eixj6ubiXnV --- config/systemd/hyprwhspr.service | 2 +- lib/src/text_injector.py | 45 +++++++++++++++++++++++++------- 2 files changed, 37 insertions(+), 10 deletions(-) diff --git a/config/systemd/hyprwhspr.service b/config/systemd/hyprwhspr.service index 35b70127..a1c8c3b7 100644 --- a/config/systemd/hyprwhspr.service +++ b/config/systemd/hyprwhspr.service @@ -13,7 +13,7 @@ After=wireplumber.service [Service] Type=simple -ExecStartPre=/bin/bash -lc 'for i in $(seq 1 60); do ls "$XDG_RUNTIME_DIR"/wayland-* >/dev/null 2>&1 && exit 0; sleep 0.25; done; echo "Wayland socket not found"; exit 1' +ExecStartPre=/bin/bash -lc 'for i in $(seq 1 60); do { ls "$XDG_RUNTIME_DIR"/wayland-* || ls /tmp/.X11-unix/X*; } >/dev/null 2>&1 && exit 0; sleep 0.25; done; echo "No Wayland or X11 display socket found"; exit 1' ExecStart=/usr/lib/hyprwhspr/bin/hyprwhspr ExecStopPost=/bin/bash -c '( pkill -9 -f "hyprwhspr-virtual-keyboard" 2>/dev/null; pkill -9 -f "hyprwhspr-ydotool.sock" 2>/dev/null ) || true' Environment=HYPRWHSPR_ROOT=/usr/lib/hyprwhspr diff --git a/lib/src/text_injector.py b/lib/src/text_injector.py index a5a77c76..0c7b2af1 100644 --- a/lib/src/text_injector.py +++ b/lib/src/text_injector.py @@ -26,6 +26,22 @@ pyperclip = require_package('pyperclip') +# pyperclip's own auto-detection (determine_clipboard(), triggered lazily on first +# copy()/paste()) prefers a GObject-Introspection GTK clipboard whenever `gi` is +# importable, ahead of xclip/xsel — and does `gi.require_version('Gtk', '3.0')` to +# get it. mic_osd's layer-shell availability probe already pins this same process's +# `Gtk` namespace to version 4.0, so pyperclip's lazy GTK3 request then raises +# "Namespace Gtk is already loaded with version 4.0" the first time the clipboard +# fallback path (wl-copy unavailable/failing, e.g. under X11) runs. Forcing a +# subprocess-based backend up front means pyperclip never touches `gi` at all. +try: + if shutil.which('xclip'): + pyperclip.set_clipboard('xclip') + elif shutil.which('xsel'): + pyperclip.set_clipboard('xsel') +except Exception: + pass + DEFAULT_PASTE_KEYCODE = 47 # Linux evdev KEY_V on QWERTY NON_XKB_INPUT_METHOD_LAYOUT = '__non_xkb_input_method__' @@ -869,11 +885,16 @@ def _save_clipboard(self) -> Optional[bytes]: def _copy_text_to_clipboard(self, text: str) -> bool: """Copy text to the clipboard without triggering paste.""" + if shutil.which("wl-copy"): + try: + result = subprocess.run(["wl-copy"], input=text.encode("utf-8"), timeout=2) + if result.returncode == 0: + return True + except Exception: + pass + # Fallback: pyperclip (X11, or wl-copy present but no compositor to reach) try: - if shutil.which("wl-copy"): - subprocess.run(["wl-copy"], input=text.encode("utf-8"), check=True, timeout=2) - else: - pyperclip.copy(text) + pyperclip.copy(text) return True except Exception as e: print(f"ERROR: Clipboard copy failed: {e}") @@ -897,12 +918,18 @@ def _restore(): if current != injected: return + restored = False if shutil.which("wl-copy"): - subprocess.run(["wl-copy"], input=saved, check=True, timeout=2) - else: - # pyperclip is text-only; only restore if the saved bytes are - # valid UTF-8 text. Binary clipboard data (images, etc.) cannot - # be round-tripped through pyperclip without corruption. + try: + result = subprocess.run(["wl-copy"], input=saved, timeout=2) + restored = result.returncode == 0 + except Exception: + restored = False + if not restored: + # Fallback: pyperclip (X11, or wl-copy present but no compositor to + # reach). It's text-only; only restore if the saved bytes are valid + # UTF-8 text. Binary clipboard data (images, etc.) cannot be + # round-tripped through pyperclip without corruption. try: pyperclip.copy(saved.decode("utf-8")) except UnicodeDecodeError: From f860c9e01bf3db6b12b7176febda2f12a5da0ceb Mon Sep 17 00:00:00 2001 From: goodroot <9484709+goodroot@users.noreply.github.com> Date: Sat, 18 Jul 2026 14:34:41 -0700 Subject: [PATCH 3/7] Revert "feat(mic-osd): replace recording dot with elapsed/processing timers" This reverts commit d4a6489ded34c7788d695806fccd1384fd5347a2. --- lib/main.py | 8 -- lib/mic_osd/visualizations/waveform.py | 189 ++++++++++++++----------- 2 files changed, 104 insertions(+), 93 deletions(-) diff --git a/lib/main.py b/lib/main.py index a16b051a..c7d1b550 100644 --- a/lib/main.py +++ b/lib/main.py @@ -262,14 +262,6 @@ def _use_notification_status(reason: str): ) if runner._ensure_daemon(): # Start daemon now self._mic_osd_runner = runner - # Force the overlay hidden at startup. If we reused an - # orphaned daemon that a previous (crashed/SIGKILLed) - # session left visible mid-recording, it would otherwise - # stay stuck on screen until the first new recording. - try: - runner.hide() - except Exception: - pass print("[INIT] Mic-OSD daemon started", flush=True) else: print("[WARN] Failed to start mic-osd daemon", flush=True) diff --git a/lib/mic_osd/visualizations/waveform.py b/lib/mic_osd/visualizations/waveform.py index 3bd50b40..6242ad79 100644 --- a/lib/mic_osd/visualizations/waveform.py +++ b/lib/mic_osd/visualizations/waveform.py @@ -43,12 +43,10 @@ def __init__(self): # State manager for visualizer states (recording, paused, processing, etc.) self.state_manager = StateManager() - # Time tracking: recording elapsed (MM:SS) and transcription time (s) + # Elapsed time tracking for long-form mode self._recording_start_time = None - self._recording_elapsed = 0.0 - self._processing_start_time = None - self._processing_elapsed = 0.0 - self._timer_mode = None # None | 'recording' | 'processing' + self._elapsed_seconds = 0.0 + self._show_elapsed_time = False def update(self, level: float, samples: np.ndarray = None): """Update with new audio samples.""" @@ -89,15 +87,19 @@ def update(self, level: float, samples: np.ndarray = None): self.state_manager.update() def draw(self, cr: cairo.Context, width: int, height: int): - """Draw the bar visualization.""" + """Draw the bar visualization with recording indicator.""" padding = 16 - - # Bars fill the full width (the red recording indicator dot was removed). - bars_start_x = padding + + # Recording indicator (just the dot) takes up left side + indicator_width = 30 + bars_start_x = padding + indicator_width bars_width = width - bars_start_x - padding bars_height = height - (padding * 2) center_y = height / 2 - + + # Draw recording indicator (red dot + "Recording...") + self._draw_recording_indicator(cr, padding, center_y) + # Calculate bar dimensions to fill available space actual_num_bars = self.num_bars bar_gap = 2 @@ -187,120 +189,137 @@ def draw(self, cr: cairo.Context, width: int, height: int): cr.rectangle(x, bar_top, bar_width, bar_h) cr.fill() - # Draw recording elapsed / transcription time - self._draw_timer(cr, width, height) + # Draw elapsed time (for long-form mode) + self._draw_elapsed_time(cr, width, height) def set_state(self, state_str: str): - """Set the visualizer state and drive the recording/processing timers.""" + """Set the visualizer state from a string value.""" self.state_manager.set_state_from_string(state_str) - now = time.time() - prev_mode = self._timer_mode - + # Start/stop elapsed time tracking based on state if state_str == 'recording': - # A new recording session begins whenever we enter recording from a - # non-recording state (idle, processing, result) — reset the timers. - if prev_mode != 'recording': - self._recording_elapsed = 0.0 - self._processing_elapsed = 0.0 - self._processing_start_time = None - self._recording_start_time = now - elif self._recording_start_time is None: - self._recording_start_time = now - self._timer_mode = 'recording' + if self._recording_start_time is None: + self._recording_start_time = time.time() + self._show_elapsed_time = True elif state_str == 'paused': - # Freeze the recording timer but keep it on screen. - if self._recording_start_time is not None: - self._recording_elapsed += now - self._recording_start_time - self._recording_start_time = None - self._timer_mode = 'recording' - elif state_str == 'processing': - # Stop the recording timer, start timing the transcription. + # Keep showing elapsed time but don't increment if self._recording_start_time is not None: - self._recording_elapsed += now - self._recording_start_time + self._elapsed_seconds += time.time() - self._recording_start_time self._recording_start_time = None - self._processing_start_time = now - self._processing_elapsed = 0.0 - self._timer_mode = 'processing' - elif state_str in ('success', 'error'): - # Freeze the processing time so the brief result display keeps it. - if self._processing_start_time is not None: - self._processing_elapsed = now - self._processing_start_time - self._processing_start_time = None - self._recording_start_time = None - self._timer_mode = 'processing' if self._processing_elapsed > 0 else None + self._show_elapsed_time = True else: + # Reset elapsed time for other states self._recording_start_time = None - self._processing_start_time = None - self._timer_mode = None + self._elapsed_seconds = 0.0 + self._show_elapsed_time = False - def _timer_text(self) -> str: - """Current timer string, or '' when no timer should be shown.""" - if self._timer_mode == 'recording': - seconds = self._recording_elapsed - if self._recording_start_time is not None: - seconds += time.time() - self._recording_start_time - return self._format_mmss(seconds) - if self._timer_mode == 'processing': - seconds = self._processing_elapsed - if self._processing_start_time is not None: - seconds += time.time() - self._processing_start_time - return self._format_processing(seconds) - return "" + def set_elapsed_time(self, seconds: float): + """Set the elapsed time directly (for long-form mode).""" + self._elapsed_seconds = seconds + self._show_elapsed_time = True - @staticmethod - def _format_mmss(seconds: float) -> str: - """Format seconds as MM:SS.""" - total = int(seconds) - return f"{total // 60:02d}:{total % 60:02d}" + def _get_elapsed_seconds(self) -> float: + """Get current elapsed time in seconds.""" + if self._recording_start_time is not None: + return self._elapsed_seconds + (time.time() - self._recording_start_time) + return self._elapsed_seconds - @staticmethod - def _format_processing(seconds: float) -> str: - """Format transcription time: '1.2s' under a minute, else MM:SS.""" - if seconds < 60: - return f"{seconds:.1f}s" - total = int(seconds) - return f"{total // 60:02d}:{total % 60:02d}" + def _format_elapsed_time(self, seconds: float) -> str: + """Format seconds as MM:SS.""" + minutes = int(seconds) // 60 + secs = int(seconds) % 60 + return f"{minutes:02d}:{secs:02d}" - def _draw_timer(self, cr: cairo.Context, width: int, height: int): - """Draw the recording/processing timer in the bottom-right corner.""" - text = self._timer_text() - if not text: + def _draw_elapsed_time(self, cr: cairo.Context, width: int, height: int): + """Draw elapsed time in the bottom-right corner.""" + if not self._show_elapsed_time: return - # Monospace so the digits don't jitter as they tick. - cr.select_font_face("monospace", cairo.FONT_SLANT_NORMAL, cairo.FONT_WEIGHT_NORMAL) + elapsed = self._get_elapsed_seconds() + text = self._format_elapsed_time(elapsed) + + # Set font (monospace for consistent width) + cr.select_font_face( + "monospace", + cairo.FONT_SLANT_NORMAL, + cairo.FONT_WEIGHT_NORMAL + ) cr.set_font_size(11) + # Measure text extents = cr.text_extents(text) text_width = extents.width text_height = extents.height - # Position: bottom-right with padding. + # Position: bottom-right with padding padding = 10 x = width - text_width - padding y = height - padding - # Background chip harmonized with the panel. + # Draw background using theme background color (harmonized with bars) bg_padding = 3 bg_color = theme.background - bg_alpha = bg_color[3] * 0.9 if len(bg_color) == 4 else 0.9 + # Use theme background with slightly higher opacity for better visibility + if len(bg_color) == 4: + bg_alpha = bg_color[3] * 0.9 # Slightly more opaque than main background + else: + bg_alpha = 0.9 cr.set_source_rgba( bg_color[0] if len(bg_color) >= 1 else 0.1, bg_color[1] if len(bg_color) >= 2 else 0.1, bg_color[2] if len(bg_color) >= 3 else 0.15, - bg_alpha, + bg_alpha ) cr.rectangle( x - bg_padding, y - text_height - bg_padding, text_width + bg_padding * 2, - text_height + bg_padding * 2, + text_height + bg_padding * 2 ) cr.fill() - # Neutral text colour (no red), readable on the dark panel. - text_color = theme.text - cr.set_source_rgba(text_color[0], text_color[1], text_color[2], 0.95) + # Draw text using bar colors (interpolated toward right/end for harmony) + # Use the right bar color (green) as it's at the end where timer is + bar_right = theme.bar_right + cr.set_source_rgba( + bar_right[0], + bar_right[1], + bar_right[2], + 0.95 # High opacity for good readability + ) cr.move_to(x, y) cr.show_text(text) + + def _draw_recording_indicator(self, cr: cairo.Context, x: float, center_y: float): + """Draw the state indicator dot with state-appropriate color and animation.""" + dot_radius = 6 + dot_x = x + dot_radius + 4 + dot_y = center_y + + # Get animation value and color from state manager + pulse = self.state_manager.get_animation_value() + dot_color = self.state_manager.get_state_color() + + # Skip drawing if animation has faded out completely (e.g., success state after 2s) + if pulse <= 0: + return + + # Draw glow behind dot + cr.set_source_rgba( + dot_color[0], + dot_color[1], + dot_color[2], + 0.3 * pulse + ) + cr.arc(dot_x, dot_y, dot_radius + 3, 0, 2 * math.pi) + cr.fill() + + # Draw main dot + cr.set_source_rgba( + dot_color[0], + dot_color[1], + dot_color[2], + pulse + ) + cr.arc(dot_x, dot_y, dot_radius, 0, 2 * math.pi) + cr.fill() From 4d11c12dbdb85604b8395cf56dc1c20e47bafa20 Mon Sep 17 00:00:00 2001 From: goodroot <9484709+goodroot@users.noreply.github.com> Date: Sat, 18 Jul 2026 19:03:41 -0700 Subject: [PATCH 4/7] revert: remove remaining mic osd timer change --- lib/mic_osd/visualizations/waveform.py | 5 ----- 1 file changed, 5 deletions(-) diff --git a/lib/mic_osd/visualizations/waveform.py b/lib/mic_osd/visualizations/waveform.py index 6242ad79..7ea9cf96 100644 --- a/lib/mic_osd/visualizations/waveform.py +++ b/lib/mic_osd/visualizations/waveform.py @@ -213,11 +213,6 @@ def set_state(self, state_str: str): self._elapsed_seconds = 0.0 self._show_elapsed_time = False - def set_elapsed_time(self, seconds: float): - """Set the elapsed time directly (for long-form mode).""" - self._elapsed_seconds = seconds - self._show_elapsed_time = True - def _get_elapsed_seconds(self) -> float: """Get current elapsed time in seconds.""" if self._recording_start_time is not None: From 06b4c92a7fa350ab4251d84d81b281e30c80be39 Mon Sep 17 00:00:00 2001 From: goodroot <9484709+goodroot@users.noreply.github.com> Date: Sat, 18 Jul 2026 19:03:57 -0700 Subject: [PATCH 5/7] fix: harden X11 session integration --- config/systemd/hyprwhspr.service | 2 +- lib/src/cli/maintenance.py | 29 +++-- lib/src/cli/systemd.py | 5 +- lib/src/session_environment.py | 22 ++++ lib/src/text_injector.py | 161 +++++++++++++++++--------- tests/test_session_environment.py | 32 ++++- tests/test_text_injector_injection.py | 135 ++++++++++++++++++++- tests/test_x11_service_contract.py | 30 +++++ 8 files changed, 350 insertions(+), 66 deletions(-) create mode 100644 tests/test_x11_service_contract.py diff --git a/config/systemd/hyprwhspr.service b/config/systemd/hyprwhspr.service index a9a176f7..59ddff74 100644 --- a/config/systemd/hyprwhspr.service +++ b/config/systemd/hyprwhspr.service @@ -13,7 +13,7 @@ After=wireplumber.service [Service] Type=simple -ExecStartPre=/bin/bash -lc 'for i in $(seq 1 60); do { ls "$XDG_RUNTIME_DIR"/wayland-* || ls /tmp/.X11-unix/X*; } >/dev/null 2>&1 && exit 0; sleep 0.25; done; echo "No Wayland or X11 display socket found"; exit 1' +ExecStartPre=/bin/bash -lc 'for i in $(seq 1 60); do if [ -n "$WAYLAND_DISPLAY" ]; then case "$WAYLAND_DISPLAY" in /*) wayland_socket="$WAYLAND_DISPLAY" ;; *) wayland_socket="${XDG_RUNTIME_DIR}/${WAYLAND_DISPLAY}" ;; esac; [ -S "$wayland_socket" ] && exit 0; fi; [ -n "$DISPLAY" ] && exit 0; if [ -n "$XDG_RUNTIME_DIR" ]; then for wayland_socket in "${XDG_RUNTIME_DIR}"/wayland-*; do [ -S "$wayland_socket" ] && exit 0; done; fi; sleep 0.25; done; echo "No usable Wayland socket or X11 DISPLAY found"; exit 1' ExecStart=/usr/lib/hyprwhspr/bin/hyprwhspr ExecStopPost=/bin/bash -c '( pkill -9 -f "hyprwhspr-virtual-keyboar[d]" 2>/dev/null; pkill -9 -f "hyprwhspr-ydotool.soc[k]" 2>/dev/null ) || true' Environment=HYPRWHSPR_ROOT=/usr/lib/hyprwhspr diff --git a/lib/src/cli/maintenance.py b/lib/src/cli/maintenance.py index f70208c6..6d8e0d4e 100644 --- a/lib/src/cli/maintenance.py +++ b/lib/src/cli/maintenance.py @@ -3,6 +3,7 @@ management and installation validation """ +import os import shutil import subprocess from pathlib import Path @@ -40,6 +41,11 @@ except ImportError: from output_control import log_info, log_success, log_warning, log_error +try: + from ..session_environment import classify_display_environment +except ImportError: + from session_environment import classify_display_environment + from ._shared import (HYPRWHSPR_ROOT, SERVICE_NAME, _check_ydotool_version, _is_niri_session, _validate_hyprwhspr_root) @@ -772,20 +778,29 @@ def validate_command(): except Exception: pass - # Check Wayland compositor environment in systemd user environment + # Check graphical session environment in the systemd user environment. try: result = subprocess.run( ['systemctl', '--user', 'show-environment'], capture_output=True, text=True, timeout=5, check=False ) env_output = result.stdout if result.returncode == 0 else '' - if 'WAYLAND_DISPLAY=' in env_output: - log_success("✓ WAYLAND_DISPLAY set in systemd user environment") + display_kind = classify_display_environment( + env_output, os.environ.get('XDG_SESSION_TYPE', '') + ) + if display_kind == 'wayland': + log_success("✓ Wayland display set in systemd user environment") + elif display_kind == 'x11': + log_success("✓ X11 DISPLAY set in systemd user environment") else: - log_warning("⚠ WAYLAND_DISPLAY not found in systemd user environment") - print(" Add the relevant compositor environment export to your startup config.") - print(" Hyprland example:") - print(" exec-once = dbus-update-activation-environment --systemd WAYLAND_DISPLAY XDG_CURRENT_DESKTOP HYPRLAND_INSTANCE_SIGNATURE") + log_warning("⚠ No Wayland or X11 display found in systemd user environment") + if os.environ.get('XDG_SESSION_TYPE', '').lower() == 'x11': + print(" Import the X11 session environment:") + print(" systemctl --user import-environment DISPLAY XAUTHORITY XDG_SESSION_TYPE") + else: + print(" Add the relevant compositor environment export to your startup config.") + print(" Hyprland example:") + print(" exec-once = dbus-update-activation-environment --systemd WAYLAND_DISPLAY XDG_CURRENT_DESKTOP HYPRLAND_INSTANCE_SIGNATURE") if _is_niri_session(): if 'NIRI_SOCKET=' in env_output: diff --git a/lib/src/cli/systemd.py b/lib/src/cli/systemd.py index 4f61575c..2caccd28 100644 --- a/lib/src/cli/systemd.py +++ b/lib/src/cli/systemd.py @@ -122,10 +122,11 @@ def setup_systemd(mode: str = 'install'): # Import the compositor environment visible to this setup process into the # systemd user manager. Niri's focused-window IPC needs NIRI_SOCKET; Hyprland # detection needs HYPRLAND_INSTANCE_SIGNATURE; wtype/wl-clipboard need the - # Wayland display environment. + # graphical-session environment used by Wayland and X11 integrations. run_command([ 'systemctl', '--user', 'import-environment', - 'WAYLAND_DISPLAY', 'XDG_CURRENT_DESKTOP', + 'WAYLAND_DISPLAY', 'DISPLAY', 'XAUTHORITY', 'XDG_SESSION_TYPE', + 'XDG_CURRENT_DESKTOP', 'XDG_SESSION_DESKTOP', 'DESKTOP_SESSION', 'HYPRLAND_INSTANCE_SIGNATURE', 'NIRI_SOCKET', ], check=False) diff --git a/lib/src/session_environment.py b/lib/src/session_environment.py index 6bf3c58d..55a4f724 100644 --- a/lib/src/session_environment.py +++ b/lib/src/session_environment.py @@ -7,6 +7,25 @@ from pathlib import Path +def classify_display_environment(environment: str, expected_session_type: str = ""): + """Return the usable display kind exported by a systemd environment dump.""" + values = {} + for line in environment.splitlines(): + name, separator, value = line.partition("=") + if separator: + values[name] = value + session_type = (values.get("XDG_SESSION_TYPE") or expected_session_type).lower() + if session_type == "wayland": + return "wayland" if values.get("WAYLAND_DISPLAY") else None + if session_type == "x11": + return "x11" if values.get("DISPLAY") else None + if values.get("WAYLAND_DISPLAY"): + return "wayland" + if values.get("DISPLAY"): + return "x11" + return None + + def ensure_wayland_display(): """ Populate WAYLAND_DISPLAY from XDG_RUNTIME_DIR when systemd has not imported it. @@ -17,6 +36,9 @@ def ensure_wayland_display(): if os.environ.get("WAYLAND_DISPLAY"): return + if os.environ.get("XDG_SESSION_TYPE", "").lower() == "x11": + return + runtime_dir = os.environ.get("XDG_RUNTIME_DIR") if not runtime_dir: print("[WARN] WAYLAND_DISPLAY unset and XDG_RUNTIME_DIR missing", flush=True) diff --git a/lib/src/text_injector.py b/lib/src/text_injector.py index 1693215e..69a84d85 100644 --- a/lib/src/text_injector.py +++ b/lib/src/text_injector.py @@ -24,36 +24,35 @@ class _LazyPyperclip: _module = None + def _load(self): + if self._module is not None: + return self._module + try: + import pyperclip as module + except ImportError as exc: + raise RuntimeError( + "clipboard fallback unavailable; install pyperclip and xclip (or xsel)" + ) from exc + + # Avoid pyperclip's GTK fallback: mic_osd may already have selected GTK4, + # while pyperclip requests GTK3. Prefer subprocess-only X11 backends. + try: + if shutil.which("xclip"): + module.set_clipboard("xclip") + elif shutil.which("xsel"): + module.set_clipboard("xsel") + except Exception: + # Preserve pyperclip's own error for the eventual copy/paste call. + pass + self._module = module + return module + def __getattr__(self, name): - if self._module is None: - try: - import pyperclip as module - except ImportError as exc: - raise RuntimeError( - "clipboard fallback unavailable; install pyperclip (and xclip on X11)" - ) from exc - self._module = module - return getattr(self._module, name) + return getattr(self._load(), name) pyperclip = _LazyPyperclip() -# pyperclip's own auto-detection (determine_clipboard(), triggered lazily on first -# copy()/paste()) prefers a GObject-Introspection GTK clipboard whenever `gi` is -# importable, ahead of xclip/xsel — and does `gi.require_version('Gtk', '3.0')` to -# get it. mic_osd's layer-shell availability probe already pins this same process's -# `Gtk` namespace to version 4.0, so pyperclip's lazy GTK3 request then raises -# "Namespace Gtk is already loaded with version 4.0" the first time the clipboard -# fallback path (wl-copy unavailable/failing, e.g. under X11) runs. Forcing a -# subprocess-based backend up front means pyperclip never touches `gi` at all. -try: - if shutil.which('xclip'): - pyperclip.set_clipboard('xclip') - elif shutil.which('xsel'): - pyperclip.set_clipboard('xsel') -except Exception: - pass - DEFAULT_PASTE_KEYCODE = 47 # Linux evdev KEY_V on QWERTY NON_XKB_INPUT_METHOD_LAYOUT = '__non_xkb_input_method__' @@ -174,9 +173,11 @@ def __init__(self, config_manager=None): # Configuration self.config_manager = config_manager - # Detect available injectors + # Detect available injectors once for the active display protocol. + self.session_type = os.environ.get('XDG_SESSION_TYPE', '').lower() self.ydotool_available = self._check_ydotool() - self.wtype_available = shutil.which('wtype') is not None + self.wtype_available = not self._is_x11_session() and shutil.which('wtype') is not None + self.xdotool_available = self._is_x11_session() and shutil.which('xdotool') is not None # Private ydotoold instance (lazily started on first uinput-fallback use, so # wtype-only sessions never spawn it). Replaces the old shared/managed @@ -184,15 +185,20 @@ def __init__(self, config_manager=None): self._ydotoold = YdotooldSession() self._atspi_unavailable = False - if not self.ydotool_available and not self.wtype_available: - print("⚠️ No injection backend found (wtype or ydotool). hyprwhspr requires wtype or ydotool for paste injection.") - elif not self.wtype_available and self.ydotool_available: + if not self.ydotool_available and not self.wtype_available and not self.xdotool_available: + print("⚠️ No injection backend found. Install wtype or ydotool on Wayland, or xdotool on X11.") + elif not self._is_x11_session() and not self.wtype_available and self.ydotool_available: print("ℹ️ wtype not found. Falling back to ydotool for paste hotkey injection.") def _check_ydotool(self) -> bool: """Check if ydotool is usable (both the client and the ydotoold daemon).""" return YdotooldSession.is_available() + def _is_x11_session(self) -> bool: + """Use the display protocol captured when this injector was initialized.""" + session_type = getattr(self, 'session_type', os.environ.get('XDG_SESSION_TYPE', '').lower()) + return session_type == 'x11' + def _run_ydotool(self, args, timeout): """Run a ydotool client command against our private ydotoold daemon. @@ -591,13 +597,15 @@ def _chord_is_usable(self, chord: str) -> bool: if not parsed: return False _modifiers, key = parsed + if getattr(self, 'xdotool_available', False): + return True if self.wtype_available and self._wtype_key_name(key) is not None: return True if self.ydotool_available and self._keycode_for_chord_key(key) is not None: return True # No injection backend at all → can't paste regardless; a parseable chord # shouldn't trigger a spurious misconfiguration warning. - return not self.wtype_available and not self.ydotool_available + return not self.wtype_available and not self.ydotool_available and not getattr(self, 'xdotool_available', False) def _resolve_paste_chord(self, window_info: Optional[Dict[str, Any]] = None): """Resolve application rule / explicit paste_mode / legacy / auto into a chord. @@ -722,6 +730,27 @@ def _send_paste_keys_wtype(self, paste_chord: str) -> bool: print(f"wtype paste failed: {e}") return False + def _send_paste_keys_xdotool(self, paste_chord: str) -> bool: + """Send a symbolic paste chord through the native X11 input path.""" + paste_chord = PASTE_MODE_CHORDS.get(paste_chord, paste_chord) + parsed = self._parse_key_chord(paste_chord) + if not parsed: + return False + modifiers, key = parsed + chord = '+'.join([*modifiers, key]) + try: + result = subprocess.run( + ['xdotool', 'key', '--clearmodifiers', chord], + capture_output=True, timeout=5, + ) + if result.returncode == 0: + return True + stderr = (result.stderr or b'').decode('utf-8', 'ignore') + print(f" xdotool paste failed: {stderr}") + except Exception as e: + print(f"xdotool paste failed: {e}") + return False + @staticmethod def _wtype_key_name(key: str) -> Optional[str]: """Translate a normalized chord key into the xkb keysym name wtype expects.""" @@ -879,7 +908,7 @@ def _gnome_restore_layout(self, prev_idx): def _save_clipboard(self) -> Optional[bytes]: """Save current clipboard contents. Returns raw bytes or None.""" - if shutil.which("wl-paste"): + if not self._is_x11_session() and shutil.which("wl-paste"): try: result = subprocess.run(["wl-paste", "--no-newline"], capture_output=True, timeout=2) if result.returncode == 0: @@ -895,21 +924,30 @@ def _save_clipboard(self) -> Optional[bytes]: pass return None + def _try_wl_copy(self, data: bytes) -> Tuple[bool, Optional[str]]: + """Try the Wayland clipboard writer and retain a useful failure detail.""" + if self._is_x11_session() or not shutil.which("wl-copy"): + return False, None + try: + result = subprocess.run(["wl-copy"], input=data, timeout=2) + if result.returncode == 0: + return True, None + return False, f"wl-copy exited with status {result.returncode}" + except Exception as exc: + return False, f"wl-copy failed: {exc}" + def _copy_text_to_clipboard(self, text: str) -> bool: """Copy text to the clipboard without triggering paste.""" - if shutil.which("wl-copy"): - try: - result = subprocess.run(["wl-copy"], input=text.encode("utf-8"), timeout=2) - if result.returncode == 0: - return True - except Exception: - pass + copied, wayland_error = self._try_wl_copy(text.encode("utf-8")) + if copied: + return True # Fallback: pyperclip (X11, or wl-copy present but no compositor to reach) try: pyperclip.copy(text) return True except Exception as e: - print(f"ERROR: Clipboard copy failed: {e}") + detail = f" after {wayland_error}" if wayland_error else "" + print(f"ERROR: Clipboard copy failed{detail}: {e}") return False def _restore_clipboard(self, saved: Optional[bytes], injected: Optional[bytes] = None, delay: float = 5.0): @@ -930,13 +968,7 @@ def _restore(): if current != injected: return - restored = False - if shutil.which("wl-copy"): - try: - result = subprocess.run(["wl-copy"], input=saved, timeout=2) - restored = result.returncode == 0 - except Exception: - restored = False + restored, wayland_error = self._try_wl_copy(saved) if not restored: # Fallback: pyperclip (X11, or wl-copy present but no compositor to # reach). It's text-only; only restore if the saved bytes are valid @@ -945,7 +977,12 @@ def _restore(): try: pyperclip.copy(saved.decode("utf-8")) except UnicodeDecodeError: - pass # Binary data — skip rather than corrupt + if wayland_error: + print(f"Warning: Could not restore binary clipboard: {wayland_error}") + # Binary data cannot safely pass through the text fallback. + except Exception as exc: + detail = f" after {wayland_error}" if wayland_error else "" + raise RuntimeError(f"clipboard fallback failed{detail}: {exc}") from exc except Exception as e: print(f"Warning: Could not restore clipboard: {e}") @@ -956,7 +993,15 @@ def _send_enter_if_auto_submit(self): if not (self.config_manager and self.config_manager.get_setting('auto_submit', False)): return try: - if self.ydotool_available: + if self._is_x11_session() and getattr(self, 'xdotool_available', False): + enter_result = subprocess.run( + ['xdotool', 'key', '--clearmodifiers', 'Return'], + capture_output=True, timeout=1, + ) + if enter_result.returncode != 0: + stderr = (enter_result.stderr or b'').decode('utf-8', 'ignore') + print(f" xdotool Enter key failed: {stderr}") + elif self.ydotool_available: enter_result = self._run_ydotool(['key', '28:1', '28:0'], timeout=1) # 28 = Enter if enter_result is None or enter_result.returncode != 0: stderr = (enter_result.stderr or b"").decode("utf-8", "ignore") if enter_result else "ydotoold unavailable" @@ -970,7 +1015,7 @@ def _send_enter_if_auto_submit(self): stderr = (enter_result.stderr or b"").decode("utf-8", "ignore") print(f" wtype Enter key failed: {stderr}") else: - print(" auto_submit enabled but no key-injection tool available (ydotool or wtype required)") + print(" auto_submit enabled but no key-injection tool available") except Exception as e: print(f" auto_submit Enter key failed: {e}") @@ -1216,13 +1261,16 @@ def _inject_via_clipboard_and_hotkey(self, text: str) -> bool: return False time.sleep(0.15) - # Send paste hotkey: prefer wtype (Wayland virtual-keyboard), fall back - # to ydotool's uinput chord. ydotool key chords DO reach Mutter (uinput + # Send paste hotkey through the session-native path first: xdotool on + # X11 or wtype on Wayland. Fall back to ydotool's uinput chord. ydotool + # key chords DO reach Mutter (uinput # is seen as a real device, unlike wtype's virtual-keyboard protocol # which Mutter blocks), so we use them on GNOME too — this is the path # taken when direct typing was skipped for a non-US layout / non-ASCII text. pasted = False - if self.wtype_available: + if self._is_x11_session() and getattr(self, 'xdotool_available', False): + pasted = self._send_paste_keys_xdotool(paste_chord) + elif self.wtype_available: pasted = self._send_paste_keys_wtype(paste_chord) if pasted: # wtype sends Wayland modifier events; clear ydotool's uinput modifier @@ -1242,7 +1290,12 @@ def _inject_via_clipboard_and_hotkey(self, text: str) -> bool: finally: self._gnome_restore_layout(_prev_layout) - if not pasted and not self.wtype_available and not self.ydotool_available: + if ( + not pasted + and not self.wtype_available + and not self.ydotool_available + and not getattr(self, 'xdotool_available', False) + ): print("No key-injection tool available; text is on the clipboard.") # Text is clipboard-only: don't restore old clipboard (would erase it) # and don't auto-submit (nothing was pasted into the field). diff --git a/tests/test_session_environment.py b/tests/test_session_environment.py index e4b6512f..7856cab0 100644 --- a/tests/test_session_environment.py +++ b/tests/test_session_environment.py @@ -10,10 +10,26 @@ ROOT = Path(__file__).resolve().parents[1] sys.path.insert(0, str(ROOT / "lib" / "src")) -from session_environment import ensure_wayland_display +from session_environment import classify_display_environment, ensure_wayland_display class SessionEnvironmentTests(unittest.TestCase): + def test_classifies_wayland_x11_and_missing_systemd_environments(self): + self.assertEqual( + classify_display_environment("WAYLAND_DISPLAY=wayland-1\nDISPLAY=:0\n"), + "wayland", + ) + self.assertEqual(classify_display_environment("DISPLAY=:0\nXAUTHORITY=/tmp/auth\n"), "x11") + self.assertIsNone(classify_display_environment("DISPLAY=\nWAYLAND_DISPLAY=\n")) + self.assertIsNone( + classify_display_environment("XDG_SESSION_TYPE=x11\nWAYLAND_DISPLAY=wayland-0\n") + ) + self.assertIsNone( + classify_display_environment("XDG_SESSION_TYPE=wayland\nDISPLAY=:0\n") + ) + self.assertIsNone(classify_display_environment("DISPLAY=:0\n", "wayland")) + self.assertIsNone(classify_display_environment("WAYLAND_DISPLAY=wayland-0\n", "x11")) + def _socket_file_at(self, path, mtime=100): path.write_text("", encoding="utf-8") path.chmod(0o600) @@ -99,6 +115,20 @@ def test_does_nothing_when_runtime_dir_has_no_sockets(self): ensure_wayland_display() self.assertIsNone(os.environ.get("WAYLAND_DISPLAY")) + def test_explicit_x11_session_ignores_stale_wayland_socket(self): + with tempfile.TemporaryDirectory() as tmpdir: + self._socket_file_at(Path(tmpdir) / "wayland-1") + with ( + mock.patch.dict( + os.environ, + {"XDG_SESSION_TYPE": "x11", "DISPLAY": ":0", "XDG_RUNTIME_DIR": tmpdir}, + clear=True, + ), + self._socket_mode_patch(), + ): + ensure_wayland_display() + self.assertIsNone(os.environ.get("WAYLAND_DISPLAY")) + def test_does_not_raise_when_xdg_runtime_dir_unset_or_missing(self): with mock.patch.dict(os.environ, {}, clear=True): ensure_wayland_display() diff --git a/tests/test_text_injector_injection.py b/tests/test_text_injector_injection.py index 42291011..28dfa28c 100644 --- a/tests/test_text_injector_injection.py +++ b/tests/test_text_injector_injection.py @@ -11,7 +11,7 @@ sys.path.insert(0, str(ROOT / "lib" / "src")) sys.modules.setdefault("pyperclip", types.SimpleNamespace(copy=lambda text: None, paste=lambda: "")) -from text_injector import TextInjector +from text_injector import TextInjector, _LazyPyperclip class ConfigStub: @@ -26,8 +26,10 @@ class TextInjectorInjectionTests(unittest.TestCase): def _injector(self): injector = TextInjector.__new__(TextInjector) injector.config_manager = ConfigStub() + injector.session_type = "wayland" injector.ydotool_available = True injector.wtype_available = False + injector.xdotool_available = False # Private ydotoold daemon manager: not running by default (so # _clear_stuck_modifiers is a no-op), but ensure_running() succeeds when a # ydotool command is actually issued. @@ -37,6 +39,113 @@ def _injector(self): injector._ydotoold.socket_env.return_value = {"YDOTOOL_SOCKET": "/run/x.sock"} return injector + def test_wayland_clipboard_success_does_not_use_fallback(self): + injector = self._injector() + completed = types.SimpleNamespace(returncode=0) + with ( + mock.patch("text_injector.shutil.which", return_value="/usr/bin/wl-copy"), + mock.patch("text_injector.subprocess.run", return_value=completed) as run, + mock.patch("text_injector.pyperclip.copy") as fallback, + ): + self.assertTrue(injector._copy_text_to_clipboard("hello")) + + run.assert_called_once() + fallback.assert_not_called() + + def test_failed_wayland_clipboard_uses_x11_fallback(self): + injector = self._injector() + completed = types.SimpleNamespace(returncode=1) + with ( + mock.patch("text_injector.shutil.which", return_value="/usr/bin/wl-copy"), + mock.patch("text_injector.subprocess.run", return_value=completed), + mock.patch("text_injector.pyperclip.copy") as fallback, + ): + self.assertTrue(injector._copy_text_to_clipboard("hello")) + + fallback.assert_called_once_with("hello") + + def test_clipboard_save_falls_back_after_failed_wayland_read(self): + injector = self._injector() + completed = types.SimpleNamespace(returncode=1, stdout=b"") + with ( + mock.patch("text_injector.shutil.which", return_value="/usr/bin/wl-paste"), + mock.patch("text_injector.subprocess.run", return_value=completed), + mock.patch("text_injector.pyperclip.paste", return_value="saved") as fallback, + ): + self.assertEqual(injector._save_clipboard(), b"saved") + + fallback.assert_called_once_with() + + def test_restore_fallback_restores_utf8_but_skips_binary(self): + injector = self._injector() + + class ImmediateThread: + def __init__(self, target, daemon): + self.target = target + + def start(self): + self.target() + + with ( + mock.patch("text_injector.time.sleep"), + mock.patch("text_injector.threading.Thread", ImmediateThread), + mock.patch("text_injector.shutil.which", return_value=None), + mock.patch("text_injector.pyperclip.copy") as fallback, + ): + injector._restore_clipboard("café".encode(), delay=0) + injector._restore_clipboard(b"\xff\x00", delay=0) + + fallback.assert_called_once_with("café") + + def test_x11_skips_wayland_clipboard_tools_and_uses_xdotool_chord(self): + injector = self._injector() + injector.session_type = "x11" + injector.ydotool_available = False + injector.wtype_available = False + injector.xdotool_available = True + clipboard = types.SimpleNamespace(copy=mock.Mock(), paste=mock.Mock(return_value="old")) + completed = types.SimpleNamespace(returncode=0, stderr=b"") + + with ( + mock.patch("text_injector.pyperclip", clipboard), + mock.patch("text_injector.shutil.which") as which, + mock.patch("text_injector.subprocess.run", return_value=completed) as run, + ): + self.assertEqual(injector._save_clipboard(), b"old") + self.assertTrue(injector._copy_text_to_clipboard("hello")) + self.assertTrue(injector._send_paste_keys_xdotool("ctrl_shift")) + + which.assert_not_called() + clipboard.copy.assert_called_once_with("hello") + run.assert_called_once_with( + ["xdotool", "key", "--clearmodifiers", "ctrl+shift+v"], + capture_output=True, + timeout=5, + ) + + def test_x11_injection_prefers_xdotool_without_wtype_or_ydotool(self): + injector = self._injector() + injector.session_type = "x11" + injector.ydotool_available = False + injector.wtype_available = False + injector.xdotool_available = True + with ( + mock.patch.object(injector, "_get_active_window_info", return_value=None), + mock.patch.object(injector, "_save_clipboard", return_value=b"old"), + mock.patch.object(injector, "_copy_text_to_clipboard", return_value=True), + mock.patch.object(injector, "_send_paste_keys_xdotool", return_value=True) as xdotool, + mock.patch.object(injector, "_send_paste_keys_wtype") as wtype, + mock.patch.object(injector, "_send_paste_keys_slow") as ydotool, + mock.patch.object(injector, "_restore_clipboard"), + mock.patch.object(injector, "_send_enter_if_auto_submit"), + mock.patch("text_injector.time.sleep"), + ): + self.assertTrue(injector._inject_via_clipboard_and_hotkey("hello")) + + xdotool.assert_called_once_with("ctrl+v") + wtype.assert_not_called() + ydotool.assert_not_called() + def test_gnome_wayland_uses_ydotool_type_instead_of_paste_chord(self): injector = self._injector() @@ -679,5 +788,29 @@ def test_valid_application_chord_does_not_warn(self): self.assertEqual(buf.getvalue(), "") +class LazyPyperclipTests(unittest.TestCase): + def test_selects_xclip_then_xsel_on_first_use(self): + for available, expected in (("xclip", "xclip"), ("xsel", "xsel")): + module = types.SimpleNamespace( + set_clipboard=mock.Mock(), copy=mock.Mock(), paste=mock.Mock(return_value="") + ) + lazy = _LazyPyperclip() + with ( + mock.patch.dict(sys.modules, {"pyperclip": module}), + mock.patch( + "text_injector.shutil.which", + side_effect=lambda name, selected=available: f"/usr/bin/{name}" if name == selected else None, + ), + ): + lazy.copy("text") + + module.set_clipboard.assert_called_once_with(expected) + module.copy.assert_called_once_with("text") + + def test_construction_does_not_import_optional_dependency(self): + lazy = _LazyPyperclip() + self.assertIsNone(lazy._module) + + if __name__ == "__main__": unittest.main() diff --git a/tests/test_x11_service_contract.py b/tests/test_x11_service_contract.py new file mode 100644 index 00000000..cadf3b74 --- /dev/null +++ b/tests/test_x11_service_contract.py @@ -0,0 +1,30 @@ +import unittest +from pathlib import Path + + +ROOT = Path(__file__).resolve().parents[1] + + +class X11ServiceContractTests(unittest.TestCase): + def test_readiness_uses_selected_wayland_socket_or_display(self): + service = (ROOT / "config/systemd/hyprwhspr.service").read_text(encoding="utf-8") + prestart = next(line for line in service.splitlines() if line.startswith("ExecStartPre=")) + + self.assertIn('${XDG_RUNTIME_DIR}/${WAYLAND_DISPLAY}', prestart) + self.assertIn('[ -S ', prestart) + self.assertIn('[ -n "$DISPLAY" ]', prestart) + self.assertIn('${XDG_RUNTIME_DIR}"/wayland-*', prestart) + self.assertIn('/*)', prestart) + self.assertNotIn('/tmp/.X11-unix/X*', prestart) + + def test_setup_imports_wayland_and_x11_session_variables(self): + setup = (ROOT / "lib/src/cli/systemd.py").read_text(encoding="utf-8") + for variable in ( + "WAYLAND_DISPLAY", "DISPLAY", "XAUTHORITY", "XDG_SESSION_TYPE", + "XDG_CURRENT_DESKTOP", "XDG_SESSION_DESKTOP", "DESKTOP_SESSION", + ): + self.assertIn(f"'{variable}'", setup) + + +if __name__ == "__main__": + unittest.main() From 89b9db94f695e58f8c0acdf9e8efef1d13ca5dd2 Mon Sep 17 00:00:00 2001 From: goodroot <9484709+goodroot@users.noreply.github.com> Date: Sat, 18 Jul 2026 19:04:20 -0700 Subject: [PATCH 6/7] build: install X11 integration dependencies --- scripts/install-deps.sh | 26 +++++++++++++++++++++++++- 1 file changed, 25 insertions(+), 1 deletion(-) diff --git a/scripts/install-deps.sh b/scripts/install-deps.sh index 3a2dc374..fa2f5b0a 100755 --- a/scripts/install-deps.sh +++ b/scripts/install-deps.sh @@ -310,6 +310,9 @@ install_deps_apt() { pipewire-pulse \ pulseaudio-utils \ wl-clipboard \ + xclip \ + xdotool \ + x11-utils \ wget # Optional Python packages (not present on all Debian/Ubuntu releases) @@ -364,7 +367,10 @@ install_deps_dnf() { pipewire \ pipewire-pulseaudio \ ydotool \ - wl-clipboard + wl-clipboard \ + xclip \ + xdotool \ + xprop # Optional Python packages (not present on all Fedora releases — e.g. python3-sounddevice # was dropped in F43). Anything missing here is picked up by pip in install_pip_packages. @@ -412,6 +418,24 @@ install_deps_zypper() { ydotool \ wl-clipboard + # X11 package availability varies between Leap and Tumbleweed. Install the + # native equivalents when published, without adding unsupported repositories. + local x11_packages=() + local x11_pkg + for x11_pkg in xclip xdotool xprop; do + if zypper info -t package "$x11_pkg" &> /dev/null; then + x11_packages+=("$x11_pkg") + else + log_warning "X11 dependency $x11_pkg is unavailable in enabled repositories" + fi + done + if [[ ${#x11_packages[@]} -gt 0 ]]; then + sudo zypper install -y "${x11_packages[@]}" + fi + if [[ ${#x11_packages[@]} -lt 3 ]]; then + log_warning "X11 support is incomplete; enable a supported distribution repository or install xclip, xdotool, and xprop manually" + fi + # Optional dbus package naming differs across openSUSE releases if sudo zypper info -t package python3-dbus-python &> /dev/null; then sudo zypper install -y python3-dbus-python From f5bf9bcb1e010e763c22c7709c8f952a49839053 Mon Sep 17 00:00:00 2001 From: goodroot <9484709+goodroot@users.noreply.github.com> Date: Sat, 18 Jul 2026 19:05:07 -0700 Subject: [PATCH 7/7] docs: document Wayland and X11 support --- AGENTS.md | 2 +- README.md | 5 +++-- docs/CONFIGURATION.md | 23 +++++++++++++++----- website/src/layouts/BlogPost.astro | 2 +- website/src/pages/index.astro | 10 ++++----- website/src/pages/linux-speech-to-text.astro | 2 +- 6 files changed, 28 insertions(+), 16 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index a62c9e30..6e51428c 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -2,7 +2,7 @@ ## Scope -This repository is a Linux/Wayland speech-to-text application. Keep changes focused: runtime behavior is hardware-, compositor-, and systemd-sensitive, while most tests are intentionally isolated with mocks. +This repository is a Linux desktop speech-to-text application supporting Wayland and X11. Keep changes focused: runtime behavior is hardware-, compositor-, and systemd-sensitive, while most tests are intentionally isolated with mocks. ## Repository map diff --git a/README.md b/README.md index ed3a2f86..4c2c0820 100644 --- a/README.md +++ b/README.md @@ -38,7 +38,7 @@ https://github.com/user-attachments/assets/4c223e85-2916-494f-b7b1-766ce1bdc991 --- -**Why hyprwhspr?** There are a lotta dictation apps. This one is built ground-up for the highest-end machines: a recent Nvidia card gets the **best possible accuracy and speed**, and everything else runs as well as the hardware you bring. Actively maintained, fully featured, works on anything with Wayland. +**Why hyprwhspr?** There are a lotta dictation apps. This one is built ground-up for the highest-end machines: a recent Nvidia card gets the **best possible accuracy and speed**, and everything else runs as well as the hardware you bring. Actively maintained, fully featured, and built for both Wayland and X11. --- @@ -47,7 +47,8 @@ https://github.com/user-attachments/assets/4c223e85-2916-494f-b7b1-766ce1bdc991 ### Prerequisites - **Linux** with systemd (Arch, Debian, Ubuntu, Fedora, openSUSE, etc.) -- **Requires a Wayland session** (GNOME, KDE Plasma Wayland, Sway, Hyprland, Niri) +- **Wayland or X11 session** (GNOME, KDE Plasma, Sway, Hyprland, Niri, etc.). GNOME/X11 on Ubuntu 24.04 is the currently validated X11 configuration. +- **Clipboard/window tools:** `wl-clipboard` and `wtype` on Wayland; `python-pyperclip`, `xclip`, `xdotool`, and `xprop` on X11 (installed by the dependency script) - **Waybar or Noctalia** (optional, for status bar) - **gtk4 + PyCairo** (optional, for visualizer) - **NVIDIA GPU** (optional, for CUDA acceleration) diff --git a/docs/CONFIGURATION.md b/docs/CONFIGURATION.md index 826bc229..cf51e549 100644 --- a/docs/CONFIGURATION.md +++ b/docs/CONFIGURATION.md @@ -693,7 +693,7 @@ The recording-status indicator — the **mic OSD** — gives visual feedback whi `mic_osd_enabled` turns the mic OSD on; *how* it's shown is chosen automatically at startup: - **Overlay mode** — compositors with layer-shell support (Hyprland, Sway, niri, KDE Plasma Wayland) get the animated always-on-top overlay. Requires GTK4, PyCairo, and `gtk4-layer-shell`. -- **Notification mode** — GNOME/Mutter lacks layer-shell, so status shows as desktop notifications (recording / transcribing / inserted), which never steal the focus the paste needs. Only requires `notify-send` (libnotify). +- **Notification mode** — GNOME/Mutter and X11 sessions use desktop notifications (recording / transcribing / inserted), which never steal the focus the paste needs. The layer-shell overlay is Wayland-only. Notifications require `notify-send` (libnotify). Set `mic_osd_enabled: false` to turn off both. The service log records which mode was selected: @@ -879,7 +879,7 @@ Automatically converts spoken words to symbols and punctuation: ## Paste and clipboard behavior -hyprwhspr copies dictated text to the clipboard, sends a paste shortcut, then restores your clipboard. The hotkey goes out via `wtype` (Wayland virtual-keyboard), falling back to `ydotool key`. Most setups need no configuration. GNOME/Mutter has a few extras — see [GNOME/Mutter notes](#gnomemutter-notes). +hyprwhspr copies dictated text to the clipboard, sends a paste shortcut, then restores your clipboard. Wayland prefers `wl-clipboard` plus `wtype` (falling back to `ydotool key`). X11 uses `python-pyperclip` with `xclip`, and `xdotool`/`xprop` for focused-window and terminal detection. `xsel` is also accepted as a clipboard fallback when installed. Most setups need no configuration. GNOME/Mutter has a few extras — see [GNOME/Mutter notes](#gnomemutter-notes). ### Paste mode @@ -954,10 +954,11 @@ hyprwhspr saves your clipboard before injection and restores it afterward — di ### GNOME/Mutter notes -GNOME/Mutter lacks layer-shell and blocks `wtype`, so hyprwhspr behaves differently there: +GNOME/Mutter lacks layer-shell, so visual feedback uses notifications. Injection depends on the session: - **Window detection** uses the AT-SPI accessibility bridge — `hyprwhspr setup` offers to enable it (`gsettings set org.gnome.desktop.interface toolkit-accessibility true`). Without it, GNOME can't tell terminals apart and paste falls back to Ctrl+V. An explicit `paste_mode` (with no `applications` rules) skips the probe entirely. -- **Direct typing:** ASCII text on a US layout is typed directly with `ydotool type`; anything else falls back to clipboard paste automatically. Set `"prefer_clipboard_paste": true` to always use clipboard paste. +- **GNOME Wayland direct typing:** Mutter blocks `wtype`, so ASCII text on a US layout is typed directly with `ydotool type`; anything else falls back to clipboard paste automatically. Set `"prefer_clipboard_paste": true` to always use clipboard paste. +- **GNOME X11 clipboard paste:** X11 uses `xclip` and a normal paste chord rather than the Wayland-only direct-typing workaround. GNOME/X11 on Ubuntu 24.04 is the currently validated X11 configuration. - **Non-Latin layouts** (Thai, Russian, Arabic, …): no physical key produces a `v` keysym, so hyprwhspr briefly switches to a Latin input source for the paste chord and restores your layout after — just keep a Latin source in Settings → Keyboard → Input Sources. ### Post-transcription hook @@ -1255,8 +1256,8 @@ Check your session: # Verify graphical-session.target is active systemctl --user is-active graphical-session.target -# Verify Wayland env is available to systemd services -systemctl --user show-environment | grep -E 'WAYLAND_DISPLAY|NIRI_SOCKET' +# Verify the active display environment is available to systemd services +systemctl --user show-environment | grep -E 'WAYLAND_DISPLAY|DISPLAY|XAUTHORITY|NIRI_SOCKET' ``` If `WAYLAND_DISPLAY` is missing, add to `~/.config/hypr/hyprland.conf`: @@ -1272,6 +1273,16 @@ socket for its own process and children. The compositor environment export above is still the recommended fix because it makes the correct display available to all systemd user services. +For X11, `DISPLAY` must be present and `XAUTHORITY` should be imported when your +session uses it: + +```bash +systemctl --user import-environment DISPLAY XAUTHORITY XDG_SESSION_TYPE XDG_CURRENT_DESKTOP +systemctl --user show-environment | grep -E 'DISPLAY|XAUTHORITY|XDG_SESSION_TYPE' +``` + +Do not set `WAYLAND_DISPLAY` in an explicit `XDG_SESSION_TYPE=x11` session. + **Niri:** hyprwhspr uses `niri msg --json focused-window` to detect the focused app and choose the correct paste shortcut. That requires `NIRI_SOCKET` to be available in the systemd user environment used by `hyprwhspr.service`. diff --git a/website/src/layouts/BlogPost.astro b/website/src/layouts/BlogPost.astro index 19a55b92..afc1604c 100644 --- a/website/src/layouts/BlogPost.astro +++ b/website/src/layouts/BlogPost.astro @@ -54,7 +54,7 @@ const { title, description, tag, date, readTime } = Astro.props;
Ready to try it? - Free, open source, runs on any Linux with Wayland. + Free, open source, runs on Linux with Wayland or X11.
Install hyprwhspr →
diff --git a/website/src/pages/index.astro b/website/src/pages/index.astro index 5adb6e7f..654ad693 100644 --- a/website/src/pages/index.astro +++ b/website/src/pages/index.astro @@ -12,7 +12,7 @@ import "../styles/global.css"; hyprwhspr — system speech-to-text for Linux @@ -28,7 +28,7 @@ import "../styles/global.css";
- Linux · Wayland + Linux · Wayland · X11

@@ -250,8 +250,8 @@ import "../styles/global.css"; >

Works everywhere

- Hyprland, GNOME, KDE Plasma, Sway — any Wayland - compositor with systemd. + Wayland and X11 across Hyprland, GNOME, KDE Plasma, + and Sway. GNOME/X11 on Ubuntu 24.04 is validated.

@@ -465,7 +465,7 @@ import "../styles/global.css"; It supports more local backends than any comparable tool, plus a full range of cloud APIs, and it's well featured: five recording modes, per-app paste rules, Waybar integration, live model unload - for VRAM, and evdev hotkeys on any Wayland compositor — Hyprland, + for VRAM, and evdev hotkeys across Wayland and X11 — Hyprland, GNOME, KDE, Sway. Actively maintained.

diff --git a/website/src/pages/linux-speech-to-text.astro b/website/src/pages/linux-speech-to-text.astro index f4192e10..3112a4cd 100644 --- a/website/src/pages/linux-speech-to-text.astro +++ b/website/src/pages/linux-speech-to-text.astro @@ -70,7 +70,7 @@ import BlogPost from "../layouts/BlogPost.astro";
  • Injection: ydotool pastes transcribed text into any - active window + active window on Wayland; xclip and xdotool provide the X11 path
  • Service: systemd keeps it running in the