From 30bd2f56a3f082ed90539628bc8622970afbab94 Mon Sep 17 00:00:00 2001 From: rgdevment Date: Fri, 13 Mar 2026 18:46:55 -0300 Subject: [PATCH 1/4] feat: add Linux X11 support with hotkey registration and error handling improvements --- app/lib/l10n/app_en.arb | 22 ++- app/lib/l10n/app_es.arb | 4 +- app/lib/l10n/app_localizations.dart | 16 ++- app/lib/l10n/app_localizations_en.dart | 12 +- app/lib/l10n/app_localizations_es.dart | 12 +- app/lib/main.dart | 86 ++++++++---- app/lib/screens/settings_screen.dart | 6 +- app/lib/shell/hotkey_handler.dart | 108 ++++++++------- app/lib/shell/linux_hotkey_registration.dart | 129 ++++++++++++++++++ app/lib/shell/linux_shell.dart | 12 +- app/linux/runner/copypaste_linux_shell.c | 82 ++++++++--- .../shell/linux_hotkey_registration_test.dart | 96 +++++++++++++ core/lib/config/app_config.dart | 115 ++++++++++------ core/test/app_config_test.dart | 10 ++ listener/lib/clipboard_writer.dart | 30 ++-- 15 files changed, 587 insertions(+), 153 deletions(-) create mode 100644 app/lib/shell/linux_hotkey_registration.dart create mode 100644 app/test/shell/linux_hotkey_registration_test.dart diff --git a/app/lib/l10n/app_en.arb b/app/lib/l10n/app_en.arb index 7ec3efd..54188de 100644 --- a/app/lib/l10n/app_en.arb +++ b/app/lib/l10n/app_en.arb @@ -357,6 +357,24 @@ "updateDismiss": "Later", "@updateDismiss": { "description": "Button to dismiss the update notification" }, - "waylandWarning": "Wayland detected: global hotkey and auto-paste are not supported. Use an X11 session for full functionality.", - "@waylandWarning": { "description": "Warning shown when running under Wayland instead of X11" } + "waylandWarning": "Wayland is not supported yet on Linux for global hotkeys and auto-paste because of desktop/session restrictions. Please use X11 or a compatible session.", + "@waylandWarning": { "description": "Warning shown immediately when running under Wayland on Linux" }, + + "linuxHotkeyFallbackWarning": "The shortcut {requested} is unavailable on this X11 desktop. CopyPaste is temporarily using {fallback}. You can change it in Settings.", + "@linuxHotkeyFallbackWarning": { + "description": "Shown when the preferred Linux hotkey is unavailable and a temporary fallback is active", + "placeholders": { + "requested": { "type": "String" }, + "fallback": { "type": "String" } + } + }, + + "linuxHotkeyConflictWarning": "The shortcut {requested} is unavailable on this X11 desktop, and the temporary fallback {fallback} also failed. Open Settings to choose another shortcut.", + "@linuxHotkeyConflictWarning": { + "description": "Shown when both the requested Linux hotkey and the temporary fallback fail", + "placeholders": { + "requested": { "type": "String" }, + "fallback": { "type": "String" } + } + } } diff --git a/app/lib/l10n/app_es.arb b/app/lib/l10n/app_es.arb index d74b3e0..f93f9a9 100644 --- a/app/lib/l10n/app_es.arb +++ b/app/lib/l10n/app_es.arb @@ -175,5 +175,7 @@ "updateViewRelease": "Ver versi\u00f3n", "updateDismiss": "Despu\u00e9s", - "waylandWarning": "Wayland detectado: el atajo global y el pegado autom\u00e1tico no est\u00e1n soportados. Usa una sesi\u00f3n X11 para funcionalidad completa." + "waylandWarning": "Wayland a\u00fan no est\u00e1 soportado en Linux para atajos globales y pegado autom\u00e1tico por restricciones del escritorio o la sesi\u00f3n. Usa X11 o una sesi\u00f3n compatible.", + "linuxHotkeyFallbackWarning": "El atajo {requested} no est\u00e1 disponible en este escritorio X11. CopyPaste est\u00e1 usando temporalmente {fallback}. Puedes cambiarlo en Configuraci\u00f3n.", + "linuxHotkeyConflictWarning": "El atajo {requested} no est\u00e1 disponible en este escritorio X11 y el fallback temporal {fallback} tambi\u00e9n fall\u00f3. Abre Configuraci\u00f3n para elegir otro atajo." } diff --git a/app/lib/l10n/app_localizations.dart b/app/lib/l10n/app_localizations.dart index 8c227f0..025850f 100644 --- a/app/lib/l10n/app_localizations.dart +++ b/app/lib/l10n/app_localizations.dart @@ -950,11 +950,23 @@ abstract class AppLocalizations { /// **'Later'** String get updateDismiss; - /// Warning shown when running under Wayland instead of X11 + /// Warning shown immediately when running under Wayland on Linux /// /// In en, this message translates to: - /// **'Wayland detected: global hotkey and auto-paste are not supported. Use an X11 session for full functionality.'** + /// **'Wayland is not supported yet on Linux for global hotkeys and auto-paste because of desktop/session restrictions. Please use X11 or a compatible session.'** String get waylandWarning; + + /// Shown when the preferred Linux hotkey is unavailable and a temporary fallback is active + /// + /// In en, this message translates to: + /// **'The shortcut {requested} is unavailable on this X11 desktop. CopyPaste is temporarily using {fallback}. You can change it in Settings.'** + String linuxHotkeyFallbackWarning(String requested, String fallback); + + /// Shown when both the requested Linux hotkey and the temporary fallback fail + /// + /// In en, this message translates to: + /// **'The shortcut {requested} is unavailable on this X11 desktop, and the temporary fallback {fallback} also failed. Open Settings to choose another shortcut.'** + String linuxHotkeyConflictWarning(String requested, String fallback); } class _AppLocalizationsDelegate diff --git a/app/lib/l10n/app_localizations_en.dart b/app/lib/l10n/app_localizations_en.dart index dfe3081..bd592d0 100644 --- a/app/lib/l10n/app_localizations_en.dart +++ b/app/lib/l10n/app_localizations_en.dart @@ -460,5 +460,15 @@ class AppLocalizationsEn extends AppLocalizations { @override String get waylandWarning => - 'Wayland detected: global hotkey and auto-paste are not supported. Use an X11 session for full functionality.'; + 'Wayland is not supported yet on Linux for global hotkeys and auto-paste because of desktop/session restrictions. Please use X11 or a compatible session.'; + + @override + String linuxHotkeyFallbackWarning(String requested, String fallback) { + return 'The shortcut $requested is unavailable on this X11 desktop. CopyPaste is temporarily using $fallback. You can change it in Settings.'; + } + + @override + String linuxHotkeyConflictWarning(String requested, String fallback) { + return 'The shortcut $requested is unavailable on this X11 desktop, and the temporary fallback $fallback also failed. Open Settings to choose another shortcut.'; + } } diff --git a/app/lib/l10n/app_localizations_es.dart b/app/lib/l10n/app_localizations_es.dart index d808bcc..2ee2c2f 100644 --- a/app/lib/l10n/app_localizations_es.dart +++ b/app/lib/l10n/app_localizations_es.dart @@ -464,5 +464,15 @@ class AppLocalizationsEs extends AppLocalizations { @override String get waylandWarning => - 'Wayland detectado: el atajo global y el pegado automático no están soportados. Usa una sesión X11 para funcionalidad completa.'; + 'Wayland aún no está soportado en Linux para atajos globales y pegado automático por restricciones del escritorio o la sesión. Usa X11 o una sesión compatible.'; + + @override + String linuxHotkeyFallbackWarning(String requested, String fallback) { + return 'El atajo $requested no está disponible en este escritorio X11. CopyPaste está usando temporalmente $fallback. Puedes cambiarlo en Configuración.'; + } + + @override + String linuxHotkeyConflictWarning(String requested, String fallback) { + return 'El atajo $requested no está disponible en este escritorio X11 y el fallback temporal $fallback también falló. Abre Configuración para elegir otro atajo.'; + } } diff --git a/app/lib/main.dart b/app/lib/main.dart index 94257ec..7662457 100644 --- a/app/lib/main.dart +++ b/app/lib/main.dart @@ -16,6 +16,7 @@ import 'services/auto_update_service.dart'; import 'shell/app_window.dart'; import 'shell/focus_manager.dart'; import 'shell/hotkey_handler.dart'; +import 'shell/linux_hotkey_registration.dart'; import 'shell/single_instance.dart'; import 'shell/startup_helper.dart'; import 'shell/tray_icon.dart'; @@ -183,7 +184,7 @@ class _CopyPasteAppState extends State if (!Platform.isMacOS || _config.showTrayIcon) { await _trayIcon.init(); } - await _hotkeyHandler.registerWithFallback(); + await _registerHotkeyWithFeedback(); if (Platform.isMacOS) { final granted = await ClipboardWriter.checkAccessibility(); @@ -212,12 +213,68 @@ class _CopyPasteAppState extends State _startListening(); AutoUpdateService.onUpdateAvailable = _onUpdateAvailable; unawaited(AutoUpdateService.initialize()); + } + + Future _registerHotkeyWithFeedback() async { + if (!Platform.isLinux) { + await _hotkeyHandler.registerWithFallback(); + return; + } + + if (isWaylandSession()) { + AppLogger.info( + 'Wayland session detected — global hotkey registration disabled', + ); + _showLinuxNotice((l) => l.waylandWarning); + return; + } + + final result = await _hotkeyHandler.registerWithFallback(); + if (result.status == HotkeyRegistrationStatus.fallbackRegistered) { + AppLogger.info( + 'Primary Linux hotkey failed, using temporary fallback: ' + '${result.requestedBinding.label()} -> ' + '${result.effectiveBinding?.label()}', + ); + _showLinuxNotice( + (l) => l.linuxHotkeyFallbackWarning( + result.requestedBinding.label(), + result.effectiveBinding?.label() ?? + kLinuxTemporaryFallbackHotkey.label(), + ), + ); + return; + } - if (Platform.isLinux) { - _checkWaylandLimitations(); + if (result.status == HotkeyRegistrationStatus.failed) { + AppLogger.error( + 'Linux hotkey registration failed for ${result.requestedBinding.label()}', + ); + _showLinuxNotice( + (l) => l.linuxHotkeyConflictWarning( + result.requestedBinding.label(), + kLinuxTemporaryFallbackHotkey.label(), + ), + ); } } + void _showLinuxNotice(String Function(AppLocalizations l) messageBuilder) { + WidgetsBinding.instance.addPostFrameCallback((_) { + final ctx = _navigatorKey.currentContext; + if (ctx == null || !ctx.mounted) return; + final messenger = ScaffoldMessenger.maybeOf(ctx); + if (messenger == null) return; + + messenger.showSnackBar( + SnackBar( + content: Text(messageBuilder(AppLocalizations.of(ctx))), + duration: const Duration(seconds: 12), + ), + ); + }); + } + void _startListening() { if (!Platform.isWindows && !Platform.isMacOS && !Platform.isLinux) return; _listenerSubscription = widget.listener.onEvent.listen(_onClipboardEvent); @@ -445,7 +502,7 @@ class _CopyPasteAppState extends State config: newConfig, onHotkey: _onHotkey, ); - await _hotkeyHandler.registerWithFallback(); + await _registerHotkeyWithFeedback(); } if (Platform.isMacOS && newConfig.showTrayIcon != oldShowTray) { if (newConfig.showTrayIcon) { @@ -508,27 +565,6 @@ class _CopyPasteAppState extends State setState(() => _availableUpdateVersion = version); } - void _checkWaylandLimitations() { - if (!isWaylandSession()) return; - - AppLogger.info('Wayland session detected — hotkey and paste limited'); - - WidgetsBinding.instance.addPostFrameCallback((_) { - final ctx = _navigatorKey.currentContext; - if (ctx == null || !ctx.mounted) return; - final l = AppLocalizations.of(ctx); - final messenger = ScaffoldMessenger.maybeOf(ctx); - if (messenger == null) return; - - messenger.showSnackBar( - SnackBar( - content: Text(l.waylandWarning), - duration: const Duration(seconds: 12), - ), - ); - }); - } - @override void dispose() { WidgetsBinding.instance.removeObserver(this); diff --git a/app/lib/screens/settings_screen.dart b/app/lib/screens/settings_screen.dart index e772b9b..ac69413 100644 --- a/app/lib/screens/settings_screen.dart +++ b/app/lib/screens/settings_screen.dart @@ -27,7 +27,7 @@ class SettingsScreen extends StatefulWidget { final String configPath; final ClipboardService clipboardService; final StorageConfig storage; - final void Function(AppConfig newConfig, bool hotkeyChanged) onSave; + final Future Function(AppConfig newConfig, bool hotkeyChanged) onSave; @override State createState() => _SettingsScreenState(); @@ -151,11 +151,11 @@ class _SettingsScreenState extends State { final newConfig = _buildConfig(); await newConfig.save(widget.configPath); await StartupHelper.apply(_runOnStartup); - widget.onSave(newConfig, hotkeyChanged); + await widget.onSave(newConfig, hotkeyChanged); } void _resetToDefaults() { - const d = AppConfig(); + final d = AppConfig.defaultForCurrentPlatform(); setState(() { _preferredLanguage = d.preferredLanguage; _runOnStartup = d.runOnStartup; diff --git a/app/lib/shell/hotkey_handler.dart b/app/lib/shell/hotkey_handler.dart index 5912a72..2481f72 100644 --- a/app/lib/shell/hotkey_handler.dart +++ b/app/lib/shell/hotkey_handler.dart @@ -6,6 +6,7 @@ import 'package:core/core.dart'; import 'package:flutter/services.dart'; import 'package:hotkey_manager/hotkey_manager.dart'; +import 'linux_hotkey_registration.dart'; import 'linux_shell.dart'; class HotkeyHandler { @@ -16,7 +17,31 @@ class HotkeyHandler { HotKey? _hotkey; StreamSubscription? _linuxEventsSubscription; - Future _tryRegister(HotKey hotkey) async { + HotkeyBinding get _requestedBinding => HotkeyBinding( + virtualKey: config.hotkeyVirtualKey, + keyName: config.hotkeyKeyName, + useCtrl: config.hotkeyUseCtrl, + useWin: config.hotkeyUseWin, + useAlt: config.hotkeyUseAlt, + useShift: config.hotkeyUseShift, + ); + + Future _tryRegisterBinding(HotkeyBinding binding) async { + final keyCode = _mapVirtualKey(binding.virtualKey); + if (keyCode == null) return false; + + final modifiers = []; + if (binding.useCtrl) modifiers.add(HotKeyModifier.control); + if (binding.useWin) modifiers.add(HotKeyModifier.meta); + if (binding.useAlt) modifiers.add(HotKeyModifier.alt); + if (binding.useShift) modifiers.add(HotKeyModifier.shift); + + final hotkey = HotKey( + key: keyCode, + modifiers: modifiers, + scope: HotKeyScope.system, + ); + try { await hotKeyManager.register(hotkey, keyDownHandler: (_) => onHotkey()); _hotkey = hotkey; @@ -27,44 +52,48 @@ class HotkeyHandler { } } - Future registerWithFallback() async { + Future registerWithFallback() async { if (Platform.isLinux) { _linuxEventsSubscription ??= LinuxShell.events.listen((event) { if (event == 'hotkey') onHotkey(); }); - await _registerLinuxWithFallback(); - return; + return registerLinuxHotkeyWithFallback( + api: const LinuxShellHotkeyBindingApi(), + requestedBinding: _requestedBinding, + ); } - final modifiers = []; - if (config.hotkeyUseCtrl) modifiers.add(HotKeyModifier.control); - if (config.hotkeyUseWin) modifiers.add(HotKeyModifier.meta); - if (config.hotkeyUseAlt) modifiers.add(HotKeyModifier.alt); - if (config.hotkeyUseShift) modifiers.add(HotKeyModifier.shift); - - final keyCode = _mapVirtualKey(config.hotkeyVirtualKey); - if (keyCode == null) return; - - final primary = HotKey( - key: keyCode, - modifiers: modifiers, - scope: HotKeyScope.system, - ); - - if (await _tryRegister(primary)) return; + final requestedBinding = _requestedBinding; + if (await _tryRegisterBinding(requestedBinding)) { + return HotkeyRegistrationResult( + status: HotkeyRegistrationStatus.registered, + requestedBinding: requestedBinding, + effectiveBinding: requestedBinding, + ); + } if (config.hotkeyUseWin) { - final fallbackMods = - modifiers.where((m) => m != HotKeyModifier.meta).toList() - ..add(HotKeyModifier.control); - - final fallback = HotKey( - key: keyCode, - modifiers: fallbackMods, - scope: HotKeyScope.system, + final fallbackBinding = HotkeyBinding( + virtualKey: requestedBinding.virtualKey, + keyName: requestedBinding.keyName, + useCtrl: true, + useWin: false, + useAlt: requestedBinding.useAlt, + useShift: requestedBinding.useShift, ); - await _tryRegister(fallback); + if (await _tryRegisterBinding(fallbackBinding)) { + return HotkeyRegistrationResult( + status: HotkeyRegistrationStatus.fallbackRegistered, + requestedBinding: requestedBinding, + effectiveBinding: fallbackBinding, + ); + } } + + return HotkeyRegistrationResult( + status: HotkeyRegistrationStatus.failed, + requestedBinding: requestedBinding, + ); } Future unregister() async { @@ -113,25 +142,4 @@ class HotkeyHandler { }; return map[vk]; } - - Future _registerLinuxWithFallback() async { - final registered = await LinuxShell.registerHotkey( - virtualKey: config.hotkeyVirtualKey, - useCtrl: config.hotkeyUseCtrl, - useWin: config.hotkeyUseWin, - useAlt: config.hotkeyUseAlt, - useShift: config.hotkeyUseShift, - ); - if (registered) return; - - if (config.hotkeyUseWin) { - await LinuxShell.registerHotkey( - virtualKey: config.hotkeyVirtualKey, - useCtrl: true, - useWin: false, - useAlt: config.hotkeyUseAlt, - useShift: config.hotkeyUseShift, - ); - } - } } diff --git a/app/lib/shell/linux_hotkey_registration.dart b/app/lib/shell/linux_hotkey_registration.dart new file mode 100644 index 0000000..2a16532 --- /dev/null +++ b/app/lib/shell/linux_hotkey_registration.dart @@ -0,0 +1,129 @@ +import 'package:flutter/foundation.dart'; + +import 'linux_shell.dart'; + +enum HotkeyRegistrationStatus { registered, fallbackRegistered, failed } + +@immutable +class HotkeyBinding { + const HotkeyBinding({ + required this.virtualKey, + required this.keyName, + required this.useCtrl, + required this.useWin, + required this.useAlt, + required this.useShift, + }); + + final int virtualKey; + final String keyName; + final bool useCtrl; + final bool useWin; + final bool useAlt; + final bool useShift; + + String label({bool isMac = false}) { + final parts = []; + if (useCtrl) parts.add('Ctrl'); + if (useWin) parts.add(isMac ? 'Cmd' : 'Win'); + if (useAlt) parts.add(isMac ? 'Option' : 'Alt'); + if (useShift) parts.add('Shift'); + parts.add(keyName); + return parts.join('+'); + } + + @override + bool operator ==(Object other) { + if (identical(this, other)) return true; + return other is HotkeyBinding && + other.virtualKey == virtualKey && + other.keyName == keyName && + other.useCtrl == useCtrl && + other.useWin == useWin && + other.useAlt == useAlt && + other.useShift == useShift; + } + + @override + int get hashCode => + Object.hash(virtualKey, keyName, useCtrl, useWin, useAlt, useShift); +} + +const HotkeyBinding kLinuxTemporaryFallbackHotkey = HotkeyBinding( + virtualKey: 0x56, + keyName: 'V', + useCtrl: true, + useWin: false, + useAlt: true, + useShift: true, +); + +@immutable +class HotkeyRegistrationResult { + const HotkeyRegistrationResult({ + required this.status, + required this.requestedBinding, + this.effectiveBinding, + }); + + final HotkeyRegistrationStatus status; + final HotkeyBinding requestedBinding; + final HotkeyBinding? effectiveBinding; + + bool get isRegistered => + status == HotkeyRegistrationStatus.registered || + status == HotkeyRegistrationStatus.fallbackRegistered; +} + +abstract class LinuxHotkeyBindingApi { + Future registerHotkey(HotkeyBinding binding); +} + +class LinuxShellHotkeyBindingApi implements LinuxHotkeyBindingApi { + const LinuxShellHotkeyBindingApi(); + + @override + Future registerHotkey(HotkeyBinding binding) { + return LinuxShell.registerHotkey( + virtualKey: binding.virtualKey, + useCtrl: binding.useCtrl, + useWin: binding.useWin, + useAlt: binding.useAlt, + useShift: binding.useShift, + ); + } +} + +Future registerLinuxHotkeyWithFallback({ + required LinuxHotkeyBindingApi api, + required HotkeyBinding requestedBinding, + HotkeyBinding fallbackBinding = kLinuxTemporaryFallbackHotkey, +}) async { + if (await api.registerHotkey(requestedBinding)) { + return HotkeyRegistrationResult( + status: HotkeyRegistrationStatus.registered, + requestedBinding: requestedBinding, + effectiveBinding: requestedBinding, + ); + } + + if (requestedBinding == fallbackBinding) { + return HotkeyRegistrationResult( + status: HotkeyRegistrationStatus.failed, + requestedBinding: requestedBinding, + ); + } + + if (await api.registerHotkey(fallbackBinding)) { + return HotkeyRegistrationResult( + status: HotkeyRegistrationStatus.fallbackRegistered, + requestedBinding: requestedBinding, + effectiveBinding: fallbackBinding, + ); + } + + return HotkeyRegistrationResult( + status: HotkeyRegistrationStatus.failed, + requestedBinding: requestedBinding, + ); +} diff --git a/app/lib/shell/linux_shell.dart b/app/lib/shell/linux_shell.dart index b461e95..2d03c94 100644 --- a/app/lib/shell/linux_shell.dart +++ b/app/lib/shell/linux_shell.dart @@ -1,6 +1,7 @@ // coverage:ignore-file import 'dart:async'; +import 'package:core/core.dart'; import 'package:flutter/services.dart'; class LinuxShell { @@ -57,7 +58,9 @@ class LinuxShell { static Future destroyTray() async { try { await _methodChannel.invokeMethod('destroyTray'); - } catch (_) {} + } catch (e) { + AppLogger.error('LinuxShell.destroyTray failed: $e'); + } } static Future registerHotkey({ @@ -76,7 +79,8 @@ class LinuxShell { 'useShift': useShift, }); return result ?? false; - } catch (_) { + } catch (e) { + AppLogger.error('LinuxShell.registerHotkey failed: $e'); return false; } } @@ -84,6 +88,8 @@ class LinuxShell { static Future unregisterHotkey() async { try { await _methodChannel.invokeMethod('unregisterHotkey'); - } catch (_) {} + } catch (e) { + AppLogger.error('LinuxShell.unregisterHotkey failed: $e'); + } } } diff --git a/app/linux/runner/copypaste_linux_shell.c b/app/linux/runner/copypaste_linux_shell.c index 7d4c058..cec0703 100644 --- a/app/linux/runner/copypaste_linux_shell.c +++ b/app/linux/runner/copypaste_linux_shell.c @@ -213,6 +213,52 @@ static gboolean destroy_tray(CopyPasteLinuxShell* shell) { #ifdef GDK_WINDOWING_X11 static guint modifier_combinations[] = {0, LockMask, Mod2Mask, LockMask | Mod2Mask}; +static int (*previous_x11_error_handler)(Display*, XErrorEvent*) = NULL; +static Display* trapped_x11_display = NULL; +static int trapped_x11_error_code = Success; + +static int hotkey_x11_error_handler(Display* display, XErrorEvent* event) { + if (display == trapped_x11_display) { + trapped_x11_error_code = event->error_code; + return 0; + } + + if (previous_x11_error_handler != NULL) { + return previous_x11_error_handler(display, event); + } + + return 0; +} + +static gboolean trap_x11_grab(Display* display, + Window root_window, + KeyCode keycode, + guint modifiers) { + previous_x11_error_handler = XSetErrorHandler(hotkey_x11_error_handler); + trapped_x11_display = display; + trapped_x11_error_code = Success; + + XGrabKey(display, (int)keycode, (int)modifiers, root_window, True, + GrabModeAsync, GrabModeAsync); + XSync(display, False); + + trapped_x11_display = NULL; + XSetErrorHandler(previous_x11_error_handler); + previous_x11_error_handler = NULL; + + return trapped_x11_error_code == Success; +} + +static void ungrab_hotkey_variants(Display* display, + Window root_window, + KeyCode keycode, + guint modifiers) { + for (guint i = 0; i < G_N_ELEMENTS(modifier_combinations); i++) { + XUngrabKey(display, (int)keycode, + (int)(modifiers | modifier_combinations[i]), root_window); + } + XSync(display, False); +} static KeySym virtual_key_to_keysym(gint64 virtual_key) { if (virtual_key >= 0x41 && virtual_key <= 0x5A) { @@ -255,12 +301,9 @@ static void unregister_hotkey(CopyPasteLinuxShell* shell) { return; } - for (guint i = 0; i < G_N_ELEMENTS(modifier_combinations); i++) { - XUngrabKey(shell->xdisplay, (int)shell->hotkey_keycode, - (int)(shell->hotkey_modifiers | modifier_combinations[i]), - shell->root_window); - } - XFlush(shell->xdisplay); + ungrab_hotkey_variants(shell->xdisplay, shell->root_window, + (KeyCode)shell->hotkey_keycode, + shell->hotkey_modifiers); shell->hotkey_registered = FALSE; shell->hotkey_keycode = 0; shell->hotkey_modifiers = 0; @@ -291,18 +334,21 @@ static gboolean register_hotkey(CopyPasteLinuxShell* shell, FlValue* args) { } for (guint i = 0; i < G_N_ELEMENTS(modifier_combinations); i++) { - XGrabKey(shell->xdisplay, (int)keycode, - (int)(modifiers | modifier_combinations[i]), shell->root_window, - True, GrabModeAsync, GrabModeAsync); - } - XWindowAttributes attrs; - if (XGetWindowAttributes(shell->xdisplay, shell->root_window, &attrs) != 0) { - XSelectInput(shell->xdisplay, shell->root_window, - attrs.your_event_mask | KeyPressMask); - } else { - XSelectInput(shell->xdisplay, shell->root_window, KeyPressMask); + if (!trap_x11_grab(shell->xdisplay, shell->root_window, keycode, + modifiers | modifier_combinations[i])) { + ungrab_hotkey_variants(shell->xdisplay, shell->root_window, keycode, + modifiers); + return FALSE; } - XFlush(shell->xdisplay); + } + XWindowAttributes attrs; + if (XGetWindowAttributes(shell->xdisplay, shell->root_window, &attrs) != 0) { + XSelectInput(shell->xdisplay, shell->root_window, + attrs.your_event_mask | KeyPressMask); + } else { + XSelectInput(shell->xdisplay, shell->root_window, KeyPressMask); + } + XSync(shell->xdisplay, False); shell->hotkey_registered = TRUE; shell->hotkey_keycode = keycode; @@ -447,4 +493,4 @@ void copypaste_linux_shell_dispose(CopyPasteLinuxShell* shell) { g_clear_object(&shell->method_channel); g_clear_object(&shell->event_channel); g_free(shell); -} \ No newline at end of file +} diff --git a/app/test/shell/linux_hotkey_registration_test.dart b/app/test/shell/linux_hotkey_registration_test.dart new file mode 100644 index 0000000..27e002a --- /dev/null +++ b/app/test/shell/linux_hotkey_registration_test.dart @@ -0,0 +1,96 @@ +import 'package:flutter_test/flutter_test.dart'; + +import 'package:copypaste/shell/linux_hotkey_registration.dart'; + +class _FakeLinuxHotkeyBindingApi implements LinuxHotkeyBindingApi { + _FakeLinuxHotkeyBindingApi(this.responses); + + final List responses; + final List attempts = []; + + @override + Future registerHotkey(HotkeyBinding binding) async { + attempts.add(binding); + if (responses.isEmpty) return false; + return responses.removeAt(0); + } +} + +void main() { + group('registerLinuxHotkeyWithFallback', () { + const requested = HotkeyBinding( + virtualKey: 0x56, + keyName: 'V', + useCtrl: true, + useWin: false, + useAlt: true, + useShift: false, + ); + + test('registers requested binding when available', () async { + final api = _FakeLinuxHotkeyBindingApi([true]); + + final result = await registerLinuxHotkeyWithFallback( + api: api, + requestedBinding: requested, + ); + + expect(result.status, HotkeyRegistrationStatus.registered); + expect(result.effectiveBinding, requested); + expect(api.attempts, [requested]); + }); + + test( + 'falls back to temporary Linux shortcut when requested binding fails', + () async { + final api = _FakeLinuxHotkeyBindingApi([false, true]); + + final result = await registerLinuxHotkeyWithFallback( + api: api, + requestedBinding: requested, + ); + + expect(result.status, HotkeyRegistrationStatus.fallbackRegistered); + expect(result.effectiveBinding, kLinuxTemporaryFallbackHotkey); + expect(api.attempts, [ + requested, + kLinuxTemporaryFallbackHotkey, + ]); + }, + ); + + test( + 'fails cleanly when requested and temporary fallback both fail', + () async { + final api = _FakeLinuxHotkeyBindingApi([false, false]); + + final result = await registerLinuxHotkeyWithFallback( + api: api, + requestedBinding: requested, + ); + + expect(result.status, HotkeyRegistrationStatus.failed); + expect(result.effectiveBinding, isNull); + expect(api.attempts, [ + requested, + kLinuxTemporaryFallbackHotkey, + ]); + }, + ); + + test( + 'does not retry when requested binding already matches temporary fallback', + () async { + final api = _FakeLinuxHotkeyBindingApi([false]); + + final result = await registerLinuxHotkeyWithFallback( + api: api, + requestedBinding: kLinuxTemporaryFallbackHotkey, + ); + + expect(result.status, HotkeyRegistrationStatus.failed); + expect(api.attempts, [kLinuxTemporaryFallbackHotkey]); + }, + ); + }); +} diff --git a/core/lib/config/app_config.dart b/core/lib/config/app_config.dart index 6981fb1..d11610b 100644 --- a/core/lib/config/app_config.dart +++ b/core/lib/config/app_config.dart @@ -38,43 +38,80 @@ class AppConfig { this.accessibilityWasGranted = false, }); - factory AppConfig.fromJson(Map json) => AppConfig( - preferredLanguage: json['preferredLanguage'] as String? ?? 'auto', - runOnStartup: json['runOnStartup'] as bool? ?? true, - hotkeyUseCtrl: json['hotkeyUseCtrl'] as bool? ?? false, - hotkeyUseWin: json['hotkeyUseWin'] as bool? ?? true, - hotkeyUseAlt: json['hotkeyUseAlt'] as bool? ?? true, - hotkeyUseShift: json['hotkeyUseShift'] as bool? ?? false, - hotkeyVirtualKey: json['hotkeyVirtualKey'] as int? ?? 0x56, - hotkeyKeyName: json['hotkeyKeyName'] as String? ?? 'V', - pageSize: json['pageSize'] as int? ?? 30, - maxItemsBeforeCleanup: json['maxItemsBeforeCleanup'] as int? ?? 100, - scrollLoadThreshold: json['scrollLoadThreshold'] as int? ?? 400, - retentionDays: json['retentionDays'] as int? ?? 30, - colorLabels: - (json['colorLabels'] as Map?)?.map( - (k, v) => MapEntry(k, v as String), - ) ?? - const {}, - duplicateIgnoreWindowMs: json['duplicateIgnoreWindowMs'] as int? ?? 450, - delayBeforeFocusMs: json['delayBeforeFocusMs'] as int? ?? 100, - delayBeforePasteMs: json['delayBeforePasteMs'] as int? ?? 180, - maxFocusVerifyAttempts: json['maxFocusVerifyAttempts'] as int? ?? 15, - lastBackupDateUtc: json['lastBackupDateUtc'] != null - ? DateTime.tryParse(json['lastBackupDateUtc'] as String) - : null, - popupWidth: json['popupWidth'] as int? ?? 368, - popupHeight: json['popupHeight'] as int? ?? 500, - cardMinLines: json['cardMinLines'] as int? ?? 2, - cardMaxLines: json['cardMaxLines'] as int? ?? 5, - hideOnDeactivate: json['hideOnDeactivate'] as bool? ?? true, - resetScrollOnShow: json['resetScrollOnShow'] as bool? ?? true, - resetSearchOnShow: json['resetSearchOnShow'] as bool? ?? true, - hasSeenHint: json['hasSeenHint'] as bool? ?? false, - themeMode: json['themeMode'] as String? ?? 'auto', - showTrayIcon: json['showTrayIcon'] as bool? ?? true, - accessibilityWasGranted: json['accessibilityWasGranted'] as bool? ?? false, - ); + factory AppConfig.fromJson(Map json) { + final defaults = defaultForCurrentPlatform(); + return AppConfig( + preferredLanguage: + json['preferredLanguage'] as String? ?? defaults.preferredLanguage, + runOnStartup: json['runOnStartup'] as bool? ?? defaults.runOnStartup, + hotkeyUseCtrl: json['hotkeyUseCtrl'] as bool? ?? defaults.hotkeyUseCtrl, + hotkeyUseWin: json['hotkeyUseWin'] as bool? ?? defaults.hotkeyUseWin, + hotkeyUseAlt: json['hotkeyUseAlt'] as bool? ?? defaults.hotkeyUseAlt, + hotkeyUseShift: + json['hotkeyUseShift'] as bool? ?? defaults.hotkeyUseShift, + hotkeyVirtualKey: + json['hotkeyVirtualKey'] as int? ?? defaults.hotkeyVirtualKey, + hotkeyKeyName: json['hotkeyKeyName'] as String? ?? defaults.hotkeyKeyName, + pageSize: json['pageSize'] as int? ?? defaults.pageSize, + maxItemsBeforeCleanup: + json['maxItemsBeforeCleanup'] as int? ?? + defaults.maxItemsBeforeCleanup, + scrollLoadThreshold: + json['scrollLoadThreshold'] as int? ?? defaults.scrollLoadThreshold, + retentionDays: json['retentionDays'] as int? ?? defaults.retentionDays, + colorLabels: + (json['colorLabels'] as Map?)?.map( + (k, v) => MapEntry(k, v as String), + ) ?? + const {}, + duplicateIgnoreWindowMs: + json['duplicateIgnoreWindowMs'] as int? ?? + defaults.duplicateIgnoreWindowMs, + delayBeforeFocusMs: + json['delayBeforeFocusMs'] as int? ?? defaults.delayBeforeFocusMs, + delayBeforePasteMs: + json['delayBeforePasteMs'] as int? ?? defaults.delayBeforePasteMs, + maxFocusVerifyAttempts: + json['maxFocusVerifyAttempts'] as int? ?? + defaults.maxFocusVerifyAttempts, + lastBackupDateUtc: json['lastBackupDateUtc'] != null + ? DateTime.tryParse(json['lastBackupDateUtc'] as String) + : null, + popupWidth: json['popupWidth'] as int? ?? defaults.popupWidth, + popupHeight: json['popupHeight'] as int? ?? defaults.popupHeight, + cardMinLines: json['cardMinLines'] as int? ?? defaults.cardMinLines, + cardMaxLines: json['cardMaxLines'] as int? ?? defaults.cardMaxLines, + hideOnDeactivate: + json['hideOnDeactivate'] as bool? ?? defaults.hideOnDeactivate, + resetScrollOnShow: + json['resetScrollOnShow'] as bool? ?? defaults.resetScrollOnShow, + resetSearchOnShow: + json['resetSearchOnShow'] as bool? ?? defaults.resetSearchOnShow, + hasSeenHint: json['hasSeenHint'] as bool? ?? defaults.hasSeenHint, + themeMode: json['themeMode'] as String? ?? defaults.themeMode, + showTrayIcon: json['showTrayIcon'] as bool? ?? defaults.showTrayIcon, + accessibilityWasGranted: + json['accessibilityWasGranted'] as bool? ?? + defaults.accessibilityWasGranted, + ); + } + + static AppConfig defaultForCurrentPlatform() => + defaultForPlatform(Platform.isLinux ? 'linux' : 'default'); + + static AppConfig defaultForPlatform(String platform) { + if (platform == 'linux') { + return const AppConfig( + hotkeyUseCtrl: true, + hotkeyUseWin: false, + hotkeyUseAlt: true, + hotkeyUseShift: false, + hotkeyVirtualKey: 0x56, + hotkeyKeyName: 'V', + ); + } + return const AppConfig(); + } static const String fileName = 'config.json'; static const String appVersion = String.fromEnvironment( @@ -227,13 +264,13 @@ class AppConfig { static Future load(String configPath) async { final file = File(configPath); - if (!file.existsSync()) return const AppConfig(); + if (!file.existsSync()) return AppConfig.defaultForCurrentPlatform(); try { final json = jsonDecode(file.readAsStringSync()) as Map; return AppConfig.fromJson(json); } catch (e) { AppLogger.error('Failed to load config: $e'); - return const AppConfig(); + return AppConfig.defaultForCurrentPlatform(); } } diff --git a/core/test/app_config_test.dart b/core/test/app_config_test.dart index 87c86cb..b37020d 100644 --- a/core/test/app_config_test.dart +++ b/core/test/app_config_test.dart @@ -106,6 +106,16 @@ void main() { expect(config.hotkeyKeyName, equals('V')); }); + test('linux platform defaults use Ctrl+Alt+V', () { + final config = AppConfig.defaultForPlatform('linux'); + expect(config.hotkeyUseCtrl, isTrue); + expect(config.hotkeyUseWin, isFalse); + expect(config.hotkeyUseAlt, isTrue); + expect(config.hotkeyUseShift, isFalse); + expect(config.hotkeyVirtualKey, equals(0x56)); + expect(config.hotkeyKeyName, equals('V')); + }); + test('copyWith all hotkey fields', () { const config = AppConfig(); final updated = config.copyWith( diff --git a/listener/lib/clipboard_writer.dart b/listener/lib/clipboard_writer.dart index cb854b4..fe71617 100644 --- a/listener/lib/clipboard_writer.dart +++ b/listener/lib/clipboard_writer.dart @@ -1,6 +1,6 @@ import 'dart:convert'; -import 'package:flutter/foundation.dart'; +import 'package:core/core.dart'; import 'package:flutter/services.dart'; class ClipboardWriter { @@ -31,7 +31,7 @@ class ClipboardWriter { args['html'] = base64Decode(htmlB64); } } catch (e) { - debugPrint('ClipboardWriter: metadata parse error: $e'); + AppLogger.error('ClipboardWriter metadata parse error: $e'); } } @@ -87,7 +87,8 @@ class ClipboardWriter { {'path': path}, ); return result; - } catch (_) { + } catch (e) { + AppLogger.error('ClipboardWriter.getMediaInfo failed: $e'); return null; } } @@ -95,7 +96,8 @@ class ClipboardWriter { static Future captureFrontmostApp() async { try { return await _channel.invokeMethod('captureFrontmostApp'); - } catch (_) { + } catch (e) { + AppLogger.error('ClipboardWriter.captureFrontmostApp failed: $e'); return null; } } @@ -112,6 +114,13 @@ class ClipboardWriter { return result ?? false; } on PlatformException catch (e) { if (e.code == 'ACCESSIBILITY_DENIED') rethrow; + AppLogger.error( + 'ClipboardWriter.activateAndPaste platform failure ' + '[${e.code}]: ${e.message}', + ); + return false; + } catch (e) { + AppLogger.error('ClipboardWriter.activateAndPaste failed: $e'); return false; } } @@ -123,7 +132,8 @@ class ClipboardWriter { ); if (result == null) return null; return result.map((k, v) => MapEntry(k, (v as num).toDouble())); - } catch (_) { + } catch (e) { + AppLogger.error('ClipboardWriter.getCursorAndScreenInfo failed: $e'); return null; } } @@ -132,7 +142,8 @@ class ClipboardWriter { try { final result = await _channel.invokeMethod('checkAccessibility'); return result ?? false; - } catch (_) { + } catch (e) { + AppLogger.error('ClipboardWriter.checkAccessibility failed: $e'); return false; } } @@ -141,7 +152,8 @@ class ClipboardWriter { try { final result = await _channel.invokeMethod('requestAccessibility'); return result ?? false; - } catch (_) { + } catch (e) { + AppLogger.error('ClipboardWriter.requestAccessibility failed: $e'); return false; } } @@ -149,6 +161,8 @@ class ClipboardWriter { static Future openAccessibilitySettings() async { try { await _channel.invokeMethod('openAccessibilitySettings'); - } catch (_) {} + } catch (e) { + AppLogger.error('ClipboardWriter.openAccessibilitySettings failed: $e'); + } } } From 5981714585fc2d13fec2508a8b35f51a21438c51 Mon Sep 17 00:00:00 2001 From: rgdevment Date: Fri, 13 Mar 2026 19:17:53 -0300 Subject: [PATCH 2/4] feat: update Linux X11 support with additional hotkey options and installation notes --- README.md | 84 +++++++++++++++++++++++++++++++------------------------ 1 file changed, 47 insertions(+), 37 deletions(-) diff --git a/README.md b/README.md index b5d0a89..a2ba8d7 100644 --- a/README.md +++ b/README.md @@ -258,23 +258,23 @@ If you care about privacy and control, this is made for you. Read our full [Priv CopyPaste is designed for power users who prefer keyboard navigation: -| Shortcut | Action | -| :------------------------ | :-------------------------------------------------- | -| `Win+Alt+V` / `Cmd+Alt+V` | Open/close CopyPaste (default hotkey, customizable) | -| `↓` or `Tab` | Navigate from search to clipboard items | -| `↑` / `↓` | Navigate between clipboard items | -| `Space` | Expand/collapse selected card to see more text | -| `Ctrl+F` / `Cmd+F` | Focus search box | -| `Enter` | Paste selected item and return to previous app | -| `Delete` | Delete selected item | -| `P` | Pin/Unpin selected item | -| `E` | Edit card (add label and color) | -| `Ctrl+1` | Switch to Recent tab | -| `Ctrl+2` | Switch to Pinned tab | -| `Alt+C` | Switch to Content filter mode (text search) | -| `Alt+G` | Switch to Category filter mode (by color) | -| `Alt+T` | Switch to Type filter mode (by item type) | -| `Esc` | Clear current filter or close window | +| Shortcut | Action | +| :--------------------------------------- | :-------------------------------------------------- | +| `Win+Alt+V` / `Cmd+Alt+V` / `Ctrl+Alt+V` | Open/close CopyPaste (default hotkey, customizable) | +| `↓` or `Tab` | Navigate from search to clipboard items | +| `↑` / `↓` | Navigate between clipboard items | +| `Space` | Expand/collapse selected card to see more text | +| `Ctrl+F` / `Cmd+F` | Focus search box | +| `Enter` | Paste selected item and return to previous app | +| `Delete` | Delete selected item | +| `P` | Pin/Unpin selected item | +| `E` | Edit card (add label and color) | +| `Ctrl+1` | Switch to Recent tab | +| `Ctrl+2` | Switch to Pinned tab | +| `Alt+C` | Switch to Content filter mode (text search) | +| `Alt+G` | Switch to Category filter mode (by color) | +| `Alt+T` | Switch to Type filter mode (by item type) | +| `Esc` | Clear current filter or close window | ### Card Customization @@ -344,7 +344,7 @@ Clipboard items (cards) can be expanded to show more text content: ### Keyboard-Only Workflow -1. **Press `Win+Alt+V`** → Window opens with focus on search box +1. **Press your platform default hotkey** (`Win+Alt+V` on Windows, `Cmd+Alt+V` on macOS, `Ctrl+Alt+V` on Linux) → Window opens with focus on search box 2. **Type to filter** (optional) → Results update in real-time (searches content and labels) 3. **Press `Esc`** (optional) → Clear search to see all items again 4. **Press `↓`** → Navigate to first clipboard item @@ -396,7 +396,7 @@ brew tap rgdevment/tap && brew install --cask copypaste Packages are hosted on [Cloudsmith](https://cloudsmith.io/~rgdevment/repos/copypaste/) — set up the repository once, then get updates through your system package manager. -**Debian, Ubuntu, Pop!_OS and derivatives:** +**Debian, Ubuntu, Pop!\_OS and derivatives:** ```bash curl -1sLf 'https://dl.cloudsmith.io/public/rgdevment/copypaste/setup.deb.sh' | sudo -E bash @@ -412,6 +412,10 @@ sudo dnf install copypaste > **Note:** Requires an **X11 session**. On Wayland, global hotkey and auto-paste are unavailable — a warning is shown at startup. +> **Permissions note:** `apt`/`dnf` installation writes to system locations, so `sudo` is required. If your user cannot use `sudo`, those commands will fail with permission errors. + +> **No-sudo alternatives:** Use **Homebrew (Linux)** if available for your user, or run the `.AppImage` from your home directory (`chmod +x CopyPaste-*.AppImage && ./CopyPaste-*.AppImage`). + **Alternative Linux (requires Homebrew installed):** ```bash @@ -420,7 +424,9 @@ brew tap rgdevment/tap && brew install copypaste --- -After installing, open CopyPaste with **`Win+Alt+V`** (Windows), **`Cmd+Alt+V`** (macOS), or **`Super+Alt+V`** (Linux). +After installing, open CopyPaste with **`Win+Alt+V`** (Windows), **`Cmd+Alt+V`** (macOS), or **`Ctrl+Alt+V`** (Linux). + +If `Ctrl+Alt+V` is already taken on Linux/X11 by another app or desktop shortcut, CopyPaste temporarily uses **`Ctrl+Alt+Shift+V`** for that session and shows a warning. ### Compatibility @@ -436,13 +442,13 @@ After installing, open CopyPaste with **`Win+Alt+V`** (Windows), **`Cmd+Alt+V`** _Not a fan of package managers? Direct packages are on [GitHub Releases](https://github.com/rgdevment/CopyPaste/releases/latest)._ -| Platform | Download | Notes | -| :---------- | :------- | :---- | -| **Windows** | [`.exe`](https://github.com/rgdevment/CopyPaste/releases/latest) | Self-signed installer — see security note below | -| **macOS** | [`.dmg`](https://github.com/rgdevment/CopyPaste/releases/latest) | Universal binary (Apple Silicon + Intel) | -| **Linux** | [`.AppImage`](https://github.com/rgdevment/CopyPaste/releases/latest) | No install — `chmod +x` and run | -| **Linux** | [`.deb`](https://github.com/rgdevment/CopyPaste/releases/latest) | Debian, Ubuntu and derivatives | -| **Linux** | [`.rpm`](https://github.com/rgdevment/CopyPaste/releases/latest) | Fedora, RHEL and derivatives | +| Platform | Download | Notes | +| :---------- | :-------------------------------------------------------------------- | :---------------------------------------------- | +| **Windows** | [`.exe`](https://github.com/rgdevment/CopyPaste/releases/latest) | Self-signed installer — see security note below | +| **macOS** | [`.dmg`](https://github.com/rgdevment/CopyPaste/releases/latest) | Universal binary (Apple Silicon + Intel) | +| **Linux** | [`.AppImage`](https://github.com/rgdevment/CopyPaste/releases/latest) | No install — `chmod +x` and run | +| **Linux** | [`.deb`](https://github.com/rgdevment/CopyPaste/releases/latest) | Debian, Ubuntu and derivatives | +| **Linux** | [`.rpm`](https://github.com/rgdevment/CopyPaste/releases/latest) | Fedora, RHEL and derivatives |
⚠️ Windows standalone: security warnings @@ -474,6 +480,9 @@ No. It works fully offline. The standalone version makes a lightweight check for **Does it sync between devices?** No. There’s intentionally no cloud sync. +**Do I need `sudo` to install on Linux?** +For `apt`/`dnf`, yes. They install to system paths, so without `sudo` (or equivalent admin rights) installation fails due to permissions. If you cannot use `sudo`, use Homebrew (if available) or the `.AppImage`. + **Where are my files stored?** Windows: `%LOCALAPPDATA%\CopyPaste\` — macOS: `~/Library/Application Support/com.rgdevment.copypaste/CopyPaste/` — Linux: `~/.local/share/com.rgdevment.copypaste/CopyPaste/`. All contain the database, images, config, and logs. @@ -597,17 +606,17 @@ Contributions are always appreciated — whether that's a bug report, a translat If you're curious about what's under the hood: -| Technology | Why | -| :--------------------------------- | :------------------------------------------------------------------------------------ | -| **Flutter** | Cross-platform UI toolkit — native on Windows, macOS, and Linux. | -| **Dart** | Clean, performant language for core logic, services, and domain models. | -| **Platform Channels + FFI** | Native integration with each OS for clipboard hooks and system APIs. | -| **Windows Mica / macOS Sidebar** | Native translucent effects that match each platform's design language. | +| Technology | Why | +| :---------------------------------------------------- | :------------------------------------------------------------------------------------ | +| **Flutter** | Cross-platform UI toolkit — native on Windows, macOS, and Linux. | +| **Dart** | Clean, performant language for core logic, services, and domain models. | +| **Platform Channels + FFI** | Native integration with each OS for clipboard hooks and system APIs. | +| **Windows Mica / macOS Sidebar** | Native translucent effects that match each platform's design language. | | **C++ Plugin (Win) / Swift (Mac) / C Plugin (Linux)** | Low-level clipboard listener to capture every content type before the OS discards it. | -| **Native C++ Launcher (Win)** | Lightweight splash process that appears instantly while Flutter warms up. | -| **SQLite (Drift) + FTS5** | Local database with full-text search across content and labels. | -| **Auto-update (Standalone)** | WinSparkle appcast on Windows · GitHub Releases API notification on macOS and Linux. | -| **Theme System** | Built-in Default and Compact themes, plus custom theme support via external packages. | +| **Native C++ Launcher (Win)** | Lightweight splash process that appears instantly while Flutter warms up. | +| **SQLite (Drift) + FTS5** | Local database with full-text search across content and labels. | +| **Auto-update (Standalone)** | WinSparkle appcast on Windows · GitHub Releases API notification on macOS and Linux. | +| **Theme System** | Built-in Default and Compact themes, plus custom theme support via external packages. | --- @@ -653,3 +662,4 @@ If that sounds good to you, I hope it serves you well — on Windows, macOS, or

Built with ❤️ and too much coffee.

+ From 5e3049c2516506a1ee3814294389a187c942bcd2 Mon Sep 17 00:00:00 2001 From: rgdevment Date: Fri, 13 Mar 2026 19:27:23 -0300 Subject: [PATCH 3/4] fix: update installation notes for Linux X11 support and clarify permissions requirements --- README.md | 2 -- 1 file changed, 2 deletions(-) diff --git a/README.md b/README.md index a2ba8d7..6c731ae 100644 --- a/README.md +++ b/README.md @@ -411,9 +411,7 @@ sudo dnf install copypaste ``` > **Note:** Requires an **X11 session**. On Wayland, global hotkey and auto-paste are unavailable — a warning is shown at startup. - > **Permissions note:** `apt`/`dnf` installation writes to system locations, so `sudo` is required. If your user cannot use `sudo`, those commands will fail with permission errors. - > **No-sudo alternatives:** Use **Homebrew (Linux)** if available for your user, or run the `.AppImage` from your home directory (`chmod +x CopyPaste-*.AppImage && ./CopyPaste-*.AppImage`). **Alternative Linux (requires Homebrew installed):** From 3a5f6358f80ab1a3f6680d7b3856f211a4147150 Mon Sep 17 00:00:00 2001 From: rgdevment Date: Fri, 13 Mar 2026 19:31:45 -0300 Subject: [PATCH 4/4] chore: remove unnecessary whitespace from README.md --- README.md | 1 - 1 file changed, 1 deletion(-) diff --git a/README.md b/README.md index 6c731ae..d3be352 100644 --- a/README.md +++ b/README.md @@ -660,4 +660,3 @@ If that sounds good to you, I hope it serves you well — on Windows, macOS, or

Built with ❤️ and too much coffee.

-