From 30248f11939f3871f443c772d2c657ecaa13987a Mon Sep 17 00:00:00 2001 From: rgdevment Date: Mon, 10 Aug 2026 11:44:50 -0400 Subject: [PATCH 1/2] Refactor AppWindow to remove showInTaskbar property and improve window visibility handling - Removed the showInTaskbar property from AppWindow class. - Updated window initialization to always skip taskbar on Windows and added visibility handling for macOS. - Refactored visibility logic in hide and exitGateMode methods. - Adjusted logging to reflect changes in taskbar behavior. Enhance WindowFocusManager to improve focus handling and diagnostics - Introduced focus repair diagnostics in WindowFocusManager. - Added methods to track and verify keyboard focus before pasting. - Improved error handling for focus-related issues during paste operations. Update WindowsHotkeyChannel to support focus and thread diagnostics - Modified sendPaste method to accept target window and focus parameters. - Enhanced response structure to include focus repair diagnostics. Improve SQLite database management for security and performance - Changed auto_vacuum pragma to secure_delete for better data handling. - Implemented incremental vacuuming to maintain database performance. Add permission restrictions for Linux storage directories - Implemented _restrictToOwner method to set directory permissions to 0700 on Linux. Enhance ClipboardService to prevent overwriting stale entries - Added logic to check for the existence of stored images before reactivating clipboard items. Refactor ListenerPlugin for better activation tracking on macOS - Implemented activation tracking to capture the last foreign application bundle ID. - Improved paste handling to ensure focus is correctly managed during paste operations. Fix clipboard handling in Windows ListenerPlugin - Added retry logic for OpenClipboard to handle brief locks by other applications. --- app/lib/l10n/app_en.arb | 2 + app/lib/l10n/app_es.arb | 2 + app/lib/l10n/app_localizations.dart | 12 ++ app/lib/l10n/app_localizations_en.dart | 8 ++ app/lib/l10n/app_localizations_es.dart | 8 ++ app/lib/main.dart | 30 +++- app/lib/shell/app_window.dart | 50 ++++--- app/lib/shell/focus_manager.dart | 103 +++++++++++--- app/lib/shell/windows_hotkey_channel.dart | 18 ++- .../shell/windows_hotkey_channel_test.dart | 64 +++++++++ app/windows/runner/flutter_window.cpp | 128 +++++++++++++++++- core/lib/config/storage_config.dart | 21 +++ core/lib/repository/sqlite_repository.dart | 27 +++- core/lib/services/clipboard_service.dart | 18 ++- .../test/clipboard_service_extended_test.dart | 14 ++ listener/macos/Classes/ListenerPlugin.swift | 123 ++++++++++++++--- listener/windows/listener_plugin.cpp | 85 ++++++++---- listener/windows/listener_plugin.h | 2 + 18 files changed, 612 insertions(+), 103 deletions(-) diff --git a/app/lib/l10n/app_en.arb b/app/lib/l10n/app_en.arb index 786807ae..a3a6cf89 100644 --- a/app/lib/l10n/app_en.arb +++ b/app/lib/l10n/app_en.arb @@ -315,6 +315,8 @@ "pasteDestinationUnavailable": "Paste was cancelled because the original destination could not be restored. Open CopyPaste with its keyboard shortcut and try again.", "plainPasteItemUnavailable": "The hovered, selected, or first item cannot be pasted as plain text.", "plainClipboardUnavailable": "There is no text on the clipboard. Copy some text first, then use plain-text paste again.", + "clipboardWriteFailed": "The item could not be placed on the clipboard because another app is holding it. Try again in a moment.", + "pasteTargetElevated": "The destination app runs as administrator, so Windows blocks the simulated paste. Run CopyPaste as administrator too, or press Ctrl+V yourself.", "hotkeyRegistrationFailed": "The shortcut {shortcut} could not be registered. It may already be in use by the system or another app.", "@hotkeyRegistrationFailed": { "placeholders": { "shortcut": { "type": "String" } } diff --git a/app/lib/l10n/app_es.arb b/app/lib/l10n/app_es.arb index b223a9c1..47f1ec85 100644 --- a/app/lib/l10n/app_es.arb +++ b/app/lib/l10n/app_es.arb @@ -166,6 +166,8 @@ "pasteDestinationUnavailable": "Se canceló el pegado porque no se pudo restaurar el destino original. Abre CopyPaste con su atajo de teclado e inténtalo nuevamente.", "plainPasteItemUnavailable": "El elemento bajo el cursor, el seleccionado o el primero no se puede pegar como texto plano.", "plainClipboardUnavailable": "No hay texto en el portapapeles. Copia primero un texto y vuelve a usar el pegado como texto plano.", + "clipboardWriteFailed": "No se pudo copiar el elemento al portapapeles porque otra aplicación lo tiene retenido. Vuelve a intentarlo en un momento.", + "pasteTargetElevated": "La aplicación de destino se ejecuta como administrador, así que Windows bloquea el pegado simulado. Ejecuta CopyPaste también como administrador o pulsa Ctrl+V tú mismo.", "hotkeyRegistrationFailed": "No se pudo registrar el atajo {shortcut}. Es posible que el sistema u otra aplicación ya lo esté usando.", "@hotkeyRegistrationFailed": { "placeholders": { "shortcut": { "type": "String" } } diff --git a/app/lib/l10n/app_localizations.dart b/app/lib/l10n/app_localizations.dart index 379da34d..88bd87b6 100644 --- a/app/lib/l10n/app_localizations.dart +++ b/app/lib/l10n/app_localizations.dart @@ -950,6 +950,18 @@ abstract class AppLocalizations { /// **'There is no text on the clipboard. Copy some text first, then use plain-text paste again.'** String get plainClipboardUnavailable; + /// No description provided for @clipboardWriteFailed. + /// + /// In en, this message translates to: + /// **'The item could not be placed on the clipboard because another app is holding it. Try again in a moment.'** + String get clipboardWriteFailed; + + /// No description provided for @pasteTargetElevated. + /// + /// In en, this message translates to: + /// **'The destination app runs as administrator, so Windows blocks the simulated paste. Run CopyPaste as administrator too, or press Ctrl+V yourself.'** + String get pasteTargetElevated; + /// No description provided for @hotkeyRegistrationFailed. /// /// In en, this message translates to: diff --git a/app/lib/l10n/app_localizations_en.dart b/app/lib/l10n/app_localizations_en.dart index d673e60c..582c7d4d 100644 --- a/app/lib/l10n/app_localizations_en.dart +++ b/app/lib/l10n/app_localizations_en.dart @@ -463,6 +463,14 @@ class AppLocalizationsEn extends AppLocalizations { String get plainClipboardUnavailable => 'There is no text on the clipboard. Copy some text first, then use plain-text paste again.'; + @override + String get clipboardWriteFailed => + 'The item could not be placed on the clipboard because another app is holding it. Try again in a moment.'; + + @override + String get pasteTargetElevated => + 'The destination app runs as administrator, so Windows blocks the simulated paste. Run CopyPaste as administrator too, or press Ctrl+V yourself.'; + @override String hotkeyRegistrationFailed(String shortcut) { return 'The shortcut $shortcut could not be registered. It may already be in use by the system or another app.'; diff --git a/app/lib/l10n/app_localizations_es.dart b/app/lib/l10n/app_localizations_es.dart index e6780a4d..5aff977f 100644 --- a/app/lib/l10n/app_localizations_es.dart +++ b/app/lib/l10n/app_localizations_es.dart @@ -466,6 +466,14 @@ class AppLocalizationsEs extends AppLocalizations { String get plainClipboardUnavailable => 'No hay texto en el portapapeles. Copia primero un texto y vuelve a usar el pegado como texto plano.'; + @override + String get clipboardWriteFailed => + 'No se pudo copiar el elemento al portapapeles porque otra aplicación lo tiene retenido. Vuelve a intentarlo en un momento.'; + + @override + String get pasteTargetElevated => + 'La aplicación de destino se ejecuta como administrador, así que Windows bloquea el pegado simulado. Ejecuta CopyPaste también como administrador o pulsa Ctrl+V tú mismo.'; + @override String hotkeyRegistrationFailed(String shortcut) { return 'No se pudo registrar el atajo $shortcut. Es posible que el sistema u otra aplicación ya lo esté usando.'; diff --git a/app/lib/main.dart b/app/lib/main.dart index f3827fee..9e0ba04d 100644 --- a/app/lib/main.dart +++ b/app/lib/main.dart @@ -247,7 +247,6 @@ class _CopyPasteAppState extends State _config = widget.config; _appWindow = AppWindow( onVisibilityChanged: _onWindowVisibilityChanged, - showInTaskbar: false, popupWidth: _config.popupWidth.toDouble(), popupHeight: _config.popupHeight.toDouble(), rememberPositionEnabled: () => _config.rememberWindowPosition, @@ -837,7 +836,12 @@ class _CopyPasteAppState extends State widget.clipboardService.notifyDirectPasteInitiated(text); final written = await ClipboardWriter.setText(text, plainText: true); if (!written) { + AppLogger.warn('Plain-text paste aborted: clipboard write failed'); if (!panelWasVisible) pasteFocusManager.clear(); + _showShellNotice( + (l) => l.clipboardWriteFailed, + revealWhenHidden: !panelWasVisible, + ); return; } @@ -853,10 +857,12 @@ class _CopyPasteAppState extends State pasteFocusManager.clear(); return; } + // With the panel open the destination lost activation and has to settle + // exactly like the item paste does; only the hidden path can skip it. final response = await pasteFocusManager.restoreAndPaste( - delayBeforeFocusMs: 0, + delayBeforeFocusMs: panelWasVisible ? _config.delayBeforeFocusMs : 0, maxFocusVerifyAttempts: _config.maxFocusVerifyAttempts, - delayBeforePasteMs: 0, + delayBeforePasteMs: panelWasVisible ? _config.delayBeforePasteMs : 0, ); if (!response.success) _reportPasteFailure(response); } on PlatformException catch (e) { @@ -945,6 +951,7 @@ class _CopyPasteAppState extends State bool plainText = false, }) async { if (_itemPasteInProgress || + _directPlainPasteInProgress || (item.isFileBasedType && !item.isFileAvailable())) { return; } @@ -963,7 +970,12 @@ class _CopyPasteAppState extends State metadata: item.metadata, plainText: plainText, ); - if (!ok) return; + if (!ok) { + AppLogger.warn('Item paste aborted: clipboard write failed'); + // The destination is intentionally kept so the user can retry. + _showShellNotice((l) => l.clipboardWriteFailed); + return; + } await _appWindow.hide(); if (!Platform.isWindows && !await _waitForShortcutModifiersReleased()) { _focusManager.clear(); @@ -1009,6 +1021,10 @@ class _CopyPasteAppState extends State ); return; } + if (response.errorCode == 'targetElevated') { + _showShellNotice((l) => l.pasteTargetElevated, revealWhenHidden: true); + return; + } _showShellNotice( (l) => l.pasteDestinationUnavailable, revealWhenHidden: true, @@ -1046,7 +1062,11 @@ class _CopyPasteAppState extends State content: item.content, metadata: item.metadata, ); - if (!ok) return; + if (!ok) { + AppLogger.warn('Copy aborted: clipboard write failed'); + _showShellNotice((l) => l.clipboardWriteFailed); + return; + } await widget.clipboardService.recordCopy(item.id); if (!mounted) return; final ctx = _navigatorKey.currentContext; diff --git a/app/lib/shell/app_window.dart b/app/lib/shell/app_window.dart index ce1dbc85..932695aa 100644 --- a/app/lib/shell/app_window.dart +++ b/app/lib/shell/app_window.dart @@ -141,7 +141,6 @@ class _Win32Pos { class AppWindow { AppWindow({ this.onVisibilityChanged, - this.showInTaskbar = true, double popupWidth = 360, double popupHeight = 500, this.rememberPositionEnabled, @@ -150,8 +149,6 @@ class AppWindow { }) : _popupWidth = popupWidth, _popupHeight = popupHeight; - bool showInTaskbar; - static const double _settingsWidth = 820; static const double _settingsHeight = 680; @@ -177,7 +174,6 @@ class AppWindow { Future init({bool startVisible = false}) async { AppLogger.info( 'AppWindow.init: startVisible=$startVisible, ' - 'showInTaskbar=$showInTaskbar, ' 'size=${_popupWidth}x$_popupHeight', ); try { @@ -217,8 +213,15 @@ class AppWindow { await windowManager.setResizable(false); await windowManager.setMaximizable(false); await windowManager.setPreventClose(true); - final inTaskbar = showInTaskbar && Platform.isWindows; - await windowManager.setSkipTaskbar(!inTaskbar); + await windowManager.setSkipTaskbar(true); + if (Platform.isMacOS) { + // Without this, opening the panel over a full-screen app switches Space, + // and the animation blows past the paste focus budget. + await windowManager.setVisibleOnAllWorkspaces( + true, + visibleOnFullScreen: true, + ); + } if (Platform.isWindows || Platform.isMacOS) { await windowManager.setBackgroundColor(const Color(0x00000000)); AppLogger.info('_configureWindow: applying initial effect'); @@ -228,9 +231,6 @@ class AppWindow { AppLogger.info('_configureWindow: centering and focusing'); await windowManager.center(); await windowManager.focus(); - } else if (inTaskbar) { - AppLogger.info('_configureWindow: minimizing to taskbar'); - await windowManager.minimize(); } else { AppLogger.info('_configureWindow: hiding window'); await windowManager.hide(); @@ -696,23 +696,19 @@ class AppWindow { if (!_visible) return; _visible = false; await _captureCurrentPosition(); - if (showInTaskbar && Platform.isWindows) { - await windowManager.minimize(); - } else { - Future? unmappedFuture; - if (Platform.isLinux) { - unmappedFuture = LinuxShell.awaitEvent( - 'unmapped', - timeout: const Duration(milliseconds: 300), - ); - } - await windowManager.hide(); - if (!Platform.isMacOS) { - await windowManager.setSkipTaskbar(true); - } - if (unmappedFuture != null) { - await unmappedFuture; - } + Future? unmappedFuture; + if (Platform.isLinux) { + unmappedFuture = LinuxShell.awaitEvent( + 'unmapped', + timeout: const Duration(milliseconds: 300), + ); + } + await windowManager.hide(); + if (!Platform.isMacOS) { + await windowManager.setSkipTaskbar(true); + } + if (unmappedFuture != null) { + await unmappedFuture; } onVisibilityChanged?.call(false); } @@ -807,7 +803,7 @@ class AppWindow { Future exitGateMode() async { _gateMode = false; await windowManager.setAlwaysOnTop(true); - await windowManager.setSkipTaskbar(!(showInTaskbar && Platform.isWindows)); + await windowManager.setSkipTaskbar(true); await windowManager.setMinimumSize(Size(_popupWidth, 400)); await windowManager.setMaximumSize(Size(_popupWidth, 900)); await windowManager.setSize(Size(_popupWidth, _popupHeight)); diff --git a/app/lib/shell/focus_manager.dart b/app/lib/shell/focus_manager.dart index 4b04d435..3d05e6ac 100644 --- a/app/lib/shell/focus_manager.dart +++ b/app/lib/shell/focus_manager.dart @@ -1,6 +1,7 @@ // coverage:ignore-file import 'dart:ffi'; import 'dart:io'; +import 'dart:math' as math; import 'package:ffi/ffi.dart'; import 'package:core/core.dart'; @@ -120,8 +121,20 @@ class _Win32 { class WindowFocusManager { int _previousWindow = 0; int _previousThreadId = 0; + int _previousFocusWindow = 0; String? _previousBundleId; + /// Failures that leave the captured destination usable for a retry. Clearing + /// it would make every following attempt report `noPreviousWindow`, because + /// re-capturing is impossible once CopyPaste itself owns the foreground. + static const _recoverableErrors = { + 'restoreFailed', + 'focusTimeout', + 'noKeyboardFocus', + 'targetNotForeground', + 'sendInputFailed', + }; + bool get hasDestination => Platform.isWindows ? _previousWindow != 0 : _previousBundleId != null; @@ -154,6 +167,7 @@ class WindowFocusManager { return const PasteResponse(success: false, errorCode: 'noPreviousWindow'); } + PasteResponse? outcome; try { await Future.delayed(Duration(milliseconds: delayBeforeFocusMs)); @@ -161,12 +175,13 @@ class WindowFocusManager { final response = await ClipboardWriter.activateAndPaste( bundleId: _previousBundleId!, delayMs: delayBeforePasteMs, + focusTimeoutMs: math.max(maxFocusVerifyAttempts * 10, 250), ); AppLogger.info( 'Paste destination result: platform=${Platform.operatingSystem}, ' 'success=${response.success}, error=${response.errorCode ?? '-'}', ); - return response; + return outcome = response; } if (!_restorePreviousWindows()) { @@ -174,7 +189,10 @@ class WindowFocusManager { 'Paste cancelled: Windows rejected destination restore ' '(hwnd=$_previousWindow)', ); - return const PasteResponse(success: false, errorCode: 'restoreFailed'); + return outcome = const PasteResponse( + success: false, + errorCode: 'restoreFailed', + ); } final focused = await _waitForFocusWindows(maxFocusVerifyAttempts); @@ -183,11 +201,24 @@ class WindowFocusManager { 'Paste cancelled: Windows destination focus verification timed out ' '(hwnd=$_previousWindow)', ); - return const PasteResponse(success: false, errorCode: 'focusTimeout'); + return outcome = const PasteResponse( + success: false, + errorCode: 'focusTimeout', + ); } await Future.delayed(Duration(milliseconds: delayBeforePasteMs)); - final focusRoot = _keyboardFocusRoot(); + final focusRoot = await _waitForKeyboardFocusWindows(); + if (focusRoot == 0) { + AppLogger.warn( + 'Paste cancelled: destination is active but nothing owns keyboard ' + 'focus (hwnd=$_previousWindow)', + ); + return outcome = const PasteResponse( + success: false, + errorCode: 'noKeyboardFocus', + ); + } if (focusRoot != _previousWindow) { AppLogger.warn( 'Paste target is active but lacks keyboard focus: ' @@ -195,19 +226,20 @@ class WindowFocusManager { ); } final inputResponse = await _simulatePasteWindows(); - if (!inputResponse.success) return inputResponse; + if (!inputResponse.success) return outcome = inputResponse; AppLogger.info( 'Paste destination result: platform=windows, success=true', ); - return const PasteResponse(success: true); + return outcome = const PasteResponse(success: true); } finally { - clear(); + if (!_recoverableErrors.contains(outcome?.errorCode)) clear(); } } void clear() { _previousWindow = 0; _previousThreadId = 0; + _previousFocusWindow = 0; _previousBundleId = null; } @@ -227,9 +259,12 @@ class WindowFocusManager { } _previousWindow = hwnd; _previousThreadId = threadId; + // Captured while the destination still owns the input queue: this is + // the only moment its inner focus target can be read reliably. + _previousFocusWindow = _focusWindowForThread(threadId); AppLogger.info( 'Focus session capture: platform=windows, hwnd=$hwnd, ' - 'pid=${pidPtr.value}, success=true', + 'pid=${pidPtr.value}, focus=$_previousFocusWindow, success=true', ); return true; } finally { @@ -309,15 +344,25 @@ class WindowFocusManager { /// leave a window active with no focus at all — both swallow the Ctrl+V /// while every call in the paste path still reports success. int _keyboardFocusRoot() { + final focused = _focusWindowForThread(0); + if (focused == 0) return 0; + try { + return _Win32.instance.getAncestor(focused, _Win32.gaRoot); + } catch (e) { + AppLogger.warn('Keyboard focus probe failed: $e'); + return 0; + } + } + + /// Window owning keyboard focus inside [threadId], or 0 when unreadable. + /// A `threadId` of 0 means whichever thread currently owns the foreground. + int _focusWindowForThread(int threadId) { final w = _Win32.instance; final info = calloc(_Win32.guiThreadInfoSize); try { info.cast().value = _Win32.guiThreadInfoSize; - if (w.getGUIThreadInfo(0, info) == 0) return 0; - final focused = (info + _Win32.guiThreadInfoFocusOffset) - .cast() - .value; - return focused == 0 ? 0 : w.getAncestor(focused, _Win32.gaRoot); + if (w.getGUIThreadInfo(threadId, info) == 0) return 0; + return (info + _Win32.guiThreadInfoFocusOffset).cast().value; } catch (e) { AppLogger.warn('Keyboard focus probe failed: $e'); return 0; @@ -326,13 +371,37 @@ class WindowFocusManager { } } + /// Chromium and XAML-island hosts install their inner focus a few frames + /// after they become active, so a single probe right after the fixed delay + /// samples a window that is still settling. + Future _waitForKeyboardFocusWindows() async { + var focusRoot = _keyboardFocusRoot(); + for (var i = 0; i < 5 && focusRoot != _previousWindow; i++) { + await Future.delayed(const Duration(milliseconds: 20)); + focusRoot = _keyboardFocusRoot(); + } + return focusRoot; + } + Future _simulatePasteWindows() async { try { - final response = await WindowsHotkeyChannel.sendPaste(); - if (response.success) return const PasteResponse(success: true); + final response = await WindowsHotkeyChannel.sendPaste( + targetHwnd: _previousWindow, + targetFocusHwnd: _previousFocusWindow, + targetThreadId: _previousThreadId, + ); + if (response.success) { + if (response.focusRepaired) { + AppLogger.info( + 'Paste input: restored destination keyboard focus to ' + '$_previousFocusWindow (was ${response.focusBefore})', + ); + } + return const PasteResponse(success: true); + } AppLogger.error( - 'Windows SendInput failed: sent=${response.sentInputs ?? 0}/' - '${response.expectedInputs ?? 0}, ' + 'Windows paste input rejected: sent=${response.sentInputs ?? 0}/' + '${response.expectedInputs ?? 0}, attached=${response.attached}, ' 'error=${response.errorCode}, win32=${response.win32Error}', ); return PasteResponse( diff --git a/app/lib/shell/windows_hotkey_channel.dart b/app/lib/shell/windows_hotkey_channel.dart index 74fee15f..766a2c69 100644 --- a/app/lib/shell/windows_hotkey_channel.dart +++ b/app/lib/shell/windows_hotkey_channel.dart @@ -20,6 +20,9 @@ class WindowsPasteInputResponse { this.expectedInputs, this.errorCode, this.win32Error, + this.attached = false, + this.focusRepaired = false, + this.focusBefore, }); final bool success; @@ -27,6 +30,9 @@ class WindowsPasteInputResponse { final int? expectedInputs; final String? errorCode; final int? win32Error; + final bool attached; + final bool focusRepaired; + final int? focusBefore; } /// Owns the Windows runner channel backed by RegisterHotKey/WM_HOTKEY. @@ -102,9 +108,16 @@ class WindowsHotkeyChannel { } static Future sendPaste({ + int targetHwnd = 0, + int targetFocusHwnd = 0, + int targetThreadId = 0, MethodChannel channel = const MethodChannel(_channelName), }) async { - final response = await channel.invokeMethod('sendPaste'); + final response = await channel.invokeMethod('sendPaste', { + 'targetHwnd': targetHwnd, + 'targetFocusHwnd': targetFocusHwnd, + 'targetThreadId': targetThreadId, + }); if (response is! Map) { return const WindowsPasteInputResponse( success: false, @@ -117,6 +130,9 @@ class WindowsHotkeyChannel { expectedInputs: response['expectedInputs'] as int?, errorCode: response['errorCode'] as String?, win32Error: response['win32Error'] as int?, + attached: response['attached'] == true, + focusRepaired: response['focusRepaired'] == true, + focusBefore: response['focusBefore'] as int?, ); } } diff --git a/app/test/shell/windows_hotkey_channel_test.dart b/app/test/shell/windows_hotkey_channel_test.dart index bb0fa29c..7137dd8d 100644 --- a/app/test/shell/windows_hotkey_channel_test.dart +++ b/app/test/shell/windows_hotkey_channel_test.dart @@ -141,4 +141,68 @@ void main() { expect(response.errorCode, 'sendInputFailed'); expect(response.win32Error, 5); }); + + test( + 'forwards the destination window, focus and thread to the runner', + () async { + MethodCall? captured; + messenger.setMockMethodCallHandler(methodChannel, (call) async { + captured = call; + return {'success': true}; + }); + + await WindowsHotkeyChannel.sendPaste( + targetHwnd: 460450, + targetFocusHwnd: 461184, + targetThreadId: 20832, + channel: methodChannel, + ); + + expect(captured!.method, 'sendPaste'); + expect(captured!.arguments['targetHwnd'], 460450); + expect(captured!.arguments['targetFocusHwnd'], 461184); + expect(captured!.arguments['targetThreadId'], 20832); + }, + ); + + test('parses the focus repair diagnostics', () async { + messenger.setMockMethodCallHandler(methodChannel, (call) async { + return { + 'success': true, + 'sentInputs': 9, + 'expectedInputs': 9, + 'attached': true, + 'focusRepaired': true, + 'focusBefore': 0, + }; + }); + + final response = await WindowsHotkeyChannel.sendPaste( + channel: methodChannel, + ); + + expect(response.attached, isTrue); + expect(response.focusRepaired, isTrue); + expect(response.focusBefore, 0); + }); + + test( + 'surfaces a destination that lost the foreground before injection', + () async { + messenger.setMockMethodCallHandler(methodChannel, (call) async { + return { + 'success': false, + 'errorCode': 'targetNotForeground', + }; + }); + + final response = await WindowsHotkeyChannel.sendPaste( + channel: methodChannel, + ); + + expect(response.success, isFalse); + expect(response.errorCode, 'targetNotForeground'); + expect(response.focusRepaired, isFalse); + }, + ); } diff --git a/app/windows/runner/flutter_window.cpp b/app/windows/runner/flutter_window.cpp index d7ec2df2..c5ce3a16 100644 --- a/app/windows/runner/flutter_window.cpp +++ b/app/windows/runner/flutter_window.cpp @@ -5,6 +5,7 @@ #include #include #include +#include #include "flutter/generated_plugin_registrant.h" #include "startup_task_channel.h" @@ -43,6 +44,14 @@ bool ReadInt(const flutter::EncodableMap& arguments, const char* name, return false; } +int64_t ReadInt64(const flutter::EncodableMap& arguments, const char* name) { + const auto* value = FindArgument(arguments, name); + if (value == nullptr) return 0; + if (const auto* int32 = std::get_if(value)) return *int32; + if (const auto* int64 = std::get_if(value)) return *int64; + return 0; +} + std::string ReadString(const flutter::EncodableMap& arguments, const char* name) { const auto* value = FindArgument(arguments, name); @@ -65,7 +74,94 @@ flutter::EncodableValue RegistrationResponse(bool success, return flutter::EncodableValue(response); } -flutter::EncodableValue SendPasteInput() { +// Mandatory integrity level of a process, or 0 when it cannot be read. +DWORD ProcessIntegrityLevel(DWORD pid) { + if (pid == 0) return 0; + HANDLE process = OpenProcess(PROCESS_QUERY_LIMITED_INFORMATION, FALSE, pid); + if (process == nullptr) return 0; + + DWORD level = 0; + HANDLE token = nullptr; + if (OpenProcessToken(process, TOKEN_QUERY, &token)) { + DWORD size = 0; + GetTokenInformation(token, TokenIntegrityLevel, nullptr, 0, &size); + if (size > 0) { + std::vector buffer(size); + if (GetTokenInformation(token, TokenIntegrityLevel, buffer.data(), size, + &size)) { + auto* label = reinterpret_cast(buffer.data()); + const UCHAR* count = GetSidSubAuthorityCount(label->Label.Sid); + if (count != nullptr && *count > 0) { + level = *GetSidSubAuthority(label->Label.Sid, *count - 1); + } + } + } + CloseHandle(token); + } + CloseHandle(process); + return level; +} + +// An active window is not the same as a usable focus: Chromium hosts (VS Code +// webviews) and XAML-island hosts (Windows Terminal) restore the focus of their +// inner child HWND asynchronously after WM_ACTIVATE, so a Ctrl+V timed on +// activation alone lands nowhere while every call still reports success. +// Attaching to the destination's input queue lets SetFocus target that child +// directly; the detach must happen after SendInput, because detaching resets +// the keyboard focus Windows had just restored. +flutter::EncodableValue SendPasteInput(HWND target, HWND target_focus, + DWORD target_thread) { + flutter::EncodableMap response; + if (target != nullptr && GetForegroundWindow() != target) { + response[flutter::EncodableValue("success")] = + flutter::EncodableValue(false); + response[flutter::EncodableValue("errorCode")] = + flutter::EncodableValue("targetNotForeground"); + return flutter::EncodableValue(response); + } + + // UIPI drops injected input at a higher integrity level and reports nothing: + // SendInput still returns the full count. Without this check an elevated + // destination is a permanent, undiagnosable "paste does nothing". + if (target != nullptr) { + DWORD target_pid = 0; + GetWindowThreadProcessId(target, &target_pid); + const DWORD target_level = ProcessIntegrityLevel(target_pid); + const DWORD self_level = ProcessIntegrityLevel(GetCurrentProcessId()); + if (target_level != 0 && self_level != 0 && target_level > self_level) { + response[flutter::EncodableValue("success")] = + flutter::EncodableValue(false); + response[flutter::EncodableValue("errorCode")] = + flutter::EncodableValue("targetElevated"); + return flutter::EncodableValue(response); + } + } + + const DWORD self_thread = GetCurrentThreadId(); + const bool attached = target_thread != 0 && target_thread != self_thread && + AttachThreadInput(self_thread, target_thread, TRUE); + + bool focus_repaired = false; + HWND focus_before = nullptr; + if (attached) { + GUITHREADINFO gui = {}; + gui.cbSize = sizeof(gui); + if (GetGUIThreadInfo(target_thread, &gui)) { + focus_before = gui.hwndFocus; + } + if (target_focus != nullptr && focus_before != target_focus && + IsWindow(target_focus)) { + // SetFocus on a foreign HWND is a blocking cross-thread send, so probe + // the destination first: a hung target would otherwise freeze our UI. + DWORD_PTR probe = 0; + if (SendMessageTimeoutW(target_focus, WM_NULL, 0, 0, + SMTO_ABORTIFHUNG | SMTO_BLOCK, 200, + &probe) != 0) { + focus_repaired = SetFocus(target_focus) != nullptr; + } + } + } + // WM_HOTKEY arrives on key-down, so physical shortcut modifiers may still // be held. Release every contaminating modifier before Ctrl+V. SendInput // inserts this array atomically; later physical key-up events are harmless. @@ -100,18 +196,29 @@ flutter::EncodableValue SendPasteInput() { SetLastError(ERROR_SUCCESS); const UINT sent = SendInput(kInputCount, inputs, sizeof(INPUT)); - flutter::EncodableMap response; + const DWORD send_error = GetLastError(); + + if (attached) { + AttachThreadInput(self_thread, target_thread, FALSE); + } + response[flutter::EncodableValue("success")] = flutter::EncodableValue(sent == kInputCount); response[flutter::EncodableValue("sentInputs")] = flutter::EncodableValue(static_cast(sent)); response[flutter::EncodableValue("expectedInputs")] = flutter::EncodableValue(static_cast(kInputCount)); + response[flutter::EncodableValue("attached")] = + flutter::EncodableValue(attached); + response[flutter::EncodableValue("focusRepaired")] = + flutter::EncodableValue(focus_repaired); + response[flutter::EncodableValue("focusBefore")] = flutter::EncodableValue( + static_cast(reinterpret_cast(focus_before))); if (sent != kInputCount) { response[flutter::EncodableValue("errorCode")] = flutter::EncodableValue("sendInputFailed"); response[flutter::EncodableValue("win32Error")] = - flutter::EncodableValue(static_cast(GetLastError())); + flutter::EncodableValue(static_cast(send_error)); } return flutter::EncodableValue(response); } @@ -222,7 +329,20 @@ void FlutterWindow::RegisterHotkeyChannel() { std::unique_ptr> result) { if (call.method_name() == "sendPaste") { - result->Success(SendPasteInput()); + const auto* paste_args = + std::get_if(call.arguments()); + HWND target = nullptr; + HWND target_focus = nullptr; + DWORD target_thread = 0; + if (paste_args != nullptr) { + target = reinterpret_cast( + static_cast(ReadInt64(*paste_args, "targetHwnd"))); + target_focus = reinterpret_cast(static_cast( + ReadInt64(*paste_args, "targetFocusHwnd"))); + target_thread = static_cast( + ReadInt64(*paste_args, "targetThreadId")); + } + result->Success(SendPasteInput(target, target_focus, target_thread)); return; } if (call.method_name() == "unregisterAll") { diff --git a/core/lib/config/storage_config.dart b/core/lib/config/storage_config.dart index 27151e67..8160a98f 100644 --- a/core/lib/config/storage_config.dart +++ b/core/lib/config/storage_config.dart @@ -49,6 +49,27 @@ class StorageConfig { for (final dir in [baseDir, imagesPath, configPath, logsPath]) { await Directory(dir).create(recursive: true); } + await _restrictToOwner(); + } + + /// The history is stored in the clear, and on Linux `~/.local/share` inherits + /// the umask (0755), so other local accounts can read whatever was copied. + /// Closing the directories is enough and stays O(1): POSIX resolves a path + /// through every parent, so 0700 here puts the files out of reach whatever + /// mode SQLite gave them. Windows and macOS already confine the container. + Future _restrictToOwner() async { + if (!Platform.isLinux) return; + try { + await Process.run('chmod', [ + '700', + baseDir, + imagesPath, + configPath, + logsPath, + ], runInShell: false); + } catch (e) { + AppLogger.warn('Could not restrict permissions on $baseDir: $e'); + } } bool get isFirstRun => !File(_initFlagPath).existsSync(); diff --git a/core/lib/repository/sqlite_repository.dart b/core/lib/repository/sqlite_repository.dart index c7c5201b..9bb7facd 100644 --- a/core/lib/repository/sqlite_repository.dart +++ b/core/lib/repository/sqlite_repository.dart @@ -63,7 +63,10 @@ class _AppDatabase extends _$_AppDatabase { await customStatement('PRAGMA journal_mode = WAL'); await customStatement('PRAGMA synchronous = NORMAL'); await customStatement('PRAGMA cache_size = -2000'); - await customStatement('PRAGMA auto_vacuum = INCREMENTAL'); + // Overwrite freed pages instead of leaving copied passwords readable in + // the file after a delete. Unlike auto_vacuum this applies at any time. + await customStatement('PRAGMA secure_delete = ON'); + await _ensureIncrementalVacuum(); await customStatement(''' CREATE VIRTUAL TABLE IF NOT EXISTS ClipboardItems_fts USING fts5( @@ -100,6 +103,24 @@ class _AppDatabase extends _$_AppDatabase { }, ); + /// SQLite silently ignores `auto_vacuum` on a database that already has + /// tables, and beforeOpen runs after onCreate — so the pragma never took and + /// every `incremental_vacuum` below was a no-op. Switching it needs a full + /// VACUUM, which is why this only runs when the mode is still NONE. + Future _ensureIncrementalVacuum() async { + try { + final rows = await customSelect('PRAGMA auto_vacuum').get(); + final mode = rows.isEmpty + ? null + : rows.first.data.values.first as int? ?? 0; + if (mode == 2) return; + await customStatement('PRAGMA auto_vacuum = INCREMENTAL'); + if (mode == 0) await customStatement('VACUUM'); + } catch (e) { + AppLogger.warn('auto_vacuum setup failed: $e'); + } + } + Future _createIndexes() async { await customStatement( 'CREATE INDEX IF NOT EXISTS idx_content_hash ON clipboard_items(content_hash)', @@ -310,6 +331,8 @@ class SqliteRepository implements IClipboardRepository { } catch (e) { AppLogger.error('incremental_vacuum failed: $e'); } + // The deleted rows survive in the -wal until it is truncated. + await walCheckpoint(); } return deleted; @@ -326,6 +349,8 @@ class SqliteRepository implements IClipboardRepository { } catch (e) { AppLogger.error('incremental_vacuum failed: $e'); } + // The deleted rows survive in the -wal until it is truncated. + await walCheckpoint(); } return deleted; } diff --git a/core/lib/services/clipboard_service.dart b/core/lib/services/clipboard_service.dart index 5dc34004..50b5ef26 100644 --- a/core/lib/services/clipboard_service.dart +++ b/core/lib/services/clipboard_service.dart @@ -238,6 +238,22 @@ class ClipboardService { return item; } + /// Reactivating an entry whose file is gone would swallow the incoming + /// capture: the payload is dropped and the user keeps a broken item. Only + /// rejects the match when there are bytes to lose — without them, keeping + /// the existing entry preserves history. Size cannot be compared: the + /// processing queue rewrites `content` to a PNG. + bool _matchesStoredImage(ClipboardItem existing, List? imageBytes) { + if (imageBytes == null || imageBytes.isEmpty) return true; + if (existing.content.isEmpty) return true; + try { + return File(existing.content).existsSync(); + } catch (e) { + AppLogger.warn('processImage: could not stat ${existing.content}: $e'); + return true; + } + } + Future processImage( String contentHash, { String? source, @@ -248,7 +264,7 @@ class ClipboardService { if (_consumeSuppression('i:$contentHash')) return null; final existing = await _repository.findByContentHash(contentHash); - if (existing != null) { + if (existing != null && _matchesStoredImage(existing, imageBytes)) { final updated = existing.copyWith(modifiedAt: DateTime.now().toUtc()); await _repository.update(updated); _itemReactivated.add(updated); diff --git a/core/test/clipboard_service_extended_test.dart b/core/test/clipboard_service_extended_test.dart index bf0868de..0aa79829 100644 --- a/core/test/clipboard_service_extended_test.dart +++ b/core/test/clipboard_service_extended_test.dart @@ -106,6 +106,20 @@ void main() { expect(reactivated?.id, equals(first!.id)); }); + test('does not swallow new bytes when the stored file is gone', () async { + const hash = 'stale-file-hash'; + final first = await service.processImage(hash, imagePath: '/gone.png'); + + final second = await service.processImage( + hash, + imagePath: '/gone.png', + imageBytes: [1, 2, 3, 4], + ); + + expect(second, isNotNull); + expect(second!.id, isNot(equals(first!.id))); + }); + test('stores image path in content field', () async { const imagePath = '/home/user/screenshot.png'; final result = await service.processImage( diff --git a/listener/macos/Classes/ListenerPlugin.swift b/listener/macos/Classes/ListenerPlugin.swift index 86d5afa7..d286ae6d 100644 --- a/listener/macos/Classes/ListenerPlugin.swift +++ b/listener/macos/Classes/ListenerPlugin.swift @@ -11,12 +11,15 @@ public class ListenerPlugin: NSObject, FlutterPlugin { private var lastChangeCount: Int = 0 private var lastContentHash: String = "" private var lastChangeTick: UInt64 = 0 + private var lastForeignBundleId: String? + private var activationObserver: NSObjectProtocol? private static let debounceMs: UInt64 = 250 private static let pollingIntervalSec: TimeInterval = 0.25 public static func register(with registrar: FlutterPluginRegistrar) { let instance = ListenerPlugin() + instance.startTrackingActivation() let eventChannel = FlutterEventChannel( name: "copypaste/clipboard", @@ -31,6 +34,46 @@ public class ListenerPlugin: NSObject, FlutterPlugin { methodChannel.setMethodCallHandler(instance.handleMethodCall) } + // MARK: - Paste Destination Tracking + + /// Unlike Windows, hiding the panel on macOS is `orderOut`, which leaves + /// CopyPaste the active application. Reading frontmostApplication at that + /// point captures ourselves and the paste is delivered back to the panel, + /// so the last application that was not us is tracked separately. + private func startTrackingActivation() { + activationObserver = NSWorkspace.shared.notificationCenter.addObserver( + forName: NSWorkspace.didActivateApplicationNotification, + object: nil, + queue: .main + ) { [weak self] notification in + guard let app = notification.userInfo?[NSWorkspace.applicationUserInfoKey] + as? NSRunningApplication else { return } + if app.processIdentifier == ProcessInfo.processInfo.processIdentifier { + return + } + if let bundleId = app.bundleIdentifier { + self?.lastForeignBundleId = bundleId + } + } + } + + private func captureDestinationBundleId() -> String? { + let front = NSWorkspace.shared.frontmostApplication + let isSelf = + front?.processIdentifier == ProcessInfo.processInfo.processIdentifier + if let bundleId = front?.bundleIdentifier, !isSelf { + lastForeignBundleId = bundleId + return bundleId + } + return lastForeignBundleId + } + + deinit { + if let observer = activationObserver { + NSWorkspace.shared.notificationCenter.removeObserver(observer) + } + } + // MARK: - Method Channel Handler private func handleMethodCall(call: FlutterMethodCall, result: @escaping FlutterResult) { @@ -42,7 +85,7 @@ public class ListenerPlugin: NSObject, FlutterPlugin { case "getNativeThumbnail": handleGetNativeThumbnail(call: call, result: result) case "captureFrontmostApp": - result(NSWorkspace.shared.frontmostApplication?.bundleIdentifier) + result(captureDestinationBundleId()) case "activateAndPaste": handleActivateAndPaste(call: call, result: result) case "getCursorAndScreenInfo": @@ -230,9 +273,20 @@ public class ListenerPlugin: NSObject, FlutterPlugin { signature += "F:" + url.path + "|" } } else if let tiffData = pb.data(forType: .tiff), !tiffData.isEmpty { - let sampleSize = min(tiffData.count, 256) - let sample = tiffData.prefix(sampleSize) - signature += "I:\(tiffData.count):" + sample.map { String(format: "%02x", $0) }.joined() + // A head-only sample makes same-sized captures collide, and a collision + // silently discards the new image in processImage. + let blocks = 16 + let blockLen = min(tiffData.count, 64) + let span = tiffData.count - blockLen + // Offsets are relative to startIndex: a Data slice does not rebase to 0. + let base = tiffData.startIndex + var sampled = Data() + for b in 0..= maxAttempts { - if !focused { - DispatchQueue.main.asyncAfter(deadline: .now() + .milliseconds(30)) { - self.simulatePaste(result: result) - } - } else { - simulatePaste(result: result) + private func waitForFocusThenPaste( + bundleId: String, + attempt: Int, + maxAttempts: Int, + settleMs: Int, + result: @escaping FlutterResult + ) { + if NSWorkspace.shared.frontmostApplication?.bundleIdentifier == bundleId { + DispatchQueue.main.asyncAfter(deadline: .now() + .milliseconds(settleMs)) { + self.simulatePaste(result: result) } return } + if attempt >= maxAttempts { + // Posting Cmd+V now would fire it at whatever app is frontmost instead. + result(Self.pasteFailure("focusTimeout")) + return + } + DispatchQueue.main.asyncAfter(deadline: .now() + .milliseconds(10)) { - self.waitForFocusThenPaste(bundleId: bundleId, attempt: attempt + 1, maxAttempts: maxAttempts, result: result) + self.waitForFocusThenPaste( + bundleId: bundleId, + attempt: attempt + 1, + maxAttempts: maxAttempts, + settleMs: settleMs, + result: result + ) } } + private static func pasteFailure(_ code: String) -> [String: Any] { + return ["success": false, "errorCode": code] + } + private func simulatePaste(result: @escaping FlutterResult) { let src = CGEventSource(stateID: .combinedSessionState) let vKey: CGKeyCode = 0x09 guard let keyDown = CGEvent(keyboardEventSource: src, virtualKey: vKey, keyDown: true), let keyUp = CGEvent(keyboardEventSource: src, virtualKey: vKey, keyDown: false) else { - result(false) + result(Self.pasteFailure("eventCreationFailed")) return } @@ -455,7 +536,7 @@ public class ListenerPlugin: NSObject, FlutterPlugin { keyUp.flags = .maskCommand keyDown.post(tap: .cghidEventTap) keyUp.post(tap: .cghidEventTap) - result(true) + result(["success": true]) } // MARK: - Cursor & Screen Info diff --git a/listener/windows/listener_plugin.cpp b/listener/windows/listener_plugin.cpp index 86c8af72..eda82ae7 100644 --- a/listener/windows/listener_plugin.cpp +++ b/listener/windows/listener_plugin.cpp @@ -43,18 +43,35 @@ std::vector ConvertDibToBmp(const std::vector& dib) { if (dib.size() < sizeof(BITMAPINFOHEADER)) return {}; const auto* bih = reinterpret_cast(dib.data()); + if (bih->biSize < sizeof(BITMAPINFOHEADER) || bih->biSize > dib.size()) { + return {}; + } + + // Masks and palette stack in this order, so they accumulate rather than + // replace each other. DWORD colorTableSize = 0; - if (bih->biBitCount <= 8) { - DWORD colors = bih->biClrUsed ? bih->biClrUsed : (1u << bih->biBitCount); - colorTableSize = colors * sizeof(RGBQUAD); - } else if (bih->biCompression == BI_BITFIELDS && - bih->biSize == sizeof(BITMAPINFOHEADER)) { + if (bih->biCompression == BI_BITFIELDS && + bih->biSize == sizeof(BITMAPINFOHEADER)) { // BI_BITFIELDS masks only follow the header for the classic // BITMAPINFOHEADER (40 bytes). For BITMAPV4HEADER (108) and // BITMAPV5HEADER (124) — produced by the Windows Snipping Tool — the // masks are embedded inside the header itself, so no extra offset. - colorTableSize = 3 * sizeof(DWORD); + colorTableSize += 3 * sizeof(DWORD); + } + if (bih->biBitCount <= 8) { + DWORD colors = bih->biClrUsed ? bih->biClrUsed : (1u << bih->biBitCount); + colorTableSize += colors * sizeof(RGBQUAD); + } else if (bih->biClrUsed != 0) { + // Above 8 bpp the palette is optional but still shifts the pixel offset. + // Producers leave biClrUsed dirty often enough that honouring it blindly + // misplaces bfOffBits, so only apply it when the buffer can hold it. + const uint64_t claimed = + static_cast(bih->biClrUsed) * sizeof(RGBQUAD); + if (bih->biSize + colorTableSize + claimed <= dib.size()) { + colorTableSize += static_cast(claimed); + } } + if (bih->biSize + colorTableSize > dib.size()) return {}; BITMAPFILEHEADER bfh = {}; bfh.bfType = 0x4D42; @@ -417,18 +434,7 @@ void ListenerPlugin::OnClipboardChanged() { return; } - // Retry OpenClipboard up to kOpenClipboardRetries times with backoff. - // Another app may hold the clipboard lock briefly; retrying avoids silent - // drops. On exhaustion, log and bail — the next WM_CLIPBOARDUPDATE retries. - bool opened = false; - for (int attempt = 0; attempt < kOpenClipboardRetries; ++attempt) { - if (OpenClipboard(hwnd)) { - opened = true; - break; - } - Sleep(kOpenClipboardBackoffMs[attempt]); - } - if (!opened) { + if (!OpenClipboardWithRetry(hwnd)) { OutputDebugStringA("[ClipboardListener] OpenClipboard failed after retries\n"); return; } @@ -595,14 +601,31 @@ std::string ListenerPlugin::ComputeClipboardHash() const { SIZE_T sz = GlobalSize(hData); void* ptr = GlobalLock(hData); if (ptr) { - size_t sample = (std::min)(sz, static_cast(256)); - std::ostringstream oss; - oss << "I:" << sz << ":"; const uint8_t* bytes = static_cast(ptr); - for (size_t i = 0; i < sample; ++i) { - oss << std::hex << static_cast(bytes[i]); + std::ostringstream oss; + oss << "I:" << sz; + if (sz >= sizeof(BITMAPINFOHEADER)) { + const auto* bih = reinterpret_cast(ptr); + oss << ':' << bih->biWidth << 'x' << bih->biHeight << ':' + << bih->biBitCount << ':' << bih->biCompression << ':' + << bih->biSizeImage; } signature += oss.str(); + + // Sampling only the head would compare the bottom rows of a bottom-up + // DIB, so two screenshots sharing a taskbar collide. Raw bytes go into + // the signature directly: a hex dump needs zero padding to stay + // unambiguous, and getting that wrong silently drops captures. + constexpr size_t kBlocks = 16; + constexpr size_t kBlockBytes = 64; + const size_t blockLen = + static_cast((std::min)(sz, static_cast(kBlockBytes))); + const size_t span = sz > blockLen ? sz - blockLen : 0; + for (size_t b = 0; b < kBlocks; ++b) { + const size_t offset = span * b / (kBlocks - 1); + signature.append(reinterpret_cast(bytes + offset), + blockLen); + } GlobalUnlock(hData); } } @@ -757,6 +780,16 @@ std::string ListenerPlugin::WideToUtf8(const std::wstring& wide) { return result; } +// Another app can hold the clipboard lock briefly — Chromium and Electron do +// it on every copy — so a single attempt drops the operation silently. +bool ListenerPlugin::OpenClipboardWithRetry(HWND hwnd) { + for (int attempt = 0; attempt < kOpenClipboardRetries; ++attempt) { + if (OpenClipboard(hwnd)) return true; + Sleep(kOpenClipboardBackoffMs[attempt]); + } + return false; +} + std::string ListenerPlugin::ComputeSimpleHash(const std::string& data) { // FNV-1a 64-bit hash uint64_t hash = 14695981039346656037ULL; @@ -949,7 +982,7 @@ bool ListenerPlugin::SetTextToClipboard( HWND hwnd = registrar_->GetView() ? registrar_->GetView()->GetNativeWindow() : nullptr; - if (!OpenClipboard(hwnd)) return false; + if (!OpenClipboardWithRetry(hwnd)) return false; EmptyClipboard(); bool ok = false; @@ -1110,7 +1143,7 @@ bool ListenerPlugin::SetImageToClipboard(const std::string& imagePath) { HWND hwnd = registrar_->GetView() ? registrar_->GetView()->GetNativeWindow() : nullptr; - if (!OpenClipboard(hwnd)) { + if (!OpenClipboardWithRetry(hwnd)) { GlobalFree(hMem); if (hContents) GlobalFree(hContents); if (hDesc) GlobalFree(hDesc); @@ -1164,7 +1197,7 @@ bool ListenerPlugin::SetFilesToClipboard( HWND hwnd = registrar_->GetView() ? registrar_->GetView()->GetNativeWindow() : nullptr; - if (!OpenClipboard(hwnd)) { + if (!OpenClipboardWithRetry(hwnd)) { GlobalFree(hMem); return false; } diff --git a/listener/windows/listener_plugin.h b/listener/windows/listener_plugin.h index 9c599f3e..004b4ad5 100644 --- a/listener/windows/listener_plugin.h +++ b/listener/windows/listener_plugin.h @@ -76,6 +76,8 @@ class ListenerPlugin : public flutter::Plugin { std::string ComputeClipboardHash() const; std::string GetClipboardSource() const; + static bool OpenClipboardWithRetry(HWND hwnd); + static std::wstring ExtractText(); static std::vector ExtractBytes(UINT format); static std::vector ExtractFilePaths(); From 8df27be6b98a59dd1aac25ae8ec985b76325a401 Mon Sep 17 00:00:00 2001 From: rgdevment Date: Mon, 10 Aug 2026 12:08:26 -0400 Subject: [PATCH 2/2] fix: update markdownlint configuration and clean up CLA formatting --- .markdownlint.yaml | 1 + CLA.md | 2 - core/lib/config/storage_config.dart | 18 +++---- core/lib/repository/sqlite_repository.dart | 2 + core/lib/services/clipboard_service.dart | 2 + .../test/clipboard_service_extended_test.dart | 53 +++++++++++++++++++ core/test/storage_config_test.dart | 21 ++++++++ 7 files changed, 88 insertions(+), 11 deletions(-) diff --git a/.markdownlint.yaml b/.markdownlint.yaml index 913ceed2..f36b77c3 100644 --- a/.markdownlint.yaml +++ b/.markdownlint.yaml @@ -3,6 +3,7 @@ default: true MD013: false MD033: false MD041: false +MD060: false MD007: indent: 4 diff --git a/CLA.md b/CLA.md index 1d1f7a66..e7c5d153 100644 --- a/CLA.md +++ b/CLA.md @@ -33,7 +33,6 @@ one You submitted before**. Code reaches a project by more routes than a Pull Request, and a signature that only looked forward would leave those earlier routes unaccounted for. - **"Project Owner"** means Mario Hidalgo G. (rgdevment), the copyright holder and maintainer of CopyPaste. @@ -61,7 +60,6 @@ above. Where such rights cannot be waived, You agree not to enforce them in a way that blocks those uses. This does not touch attribution: section 6 commits the Project Owner to preserving Your authorship. - ## 3. Patent licence You grant the Project Owner, and to recipients of software distributed by the diff --git a/core/lib/config/storage_config.dart b/core/lib/config/storage_config.dart index 8160a98f..e1a21730 100644 --- a/core/lib/config/storage_config.dart +++ b/core/lib/config/storage_config.dart @@ -67,8 +67,10 @@ class StorageConfig { configPath, logsPath, ], runInShell: false); + // coverage:ignore-start } catch (e) { AppLogger.warn('Could not restrict permissions on $baseDir: $e'); + // coverage:ignore-end } } @@ -80,9 +82,7 @@ class StorageConfig { ..createSync(recursive: true) ..writeAsStringSync(DateTime.now().toUtc().toIso8601String()); } catch (e) { - AppLogger.error( - 'Failed to mark as initialized: $e', - ); // coverage:ignore-line + AppLogger.error('Failed to mark as initialized: $e'); } } @@ -90,10 +90,10 @@ class StorageConfig { try { final f = File(_initFlagPath); if (f.existsSync()) f.deleteSync(); + // coverage:ignore-start } catch (e) { - AppLogger.error( - 'Failed to clear initialized flag: $e', - ); // coverage:ignore-line + AppLogger.error('Failed to clear initialized flag: $e'); + // coverage:ignore-end } } @@ -108,10 +108,10 @@ class StorageConfig { if (!validFiles.contains(file.path)) { try { file.deleteSync(); + // coverage:ignore-start } catch (e) { - AppLogger.error( - 'Failed to delete orphan file: $e', - ); // coverage:ignore-line + AppLogger.error('Failed to delete orphan file: $e'); + // coverage:ignore-end } } } diff --git a/core/lib/repository/sqlite_repository.dart b/core/lib/repository/sqlite_repository.dart index 9bb7facd..7d8ae89c 100644 --- a/core/lib/repository/sqlite_repository.dart +++ b/core/lib/repository/sqlite_repository.dart @@ -116,8 +116,10 @@ class _AppDatabase extends _$_AppDatabase { if (mode == 2) return; await customStatement('PRAGMA auto_vacuum = INCREMENTAL'); if (mode == 0) await customStatement('VACUUM'); + // coverage:ignore-start } catch (e) { AppLogger.warn('auto_vacuum setup failed: $e'); + // coverage:ignore-end } } diff --git a/core/lib/services/clipboard_service.dart b/core/lib/services/clipboard_service.dart index 50b5ef26..8469b84d 100644 --- a/core/lib/services/clipboard_service.dart +++ b/core/lib/services/clipboard_service.dart @@ -248,9 +248,11 @@ class ClipboardService { if (existing.content.isEmpty) return true; try { return File(existing.content).existsSync(); + // coverage:ignore-start } catch (e) { AppLogger.warn('processImage: could not stat ${existing.content}: $e'); return true; + // coverage:ignore-end } } diff --git a/core/test/clipboard_service_extended_test.dart b/core/test/clipboard_service_extended_test.dart index 0aa79829..ff6b3609 100644 --- a/core/test/clipboard_service_extended_test.dart +++ b/core/test/clipboard_service_extended_test.dart @@ -1,4 +1,7 @@ +import 'dart:io'; + import 'package:flutter_test/flutter_test.dart'; +import 'package:path/path.dart' as p; import 'package:core/core.dart'; @@ -120,6 +123,56 @@ void main() { expect(second!.id, isNot(equals(first!.id))); }); + test( + 'reactivates instead of duplicating when the file is still there', + () async { + final dir = Directory.systemTemp.createTempSync('cp_img_'); + addTearDown(() => dir.deleteSync(recursive: true)); + final onDisk = File(p.join(dir.path, 'kept.png')) + ..writeAsBytesSync([1, 2, 3]); + + const hash = 'live-file-hash'; + final first = await service.processImage(hash, imagePath: onDisk.path); + final second = await service.processImage( + hash, + imagePath: onDisk.path, + imageBytes: [9, 9], + ); + + expect(second!.id, equals(first!.id)); + }, + ); + + test('still creates the item when the BMP cannot be written', () async { + final missingDir = p.join( + Directory.systemTemp.path, + 'cp_absent_${DateTime.now().microsecondsSinceEpoch}', + 'nested', + ); + final isolated = ClipboardService(repo, imagesPath: missingDir); + addTearDown(isolated.dispose); + + final result = await isolated.processImage( + 'unwritable-hash', + imageBytes: [1, 2, 3], + ); + + expect(result, isNotNull); + expect(result!.content, isEmpty); + }); + + test('reactivates a pathless entry even when bytes are present', () async { + const hash = 'pathless-hash'; + final first = await service.processImage(hash); + + final second = await service.processImage( + hash, + imageBytes: [7, 7, 7], + ); + + expect(second!.id, equals(first!.id)); + }); + test('stores image path in content field', () async { const imagePath = '/home/user/screenshot.png'; final result = await service.processImage( diff --git a/core/test/storage_config_test.dart b/core/test/storage_config_test.dart index 95969739..0252ee3c 100644 --- a/core/test/storage_config_test.dart +++ b/core/test/storage_config_test.dart @@ -64,6 +64,27 @@ void main() { expect(config.logsPath, equals(p.join(tempDir.path, 'logs'))); }); + test('markAsInitialized swallows an unwritable flag path', () { + Directory(p.join(tempDir.path, '.initialized')).createSync(); + + expect(() => config.markAsInitialized(), returnsNormally); + expect(config.isFirstRun, isTrue); + }); + + test( + 'ensureDirectories is idempotent and keeps existing content', + () async { + await config.ensureDirectories(); + final marker = File(p.join(config.imagesPath, 'kept.png')) + ..writeAsBytesSync([9]); + + await config.ensureDirectories(); + + expect(marker.existsSync(), isTrue); + expect(Directory(config.logsPath).existsSync(), isTrue); + }, + ); + test('clearInitialized removes the init flag', () { config.markAsInitialized(); expect(config.isFirstRun, isFalse);