From 12f6cecff8c2b004844729caf982f718a9717a10 Mon Sep 17 00:00:00 2001 From: rgdevment Date: Fri, 24 Apr 2026 12:00:40 -0400 Subject: [PATCH 01/31] feat: refactor Linux session detection and enhance testing coverage --- app/lib/shell/linux_session.dart | 183 ++++++++++++++------ app/test/shell/linux_session_test.dart | 223 ++++++++++++++++++------- 2 files changed, 300 insertions(+), 106 deletions(-) diff --git a/app/lib/shell/linux_session.dart b/app/lib/shell/linux_session.dart index 76895ac8..2ddd7a73 100644 --- a/app/lib/shell/linux_session.dart +++ b/app/lib/shell/linux_session.dart @@ -1,47 +1,136 @@ -import 'dart:io'; - -bool isWaylandSession() { - if (!Platform.isLinux) return false; - - final sessionType = Platform.environment['XDG_SESSION_TYPE'] ?? ''; - if (sessionType == 'wayland') return true; - if (sessionType == 'x11' || sessionType == 'mir') return false; - - final waylandDisplay = Platform.environment['WAYLAND_DISPLAY'] ?? ''; - if (waylandDisplay.isNotEmpty) return true; - - final display = Platform.environment['DISPLAY'] ?? ''; - if (display.isNotEmpty) return false; - - return _hasWaylandSocket(); -} - -bool _hasWaylandSocket() { - final runtimeDir = Platform.environment['XDG_RUNTIME_DIR'] ?? ''; - if (runtimeDir.isEmpty) return false; - try { - return Directory(runtimeDir) - .listSync(followLinks: false) - .any((e) => e.uri.pathSegments.last.startsWith('wayland')); - } catch (_) { - return false; - } -} - -Future linuxPrefersDarkMode() async { - if (!Platform.isLinux) return false; - - try { - final result = await Process.run('gsettings', [ - 'get', - 'org.gnome.desktop.interface', - 'color-scheme', - ]); - if (result.exitCode == 0) { - return (result.stdout as String).contains('dark'); - } - } catch (_) {} - - final gtkTheme = (Platform.environment['GTK_THEME'] ?? '').toLowerCase(); - return gtkTheme.contains('dark'); -} +import 'dart:io'; + +import 'package:flutter/foundation.dart'; + +@immutable +class LinuxSessionInfo { + const LinuxSessionInfo({ + required this.sessionType, + required this.hasDisplay, + required this.hasWaylandDisplay, + required this.hasWaylandSocket, + required this.desktopEnv, + required this.wmName, + }); + + final String sessionType; + final bool hasDisplay; + final bool hasWaylandDisplay; + final bool hasWaylandSocket; + final String desktopEnv; + final String wmName; + + bool get isWayland { + if (sessionType == 'wayland') return true; + if (sessionType == 'x11' || sessionType == 'mir' || sessionType == 'tty') { + return false; + } + if (hasWaylandDisplay) return true; + if (hasWaylandSocket && !hasDisplay) return true; + if (hasDisplay) return false; + return hasWaylandSocket; + } + + bool get isX11 { + if (sessionType == 'x11') return true; + if (sessionType == 'wayland' || sessionType == 'mir' || sessionType == 'tty') { + return false; + } + if (hasDisplay && !hasWaylandDisplay && !hasWaylandSocket) return true; + return false; + } + + bool get isXWayland => + hasDisplay && (hasWaylandDisplay || hasWaylandSocket) && sessionType == 'wayland'; + + bool get isUsable => isX11 || isWayland; + + static const LinuxSessionInfo unsupported = LinuxSessionInfo( + sessionType: '', + hasDisplay: false, + hasWaylandDisplay: false, + hasWaylandSocket: false, + desktopEnv: '', + wmName: '', + ); + + @override + bool operator ==(Object other) { + if (identical(this, other)) return true; + return other is LinuxSessionInfo && + other.sessionType == sessionType && + other.hasDisplay == hasDisplay && + other.hasWaylandDisplay == hasWaylandDisplay && + other.hasWaylandSocket == hasWaylandSocket && + other.desktopEnv == desktopEnv && + other.wmName == wmName; + } + + @override + int get hashCode => Object.hash( + sessionType, + hasDisplay, + hasWaylandDisplay, + hasWaylandSocket, + desktopEnv, + wmName, + ); + + @override + String toString() => + 'LinuxSessionInfo(sessionType=$sessionType, hasDisplay=$hasDisplay, ' + 'hasWaylandDisplay=$hasWaylandDisplay, hasWaylandSocket=$hasWaylandSocket, ' + 'desktopEnv=$desktopEnv, wmName=$wmName)'; +} + +LinuxSessionInfo detectLinuxSession() { + if (!Platform.isLinux) return LinuxSessionInfo.unsupported; + + final env = Platform.environment; + final sessionType = (env['XDG_SESSION_TYPE'] ?? '').trim().toLowerCase(); + final display = (env['DISPLAY'] ?? '').trim(); + final waylandDisplay = (env['WAYLAND_DISPLAY'] ?? '').trim(); + final desktopEnv = + (env['XDG_CURRENT_DESKTOP'] ?? env['DESKTOP_SESSION'] ?? '').trim(); + final wmName = (env['XDG_SESSION_DESKTOP'] ?? '').trim(); + + return LinuxSessionInfo( + sessionType: sessionType, + hasDisplay: display.isNotEmpty, + hasWaylandDisplay: waylandDisplay.isNotEmpty, + hasWaylandSocket: _hasWaylandSocket(env['XDG_RUNTIME_DIR']), + desktopEnv: desktopEnv, + wmName: wmName, + ); +} + +bool isWaylandSession() => detectLinuxSession().isWayland; + +bool _hasWaylandSocket(String? runtimeDir) { + if (runtimeDir == null || runtimeDir.isEmpty) return false; + try { + return Directory(runtimeDir) + .listSync(followLinks: false) + .any((e) => e.uri.pathSegments.last.startsWith('wayland')); + } catch (_) { + return false; + } +} + +Future linuxPrefersDarkMode() async { + if (!Platform.isLinux) return false; + + try { + final result = await Process.run('gsettings', [ + 'get', + 'org.gnome.desktop.interface', + 'color-scheme', + ]); + if (result.exitCode == 0) { + return (result.stdout as String).contains('dark'); + } + } catch (_) {} + + final gtkTheme = (Platform.environment['GTK_THEME'] ?? '').toLowerCase(); + return gtkTheme.contains('dark'); +} diff --git a/app/test/shell/linux_session_test.dart b/app/test/shell/linux_session_test.dart index 508168e5..eabad8e2 100644 --- a/app/test/shell/linux_session_test.dart +++ b/app/test/shell/linux_session_test.dart @@ -1,59 +1,164 @@ -import 'dart:io'; - -import 'package:flutter_test/flutter_test.dart'; - -import 'package:copypaste/shell/linux_session.dart'; - -void main() { - group('isWaylandSession', () { - test('returns false on non-Linux platforms', () { - if (Platform.isLinux) return; - expect(isWaylandSession(), isFalse); - }); - - test('is consistent with current environment variables', () { - if (!Platform.isLinux) { - expect(isWaylandSession(), isFalse); - return; - } - - final sessionType = Platform.environment['XDG_SESSION_TYPE'] ?? ''; - final waylandDisplay = Platform.environment['WAYLAND_DISPLAY'] ?? ''; - - if (sessionType == 'wayland' || waylandDisplay.isNotEmpty) { - expect(isWaylandSession(), isTrue); - } - }); - - test('returns false on headless / X11 CI environment', () { - final sessionType = Platform.environment['XDG_SESSION_TYPE'] ?? ''; - final waylandDisplay = Platform.environment['WAYLAND_DISPLAY'] ?? ''; - - final hasEnvIndicator = - sessionType == 'wayland' || waylandDisplay.isNotEmpty; - - if (!hasEnvIndicator && Platform.isLinux) { - expect(isWaylandSession(), isA()); - } - }); - - test('return type is bool', () { - expect(isWaylandSession(), isA()); - }); - - test('is idempotent — same result on repeated calls', () { - expect(isWaylandSession(), equals(isWaylandSession())); - }); - }); - - group('linuxPrefersDarkMode', () { - test('returns a bool', () async { - expect(await linuxPrefersDarkMode(), isA()); - }); - - test('returns false on non-Linux platforms', () async { - if (Platform.isLinux) return; - expect(await linuxPrefersDarkMode(), isFalse); - }); - }); -} +import 'dart:io'; + +import 'package:flutter_test/flutter_test.dart'; + +import 'package:copypaste/shell/linux_session.dart'; + +void main() { + group('isWaylandSession', () { + test('returns false on non-Linux platforms', () { + if (Platform.isLinux) return; + expect(isWaylandSession(), isFalse); + }); + + test('is consistent with current environment variables', () { + if (!Platform.isLinux) { + expect(isWaylandSession(), isFalse); + return; + } + + final sessionType = Platform.environment['XDG_SESSION_TYPE'] ?? ''; + final waylandDisplay = Platform.environment['WAYLAND_DISPLAY'] ?? ''; + + if (sessionType == 'wayland' || waylandDisplay.isNotEmpty) { + expect(isWaylandSession(), isTrue); + } + }); + + test('returns false on headless / X11 CI environment', () { + final sessionType = Platform.environment['XDG_SESSION_TYPE'] ?? ''; + final waylandDisplay = Platform.environment['WAYLAND_DISPLAY'] ?? ''; + + final hasEnvIndicator = + sessionType == 'wayland' || waylandDisplay.isNotEmpty; + + if (!hasEnvIndicator && Platform.isLinux) { + expect(isWaylandSession(), isA()); + } + }); + + test('return type is bool', () { + expect(isWaylandSession(), isA()); + }); + + test('is idempotent — same result on repeated calls', () { + expect(isWaylandSession(), equals(isWaylandSession())); + }); + }); + + group('linuxPrefersDarkMode', () { + test('returns a bool', () async { + expect(await linuxPrefersDarkMode(), isA()); + }); + + test('returns false on non-Linux platforms', () async { + if (Platform.isLinux) return; + expect(await linuxPrefersDarkMode(), isFalse); + }); + }); + + group('LinuxSessionInfo', () { + test('unsupported is the safe default for non-Linux', () { + if (Platform.isLinux) return; + final info = detectLinuxSession(); + expect(info, equals(LinuxSessionInfo.unsupported)); + expect(info.isWayland, isFalse); + expect(info.isX11, isFalse); + expect(info.isUsable, isFalse); + }); + + test('detectLinuxSession returns a value type', () { + expect(detectLinuxSession(), isA()); + }); + + test('isWayland prioritises XDG_SESSION_TYPE=wayland', () { + const info = LinuxSessionInfo( + sessionType: 'wayland', + hasDisplay: true, + hasWaylandDisplay: true, + hasWaylandSocket: true, + desktopEnv: 'GNOME', + wmName: '', + ); + expect(info.isWayland, isTrue); + expect(info.isX11, isFalse); + expect(info.isXWayland, isTrue); + }); + + test('isX11 honours XDG_SESSION_TYPE=x11 even with Wayland socket', () { + const info = LinuxSessionInfo( + sessionType: 'x11', + hasDisplay: true, + hasWaylandDisplay: false, + hasWaylandSocket: true, + desktopEnv: 'KDE', + wmName: '', + ); + expect(info.isX11, isTrue); + expect(info.isWayland, isFalse); + }); + + test('empty sessionType + WAYLAND_DISPLAY set => Wayland', () { + const info = LinuxSessionInfo( + sessionType: '', + hasDisplay: true, + hasWaylandDisplay: true, + hasWaylandSocket: true, + desktopEnv: '', + wmName: '', + ); + expect(info.isWayland, isTrue); + expect(info.isX11, isFalse); + }); + + test('empty sessionType + only DISPLAY => X11', () { + const info = LinuxSessionInfo( + sessionType: '', + hasDisplay: true, + hasWaylandDisplay: false, + hasWaylandSocket: false, + desktopEnv: '', + wmName: '', + ); + expect(info.isX11, isTrue); + expect(info.isWayland, isFalse); + }); + + test('TTY / headless => neither X11 nor Wayland', () { + const info = LinuxSessionInfo( + sessionType: 'tty', + hasDisplay: false, + hasWaylandDisplay: false, + hasWaylandSocket: false, + desktopEnv: '', + wmName: '', + ); + expect(info.isUsable, isFalse); + }); + + test('isWaylandSession is a derived alias of detectLinuxSession', () { + expect(isWaylandSession(), equals(detectLinuxSession().isWayland)); + }); + + test('equality and hashCode work for value type', () { + const a = LinuxSessionInfo( + sessionType: 'x11', + hasDisplay: true, + hasWaylandDisplay: false, + hasWaylandSocket: false, + desktopEnv: 'GNOME', + wmName: 'gnome', + ); + const b = LinuxSessionInfo( + sessionType: 'x11', + hasDisplay: true, + hasWaylandDisplay: false, + hasWaylandSocket: false, + desktopEnv: 'GNOME', + wmName: 'gnome', + ); + expect(a, equals(b)); + expect(a.hashCode, equals(b.hashCode)); + }); + }); +} From 74949b4de3eacf89bb727fb2b91d1f04a51e800d Mon Sep 17 00:00:00 2001 From: rgdevment Date: Fri, 24 Apr 2026 12:13:03 -0400 Subject: [PATCH 02/31] chore: clean up empty code change sections in the changes log --- app/lib/main.dart | 10 + app/lib/services/linux_capabilities.dart | 203 ++ app/lib/services/linux_guard.dart | 20 + app/linux/runner/copypaste_linux_shell.c | 1296 +++++----- .../services/linux_capabilities_test.dart | 176 ++ listener/linux/listener_plugin.c | 2218 +++++++++-------- 6 files changed, 2243 insertions(+), 1680 deletions(-) create mode 100644 app/lib/services/linux_capabilities.dart create mode 100644 app/lib/services/linux_guard.dart create mode 100644 app/test/services/linux_capabilities_test.dart diff --git a/app/lib/main.dart b/app/lib/main.dart index 4fece463..6ffe90a6 100644 --- a/app/lib/main.dart +++ b/app/lib/main.dart @@ -14,6 +14,7 @@ import 'package:window_manager/window_manager.dart'; import 'services/auto_update_service.dart'; import 'services/install_channel.dart'; +import 'services/linux_capabilities.dart'; import 'services/release_manifest_service.dart'; import 'shell/app_window.dart'; @@ -165,6 +166,15 @@ Future _run() async { AppLogger.warn('main: Window.setEffect failed (non-fatal): $e'); } + if (Platform.isLinux) { + try { + final caps = await LinuxCapabilitiesService.detect(); + AppLogger.info('main: linux capabilities $caps'); + } catch (e) { + AppLogger.warn('main: LinuxCapabilities.detect failed (non-fatal): $e'); + } + } + runApp( CopyPasteApp( storage: storage, diff --git a/app/lib/services/linux_capabilities.dart b/app/lib/services/linux_capabilities.dart new file mode 100644 index 00000000..6b47e468 --- /dev/null +++ b/app/lib/services/linux_capabilities.dart @@ -0,0 +1,203 @@ +import 'dart:async'; +import 'dart:io'; + +import 'package:core/core.dart'; +import 'package:flutter/foundation.dart'; +import 'package:flutter/services.dart'; + +import '../shell/linux_session.dart'; + +@immutable +class LinuxCapabilities { + const LinuxCapabilities({ + required this.session, + required this.isX11, + required this.hasXTest, + required this.hasAppIndicator, + required this.hasClipboardManager, + required this.hasEwmh, + required this.detectedDesktopEnv, + required this.detectedWmName, + required this.detectionTimedOut, + }); + + final LinuxSessionInfo session; + final bool isX11; + final bool hasXTest; + final bool hasAppIndicator; + final bool hasClipboardManager; + final bool hasEwmh; + final String detectedDesktopEnv; + final String detectedWmName; + final bool detectionTimedOut; + + bool get isLinux => Platform.isLinux; + bool get isWayland => session.isWayland; + bool get isUsable => isLinux && isX11; + + static const LinuxCapabilities unsupported = LinuxCapabilities( + session: LinuxSessionInfo.unsupported, + isX11: false, + hasXTest: false, + hasAppIndicator: false, + hasClipboardManager: false, + hasEwmh: false, + detectedDesktopEnv: '', + detectedWmName: '', + detectionTimedOut: false, + ); + + LinuxCapabilities copyWith({ + bool? isX11, + bool? hasXTest, + bool? hasAppIndicator, + bool? hasClipboardManager, + bool? hasEwmh, + String? detectedDesktopEnv, + String? detectedWmName, + bool? detectionTimedOut, + }) { + return LinuxCapabilities( + session: session, + isX11: isX11 ?? this.isX11, + hasXTest: hasXTest ?? this.hasXTest, + hasAppIndicator: hasAppIndicator ?? this.hasAppIndicator, + hasClipboardManager: hasClipboardManager ?? this.hasClipboardManager, + hasEwmh: hasEwmh ?? this.hasEwmh, + detectedDesktopEnv: detectedDesktopEnv ?? this.detectedDesktopEnv, + detectedWmName: detectedWmName ?? this.detectedWmName, + detectionTimedOut: detectionTimedOut ?? this.detectionTimedOut, + ); + } + + @override + String toString() => + 'LinuxCapabilities(isX11=$isX11, hasXTest=$hasXTest, ' + 'hasAppIndicator=$hasAppIndicator, hasClipboardManager=$hasClipboardManager, ' + 'hasEwmh=$hasEwmh, desktopEnv=$detectedDesktopEnv, wm=$detectedWmName, ' + 'timedOut=$detectionTimedOut, session=$session)'; +} + +abstract class LinuxCapabilitiesChannel { + Future?> invokeShell(String method); + Future?> invokeListener(String method); +} + +class _DefaultLinuxCapabilitiesChannel implements LinuxCapabilitiesChannel { + const _DefaultLinuxCapabilitiesChannel(); + + static const MethodChannel _shell = MethodChannel('copypaste/linux_shell'); + static const MethodChannel _listener = + MethodChannel('copypaste/clipboard_writer'); + + @override + Future?> invokeShell(String method) async { + final result = await _shell.invokeMethod(method); + return result is Map ? Map.from(result) : null; + } + + @override + Future?> invokeListener(String method) async { + final result = await _listener.invokeMethod(method); + return result is Map ? Map.from(result) : null; + } +} + +class LinuxCapabilitiesService { + LinuxCapabilitiesService._(); + + static LinuxCapabilities _cache = LinuxCapabilities.unsupported; + static bool _initialized = false; + + static LinuxCapabilities get current => _cache; + static bool get isInitialized => _initialized; + + @visibleForTesting + static void resetForTesting([LinuxCapabilities? value]) { + _cache = value ?? LinuxCapabilities.unsupported; + _initialized = value != null; + } + + static Future detect({ + LinuxCapabilitiesChannel channel = const _DefaultLinuxCapabilitiesChannel(), + Duration timeout = const Duration(milliseconds: 800), + }) async { + if (!Platform.isLinux) { + _cache = LinuxCapabilities.unsupported; + _initialized = true; + return _cache; + } + + final session = detectLinuxSession(); + final base = LinuxCapabilities.unsupported.copyWith().copyWithSession(session); + + if (!session.isX11) { + _cache = base; + _initialized = true; + return _cache; + } + + bool timedOut = false; + Map? shellCaps; + Map? listenerCaps; + + try { + final results = await Future.wait([ + channel.invokeShell('getCapabilities').catchError((_) => null), + channel.invokeListener('getCapabilities').catchError((_) => null), + ]).timeout(timeout, onTimeout: () { + timedOut = true; + return [null, null]; + }); + shellCaps = results[0]; + listenerCaps = results[1]; + } catch (e) { + AppLogger.warn('LinuxCapabilities.detect failed: $e'); + } + + final result = LinuxCapabilities( + session: session, + isX11: _readBool(shellCaps, 'isX11', fallback: true), + hasXTest: _readBool(listenerCaps, 'hasXTest'), + hasAppIndicator: _readBool(shellCaps, 'hasAppIndicator'), + hasClipboardManager: _readBool(shellCaps, 'hasClipboardManager'), + hasEwmh: _readBool(shellCaps, 'hasEwmh'), + detectedDesktopEnv: _readString(shellCaps, 'desktopEnv'), + detectedWmName: _readString(shellCaps, 'wmName'), + detectionTimedOut: timedOut, + ); + + _cache = result; + _initialized = true; + return result; + } + + static bool _readBool(Map? map, String key, + {bool fallback = false}) { + if (map == null) return fallback; + final value = map[key]; + return value is bool ? value : fallback; + } + + static String _readString(Map? map, String key) { + if (map == null) return ''; + final value = map[key]; + return value is String ? value : ''; + } +} + +extension _LinuxCapabilitiesSession on LinuxCapabilities { + LinuxCapabilities copyWithSession(LinuxSessionInfo session) { + return LinuxCapabilities( + session: session, + isX11: isX11, + hasXTest: hasXTest, + hasAppIndicator: hasAppIndicator, + hasClipboardManager: hasClipboardManager, + hasEwmh: hasEwmh, + detectedDesktopEnv: detectedDesktopEnv, + detectedWmName: detectedWmName, + detectionTimedOut: detectionTimedOut, + ); + } +} diff --git a/app/lib/services/linux_guard.dart b/app/lib/services/linux_guard.dart new file mode 100644 index 00000000..1759cc5e --- /dev/null +++ b/app/lib/services/linux_guard.dart @@ -0,0 +1,20 @@ +import 'dart:io'; + +import 'linux_capabilities.dart'; + +class LinuxGuard { + const LinuxGuard._(); + + static LinuxCapabilities get _caps => LinuxCapabilitiesService.current; + + static bool get isLinux => Platform.isLinux; + static bool get isUsable => isLinux && _caps.isX11; + static bool get isWayland => isLinux && _caps.isWayland; + + static bool get canRegisterHotkey => isUsable && _caps.hasEwmh; + static bool get canPasteBack => isUsable && _caps.hasXTest; + static bool get canShowTray => isUsable && _caps.hasAppIndicator; + static bool get canPersistClipboard => isUsable && _caps.hasClipboardManager; + static bool get canAutostart => isUsable; + static bool get usesNativeWindowEffects => false; +} diff --git a/app/linux/runner/copypaste_linux_shell.c b/app/linux/runner/copypaste_linux_shell.c index 61849f4c..43d92e5a 100644 --- a/app/linux/runner/copypaste_linux_shell.c +++ b/app/linux/runner/copypaste_linux_shell.c @@ -1,578 +1,718 @@ -#include "copypaste_linux_shell.h" - -#include -#include -#include -#ifdef HAVE_APPINDICATOR -#include -#endif -#include -#include -#include -#include - -#ifdef GDK_WINDOWING_X11 -#include -#include -#include -#include -#include -#endif - -struct _CopyPasteLinuxShell { - FlMethodChannel* method_channel; - FlEventChannel* event_channel; - gboolean events_listening; - -#ifdef HAVE_APPINDICATOR - AppIndicator* app_indicator; -#else - GtkStatusIcon* tray_icon; -#endif - GtkWidget* tray_menu; - GtkWidget* toggle_item; - GtkWidget* exit_item; - gchar* resolved_icon_path; - - GtkWindow* gtk_window; - - gboolean hotkey_registered; -#ifdef GDK_WINDOWING_X11 - Display* xdisplay; - Window root_window; - guint hotkey_keycode; - guint hotkey_modifiers; - guint32 last_hotkey_time; -#endif -}; - -static const gchar* kShellChannelName = "copypaste/linux_shell"; -static const gchar* kShellEventChannelName = "copypaste/linux_shell/events"; - -static gboolean shell_is_x11(void) { -#ifdef GDK_WINDOWING_X11 - GdkDisplay* display = gdk_display_get_default(); - return display != NULL && GDK_IS_X11_DISPLAY(display); -#else - return FALSE; -#endif -} - -static FlValue* shell_event(const gchar* type) { - g_autoptr(FlValue) event = fl_value_new_map(); - fl_value_set_string_take(event, "type", fl_value_new_string(type)); - return fl_value_ref(event); -} - -static void send_shell_event(CopyPasteLinuxShell* shell, const gchar* type) { - if (!shell->events_listening || shell->event_channel == NULL) { - return; - } - - g_autoptr(FlValue) event = shell_event(type); - g_autoptr(GError) error = NULL; - if (!fl_event_channel_send(shell->event_channel, event, NULL, &error) && - error != NULL) { - g_warning("Failed to send linux shell event: %s", error->message); - } -} - -static gchar* resolve_asset_path(const gchar* asset_path) { - if (asset_path == NULL || *asset_path == '\0') { - return NULL; - } - - if (g_path_is_absolute(asset_path) && g_file_test(asset_path, G_FILE_TEST_EXISTS)) { - return g_strdup(asset_path); - } - - gchar exe_path[PATH_MAX + 1]; - ssize_t length = readlink("/proc/self/exe", exe_path, PATH_MAX); - if (length <= 0) { - return g_file_test(asset_path, G_FILE_TEST_EXISTS) ? g_strdup(asset_path) : NULL; - } - - exe_path[length] = '\0'; - g_autofree gchar* exe_dir = g_path_get_dirname(exe_path); - g_autofree gchar* flutter_asset_path = - g_build_filename(exe_dir, "data", "flutter_assets", asset_path, NULL); - if (g_file_test(flutter_asset_path, G_FILE_TEST_EXISTS)) { - return g_strdup(flutter_asset_path); - } - - g_autofree gchar* sibling_asset_path = g_build_filename(exe_dir, asset_path, NULL); - if (g_file_test(sibling_asset_path, G_FILE_TEST_EXISTS)) { - return g_strdup(sibling_asset_path); - } - - return NULL; -} - -static void destroy_tray_menu(CopyPasteLinuxShell* shell) { - if (shell->tray_menu != NULL) { - gtk_widget_destroy(shell->tray_menu); - shell->tray_menu = NULL; - shell->toggle_item = NULL; - shell->exit_item = NULL; - } -} - -static void tray_toggle_cb(GtkMenuItem* item, gpointer user_data) { - (void)item; - send_shell_event((CopyPasteLinuxShell*)user_data, "toggle"); -} - -static void tray_exit_cb(GtkMenuItem* item, gpointer user_data) { - (void)item; - send_shell_event((CopyPasteLinuxShell*)user_data, "exit"); -} - -#ifndef HAVE_APPINDICATOR -static void tray_activate_cb(GtkStatusIcon* status_icon, gpointer user_data) { - (void)status_icon; - send_shell_event((CopyPasteLinuxShell*)user_data, "toggle"); -} - -static void tray_popup_menu_cb(GtkStatusIcon* status_icon, - guint button, - guint activate_time, - gpointer user_data) { - CopyPasteLinuxShell* shell = (CopyPasteLinuxShell*)user_data; - if (shell->tray_menu == NULL) { - return; - } - - gtk_menu_popup(GTK_MENU(shell->tray_menu), NULL, NULL, - gtk_status_icon_position_menu, status_icon, button, - activate_time); -} -#endif - -static void rebuild_tray_menu(CopyPasteLinuxShell* shell, - const gchar* show_hide_label, - const gchar* exit_label) { - destroy_tray_menu(shell); - - shell->tray_menu = gtk_menu_new(); - shell->toggle_item = gtk_menu_item_new_with_label(show_hide_label); - shell->exit_item = gtk_menu_item_new_with_label(exit_label); - GtkWidget* separator = gtk_separator_menu_item_new(); - - g_signal_connect(shell->toggle_item, "activate", G_CALLBACK(tray_toggle_cb), - shell); - g_signal_connect(shell->exit_item, "activate", G_CALLBACK(tray_exit_cb), shell); - - gtk_menu_shell_append(GTK_MENU_SHELL(shell->tray_menu), shell->toggle_item); - gtk_menu_shell_append(GTK_MENU_SHELL(shell->tray_menu), separator); - gtk_menu_shell_append(GTK_MENU_SHELL(shell->tray_menu), shell->exit_item); - gtk_widget_show_all(shell->tray_menu); -} - -static void parse_tray_args(FlValue* args, - const gchar** out_icon_path, - const gchar** out_tooltip, - const gchar** out_show_hide, - const gchar** out_exit_label) { - FlValue* icon_value = args != NULL ? fl_value_lookup_string(args, "iconPath") : NULL; - FlValue* tooltip_value = args != NULL ? fl_value_lookup_string(args, "tooltip") : NULL; - FlValue* toggle_value = args != NULL ? fl_value_lookup_string(args, "showHideLabel") : NULL; - FlValue* exit_value = args != NULL ? fl_value_lookup_string(args, "exitLabel") : NULL; - - *out_icon_path = - icon_value != NULL && fl_value_get_type(icon_value) == FL_VALUE_TYPE_STRING - ? fl_value_get_string(icon_value) - : NULL; - *out_tooltip = - tooltip_value != NULL && fl_value_get_type(tooltip_value) == FL_VALUE_TYPE_STRING - ? fl_value_get_string(tooltip_value) - : "CopyPaste"; - *out_show_hide = - toggle_value != NULL && fl_value_get_type(toggle_value) == FL_VALUE_TYPE_STRING - ? fl_value_get_string(toggle_value) - : "Show/Hide"; - *out_exit_label = - exit_value != NULL && fl_value_get_type(exit_value) == FL_VALUE_TYPE_STRING - ? fl_value_get_string(exit_value) - : "Exit"; -} - -static gboolean init_tray(CopyPasteLinuxShell* shell, FlValue* args) { - const gchar* icon_path; - const gchar* tooltip; - const gchar* show_hide; - const gchar* exit_label; - parse_tray_args(args, &icon_path, &tooltip, &show_hide, &exit_label); - - g_free(shell->resolved_icon_path); - shell->resolved_icon_path = resolve_asset_path(icon_path); - -#ifdef HAVE_APPINDICATOR - if (shell->app_indicator == NULL) { - shell->app_indicator = app_indicator_new( - "com.rgdevment.copypaste", "copypaste", - APP_INDICATOR_CATEGORY_APPLICATION_STATUS); - } - - if (shell->resolved_icon_path != NULL) { - g_autofree gchar* icon_dir = - g_path_get_dirname(shell->resolved_icon_path); - g_autofree gchar* icon_base = - g_path_get_basename(shell->resolved_icon_path); - gchar* dot = strrchr(icon_base, '.'); - if (dot != NULL) *dot = '\0'; - app_indicator_set_icon_theme_path(shell->app_indicator, icon_dir); - app_indicator_set_icon_full(shell->app_indicator, icon_base, tooltip); - } - - app_indicator_set_title(shell->app_indicator, tooltip); - rebuild_tray_menu(shell, show_hide, exit_label); - app_indicator_set_menu(shell->app_indicator, GTK_MENU(shell->tray_menu)); - app_indicator_set_status(shell->app_indicator, APP_INDICATOR_STATUS_ACTIVE); -#else - if (shell->tray_icon == NULL) { - shell->tray_icon = gtk_status_icon_new(); - g_signal_connect(shell->tray_icon, "activate", - G_CALLBACK(tray_activate_cb), shell); - g_signal_connect(shell->tray_icon, "popup-menu", - G_CALLBACK(tray_popup_menu_cb), shell); - } - - if (shell->resolved_icon_path != NULL) { - gtk_status_icon_set_from_file(shell->tray_icon, shell->resolved_icon_path); - } - - gtk_status_icon_set_tooltip_text(shell->tray_icon, tooltip); - gtk_status_icon_set_visible(shell->tray_icon, TRUE); - rebuild_tray_menu(shell, show_hide, exit_label); -#endif - - return TRUE; -} - -static gboolean destroy_tray(CopyPasteLinuxShell* shell) { - destroy_tray_menu(shell); - -#ifdef HAVE_APPINDICATOR - if (shell->app_indicator != NULL) { - app_indicator_set_status(shell->app_indicator, APP_INDICATOR_STATUS_PASSIVE); - g_clear_object(&shell->app_indicator); - } -#else - if (shell->tray_icon != NULL) { - gtk_status_icon_set_visible(shell->tray_icon, FALSE); - g_clear_object(&shell->tray_icon); - } -#endif - - g_clear_pointer(&shell->resolved_icon_path, g_free); - return TRUE; -} - -#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) { - return (KeySym)(XK_A + (virtual_key - 0x41)); - } - return NoSymbol; -} - -static guint compute_modifier_mask(FlValue* args) { - guint modifiers = 0; - - FlValue* ctrl = fl_value_lookup_string(args, "useCtrl"); - FlValue* meta = fl_value_lookup_string(args, "useWin"); - FlValue* alt = fl_value_lookup_string(args, "useAlt"); - FlValue* shift = fl_value_lookup_string(args, "useShift"); - - if (ctrl != NULL && fl_value_get_type(ctrl) == FL_VALUE_TYPE_BOOL && - fl_value_get_bool(ctrl)) { - modifiers |= ControlMask; - } - if (meta != NULL && fl_value_get_type(meta) == FL_VALUE_TYPE_BOOL && - fl_value_get_bool(meta)) { - modifiers |= Mod4Mask; - } - if (alt != NULL && fl_value_get_type(alt) == FL_VALUE_TYPE_BOOL && - fl_value_get_bool(alt)) { - modifiers |= Mod1Mask; - } - if (shift != NULL && fl_value_get_type(shift) == FL_VALUE_TYPE_BOOL && - fl_value_get_bool(shift)) { - modifiers |= ShiftMask; - } - - return modifiers; -} - -static void unregister_hotkey(CopyPasteLinuxShell* shell) { - if (!shell->hotkey_registered || shell->xdisplay == NULL || shell->hotkey_keycode == 0) { - shell->hotkey_registered = FALSE; - return; - } - - 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; -} - -static gboolean register_hotkey(CopyPasteLinuxShell* shell, FlValue* args) { - if (!shell_is_x11() || shell->xdisplay == NULL) { - return FALSE; - } - - unregister_hotkey(shell); - - FlValue* key_value = args != NULL ? fl_value_lookup_string(args, "virtualKey") : NULL; - gint64 virtual_key = (key_value != NULL && fl_value_get_type(key_value) == FL_VALUE_TYPE_INT) - ? fl_value_get_int(key_value) : 0; - KeySym keysym = virtual_key_to_keysym(virtual_key); - if (keysym == NoSymbol) { - g_warning("registerHotkey: unsupported virtual key 0x%llx", (unsigned long long)virtual_key); - return FALSE; - } - - guint modifiers = compute_modifier_mask(args); - if (modifiers == 0) { - g_warning("registerHotkey: no modifier keys specified"); - return FALSE; - } - - KeyCode keycode = XKeysymToKeycode(shell->xdisplay, keysym); - if (keycode == 0) { - g_warning("registerHotkey: no keycode for keysym %lu", (unsigned long)keysym); - return FALSE; - } - - for (guint i = 0; i < G_N_ELEMENTS(modifier_combinations); i++) { - if (!trap_x11_grab(shell->xdisplay, shell->root_window, keycode, - modifiers | modifier_combinations[i])) { - g_warning("registerHotkey: XGrabKey failed (modifier variant 0x%x) — key may be in use", - modifiers | modifier_combinations[i]); - ungrab_hotkey_variants(shell->xdisplay, shell->root_window, keycode, - modifiers); - return FALSE; - } - } - - // Flush pending requests before reading window attributes to avoid stale state. - XSync(shell->xdisplay, False); - 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; - shell->hotkey_modifiers = modifiers; - return TRUE; -} - -static GdkFilterReturn x11_event_filter(GdkXEvent* xevent, - GdkEvent* event, - gpointer user_data) { - (void)event; - CopyPasteLinuxShell* shell = (CopyPasteLinuxShell*)user_data; - if (!shell->hotkey_registered) { - return GDK_FILTER_CONTINUE; - } - - XEvent* x_event = (XEvent*)xevent; - if (x_event->type != KeyPress) { - return GDK_FILTER_CONTINUE; - } - - guint relevant_mask = ControlMask | ShiftMask | Mod1Mask | Mod4Mask; - guint state = (guint)x_event->xkey.state & relevant_mask; - if ((guint)x_event->xkey.keycode == shell->hotkey_keycode && - state == shell->hotkey_modifiers) { - shell->last_hotkey_time = x_event->xkey.time; - send_shell_event(shell, "hotkey"); - return GDK_FILTER_REMOVE; - } - - return GDK_FILTER_CONTINUE; -} -#endif - -static FlMethodErrorResponse* shell_listen_cb(FlEventChannel* channel, - FlValue* args, - gpointer user_data) { - (void)channel; - (void)args; - CopyPasteLinuxShell* shell = (CopyPasteLinuxShell*)user_data; - shell->events_listening = TRUE; - return NULL; -} - -static FlMethodErrorResponse* shell_cancel_cb(FlEventChannel* channel, - FlValue* args, - gpointer user_data) { - (void)channel; - (void)args; - CopyPasteLinuxShell* shell = (CopyPasteLinuxShell*)user_data; - shell->events_listening = FALSE; - return NULL; -} - -static void respond_method_success(FlMethodCall* method_call, FlValue* result) { - g_autoptr(GError) error = NULL; - g_autoptr(FlValue) owned = result; - if (!fl_method_call_respond_success(method_call, owned, &error) && error != NULL) { - g_warning("Failed to respond to linux shell method call: %s", error->message); - } -} - -static void shell_method_call_cb(FlMethodChannel* channel, - FlMethodCall* method_call, - gpointer user_data) { - (void)channel; - CopyPasteLinuxShell* shell = (CopyPasteLinuxShell*)user_data; - const gchar* method = fl_method_call_get_name(method_call); - FlValue* args = fl_method_call_get_args(method_call); - - if (strcmp(method, "initTray") == 0 || strcmp(method, "updateTray") == 0) { - respond_method_success(method_call, fl_value_new_bool(init_tray(shell, args))); - return; - } - - if (strcmp(method, "destroyTray") == 0) { - respond_method_success(method_call, fl_value_new_bool(destroy_tray(shell))); - return; - } - - if (strcmp(method, "registerHotkey") == 0) { -#ifdef GDK_WINDOWING_X11 - respond_method_success(method_call, fl_value_new_bool(register_hotkey(shell, args))); -#else - respond_method_success(method_call, fl_value_new_bool(FALSE)); -#endif - return; - } - - if (strcmp(method, "unregisterHotkey") == 0) { -#ifdef GDK_WINDOWING_X11 - unregister_hotkey(shell); -#endif - respond_method_success(method_call, fl_value_new_bool(TRUE)); - return; - } - - if (strcmp(method, "focusWindow") == 0) { - if (shell->gtk_window != NULL) { -#ifdef GDK_WINDOWING_X11 - guint32 t = shell->last_hotkey_time != 0 ? shell->last_hotkey_time - : GDK_CURRENT_TIME; - gtk_window_present_with_time(shell->gtk_window, t); -#else - gtk_window_present(shell->gtk_window); -#endif - } - respond_method_success(method_call, fl_value_new_bool(TRUE)); - return; - } - - g_autoptr(FlMethodResponse) response = - FL_METHOD_RESPONSE(fl_method_not_implemented_response_new()); - fl_method_call_respond(method_call, response, NULL); -} - -CopyPasteLinuxShell* copypaste_linux_shell_new(FlBinaryMessenger* messenger, - GtkWindow* window) { - CopyPasteLinuxShell* shell = g_new0(CopyPasteLinuxShell, 1); - shell->gtk_window = window; - - g_autoptr(FlStandardMethodCodec) codec = fl_standard_method_codec_new(); - shell->method_channel = - fl_method_channel_new(messenger, kShellChannelName, FL_METHOD_CODEC(codec)); - fl_method_channel_set_method_call_handler(shell->method_channel, - shell_method_call_cb, shell, NULL); - - shell->event_channel = - fl_event_channel_new(messenger, kShellEventChannelName, FL_METHOD_CODEC(codec)); - fl_event_channel_set_stream_handlers(shell->event_channel, shell_listen_cb, - shell_cancel_cb, shell, NULL); - -#ifdef GDK_WINDOWING_X11 - if (shell_is_x11()) { - GdkDisplay* display = gdk_display_get_default(); - shell->xdisplay = gdk_x11_display_get_xdisplay(display); - shell->root_window = DefaultRootWindow(shell->xdisplay); - gdk_window_add_filter(NULL, x11_event_filter, shell); - } -#endif - - return shell; -} - -void copypaste_linux_shell_dispose(CopyPasteLinuxShell* shell) { - if (shell == NULL) { - return; - } - -#ifdef GDK_WINDOWING_X11 - if (shell_is_x11()) { - unregister_hotkey(shell); - gdk_window_remove_filter(NULL, x11_event_filter, shell); - } -#endif - - destroy_tray(shell); - g_clear_object(&shell->method_channel); - g_clear_object(&shell->event_channel); - g_free(shell); -} +#include "copypaste_linux_shell.h" + +#include +#include +#include +#ifdef HAVE_APPINDICATOR +#include +#endif +#include +#include +#include +#include + +#ifdef GDK_WINDOWING_X11 +#include +#include +#include +#include +#include +#include +#endif + +struct _CopyPasteLinuxShell { + FlMethodChannel* method_channel; + FlEventChannel* event_channel; + gboolean events_listening; + +#ifdef HAVE_APPINDICATOR + AppIndicator* app_indicator; +#else + GtkStatusIcon* tray_icon; +#endif + GtkWidget* tray_menu; + GtkWidget* toggle_item; + GtkWidget* exit_item; + gchar* resolved_icon_path; + + GtkWindow* gtk_window; + + gboolean hotkey_registered; +#ifdef GDK_WINDOWING_X11 + Display* xdisplay; + Window root_window; + guint hotkey_keycode; + guint hotkey_modifiers; + guint32 last_hotkey_time; +#endif +}; + +static const gchar* kShellChannelName = "copypaste/linux_shell"; +static const gchar* kShellEventChannelName = "copypaste/linux_shell/events"; + +static gboolean shell_is_x11(void) { +#ifdef GDK_WINDOWING_X11 + GdkDisplay* display = gdk_display_get_default(); + return display != NULL && GDK_IS_X11_DISPLAY(display); +#else + return FALSE; +#endif +} + +static FlValue* shell_event(const gchar* type) { + g_autoptr(FlValue) event = fl_value_new_map(); + fl_value_set_string_take(event, "type", fl_value_new_string(type)); + return fl_value_ref(event); +} + +static void send_shell_event(CopyPasteLinuxShell* shell, const gchar* type) { + if (!shell->events_listening || shell->event_channel == NULL) { + return; + } + + g_autoptr(FlValue) event = shell_event(type); + g_autoptr(GError) error = NULL; + if (!fl_event_channel_send(shell->event_channel, event, NULL, &error) && + error != NULL) { + g_warning("Failed to send linux shell event: %s", error->message); + } +} + +static gchar* resolve_asset_path(const gchar* asset_path) { + if (asset_path == NULL || *asset_path == '\0') { + return NULL; + } + + if (g_path_is_absolute(asset_path) && g_file_test(asset_path, G_FILE_TEST_EXISTS)) { + return g_strdup(asset_path); + } + + gchar exe_path[PATH_MAX + 1]; + ssize_t length = readlink("/proc/self/exe", exe_path, PATH_MAX); + if (length <= 0) { + return g_file_test(asset_path, G_FILE_TEST_EXISTS) ? g_strdup(asset_path) : NULL; + } + + exe_path[length] = '\0'; + g_autofree gchar* exe_dir = g_path_get_dirname(exe_path); + g_autofree gchar* flutter_asset_path = + g_build_filename(exe_dir, "data", "flutter_assets", asset_path, NULL); + if (g_file_test(flutter_asset_path, G_FILE_TEST_EXISTS)) { + return g_strdup(flutter_asset_path); + } + + g_autofree gchar* sibling_asset_path = g_build_filename(exe_dir, asset_path, NULL); + if (g_file_test(sibling_asset_path, G_FILE_TEST_EXISTS)) { + return g_strdup(sibling_asset_path); + } + + return NULL; +} + +static void destroy_tray_menu(CopyPasteLinuxShell* shell) { + if (shell->tray_menu != NULL) { + gtk_widget_destroy(shell->tray_menu); + shell->tray_menu = NULL; + shell->toggle_item = NULL; + shell->exit_item = NULL; + } +} + +static void tray_toggle_cb(GtkMenuItem* item, gpointer user_data) { + (void)item; + send_shell_event((CopyPasteLinuxShell*)user_data, "toggle"); +} + +static void tray_exit_cb(GtkMenuItem* item, gpointer user_data) { + (void)item; + send_shell_event((CopyPasteLinuxShell*)user_data, "exit"); +} + +#ifndef HAVE_APPINDICATOR +static void tray_activate_cb(GtkStatusIcon* status_icon, gpointer user_data) { + (void)status_icon; + send_shell_event((CopyPasteLinuxShell*)user_data, "toggle"); +} + +static void tray_popup_menu_cb(GtkStatusIcon* status_icon, + guint button, + guint activate_time, + gpointer user_data) { + CopyPasteLinuxShell* shell = (CopyPasteLinuxShell*)user_data; + if (shell->tray_menu == NULL) { + return; + } + + gtk_menu_popup(GTK_MENU(shell->tray_menu), NULL, NULL, + gtk_status_icon_position_menu, status_icon, button, + activate_time); +} +#endif + +static void rebuild_tray_menu(CopyPasteLinuxShell* shell, + const gchar* show_hide_label, + const gchar* exit_label) { + destroy_tray_menu(shell); + + shell->tray_menu = gtk_menu_new(); + shell->toggle_item = gtk_menu_item_new_with_label(show_hide_label); + shell->exit_item = gtk_menu_item_new_with_label(exit_label); + GtkWidget* separator = gtk_separator_menu_item_new(); + + g_signal_connect(shell->toggle_item, "activate", G_CALLBACK(tray_toggle_cb), + shell); + g_signal_connect(shell->exit_item, "activate", G_CALLBACK(tray_exit_cb), shell); + + gtk_menu_shell_append(GTK_MENU_SHELL(shell->tray_menu), shell->toggle_item); + gtk_menu_shell_append(GTK_MENU_SHELL(shell->tray_menu), separator); + gtk_menu_shell_append(GTK_MENU_SHELL(shell->tray_menu), shell->exit_item); + gtk_widget_show_all(shell->tray_menu); +} + +static void parse_tray_args(FlValue* args, + const gchar** out_icon_path, + const gchar** out_tooltip, + const gchar** out_show_hide, + const gchar** out_exit_label) { + FlValue* icon_value = args != NULL ? fl_value_lookup_string(args, "iconPath") : NULL; + FlValue* tooltip_value = args != NULL ? fl_value_lookup_string(args, "tooltip") : NULL; + FlValue* toggle_value = args != NULL ? fl_value_lookup_string(args, "showHideLabel") : NULL; + FlValue* exit_value = args != NULL ? fl_value_lookup_string(args, "exitLabel") : NULL; + + *out_icon_path = + icon_value != NULL && fl_value_get_type(icon_value) == FL_VALUE_TYPE_STRING + ? fl_value_get_string(icon_value) + : NULL; + *out_tooltip = + tooltip_value != NULL && fl_value_get_type(tooltip_value) == FL_VALUE_TYPE_STRING + ? fl_value_get_string(tooltip_value) + : "CopyPaste"; + *out_show_hide = + toggle_value != NULL && fl_value_get_type(toggle_value) == FL_VALUE_TYPE_STRING + ? fl_value_get_string(toggle_value) + : "Show/Hide"; + *out_exit_label = + exit_value != NULL && fl_value_get_type(exit_value) == FL_VALUE_TYPE_STRING + ? fl_value_get_string(exit_value) + : "Exit"; +} + +static gboolean init_tray(CopyPasteLinuxShell* shell, FlValue* args) { + const gchar* icon_path; + const gchar* tooltip; + const gchar* show_hide; + const gchar* exit_label; + parse_tray_args(args, &icon_path, &tooltip, &show_hide, &exit_label); + + g_free(shell->resolved_icon_path); + shell->resolved_icon_path = resolve_asset_path(icon_path); + +#ifdef HAVE_APPINDICATOR + if (shell->app_indicator == NULL) { + shell->app_indicator = app_indicator_new( + "com.rgdevment.copypaste", "copypaste", + APP_INDICATOR_CATEGORY_APPLICATION_STATUS); + } + + if (shell->resolved_icon_path != NULL) { + g_autofree gchar* icon_dir = + g_path_get_dirname(shell->resolved_icon_path); + g_autofree gchar* icon_base = + g_path_get_basename(shell->resolved_icon_path); + gchar* dot = strrchr(icon_base, '.'); + if (dot != NULL) *dot = '\0'; + app_indicator_set_icon_theme_path(shell->app_indicator, icon_dir); + app_indicator_set_icon_full(shell->app_indicator, icon_base, tooltip); + } + + app_indicator_set_title(shell->app_indicator, tooltip); + rebuild_tray_menu(shell, show_hide, exit_label); + app_indicator_set_menu(shell->app_indicator, GTK_MENU(shell->tray_menu)); + app_indicator_set_status(shell->app_indicator, APP_INDICATOR_STATUS_ACTIVE); +#else + if (shell->tray_icon == NULL) { + shell->tray_icon = gtk_status_icon_new(); + g_signal_connect(shell->tray_icon, "activate", + G_CALLBACK(tray_activate_cb), shell); + g_signal_connect(shell->tray_icon, "popup-menu", + G_CALLBACK(tray_popup_menu_cb), shell); + } + + if (shell->resolved_icon_path != NULL) { + gtk_status_icon_set_from_file(shell->tray_icon, shell->resolved_icon_path); + } + + gtk_status_icon_set_tooltip_text(shell->tray_icon, tooltip); + gtk_status_icon_set_visible(shell->tray_icon, TRUE); + rebuild_tray_menu(shell, show_hide, exit_label); +#endif + + return TRUE; +} + +static gboolean destroy_tray(CopyPasteLinuxShell* shell) { + destroy_tray_menu(shell); + +#ifdef HAVE_APPINDICATOR + if (shell->app_indicator != NULL) { + app_indicator_set_status(shell->app_indicator, APP_INDICATOR_STATUS_PASSIVE); + g_clear_object(&shell->app_indicator); + } +#else + if (shell->tray_icon != NULL) { + gtk_status_icon_set_visible(shell->tray_icon, FALSE); + g_clear_object(&shell->tray_icon); + } +#endif + + g_clear_pointer(&shell->resolved_icon_path, g_free); + return TRUE; +} + +#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) { + return (KeySym)(XK_A + (virtual_key - 0x41)); + } + return NoSymbol; +} + +static guint compute_modifier_mask(FlValue* args) { + guint modifiers = 0; + + FlValue* ctrl = fl_value_lookup_string(args, "useCtrl"); + FlValue* meta = fl_value_lookup_string(args, "useWin"); + FlValue* alt = fl_value_lookup_string(args, "useAlt"); + FlValue* shift = fl_value_lookup_string(args, "useShift"); + + if (ctrl != NULL && fl_value_get_type(ctrl) == FL_VALUE_TYPE_BOOL && + fl_value_get_bool(ctrl)) { + modifiers |= ControlMask; + } + if (meta != NULL && fl_value_get_type(meta) == FL_VALUE_TYPE_BOOL && + fl_value_get_bool(meta)) { + modifiers |= Mod4Mask; + } + if (alt != NULL && fl_value_get_type(alt) == FL_VALUE_TYPE_BOOL && + fl_value_get_bool(alt)) { + modifiers |= Mod1Mask; + } + if (shift != NULL && fl_value_get_type(shift) == FL_VALUE_TYPE_BOOL && + fl_value_get_bool(shift)) { + modifiers |= ShiftMask; + } + + return modifiers; +} + +static void unregister_hotkey(CopyPasteLinuxShell* shell) { + if (!shell->hotkey_registered || shell->xdisplay == NULL || shell->hotkey_keycode == 0) { + shell->hotkey_registered = FALSE; + return; + } + + 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; +} + +static gboolean register_hotkey(CopyPasteLinuxShell* shell, FlValue* args) { + if (!shell_is_x11() || shell->xdisplay == NULL) { + return FALSE; + } + + unregister_hotkey(shell); + + FlValue* key_value = args != NULL ? fl_value_lookup_string(args, "virtualKey") : NULL; + gint64 virtual_key = (key_value != NULL && fl_value_get_type(key_value) == FL_VALUE_TYPE_INT) + ? fl_value_get_int(key_value) : 0; + KeySym keysym = virtual_key_to_keysym(virtual_key); + if (keysym == NoSymbol) { + g_warning("registerHotkey: unsupported virtual key 0x%llx", (unsigned long long)virtual_key); + return FALSE; + } + + guint modifiers = compute_modifier_mask(args); + if (modifiers == 0) { + g_warning("registerHotkey: no modifier keys specified"); + return FALSE; + } + + KeyCode keycode = XKeysymToKeycode(shell->xdisplay, keysym); + if (keycode == 0) { + g_warning("registerHotkey: no keycode for keysym %lu", (unsigned long)keysym); + return FALSE; + } + + for (guint i = 0; i < G_N_ELEMENTS(modifier_combinations); i++) { + if (!trap_x11_grab(shell->xdisplay, shell->root_window, keycode, + modifiers | modifier_combinations[i])) { + g_warning("registerHotkey: XGrabKey failed (modifier variant 0x%x) — key may be in use", + modifiers | modifier_combinations[i]); + ungrab_hotkey_variants(shell->xdisplay, shell->root_window, keycode, + modifiers); + return FALSE; + } + } + + // Flush pending requests before reading window attributes to avoid stale state. + XSync(shell->xdisplay, False); + 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; + shell->hotkey_modifiers = modifiers; + return TRUE; +} + +static GdkFilterReturn x11_event_filter(GdkXEvent* xevent, + GdkEvent* event, + gpointer user_data) { + (void)event; + CopyPasteLinuxShell* shell = (CopyPasteLinuxShell*)user_data; + if (!shell->hotkey_registered) { + return GDK_FILTER_CONTINUE; + } + + XEvent* x_event = (XEvent*)xevent; + if (x_event->type != KeyPress) { + return GDK_FILTER_CONTINUE; + } + + guint relevant_mask = ControlMask | ShiftMask | Mod1Mask | Mod4Mask; + guint state = (guint)x_event->xkey.state & relevant_mask; + if ((guint)x_event->xkey.keycode == shell->hotkey_keycode && + state == shell->hotkey_modifiers) { + shell->last_hotkey_time = x_event->xkey.time; + send_shell_event(shell, "hotkey"); + return GDK_FILTER_REMOVE; + } + + return GDK_FILTER_CONTINUE; +} +#endif + +static FlMethodErrorResponse* shell_listen_cb(FlEventChannel* channel, + FlValue* args, + gpointer user_data) { + (void)channel; + (void)args; + CopyPasteLinuxShell* shell = (CopyPasteLinuxShell*)user_data; + shell->events_listening = TRUE; + return NULL; +} + +static FlMethodErrorResponse* shell_cancel_cb(FlEventChannel* channel, + FlValue* args, + gpointer user_data) { + (void)channel; + (void)args; + CopyPasteLinuxShell* shell = (CopyPasteLinuxShell*)user_data; + shell->events_listening = FALSE; + return NULL; +} + +static void respond_method_success(FlMethodCall* method_call, FlValue* result) { + g_autoptr(GError) error = NULL; + g_autoptr(FlValue) owned = result; + if (!fl_method_call_respond_success(method_call, owned, &error) && error != NULL) { + g_warning("Failed to respond to linux shell method call: %s", error->message); + } +} + +static gboolean has_app_indicator_runtime(void) { +#ifdef HAVE_APPINDICATOR + return TRUE; +#else + return FALSE; +#endif +} + +static gboolean ewmh_supports_active_window(void) { +#ifdef GDK_WINDOWING_X11 + GdkDisplay* gdk_display = gdk_display_get_default(); + if (gdk_display == NULL || !GDK_IS_X11_DISPLAY(gdk_display)) { + return FALSE; + } + Display* xdisplay = GDK_DISPLAY_XDISPLAY(gdk_display); + if (xdisplay == NULL) return FALSE; + Atom net_supported = XInternAtom(xdisplay, "_NET_SUPPORTED", True); + Atom net_active_window = XInternAtom(xdisplay, "_NET_ACTIVE_WINDOW", True); + if (net_supported == None || net_active_window == None) return FALSE; + Window root = DefaultRootWindow(xdisplay); + Atom actual_type = None; + int actual_format = 0; + unsigned long nitems = 0; + unsigned long bytes_after = 0; + unsigned char* data = NULL; + int status = XGetWindowProperty(xdisplay, root, net_supported, 0, 1024, False, + XA_ATOM, &actual_type, &actual_format, + &nitems, &bytes_after, &data); + gboolean found = FALSE; + if (status == Success && actual_type == XA_ATOM && actual_format == 32 && data != NULL) { + Atom* atoms = (Atom*)data; + for (unsigned long i = 0; i < nitems; ++i) { + if (atoms[i] == net_active_window) { found = TRUE; break; } + } + } + if (data != NULL) XFree(data); + return found; +#else + return FALSE; +#endif +} + +static gchar* read_wm_name(void) { +#ifdef GDK_WINDOWING_X11 + GdkDisplay* gdk_display = gdk_display_get_default(); + if (gdk_display == NULL || !GDK_IS_X11_DISPLAY(gdk_display)) return NULL; + Display* xdisplay = GDK_DISPLAY_XDISPLAY(gdk_display); + Atom check = XInternAtom(xdisplay, "_NET_SUPPORTING_WM_CHECK", True); + Atom utf8 = XInternAtom(xdisplay, "UTF8_STRING", True); + Atom wm_name = XInternAtom(xdisplay, "_NET_WM_NAME", True); + if (check == None || wm_name == None) return NULL; + Window root = DefaultRootWindow(xdisplay); + Atom actual_type = None; + int actual_format = 0; + unsigned long nitems = 0; + unsigned long bytes_after = 0; + unsigned char* data = NULL; + if (XGetWindowProperty(xdisplay, root, check, 0, 1, False, XA_WINDOW, + &actual_type, &actual_format, &nitems, &bytes_after, + &data) != Success || data == NULL) { + return NULL; + } + Window wm_window = *(Window*)data; + XFree(data); + data = NULL; + if (wm_window == None) return NULL; + Atom string_type = utf8 != None ? utf8 : XA_STRING; + if (XGetWindowProperty(xdisplay, wm_window, wm_name, 0, 256, False, + string_type, &actual_type, &actual_format, &nitems, + &bytes_after, &data) != Success || data == NULL) { + return NULL; + } + gchar* name = g_strndup((const gchar*)data, nitems); + XFree(data); + return name; +#else + return NULL; +#endif +} + +static gboolean is_clipboard_manager_name(const gchar* comm) { + if (comm == NULL) return FALSE; + static const gchar* kKnown[] = { + "klipper", "gpaste-applet", "gpaste-client", "clipman", "copyq", + "xfce4-clipman", "parcellite", "diodon", "clipit", NULL, + }; + for (int i = 0; kKnown[i] != NULL; ++i) { + if (g_strcmp0(comm, kKnown[i]) == 0) return TRUE; + } + return FALSE; +} + +static gboolean clipboard_manager_running(void) { + GDir* dir = g_dir_open("/proc", 0, NULL); + if (dir == NULL) return FALSE; + gboolean found = FALSE; + const gchar* name = NULL; + while ((name = g_dir_read_name(dir)) != NULL) { + gboolean only_digits = TRUE; + for (const gchar* p = name; *p != '\0'; ++p) { + if (!g_ascii_isdigit(*p)) { only_digits = FALSE; break; } + } + if (!only_digits) continue; + g_autofree gchar* path = g_build_filename("/proc", name, "comm", NULL); + g_autofree gchar* contents = NULL; + gsize length = 0; + if (!g_file_get_contents(path, &contents, &length, NULL)) continue; + if (contents == NULL) continue; + g_strstrip(contents); + if (is_clipboard_manager_name(contents)) { found = TRUE; break; } + } + g_dir_close(dir); + return found; +} + +static FlValue* build_capabilities(void) { + FlValue* caps = fl_value_new_map(); + fl_value_set_string_take(caps, "isX11", fl_value_new_bool(shell_is_x11())); + fl_value_set_string_take(caps, "hasAppIndicator", + fl_value_new_bool(has_app_indicator_runtime())); + fl_value_set_string_take(caps, "hasEwmh", + fl_value_new_bool(ewmh_supports_active_window())); + fl_value_set_string_take(caps, "hasClipboardManager", + fl_value_new_bool(clipboard_manager_running())); + const gchar* desktop_env = g_getenv("XDG_CURRENT_DESKTOP"); + if (desktop_env == NULL) desktop_env = g_getenv("DESKTOP_SESSION"); + fl_value_set_string_take(caps, "desktopEnv", + fl_value_new_string(desktop_env != NULL ? desktop_env : "")); + g_autofree gchar* wm = read_wm_name(); + fl_value_set_string_take(caps, "wmName", + fl_value_new_string(wm != NULL ? wm : "")); + return caps; +} + +static void shell_method_call_cb(FlMethodChannel* channel, + FlMethodCall* method_call, + gpointer user_data) { + (void)channel; + CopyPasteLinuxShell* shell = (CopyPasteLinuxShell*)user_data; + const gchar* method = fl_method_call_get_name(method_call); + FlValue* args = fl_method_call_get_args(method_call); + + if (strcmp(method, "getCapabilities") == 0) { + respond_method_success(method_call, build_capabilities()); + return; + } + + if (strcmp(method, "initTray") == 0 || strcmp(method, "updateTray") == 0) { + respond_method_success(method_call, fl_value_new_bool(init_tray(shell, args))); + return; + } + + if (strcmp(method, "destroyTray") == 0) { + respond_method_success(method_call, fl_value_new_bool(destroy_tray(shell))); + return; + } + + if (strcmp(method, "registerHotkey") == 0) { +#ifdef GDK_WINDOWING_X11 + respond_method_success(method_call, fl_value_new_bool(register_hotkey(shell, args))); +#else + respond_method_success(method_call, fl_value_new_bool(FALSE)); +#endif + return; + } + + if (strcmp(method, "unregisterHotkey") == 0) { +#ifdef GDK_WINDOWING_X11 + unregister_hotkey(shell); +#endif + respond_method_success(method_call, fl_value_new_bool(TRUE)); + return; + } + + if (strcmp(method, "focusWindow") == 0) { + if (shell->gtk_window != NULL) { +#ifdef GDK_WINDOWING_X11 + guint32 t = shell->last_hotkey_time != 0 ? shell->last_hotkey_time + : GDK_CURRENT_TIME; + gtk_window_present_with_time(shell->gtk_window, t); +#else + gtk_window_present(shell->gtk_window); +#endif + } + respond_method_success(method_call, fl_value_new_bool(TRUE)); + return; + } + + g_autoptr(FlMethodResponse) response = + FL_METHOD_RESPONSE(fl_method_not_implemented_response_new()); + fl_method_call_respond(method_call, response, NULL); +} + +CopyPasteLinuxShell* copypaste_linux_shell_new(FlBinaryMessenger* messenger, + GtkWindow* window) { + CopyPasteLinuxShell* shell = g_new0(CopyPasteLinuxShell, 1); + shell->gtk_window = window; + + g_autoptr(FlStandardMethodCodec) codec = fl_standard_method_codec_new(); + shell->method_channel = + fl_method_channel_new(messenger, kShellChannelName, FL_METHOD_CODEC(codec)); + fl_method_channel_set_method_call_handler(shell->method_channel, + shell_method_call_cb, shell, NULL); + + shell->event_channel = + fl_event_channel_new(messenger, kShellEventChannelName, FL_METHOD_CODEC(codec)); + fl_event_channel_set_stream_handlers(shell->event_channel, shell_listen_cb, + shell_cancel_cb, shell, NULL); + +#ifdef GDK_WINDOWING_X11 + if (shell_is_x11()) { + GdkDisplay* display = gdk_display_get_default(); + shell->xdisplay = gdk_x11_display_get_xdisplay(display); + shell->root_window = DefaultRootWindow(shell->xdisplay); + gdk_window_add_filter(NULL, x11_event_filter, shell); + } +#endif + + return shell; +} + +void copypaste_linux_shell_dispose(CopyPasteLinuxShell* shell) { + if (shell == NULL) { + return; + } + +#ifdef GDK_WINDOWING_X11 + if (shell_is_x11()) { + unregister_hotkey(shell); + gdk_window_remove_filter(NULL, x11_event_filter, shell); + } +#endif + + destroy_tray(shell); + g_clear_object(&shell->method_channel); + g_clear_object(&shell->event_channel); + g_free(shell); +} diff --git a/app/test/services/linux_capabilities_test.dart b/app/test/services/linux_capabilities_test.dart new file mode 100644 index 00000000..1fd5a55b --- /dev/null +++ b/app/test/services/linux_capabilities_test.dart @@ -0,0 +1,176 @@ +import 'dart:io'; + +import 'package:copypaste/services/linux_capabilities.dart'; +import 'package:copypaste/services/linux_guard.dart'; +import 'package:flutter_test/flutter_test.dart'; + +class _FakeChannel implements LinuxCapabilitiesChannel { + _FakeChannel({ + this.shellResponse, + this.listenerResponse, + this.shellThrows, + this.listenerThrows, + this.shellDelay = Duration.zero, + }); + + final Map? shellResponse; + final Map? listenerResponse; + final Object? shellThrows; + final Object? listenerThrows; + final Duration shellDelay; + + int shellCalls = 0; + int listenerCalls = 0; + + @override + Future?> invokeShell(String method) async { + shellCalls++; + if (shellDelay > Duration.zero) await Future.delayed(shellDelay); + if (shellThrows != null) throw shellThrows!; + return shellResponse; + } + + @override + Future?> invokeListener(String method) async { + listenerCalls++; + if (listenerThrows != null) throw listenerThrows!; + return listenerResponse; + } +} + +void main() { + setUp(() { + LinuxCapabilitiesService.resetForTesting(); + }); + + group('LinuxCapabilitiesService.detect', () { + test('returns unsupported on non-Linux platforms', () async { + if (Platform.isLinux) return; + final caps = await LinuxCapabilitiesService.detect(); + expect(caps, equals(LinuxCapabilities.unsupported)); + expect(LinuxCapabilitiesService.isInitialized, isTrue); + expect(LinuxCapabilitiesService.current, equals(caps)); + }); + + test('parses full capability map from both channels', () async { + if (!Platform.isLinux) return; + final channel = _FakeChannel( + shellResponse: const { + 'isX11': true, + 'hasAppIndicator': true, + 'hasClipboardManager': true, + 'hasEwmh': true, + 'desktopEnv': 'GNOME', + 'wmName': 'Mutter', + }, + listenerResponse: const { + 'isX11': true, + 'hasXTest': true, + }, + ); + final caps = await LinuxCapabilitiesService.detect(channel: channel); + expect(caps.hasXTest, isTrue); + expect(caps.hasAppIndicator, isTrue); + expect(caps.hasClipboardManager, isTrue); + expect(caps.hasEwmh, isTrue); + expect(caps.detectedDesktopEnv, equals('GNOME')); + expect(caps.detectedWmName, equals('Mutter')); + expect(caps.detectionTimedOut, isFalse); + }); + + test('returns conservative defaults when channels throw', () async { + if (!Platform.isLinux) return; + final channel = _FakeChannel( + shellThrows: Exception('shell boom'), + listenerThrows: Exception('listener boom'), + ); + final caps = await LinuxCapabilitiesService.detect(channel: channel); + expect(caps.hasXTest, isFalse); + expect(caps.hasAppIndicator, isFalse); + expect(caps.hasClipboardManager, isFalse); + expect(caps.hasEwmh, isFalse); + expect(caps.detectionTimedOut, isFalse); + }); + + test('marks detectionTimedOut when timeout fires', () async { + if (!Platform.isLinux) return; + final channel = _FakeChannel( + shellResponse: const {'isX11': true, 'hasEwmh': true}, + shellDelay: const Duration(milliseconds: 200), + ); + final caps = await LinuxCapabilitiesService.detect( + channel: channel, + timeout: const Duration(milliseconds: 20), + ); + expect(caps.detectionTimedOut, isTrue); + expect(caps.hasEwmh, isFalse); + }); + + test('does not query channels when session is not X11', () async { + if (Platform.isLinux) return; + final channel = _FakeChannel(shellResponse: const {'isX11': true}); + await LinuxCapabilitiesService.detect(channel: channel); + expect(channel.shellCalls, equals(0)); + expect(channel.listenerCalls, equals(0)); + }); + + test('caches the last detected value in current', () async { + if (!Platform.isLinux) return; + final channel = _FakeChannel( + shellResponse: const {'isX11': true, 'hasEwmh': true}, + listenerResponse: const {'hasXTest': true}, + ); + final caps = await LinuxCapabilitiesService.detect(channel: channel); + expect(LinuxCapabilitiesService.current, equals(caps)); + }); + }); + + group('LinuxGuard', () { + test('isLinux delegates to Platform', () { + expect(LinuxGuard.isLinux, equals(Platform.isLinux)); + }); + + test('all guards are false when capabilities are unsupported', () { + LinuxCapabilitiesService.resetForTesting(LinuxCapabilities.unsupported); + expect(LinuxGuard.canRegisterHotkey, isFalse); + expect(LinuxGuard.canPasteBack, isFalse); + expect(LinuxGuard.canShowTray, isFalse); + expect(LinuxGuard.canPersistClipboard, isFalse); + expect(LinuxGuard.canAutostart, isFalse); + expect(LinuxGuard.usesNativeWindowEffects, isFalse); + }); + + test('canPasteBack requires X11 + XTest', () { + if (!Platform.isLinux) return; + LinuxCapabilitiesService.resetForTesting( + LinuxCapabilities.unsupported.copyWith(isX11: true, hasXTest: true), + ); + expect(LinuxGuard.canPasteBack, isTrue); + LinuxCapabilitiesService.resetForTesting( + LinuxCapabilities.unsupported.copyWith(isX11: true, hasXTest: false), + ); + expect(LinuxGuard.canPasteBack, isFalse); + }); + + test('canShowTray requires X11 + AppIndicator', () { + if (!Platform.isLinux) return; + LinuxCapabilitiesService.resetForTesting( + LinuxCapabilities.unsupported + .copyWith(isX11: true, hasAppIndicator: true), + ); + expect(LinuxGuard.canShowTray, isTrue); + LinuxCapabilitiesService.resetForTesting( + LinuxCapabilities.unsupported.copyWith(isX11: true), + ); + expect(LinuxGuard.canShowTray, isFalse); + }); + + test('canRegisterHotkey requires X11 + EWMH', () { + if (!Platform.isLinux) return; + LinuxCapabilitiesService.resetForTesting( + LinuxCapabilities.unsupported.copyWith(isX11: true, hasEwmh: true), + ); + expect(LinuxGuard.canRegisterHotkey, isTrue); + }); + }); +} diff --git a/listener/linux/listener_plugin.c b/listener/linux/listener_plugin.c index 967fa18d..66cd69b2 100644 --- a/listener/linux/listener_plugin.c +++ b/listener/linux/listener_plugin.c @@ -1,1102 +1,1116 @@ -#include "include/listener/listener_plugin.h" - -#include -#include -#include -#include -#include - -#ifdef GDK_WINDOWING_X11 -#include -#include -#include -#include -#include -#include -#endif - -#include -#include -#include -#include -#include - -#include "listener_plugin_private.h" - -// Clipboard content type codes — must match Dart ClipboardDataType enum order. -#define CLIP_TYPE_TEXT 0 -#define CLIP_TYPE_IMAGE 1 -#define CLIP_TYPE_FILE 2 -#define CLIP_TYPE_FOLDER 3 -#define CLIP_TYPE_LINK 4 -#define CLIP_TYPE_AUDIO 5 -#define CLIP_TYPE_VIDEO 6 - -#define LISTENER_PLUGIN(obj) \ - (G_TYPE_CHECK_INSTANCE_CAST((obj), listener_plugin_get_type(), ListenerPlugin)) - -static const gchar* kClipboardChannelName = "copypaste/clipboard"; -static const gchar* kClipboardWriterChannelName = "copypaste/clipboard_writer"; -static const guint64 kClipboardDebounceMs = 500; -static const guint kClipboardPollIntervalMs = 250; -static const guint64 kClipboardWriteIgnoreMs = 700; - -typedef struct { -#ifdef GDK_WINDOWING_X11 - Window window; -#else - unsigned long window; -#endif - gboolean valid; -} ActiveX11Window; - -struct _ListenerPlugin { - GObject parent_instance; - - FlEventChannel* event_channel; - FlMethodChannel* method_channel; - - gboolean is_listening; - guint poll_timer_id; - gchar* last_content_hash; - guint64 last_change_tick_ms; - guint64 last_write_tick_ms; -}; - -G_DEFINE_TYPE(ListenerPlugin, listener_plugin, g_object_get_type()) - -static guint64 now_ms(void) { - return (guint64)(g_get_monotonic_time() / 1000); -} - -static gchar* compute_fnv1a_hash(const gchar* text) { - uint64_t hash = 14695981039346656037ULL; - const guchar* bytes = (const guchar*)text; - for (gsize i = 0; bytes[i] != 0; i++) { - hash ^= bytes[i]; - hash *= 1099511628211ULL; - } - return g_strdup_printf("%" G_GINT64_MODIFIER "x", (guint64)hash); -} - -static gboolean is_url_text(const gchar* text) { - if (text == NULL || *text == '\0') { - return FALSE; - } - - const gchar* prefixes[] = { - "https://", "http://", "ftp://", "file:///", "mailto:", NULL, - }; - - gchar* lower = g_ascii_strdown(text, -1); - gboolean matches = FALSE; - for (guint i = 0; prefixes[i] != NULL; i++) { - if (g_str_has_prefix(lower, prefixes[i])) { - matches = TRUE; - break; - } - } - g_free(lower); - - return matches && strchr(text, ' ') == NULL && strchr(text, '\n') == NULL; -} - -static int detect_file_type(const gchar* path) { - if (path == NULL || *path == '\0') { - return CLIP_TYPE_FILE; - } - - if (g_file_test(path, G_FILE_TEST_IS_DIR)) { - return CLIP_TYPE_FOLDER; - } - - gchar* lower = g_ascii_strdown(path, -1); - const gchar* ext = strrchr(lower, '.'); - int type = CLIP_TYPE_FILE; - - if (ext != NULL) { - if (g_strcmp0(ext, ".png") == 0 || g_strcmp0(ext, ".jpg") == 0 || - g_strcmp0(ext, ".jpeg") == 0 || g_strcmp0(ext, ".gif") == 0 || - g_strcmp0(ext, ".bmp") == 0 || g_strcmp0(ext, ".webp") == 0 || - g_strcmp0(ext, ".svg") == 0 || g_strcmp0(ext, ".ico") == 0 || - g_strcmp0(ext, ".tiff") == 0 || g_strcmp0(ext, ".heic") == 0) { - type = CLIP_TYPE_IMAGE; - } else if (g_strcmp0(ext, ".mp3") == 0 || g_strcmp0(ext, ".wav") == 0 || - g_strcmp0(ext, ".flac") == 0 || g_strcmp0(ext, ".aac") == 0 || - g_strcmp0(ext, ".ogg") == 0 || g_strcmp0(ext, ".m4a") == 0) { - type = CLIP_TYPE_AUDIO; - } else if (g_strcmp0(ext, ".mp4") == 0 || g_strcmp0(ext, ".avi") == 0 || - g_strcmp0(ext, ".mkv") == 0 || g_strcmp0(ext, ".mov") == 0 || - g_strcmp0(ext, ".wmv") == 0 || g_strcmp0(ext, ".flv") == 0 || - g_strcmp0(ext, ".webm") == 0) { - type = CLIP_TYPE_VIDEO; - } - } - - g_free(lower); - return type; -} - -static gboolean plugin_is_x11(void) { -#ifdef GDK_WINDOWING_X11 - GdkDisplay* display = gdk_display_get_default(); - return display != NULL && GDK_IS_X11_DISPLAY(display); -#else - return FALSE; -#endif -} - -#ifdef GDK_WINDOWING_X11 -// Cached X11 atoms — interned once per process. -static Atom s_atom_net_active_window = None; -static Atom s_atom_net_wm_pid = None; - -static Atom atom_net_active_window(Display* display) { - if (s_atom_net_active_window == None) { - s_atom_net_active_window = XInternAtom(display, "_NET_ACTIVE_WINDOW", False); - } - return s_atom_net_active_window; -} - -static Atom atom_net_wm_pid(Display* display) { - if (s_atom_net_wm_pid == None) { - s_atom_net_wm_pid = XInternAtom(display, "_NET_WM_PID", False); - } - return s_atom_net_wm_pid; -} - -// XTest extension availability — checked once per process. -static gboolean s_xtest_checked = FALSE; -static gboolean s_xtest_available = FALSE; - -static gboolean ensure_xtest(Display* display) { - if (s_xtest_checked) { - return s_xtest_available; - } - s_xtest_checked = TRUE; - int event_base, error_base, major, minor; - s_xtest_available = XTestQueryExtension(display, &event_base, &error_base, - &major, &minor) != 0; - if (!s_xtest_available) { - g_warning("XTest extension not available — paste simulation disabled"); - } - return s_xtest_available; -} - -static Display* get_xdisplay(void) { - GdkDisplay* display = gdk_display_get_default(); - if (display == NULL || !GDK_IS_X11_DISPLAY(display)) { - return NULL; - } - - return gdk_x11_display_get_xdisplay(display); -} - -static ActiveX11Window get_active_x11_window(void) { - ActiveX11Window result = {0}; - Display* display = get_xdisplay(); - if (display == NULL) { - return result; - } - - Atom property = atom_net_active_window(display); - Atom actual_type = None; - int actual_format = 0; - unsigned long item_count = 0; - unsigned long bytes_after = 0; - unsigned char* data = NULL; - - if (XGetWindowProperty(display, DefaultRootWindow(display), property, 0, 1, - False, AnyPropertyType, &actual_type, &actual_format, - &item_count, &bytes_after, &data) == Success && - data != NULL && item_count == 1) { - result.window = *(Window*)data; - result.valid = result.window != 0; - } - - (void)actual_type; - (void)actual_format; - (void)bytes_after; - - if (data != NULL) { - XFree(data); - } - - return result; -} - -static gchar* read_proc_comm(unsigned long pid) { - gchar path[64]; - g_snprintf(path, sizeof(path), "/proc/%lu/comm", pid); - gchar* content = NULL; - gsize length = 0; - if (!g_file_get_contents(path, &content, &length, NULL) || content == NULL) { - return NULL; - } - - g_strchomp(content); - return content; -} - -static gchar* get_x11_window_source(Window window) { - Display* display = get_xdisplay(); - if (display == NULL || window == 0) { - return g_strdup(""); - } - - XClassHint class_hint; - if (XGetClassHint(display, window, &class_hint) != 0) { - gchar* value = g_strdup(class_hint.res_class != NULL ? class_hint.res_class - : class_hint.res_name); - if (class_hint.res_name != NULL) { - XFree(class_hint.res_name); - } - if (class_hint.res_class != NULL) { - XFree(class_hint.res_class); - } - if (value != NULL && *value != '\0') { - return value; - } - g_free(value); - } - - Atom pid_atom = atom_net_wm_pid(display); - Atom actual_type = None; - int actual_format = 0; - unsigned long item_count = 0; - unsigned long bytes_after = 0; - unsigned char* data = NULL; - - if (XGetWindowProperty(display, window, pid_atom, 0, 1, False, - XA_CARDINAL, &actual_type, &actual_format, - &item_count, &bytes_after, &data) == Success && - data != NULL && item_count == 1) { - unsigned long pid = *(unsigned long*)data; - XFree(data); - data = NULL; - gchar* comm = read_proc_comm(pid); - if (comm != NULL) { - return comm; - } - } - - (void)actual_type; - (void)actual_format; - (void)bytes_after; - - if (data != NULL) { - XFree(data); - } - - return g_strdup(""); -} - -static gchar* capture_frontmost_x11_identifier(void) { - ActiveX11Window active = get_active_x11_window(); - if (!active.valid) { - return NULL; - } - - return g_strdup_printf("x11:0x%lx", (unsigned long)active.window); -} - -static int activate_noop_error_handler(Display* display, XErrorEvent* event) { - (void)display; - (void)event; - return 0; -} - -static gboolean request_activate_x11_window(Window window) { - Display* display = get_xdisplay(); - if (display == NULL || window == 0) { - return FALSE; - } - - // 1. Send the EWMH _NET_ACTIVE_WINDOW message (honours ICCCM; most WMs). - // source=2 (pager) is more trusted than 1 (application) on WMs that - // apply focus-stealing prevention (KDE Plasma, some GNOME configs). - XEvent event; - memset(&event, 0, sizeof(event)); - event.xclient.type = ClientMessage; - event.xclient.window = window; - event.xclient.message_type = atom_net_active_window(display); - event.xclient.format = 32; - event.xclient.data.l[0] = 2; // pager source — more likely to bypass focus-steal guards - event.xclient.data.l[1] = CurrentTime; - event.xclient.data.l[2] = 0; - event.xclient.data.l[3] = 0; - event.xclient.data.l[4] = 0; - - Status status = XSendEvent(display, DefaultRootWindow(display), False, - SubstructureNotifyMask | SubstructureRedirectMask, - &event); - - // 2. Raise the window and attempt a direct input focus as a fallback for WMs - // that ignore _NET_ACTIVE_WINDOW (tiling WMs, minimal WMs). - // Trap X errors: XSetInputFocus produces BadMatch on unmapped/invisible windows. - XRaiseWindow(display, window); - XSync(display, False); - int (*prev_handler)(Display*, XErrorEvent*) = XSetErrorHandler(activate_noop_error_handler); - XSetInputFocus(display, window, RevertToParent, CurrentTime); - XSync(display, False); - XSetErrorHandler(prev_handler); - - XFlush(display); - return status != 0; -} - -static gboolean simulate_paste_x11(void) { - Display* display = get_xdisplay(); - if (display == NULL) { - return FALSE; - } - - if (!ensure_xtest(display)) { - return FALSE; - } - - KeyCode ctrl = XKeysymToKeycode(display, XK_Control_L); - KeyCode v = XKeysymToKeycode(display, XK_v); - if (ctrl == 0 || v == 0) { - return FALSE; - } - - XTestFakeKeyEvent(display, ctrl, True, CurrentTime); - XTestFakeKeyEvent(display, v, True, CurrentTime); - XTestFakeKeyEvent(display, v, False, CurrentTime); - XTestFakeKeyEvent(display, ctrl, False, CurrentTime); - XFlush(display); - return TRUE; -} -#endif - -static gchar* get_clipboard_source(void) { -#ifdef GDK_WINDOWING_X11 - if (plugin_is_x11()) { - ActiveX11Window active = get_active_x11_window(); - if (active.valid) { - return get_x11_window_source(active.window); - } - } -#endif - return g_strdup(""); -} - -static GtkSelectionData* get_target_contents(GtkClipboard* clipboard, - const gchar* target_name) { - GdkAtom atom = gdk_atom_intern(target_name, FALSE); - return gtk_clipboard_wait_for_contents(clipboard, atom); -} - -static FlValue* get_selection_data_value(GtkClipboard* clipboard, - const gchar* const* targets) { - for (guint i = 0; targets[i] != NULL; i++) { - GtkSelectionData* data = get_target_contents(clipboard, targets[i]); - if (data == NULL) { - continue; - } - - gint length = gtk_selection_data_get_length(data); - const guchar* bytes = gtk_selection_data_get_data(data); - FlValue* result = NULL; - if (bytes != NULL && length > 0) { - result = fl_value_new_uint8_list(bytes, (size_t)length); - } - - gtk_selection_data_free(data); - if (result != NULL) { - return result; - } - } - - return NULL; -} - -static gchar* build_clipboard_signature(GtkClipboard* clipboard) { - GString* signature = g_string_new(""); - - gchar** uris = gtk_clipboard_wait_for_uris(clipboard); - if (uris != NULL && uris[0] != NULL) { - for (guint i = 0; uris[i] != NULL; i++) { - g_autofree gchar* path = g_filename_from_uri(uris[i], NULL, NULL); - if (path != NULL) { - g_string_append_printf(signature, "F:%s|", path); - } else { - g_string_append_printf(signature, "U:%s|", uris[i]); - } - } - g_strfreev(uris); - return g_string_free(signature, FALSE); - } - - if (uris != NULL) { - g_strfreev(uris); - } - - gchar* text = gtk_clipboard_wait_for_text(clipboard); - if (text != NULL && *text != '\0') { - gsize length = strlen(text); - gsize sample_length = length > 100 ? 100 : length; - g_string_append(signature, "T:"); - g_string_append_len(signature, text, sample_length); - g_free(text); - return g_string_free(signature, FALSE); - } - g_free(text); - - GdkPixbuf* image = gtk_clipboard_wait_for_image(clipboard); - if (image != NULL) { - const guchar* pixels = gdk_pixbuf_read_pixels(image); - gsize rowstride = (gsize)gdk_pixbuf_get_rowstride(image); - gint height = gdk_pixbuf_get_height(image); - gsize total = rowstride * (gsize)height; - gsize sample_len = total > 256 ? 256 : total; - g_string_append(signature, "I:"); - g_string_append_printf(signature, "%" G_GSIZE_FORMAT ":", total); - for (gsize i = 0; i < sample_len; i++) { - g_string_append_printf(signature, "%02x", pixels[i]); - } - g_object_unref(image); - return g_string_free(signature, FALSE); - } - - return g_string_free(signature, FALSE); -} - -static gboolean is_duplicate_change(ListenerPlugin* self, const gchar* hash) { - guint64 now = now_ms(); - if (self->last_content_hash != NULL && g_strcmp0(self->last_content_hash, hash) == 0 && - (now - self->last_change_tick_ms) < kClipboardDebounceMs) { - return TRUE; - } - - g_free(self->last_content_hash); - self->last_content_hash = g_strdup(hash); - self->last_change_tick_ms = now; - return FALSE; -} - -static gboolean should_ignore_recent_write(ListenerPlugin* self) { - guint64 now = now_ms(); - return self->last_write_tick_ms != 0 && - (now - self->last_write_tick_ms) < kClipboardWriteIgnoreMs; -} - -static gboolean send_clipboard_event(ListenerPlugin* self, FlValue* event) { - if (!self->is_listening || self->event_channel == NULL || event == NULL) { - return FALSE; - } - - g_autoptr(GError) error = NULL; - gboolean success = fl_event_channel_send(self->event_channel, event, NULL, &error); - if (!success && error != NULL) { - g_warning("Failed to send clipboard event: %s", error->message); - } - return success; -} - -static FlValue* build_file_event(GtkClipboard* clipboard, - const gchar* source, - const gchar* hash) { - gchar** uris = gtk_clipboard_wait_for_uris(clipboard); - if (uris == NULL || uris[0] == NULL) { - g_strfreev(uris); - return NULL; - } - - g_autoptr(FlValue) files = fl_value_new_list(); - guint count = 0; - gint event_type = CLIP_TYPE_FILE; - gchar* first_path = NULL; - - for (guint i = 0; uris[i] != NULL; i++) { - g_autofree gchar* path = g_filename_from_uri(uris[i], NULL, NULL); - if (path == NULL || *path == '\0') { - continue; - } - if (first_path == NULL) { - first_path = g_strdup(path); - } - fl_value_append_take(files, fl_value_new_string(path)); - count++; - } - - g_strfreev(uris); - - if (count == 0) { - g_free(first_path); - return NULL; - } - - if (count == 1 && first_path != NULL) { - event_type = detect_file_type(first_path); - } - g_free(first_path); - - g_autoptr(FlValue) event = fl_value_new_map(); - fl_value_set_string_take(event, "type", fl_value_new_int(event_type)); - fl_value_set_string_take(event, "files", fl_value_ref(files)); - fl_value_set_string_take(event, "source", fl_value_new_string(source)); - fl_value_set_string_take(event, "contentHash", fl_value_new_string(hash)); - return fl_value_ref(event); -} - -static FlValue* build_text_event(GtkClipboard* clipboard, - const gchar* source, - const gchar* hash) { - gchar* text = gtk_clipboard_wait_for_text(clipboard); - if (text == NULL || *text == '\0') { - g_free(text); - return NULL; - } - - g_autoptr(FlValue) event = fl_value_new_map(); - fl_value_set_string_take(event, "type", - fl_value_new_int(is_url_text(text) ? CLIP_TYPE_LINK : CLIP_TYPE_TEXT)); - fl_value_set_string_take(event, "text", fl_value_new_string(text)); - fl_value_set_string_take(event, "source", fl_value_new_string(source)); - fl_value_set_string_take(event, "contentHash", fl_value_new_string(hash)); - - const gchar* const rtf_targets[] = {"text/rtf", "application/rtf", - "Rich Text Format", NULL}; - const gchar* const html_targets[] = {"text/html", "HTML Format", NULL}; - - FlValue* rtf = get_selection_data_value(clipboard, rtf_targets); - if (rtf != NULL) { - fl_value_set_string_take(event, "rtf", rtf); - } - FlValue* html = get_selection_data_value(clipboard, html_targets); - if (html != NULL) { - fl_value_set_string_take(event, "html", html); - } - - g_free(text); - return fl_value_ref(event); -} - -static FlValue* build_image_event(GtkClipboard* clipboard, - const gchar* source, - const gchar* hash) { - GdkPixbuf* pixbuf = gtk_clipboard_wait_for_image(clipboard); - if (pixbuf == NULL) { - return NULL; - } - - gchar* buffer = NULL; - gsize buffer_size = 0; - g_autoptr(GError) error = NULL; - gboolean ok = gdk_pixbuf_save_to_buffer(pixbuf, &buffer, &buffer_size, "bmp", - &error, NULL); - g_object_unref(pixbuf); - if (!ok || buffer == NULL || buffer_size == 0) { - if (error != NULL) { - g_warning("Failed to serialize clipboard image: %s", error->message); - } - g_free(buffer); - return NULL; - } - - g_autoptr(FlValue) event = fl_value_new_map(); - fl_value_set_string_take(event, "type", fl_value_new_int(CLIP_TYPE_IMAGE)); - fl_value_set_string_take(event, "bytes", - fl_value_new_uint8_list((const uint8_t*)buffer, - (size_t)buffer_size)); - fl_value_set_string_take(event, "source", fl_value_new_string(source)); - fl_value_set_string_take(event, "contentHash", fl_value_new_string(hash)); - - g_free(buffer); - return fl_value_ref(event); -} - -static void process_clipboard(ListenerPlugin* self) { - GtkClipboard* clipboard = gtk_clipboard_get(GDK_SELECTION_CLIPBOARD); - if (clipboard == NULL) { - return; - } - - if (should_ignore_recent_write(self)) { - return; - } - - g_autofree gchar* signature = build_clipboard_signature(clipboard); - if (signature == NULL || *signature == '\0') { - return; - } - - g_autofree gchar* hash = compute_fnv1a_hash(signature); - if (hash == NULL || *hash == '\0' || is_duplicate_change(self, hash)) { - return; - } - - g_autofree gchar* source = get_clipboard_source(); - - g_autoptr(FlValue) event = build_file_event(clipboard, source, hash); - if (event == NULL) { - event = build_text_event(clipboard, source, hash); - } - if (event == NULL) { - event = build_image_event(clipboard, source, hash); - } - - if (event != NULL) { - send_clipboard_event(self, event); - } -} - -static gboolean clipboard_poll_cb(gpointer user_data) { - ListenerPlugin* self = LISTENER_PLUGIN(user_data); - if (!self->is_listening) { - self->poll_timer_id = 0; - return G_SOURCE_REMOVE; - } - - process_clipboard(self); - return G_SOURCE_CONTINUE; -} - -static void ensure_polling(ListenerPlugin* self) { - if (self->poll_timer_id == 0) { - self->poll_timer_id = g_timeout_add(kClipboardPollIntervalMs, - clipboard_poll_cb, self); - } -} - -static void stop_polling(ListenerPlugin* self) { - if (self->poll_timer_id != 0) { - g_source_remove(self->poll_timer_id); - self->poll_timer_id = 0; - } -} - -static FlValue* get_cursor_and_screen_info(void) { - GdkDisplay* display = gdk_display_get_default(); - if (display == NULL) { - return NULL; - } - - GdkSeat* seat = gdk_display_get_default_seat(display); - if (seat == NULL) { - return NULL; - } - - GdkDevice* pointer = gdk_seat_get_pointer(seat); - if (pointer == NULL) { - return NULL; - } - - gint cursor_x = 0; - gint cursor_y = 0; - gdk_device_get_position(pointer, NULL, &cursor_x, &cursor_y); - - GdkMonitor* monitor = gdk_display_get_monitor_at_point(display, cursor_x, cursor_y); - if (monitor == NULL) { - return NULL; - } - - GdkRectangle workarea; - memset(&workarea, 0, sizeof(workarea)); - gdk_monitor_get_workarea(monitor, &workarea); - - g_autoptr(FlValue) info = fl_value_new_map(); - fl_value_set_string_take(info, "cursorX", fl_value_new_float((double)cursor_x)); - fl_value_set_string_take(info, "cursorY", fl_value_new_float((double)cursor_y)); - fl_value_set_string_take(info, "waLeft", fl_value_new_float((double)workarea.x)); - fl_value_set_string_take(info, "waTop", fl_value_new_float((double)workarea.y)); - fl_value_set_string_take(info, "waRight", - fl_value_new_float((double)(workarea.x + workarea.width))); - fl_value_set_string_take(info, "waBottom", - fl_value_new_float((double)(workarea.y + workarea.height))); - return fl_value_ref(info); -} - -static gboolean set_text_to_clipboard(const gchar* text) { - if (text == NULL || *text == '\0') { - return FALSE; - } - - GtkClipboard* clipboard = gtk_clipboard_get(GDK_SELECTION_CLIPBOARD); - if (clipboard == NULL) { - return FALSE; - } - - gtk_clipboard_set_text(clipboard, text, -1); - gtk_clipboard_store(clipboard); - return TRUE; -} - -typedef struct { - GdkPixbuf* pixbuf; - gchar* uri; -} ImageClipData; - -static void image_clip_get_cb(GtkClipboard* clipboard, - GtkSelectionData* selection_data, - guint info, - gpointer user_data) { - (void)clipboard; - ImageClipData* d = (ImageClipData*)user_data; - - if (info == 0) { - GdkAtom target = gdk_atom_intern_static_string("text/uri-list"); - gtk_selection_data_set(selection_data, target, 8, - (const guchar*)d->uri, (gint)strlen(d->uri)); - } else { - gtk_selection_data_set_pixbuf(selection_data, d->pixbuf); - } -} - -static void image_clip_clear_cb(GtkClipboard* clipboard, gpointer user_data) { - (void)clipboard; - ImageClipData* d = (ImageClipData*)user_data; - if (d->pixbuf) g_object_unref(d->pixbuf); - g_free(d->uri); - g_free(d); -} - -static gboolean set_image_to_clipboard(const gchar* image_path) { - if (image_path == NULL || *image_path == '\0') { - return FALSE; - } - - g_autoptr(GError) error = NULL; - GdkPixbuf* pixbuf = gdk_pixbuf_new_from_file(image_path, &error); - if (pixbuf == NULL) { - if (error != NULL) { - g_warning("Failed to load image for clipboard: %s", error->message); - } - return FALSE; - } - - GtkClipboard* clipboard = gtk_clipboard_get(GDK_SELECTION_CLIPBOARD); - if (clipboard == NULL) { - g_object_unref(pixbuf); - return FALSE; - } - - GtkTargetList* tl = gtk_target_list_new(NULL, 0); - gtk_target_list_add(tl, gdk_atom_intern_static_string("text/uri-list"), 0, 0); - gtk_target_list_add_image_targets(tl, 1, TRUE); - - gint n_targets = 0; - GtkTargetEntry* targets = gtk_target_table_new_from_list(tl, &n_targets); - gtk_target_list_unref(tl); - - gchar* uri = g_filename_to_uri(image_path, NULL, NULL); - if (uri == NULL) { - g_object_unref(pixbuf); - gtk_target_table_free(targets, n_targets); - return FALSE; - } - - gchar* uri_line = g_strdup_printf("%s\r\n", uri); - g_free(uri); - - ImageClipData* data = g_new0(ImageClipData, 1); - data->pixbuf = pixbuf; - data->uri = uri_line; - - gboolean ok = gtk_clipboard_set_with_data( - clipboard, targets, n_targets, - image_clip_get_cb, image_clip_clear_cb, data); - gtk_target_table_free(targets, n_targets); - - if (!ok) { - g_object_unref(pixbuf); - g_free(uri_line); - g_free(data); - return FALSE; - } - - gtk_clipboard_store(clipboard); - return TRUE; -} - -static void clipboard_uri_list_get_cb(GtkClipboard* clipboard, - GtkSelectionData* selection_data, - guint info, - gpointer user_data) { - (void)clipboard; - (void)info; - - const gchar* uri_list = (const gchar*)user_data; - if (uri_list == NULL || *uri_list == '\0') { - return; - } - - GdkAtom target = gdk_atom_intern_static_string("text/uri-list"); - gtk_selection_data_set(selection_data, target, 8, (const guchar*)uri_list, - (gint)strlen(uri_list)); -} - -static void clipboard_uri_list_clear_cb(GtkClipboard* clipboard, gpointer user_data) { - (void)clipboard; - g_free(user_data); -} - -static gboolean set_files_to_clipboard(const gchar* content) { - if (content == NULL || *content == '\0') { - return FALSE; - } - - gchar** parts = g_strsplit(content, "\n", -1); - g_autoptr(GString) uri_list = g_string_new(NULL); - for (guint i = 0; parts[i] != NULL; i++) { - if (parts[i][0] == '\0' || !g_file_test(parts[i], G_FILE_TEST_EXISTS)) { - continue; - } - gchar* uri = g_filename_to_uri(parts[i], NULL, NULL); - if (uri != NULL) { - g_string_append(uri_list, uri); - g_string_append(uri_list, "\r\n"); - g_free(uri); - } - } - g_strfreev(parts); - - if (uri_list->len == 0) { - return FALSE; - } - - GtkClipboard* clipboard = gtk_clipboard_get(GDK_SELECTION_CLIPBOARD); - if (clipboard == NULL) { - return FALSE; - } - - static GtkTargetEntry targets[] = { - {(gchar*)"text/uri-list", 0, 0}, - }; - - gchar* uri_payload = g_string_free(g_steal_pointer(&uri_list), FALSE); - gboolean set_ok = gtk_clipboard_set_with_data( - clipboard, targets, G_N_ELEMENTS(targets), clipboard_uri_list_get_cb, - clipboard_uri_list_clear_cb, uri_payload); - if (!set_ok) { - g_free(uri_payload); - return FALSE; - } - - gtk_clipboard_store(clipboard); - return TRUE; -} - -static FlValue* get_media_info(void) { - return NULL; -} - -static void respond_success(FlMethodCall* method_call, FlValue* result) { - g_autoptr(GError) error = NULL; - if (!fl_method_call_respond_success(method_call, result, &error) && error != NULL) { - g_warning("Failed to respond to method call: %s", error->message); - } -} - -#ifdef GDK_WINDOWING_X11 -static gboolean paste_after_delay_cb(gpointer data) { - FlMethodCall* mc = FL_METHOD_CALL(data); - simulate_paste_x11(); - respond_success(mc, fl_value_new_bool(TRUE)); - g_object_unref(mc); - return G_SOURCE_REMOVE; -} -#endif - -static void listener_plugin_handle_method_call(ListenerPlugin* self, - FlMethodCall* method_call) { - const gchar* method = fl_method_call_get_name(method_call); - FlValue* args = fl_method_call_get_args(method_call); - - if (strcmp(method, "setClipboardContent") == 0) { - FlValue* type_value = args != NULL ? fl_value_lookup_string(args, "type") : NULL; - gint64 type = type_value != NULL ? fl_value_get_int(type_value) : -1; - FlValue* content_value = args != NULL ? fl_value_lookup_string(args, "content") : NULL; - const gchar* content = content_value != NULL && - fl_value_get_type(content_value) == FL_VALUE_TYPE_STRING - ? fl_value_get_string(content_value) - : ""; - gboolean success = FALSE; - - switch (type) { - case CLIP_TYPE_TEXT: - case CLIP_TYPE_LINK: - success = set_text_to_clipboard(content); - break; - case CLIP_TYPE_IMAGE: - success = set_image_to_clipboard(content); - break; - case CLIP_TYPE_FILE: - case CLIP_TYPE_FOLDER: - case CLIP_TYPE_AUDIO: - case CLIP_TYPE_VIDEO: - success = set_files_to_clipboard(content); - break; - default: - success = FALSE; - break; - } - - if (success) { - self->last_write_tick_ms = now_ms(); - } - respond_success(method_call, fl_value_new_bool(success)); - return; - } - - if (strcmp(method, "getMediaInfo") == 0) { - respond_success(method_call, get_media_info()); - return; - } - - if (strcmp(method, "captureFrontmostApp") == 0) { -#ifdef GDK_WINDOWING_X11 - if (plugin_is_x11()) { - gchar* id = capture_frontmost_x11_identifier(); - FlValue* value = id != NULL ? fl_value_new_string(id) : NULL; - g_free(id); - respond_success(method_call, value); - return; - } -#endif - respond_success(method_call, NULL); - return; - } - - if (strcmp(method, "activateAndPaste") == 0) { -#ifdef GDK_WINDOWING_X11 - if (plugin_is_x11()) { - FlValue* id_value = args != NULL ? fl_value_lookup_string(args, "bundleId") : NULL; - FlValue* delay_value = args != NULL ? fl_value_lookup_string(args, "delayMs") : NULL; - const gchar* identifier = id_value != NULL && - fl_value_get_type(id_value) == FL_VALUE_TYPE_STRING - ? fl_value_get_string(id_value) - : NULL; - gint64 delay_ms = delay_value != NULL ? fl_value_get_int(delay_value) : 0; - gboolean activated = FALSE; - - if (identifier != NULL && g_str_has_prefix(identifier, "x11:0x")) { - Window window = (Window)g_ascii_strtoull(identifier + 6, NULL, 16); - activated = request_activate_x11_window(window); - } - - if (activated && delay_ms > 0) { - FlMethodCall* held_call = FL_METHOD_CALL(g_object_ref(method_call)); - guint timer_id = g_timeout_add((guint)delay_ms, paste_after_delay_cb, held_call); - if (timer_id != 0) { - return; // held_call will be released by paste_after_delay_cb - } - // Timer registration failed — release the ref and fall through to immediate paste. - g_object_unref(held_call); - g_warning("activateAndPaste: g_timeout_add failed, pasting immediately"); - } - - if (activated) { - simulate_paste_x11(); - } - respond_success(method_call, fl_value_new_bool(activated)); - return; - } -#endif - respond_success(method_call, fl_value_new_bool(FALSE)); - return; - } - - if (strcmp(method, "getCursorAndScreenInfo") == 0) { - respond_success(method_call, get_cursor_and_screen_info()); - return; - } - - if (strcmp(method, "checkAccessibility") == 0 || - strcmp(method, "requestAccessibility") == 0 || - strcmp(method, "openAccessibilitySettings") == 0) { - respond_success(method_call, fl_value_new_bool(TRUE)); - return; - } - - g_autoptr(FlMethodResponse) response = - FL_METHOD_RESPONSE(fl_method_not_implemented_response_new()); - fl_method_call_respond(method_call, response, NULL); -} - -static FlMethodErrorResponse* stream_listen_cb(FlEventChannel* channel, - FlValue* args, - gpointer user_data) { - (void)channel; - (void)args; - ListenerPlugin* self = LISTENER_PLUGIN(user_data); - self->is_listening = TRUE; - ensure_polling(self); - return NULL; -} - -static FlMethodErrorResponse* stream_cancel_cb(FlEventChannel* channel, - FlValue* args, - gpointer user_data) { - (void)channel; - (void)args; - ListenerPlugin* self = LISTENER_PLUGIN(user_data); - self->is_listening = FALSE; - stop_polling(self); - return NULL; -} - -static void method_call_cb(FlMethodChannel* channel, - FlMethodCall* method_call, - gpointer user_data) { - (void)channel; - listener_plugin_handle_method_call(LISTENER_PLUGIN(user_data), method_call); -} - -FlMethodResponse* get_platform_version(void) { - struct utsname uname_data = {}; - uname(&uname_data); - g_autofree gchar* version = g_strdup_printf("Linux %s", uname_data.version); - g_autoptr(FlValue) result = fl_value_new_string(version); - return FL_METHOD_RESPONSE(fl_method_success_response_new(result)); -} - -static void listener_plugin_dispose(GObject* object) { - ListenerPlugin* self = LISTENER_PLUGIN(object); - stop_polling(self); - self->is_listening = FALSE; - - g_clear_object(&self->event_channel); - g_clear_object(&self->method_channel); - g_free(self->last_content_hash); - self->last_content_hash = NULL; - - G_OBJECT_CLASS(listener_plugin_parent_class)->dispose(object); -} - -static void listener_plugin_class_init(ListenerPluginClass* klass) { - G_OBJECT_CLASS(klass)->dispose = listener_plugin_dispose; -} - -static void listener_plugin_init(ListenerPlugin* self) { - self->last_content_hash = NULL; - self->last_change_tick_ms = 0; - self->last_write_tick_ms = 0; - self->is_listening = FALSE; - self->poll_timer_id = 0; -} - -void listener_plugin_register_with_registrar(FlPluginRegistrar* registrar) { - ListenerPlugin* plugin = LISTENER_PLUGIN( - g_object_new(listener_plugin_get_type(), NULL)); - - FlBinaryMessenger* messenger = fl_plugin_registrar_get_messenger(registrar); - g_autoptr(FlStandardMethodCodec) codec = fl_standard_method_codec_new(); - - plugin->event_channel = fl_event_channel_new(messenger, kClipboardChannelName, - FL_METHOD_CODEC(codec)); - fl_event_channel_set_stream_handlers(plugin->event_channel, stream_listen_cb, - stream_cancel_cb, g_object_ref(plugin), - g_object_unref); - - plugin->method_channel = fl_method_channel_new( - messenger, kClipboardWriterChannelName, FL_METHOD_CODEC(codec)); - fl_method_channel_set_method_call_handler(plugin->method_channel, - method_call_cb, - g_object_ref(plugin), - g_object_unref); - - g_object_unref(plugin); -} +#include "include/listener/listener_plugin.h" + +#include +#include +#include +#include +#include + +#ifdef GDK_WINDOWING_X11 +#include +#include +#include +#include +#include +#include +#endif + +#include +#include +#include +#include +#include + +#include "listener_plugin_private.h" + +// Clipboard content type codes — must match Dart ClipboardDataType enum order. +#define CLIP_TYPE_TEXT 0 +#define CLIP_TYPE_IMAGE 1 +#define CLIP_TYPE_FILE 2 +#define CLIP_TYPE_FOLDER 3 +#define CLIP_TYPE_LINK 4 +#define CLIP_TYPE_AUDIO 5 +#define CLIP_TYPE_VIDEO 6 + +#define LISTENER_PLUGIN(obj) \ + (G_TYPE_CHECK_INSTANCE_CAST((obj), listener_plugin_get_type(), ListenerPlugin)) + +static const gchar* kClipboardChannelName = "copypaste/clipboard"; +static const gchar* kClipboardWriterChannelName = "copypaste/clipboard_writer"; +static const guint64 kClipboardDebounceMs = 500; +static const guint kClipboardPollIntervalMs = 250; +static const guint64 kClipboardWriteIgnoreMs = 700; + +typedef struct { +#ifdef GDK_WINDOWING_X11 + Window window; +#else + unsigned long window; +#endif + gboolean valid; +} ActiveX11Window; + +struct _ListenerPlugin { + GObject parent_instance; + + FlEventChannel* event_channel; + FlMethodChannel* method_channel; + + gboolean is_listening; + guint poll_timer_id; + gchar* last_content_hash; + guint64 last_change_tick_ms; + guint64 last_write_tick_ms; +}; + +G_DEFINE_TYPE(ListenerPlugin, listener_plugin, g_object_get_type()) + +static guint64 now_ms(void) { + return (guint64)(g_get_monotonic_time() / 1000); +} + +static gchar* compute_fnv1a_hash(const gchar* text) { + uint64_t hash = 14695981039346656037ULL; + const guchar* bytes = (const guchar*)text; + for (gsize i = 0; bytes[i] != 0; i++) { + hash ^= bytes[i]; + hash *= 1099511628211ULL; + } + return g_strdup_printf("%" G_GINT64_MODIFIER "x", (guint64)hash); +} + +static gboolean is_url_text(const gchar* text) { + if (text == NULL || *text == '\0') { + return FALSE; + } + + const gchar* prefixes[] = { + "https://", "http://", "ftp://", "file:///", "mailto:", NULL, + }; + + gchar* lower = g_ascii_strdown(text, -1); + gboolean matches = FALSE; + for (guint i = 0; prefixes[i] != NULL; i++) { + if (g_str_has_prefix(lower, prefixes[i])) { + matches = TRUE; + break; + } + } + g_free(lower); + + return matches && strchr(text, ' ') == NULL && strchr(text, '\n') == NULL; +} + +static int detect_file_type(const gchar* path) { + if (path == NULL || *path == '\0') { + return CLIP_TYPE_FILE; + } + + if (g_file_test(path, G_FILE_TEST_IS_DIR)) { + return CLIP_TYPE_FOLDER; + } + + gchar* lower = g_ascii_strdown(path, -1); + const gchar* ext = strrchr(lower, '.'); + int type = CLIP_TYPE_FILE; + + if (ext != NULL) { + if (g_strcmp0(ext, ".png") == 0 || g_strcmp0(ext, ".jpg") == 0 || + g_strcmp0(ext, ".jpeg") == 0 || g_strcmp0(ext, ".gif") == 0 || + g_strcmp0(ext, ".bmp") == 0 || g_strcmp0(ext, ".webp") == 0 || + g_strcmp0(ext, ".svg") == 0 || g_strcmp0(ext, ".ico") == 0 || + g_strcmp0(ext, ".tiff") == 0 || g_strcmp0(ext, ".heic") == 0) { + type = CLIP_TYPE_IMAGE; + } else if (g_strcmp0(ext, ".mp3") == 0 || g_strcmp0(ext, ".wav") == 0 || + g_strcmp0(ext, ".flac") == 0 || g_strcmp0(ext, ".aac") == 0 || + g_strcmp0(ext, ".ogg") == 0 || g_strcmp0(ext, ".m4a") == 0) { + type = CLIP_TYPE_AUDIO; + } else if (g_strcmp0(ext, ".mp4") == 0 || g_strcmp0(ext, ".avi") == 0 || + g_strcmp0(ext, ".mkv") == 0 || g_strcmp0(ext, ".mov") == 0 || + g_strcmp0(ext, ".wmv") == 0 || g_strcmp0(ext, ".flv") == 0 || + g_strcmp0(ext, ".webm") == 0) { + type = CLIP_TYPE_VIDEO; + } + } + + g_free(lower); + return type; +} + +static gboolean plugin_is_x11(void) { +#ifdef GDK_WINDOWING_X11 + GdkDisplay* display = gdk_display_get_default(); + return display != NULL && GDK_IS_X11_DISPLAY(display); +#else + return FALSE; +#endif +} + +#ifdef GDK_WINDOWING_X11 +// Cached X11 atoms — interned once per process. +static Atom s_atom_net_active_window = None; +static Atom s_atom_net_wm_pid = None; + +static Atom atom_net_active_window(Display* display) { + if (s_atom_net_active_window == None) { + s_atom_net_active_window = XInternAtom(display, "_NET_ACTIVE_WINDOW", False); + } + return s_atom_net_active_window; +} + +static Atom atom_net_wm_pid(Display* display) { + if (s_atom_net_wm_pid == None) { + s_atom_net_wm_pid = XInternAtom(display, "_NET_WM_PID", False); + } + return s_atom_net_wm_pid; +} + +// XTest extension availability — checked once per process. +static gboolean s_xtest_checked = FALSE; +static gboolean s_xtest_available = FALSE; + +static gboolean ensure_xtest(Display* display) { + if (s_xtest_checked) { + return s_xtest_available; + } + s_xtest_checked = TRUE; + int event_base, error_base, major, minor; + s_xtest_available = XTestQueryExtension(display, &event_base, &error_base, + &major, &minor) != 0; + if (!s_xtest_available) { + g_warning("XTest extension not available — paste simulation disabled"); + } + return s_xtest_available; +} + +static Display* get_xdisplay(void) { + GdkDisplay* display = gdk_display_get_default(); + if (display == NULL || !GDK_IS_X11_DISPLAY(display)) { + return NULL; + } + + return gdk_x11_display_get_xdisplay(display); +} + +static ActiveX11Window get_active_x11_window(void) { + ActiveX11Window result = {0}; + Display* display = get_xdisplay(); + if (display == NULL) { + return result; + } + + Atom property = atom_net_active_window(display); + Atom actual_type = None; + int actual_format = 0; + unsigned long item_count = 0; + unsigned long bytes_after = 0; + unsigned char* data = NULL; + + if (XGetWindowProperty(display, DefaultRootWindow(display), property, 0, 1, + False, AnyPropertyType, &actual_type, &actual_format, + &item_count, &bytes_after, &data) == Success && + data != NULL && item_count == 1) { + result.window = *(Window*)data; + result.valid = result.window != 0; + } + + (void)actual_type; + (void)actual_format; + (void)bytes_after; + + if (data != NULL) { + XFree(data); + } + + return result; +} + +static gchar* read_proc_comm(unsigned long pid) { + gchar path[64]; + g_snprintf(path, sizeof(path), "/proc/%lu/comm", pid); + gchar* content = NULL; + gsize length = 0; + if (!g_file_get_contents(path, &content, &length, NULL) || content == NULL) { + return NULL; + } + + g_strchomp(content); + return content; +} + +static gchar* get_x11_window_source(Window window) { + Display* display = get_xdisplay(); + if (display == NULL || window == 0) { + return g_strdup(""); + } + + XClassHint class_hint; + if (XGetClassHint(display, window, &class_hint) != 0) { + gchar* value = g_strdup(class_hint.res_class != NULL ? class_hint.res_class + : class_hint.res_name); + if (class_hint.res_name != NULL) { + XFree(class_hint.res_name); + } + if (class_hint.res_class != NULL) { + XFree(class_hint.res_class); + } + if (value != NULL && *value != '\0') { + return value; + } + g_free(value); + } + + Atom pid_atom = atom_net_wm_pid(display); + Atom actual_type = None; + int actual_format = 0; + unsigned long item_count = 0; + unsigned long bytes_after = 0; + unsigned char* data = NULL; + + if (XGetWindowProperty(display, window, pid_atom, 0, 1, False, + XA_CARDINAL, &actual_type, &actual_format, + &item_count, &bytes_after, &data) == Success && + data != NULL && item_count == 1) { + unsigned long pid = *(unsigned long*)data; + XFree(data); + data = NULL; + gchar* comm = read_proc_comm(pid); + if (comm != NULL) { + return comm; + } + } + + (void)actual_type; + (void)actual_format; + (void)bytes_after; + + if (data != NULL) { + XFree(data); + } + + return g_strdup(""); +} + +static gchar* capture_frontmost_x11_identifier(void) { + ActiveX11Window active = get_active_x11_window(); + if (!active.valid) { + return NULL; + } + + return g_strdup_printf("x11:0x%lx", (unsigned long)active.window); +} + +static int activate_noop_error_handler(Display* display, XErrorEvent* event) { + (void)display; + (void)event; + return 0; +} + +static gboolean request_activate_x11_window(Window window) { + Display* display = get_xdisplay(); + if (display == NULL || window == 0) { + return FALSE; + } + + // 1. Send the EWMH _NET_ACTIVE_WINDOW message (honours ICCCM; most WMs). + // source=2 (pager) is more trusted than 1 (application) on WMs that + // apply focus-stealing prevention (KDE Plasma, some GNOME configs). + XEvent event; + memset(&event, 0, sizeof(event)); + event.xclient.type = ClientMessage; + event.xclient.window = window; + event.xclient.message_type = atom_net_active_window(display); + event.xclient.format = 32; + event.xclient.data.l[0] = 2; // pager source — more likely to bypass focus-steal guards + event.xclient.data.l[1] = CurrentTime; + event.xclient.data.l[2] = 0; + event.xclient.data.l[3] = 0; + event.xclient.data.l[4] = 0; + + Status status = XSendEvent(display, DefaultRootWindow(display), False, + SubstructureNotifyMask | SubstructureRedirectMask, + &event); + + // 2. Raise the window and attempt a direct input focus as a fallback for WMs + // that ignore _NET_ACTIVE_WINDOW (tiling WMs, minimal WMs). + // Trap X errors: XSetInputFocus produces BadMatch on unmapped/invisible windows. + XRaiseWindow(display, window); + XSync(display, False); + int (*prev_handler)(Display*, XErrorEvent*) = XSetErrorHandler(activate_noop_error_handler); + XSetInputFocus(display, window, RevertToParent, CurrentTime); + XSync(display, False); + XSetErrorHandler(prev_handler); + + XFlush(display); + return status != 0; +} + +static gboolean simulate_paste_x11(void) { + Display* display = get_xdisplay(); + if (display == NULL) { + return FALSE; + } + + if (!ensure_xtest(display)) { + return FALSE; + } + + KeyCode ctrl = XKeysymToKeycode(display, XK_Control_L); + KeyCode v = XKeysymToKeycode(display, XK_v); + if (ctrl == 0 || v == 0) { + return FALSE; + } + + XTestFakeKeyEvent(display, ctrl, True, CurrentTime); + XTestFakeKeyEvent(display, v, True, CurrentTime); + XTestFakeKeyEvent(display, v, False, CurrentTime); + XTestFakeKeyEvent(display, ctrl, False, CurrentTime); + XFlush(display); + return TRUE; +} +#endif + +static gchar* get_clipboard_source(void) { +#ifdef GDK_WINDOWING_X11 + if (plugin_is_x11()) { + ActiveX11Window active = get_active_x11_window(); + if (active.valid) { + return get_x11_window_source(active.window); + } + } +#endif + return g_strdup(""); +} + +static GtkSelectionData* get_target_contents(GtkClipboard* clipboard, + const gchar* target_name) { + GdkAtom atom = gdk_atom_intern(target_name, FALSE); + return gtk_clipboard_wait_for_contents(clipboard, atom); +} + +static FlValue* get_selection_data_value(GtkClipboard* clipboard, + const gchar* const* targets) { + for (guint i = 0; targets[i] != NULL; i++) { + GtkSelectionData* data = get_target_contents(clipboard, targets[i]); + if (data == NULL) { + continue; + } + + gint length = gtk_selection_data_get_length(data); + const guchar* bytes = gtk_selection_data_get_data(data); + FlValue* result = NULL; + if (bytes != NULL && length > 0) { + result = fl_value_new_uint8_list(bytes, (size_t)length); + } + + gtk_selection_data_free(data); + if (result != NULL) { + return result; + } + } + + return NULL; +} + +static gchar* build_clipboard_signature(GtkClipboard* clipboard) { + GString* signature = g_string_new(""); + + gchar** uris = gtk_clipboard_wait_for_uris(clipboard); + if (uris != NULL && uris[0] != NULL) { + for (guint i = 0; uris[i] != NULL; i++) { + g_autofree gchar* path = g_filename_from_uri(uris[i], NULL, NULL); + if (path != NULL) { + g_string_append_printf(signature, "F:%s|", path); + } else { + g_string_append_printf(signature, "U:%s|", uris[i]); + } + } + g_strfreev(uris); + return g_string_free(signature, FALSE); + } + + if (uris != NULL) { + g_strfreev(uris); + } + + gchar* text = gtk_clipboard_wait_for_text(clipboard); + if (text != NULL && *text != '\0') { + gsize length = strlen(text); + gsize sample_length = length > 100 ? 100 : length; + g_string_append(signature, "T:"); + g_string_append_len(signature, text, sample_length); + g_free(text); + return g_string_free(signature, FALSE); + } + g_free(text); + + GdkPixbuf* image = gtk_clipboard_wait_for_image(clipboard); + if (image != NULL) { + const guchar* pixels = gdk_pixbuf_read_pixels(image); + gsize rowstride = (gsize)gdk_pixbuf_get_rowstride(image); + gint height = gdk_pixbuf_get_height(image); + gsize total = rowstride * (gsize)height; + gsize sample_len = total > 256 ? 256 : total; + g_string_append(signature, "I:"); + g_string_append_printf(signature, "%" G_GSIZE_FORMAT ":", total); + for (gsize i = 0; i < sample_len; i++) { + g_string_append_printf(signature, "%02x", pixels[i]); + } + g_object_unref(image); + return g_string_free(signature, FALSE); + } + + return g_string_free(signature, FALSE); +} + +static gboolean is_duplicate_change(ListenerPlugin* self, const gchar* hash) { + guint64 now = now_ms(); + if (self->last_content_hash != NULL && g_strcmp0(self->last_content_hash, hash) == 0 && + (now - self->last_change_tick_ms) < kClipboardDebounceMs) { + return TRUE; + } + + g_free(self->last_content_hash); + self->last_content_hash = g_strdup(hash); + self->last_change_tick_ms = now; + return FALSE; +} + +static gboolean should_ignore_recent_write(ListenerPlugin* self) { + guint64 now = now_ms(); + return self->last_write_tick_ms != 0 && + (now - self->last_write_tick_ms) < kClipboardWriteIgnoreMs; +} + +static gboolean send_clipboard_event(ListenerPlugin* self, FlValue* event) { + if (!self->is_listening || self->event_channel == NULL || event == NULL) { + return FALSE; + } + + g_autoptr(GError) error = NULL; + gboolean success = fl_event_channel_send(self->event_channel, event, NULL, &error); + if (!success && error != NULL) { + g_warning("Failed to send clipboard event: %s", error->message); + } + return success; +} + +static FlValue* build_file_event(GtkClipboard* clipboard, + const gchar* source, + const gchar* hash) { + gchar** uris = gtk_clipboard_wait_for_uris(clipboard); + if (uris == NULL || uris[0] == NULL) { + g_strfreev(uris); + return NULL; + } + + g_autoptr(FlValue) files = fl_value_new_list(); + guint count = 0; + gint event_type = CLIP_TYPE_FILE; + gchar* first_path = NULL; + + for (guint i = 0; uris[i] != NULL; i++) { + g_autofree gchar* path = g_filename_from_uri(uris[i], NULL, NULL); + if (path == NULL || *path == '\0') { + continue; + } + if (first_path == NULL) { + first_path = g_strdup(path); + } + fl_value_append_take(files, fl_value_new_string(path)); + count++; + } + + g_strfreev(uris); + + if (count == 0) { + g_free(first_path); + return NULL; + } + + if (count == 1 && first_path != NULL) { + event_type = detect_file_type(first_path); + } + g_free(first_path); + + g_autoptr(FlValue) event = fl_value_new_map(); + fl_value_set_string_take(event, "type", fl_value_new_int(event_type)); + fl_value_set_string_take(event, "files", fl_value_ref(files)); + fl_value_set_string_take(event, "source", fl_value_new_string(source)); + fl_value_set_string_take(event, "contentHash", fl_value_new_string(hash)); + return fl_value_ref(event); +} + +static FlValue* build_text_event(GtkClipboard* clipboard, + const gchar* source, + const gchar* hash) { + gchar* text = gtk_clipboard_wait_for_text(clipboard); + if (text == NULL || *text == '\0') { + g_free(text); + return NULL; + } + + g_autoptr(FlValue) event = fl_value_new_map(); + fl_value_set_string_take(event, "type", + fl_value_new_int(is_url_text(text) ? CLIP_TYPE_LINK : CLIP_TYPE_TEXT)); + fl_value_set_string_take(event, "text", fl_value_new_string(text)); + fl_value_set_string_take(event, "source", fl_value_new_string(source)); + fl_value_set_string_take(event, "contentHash", fl_value_new_string(hash)); + + const gchar* const rtf_targets[] = {"text/rtf", "application/rtf", + "Rich Text Format", NULL}; + const gchar* const html_targets[] = {"text/html", "HTML Format", NULL}; + + FlValue* rtf = get_selection_data_value(clipboard, rtf_targets); + if (rtf != NULL) { + fl_value_set_string_take(event, "rtf", rtf); + } + FlValue* html = get_selection_data_value(clipboard, html_targets); + if (html != NULL) { + fl_value_set_string_take(event, "html", html); + } + + g_free(text); + return fl_value_ref(event); +} + +static FlValue* build_image_event(GtkClipboard* clipboard, + const gchar* source, + const gchar* hash) { + GdkPixbuf* pixbuf = gtk_clipboard_wait_for_image(clipboard); + if (pixbuf == NULL) { + return NULL; + } + + gchar* buffer = NULL; + gsize buffer_size = 0; + g_autoptr(GError) error = NULL; + gboolean ok = gdk_pixbuf_save_to_buffer(pixbuf, &buffer, &buffer_size, "bmp", + &error, NULL); + g_object_unref(pixbuf); + if (!ok || buffer == NULL || buffer_size == 0) { + if (error != NULL) { + g_warning("Failed to serialize clipboard image: %s", error->message); + } + g_free(buffer); + return NULL; + } + + g_autoptr(FlValue) event = fl_value_new_map(); + fl_value_set_string_take(event, "type", fl_value_new_int(CLIP_TYPE_IMAGE)); + fl_value_set_string_take(event, "bytes", + fl_value_new_uint8_list((const uint8_t*)buffer, + (size_t)buffer_size)); + fl_value_set_string_take(event, "source", fl_value_new_string(source)); + fl_value_set_string_take(event, "contentHash", fl_value_new_string(hash)); + + g_free(buffer); + return fl_value_ref(event); +} + +static void process_clipboard(ListenerPlugin* self) { + GtkClipboard* clipboard = gtk_clipboard_get(GDK_SELECTION_CLIPBOARD); + if (clipboard == NULL) { + return; + } + + if (should_ignore_recent_write(self)) { + return; + } + + g_autofree gchar* signature = build_clipboard_signature(clipboard); + if (signature == NULL || *signature == '\0') { + return; + } + + g_autofree gchar* hash = compute_fnv1a_hash(signature); + if (hash == NULL || *hash == '\0' || is_duplicate_change(self, hash)) { + return; + } + + g_autofree gchar* source = get_clipboard_source(); + + g_autoptr(FlValue) event = build_file_event(clipboard, source, hash); + if (event == NULL) { + event = build_text_event(clipboard, source, hash); + } + if (event == NULL) { + event = build_image_event(clipboard, source, hash); + } + + if (event != NULL) { + send_clipboard_event(self, event); + } +} + +static gboolean clipboard_poll_cb(gpointer user_data) { + ListenerPlugin* self = LISTENER_PLUGIN(user_data); + if (!self->is_listening) { + self->poll_timer_id = 0; + return G_SOURCE_REMOVE; + } + + process_clipboard(self); + return G_SOURCE_CONTINUE; +} + +static void ensure_polling(ListenerPlugin* self) { + if (self->poll_timer_id == 0) { + self->poll_timer_id = g_timeout_add(kClipboardPollIntervalMs, + clipboard_poll_cb, self); + } +} + +static void stop_polling(ListenerPlugin* self) { + if (self->poll_timer_id != 0) { + g_source_remove(self->poll_timer_id); + self->poll_timer_id = 0; + } +} + +static FlValue* get_cursor_and_screen_info(void) { + GdkDisplay* display = gdk_display_get_default(); + if (display == NULL) { + return NULL; + } + + GdkSeat* seat = gdk_display_get_default_seat(display); + if (seat == NULL) { + return NULL; + } + + GdkDevice* pointer = gdk_seat_get_pointer(seat); + if (pointer == NULL) { + return NULL; + } + + gint cursor_x = 0; + gint cursor_y = 0; + gdk_device_get_position(pointer, NULL, &cursor_x, &cursor_y); + + GdkMonitor* monitor = gdk_display_get_monitor_at_point(display, cursor_x, cursor_y); + if (monitor == NULL) { + return NULL; + } + + GdkRectangle workarea; + memset(&workarea, 0, sizeof(workarea)); + gdk_monitor_get_workarea(monitor, &workarea); + + g_autoptr(FlValue) info = fl_value_new_map(); + fl_value_set_string_take(info, "cursorX", fl_value_new_float((double)cursor_x)); + fl_value_set_string_take(info, "cursorY", fl_value_new_float((double)cursor_y)); + fl_value_set_string_take(info, "waLeft", fl_value_new_float((double)workarea.x)); + fl_value_set_string_take(info, "waTop", fl_value_new_float((double)workarea.y)); + fl_value_set_string_take(info, "waRight", + fl_value_new_float((double)(workarea.x + workarea.width))); + fl_value_set_string_take(info, "waBottom", + fl_value_new_float((double)(workarea.y + workarea.height))); + return fl_value_ref(info); +} + +static gboolean set_text_to_clipboard(const gchar* text) { + if (text == NULL || *text == '\0') { + return FALSE; + } + + GtkClipboard* clipboard = gtk_clipboard_get(GDK_SELECTION_CLIPBOARD); + if (clipboard == NULL) { + return FALSE; + } + + gtk_clipboard_set_text(clipboard, text, -1); + gtk_clipboard_store(clipboard); + return TRUE; +} + +typedef struct { + GdkPixbuf* pixbuf; + gchar* uri; +} ImageClipData; + +static void image_clip_get_cb(GtkClipboard* clipboard, + GtkSelectionData* selection_data, + guint info, + gpointer user_data) { + (void)clipboard; + ImageClipData* d = (ImageClipData*)user_data; + + if (info == 0) { + GdkAtom target = gdk_atom_intern_static_string("text/uri-list"); + gtk_selection_data_set(selection_data, target, 8, + (const guchar*)d->uri, (gint)strlen(d->uri)); + } else { + gtk_selection_data_set_pixbuf(selection_data, d->pixbuf); + } +} + +static void image_clip_clear_cb(GtkClipboard* clipboard, gpointer user_data) { + (void)clipboard; + ImageClipData* d = (ImageClipData*)user_data; + if (d->pixbuf) g_object_unref(d->pixbuf); + g_free(d->uri); + g_free(d); +} + +static gboolean set_image_to_clipboard(const gchar* image_path) { + if (image_path == NULL || *image_path == '\0') { + return FALSE; + } + + g_autoptr(GError) error = NULL; + GdkPixbuf* pixbuf = gdk_pixbuf_new_from_file(image_path, &error); + if (pixbuf == NULL) { + if (error != NULL) { + g_warning("Failed to load image for clipboard: %s", error->message); + } + return FALSE; + } + + GtkClipboard* clipboard = gtk_clipboard_get(GDK_SELECTION_CLIPBOARD); + if (clipboard == NULL) { + g_object_unref(pixbuf); + return FALSE; + } + + GtkTargetList* tl = gtk_target_list_new(NULL, 0); + gtk_target_list_add(tl, gdk_atom_intern_static_string("text/uri-list"), 0, 0); + gtk_target_list_add_image_targets(tl, 1, TRUE); + + gint n_targets = 0; + GtkTargetEntry* targets = gtk_target_table_new_from_list(tl, &n_targets); + gtk_target_list_unref(tl); + + gchar* uri = g_filename_to_uri(image_path, NULL, NULL); + if (uri == NULL) { + g_object_unref(pixbuf); + gtk_target_table_free(targets, n_targets); + return FALSE; + } + + gchar* uri_line = g_strdup_printf("%s\r\n", uri); + g_free(uri); + + ImageClipData* data = g_new0(ImageClipData, 1); + data->pixbuf = pixbuf; + data->uri = uri_line; + + gboolean ok = gtk_clipboard_set_with_data( + clipboard, targets, n_targets, + image_clip_get_cb, image_clip_clear_cb, data); + gtk_target_table_free(targets, n_targets); + + if (!ok) { + g_object_unref(pixbuf); + g_free(uri_line); + g_free(data); + return FALSE; + } + + gtk_clipboard_store(clipboard); + return TRUE; +} + +static void clipboard_uri_list_get_cb(GtkClipboard* clipboard, + GtkSelectionData* selection_data, + guint info, + gpointer user_data) { + (void)clipboard; + (void)info; + + const gchar* uri_list = (const gchar*)user_data; + if (uri_list == NULL || *uri_list == '\0') { + return; + } + + GdkAtom target = gdk_atom_intern_static_string("text/uri-list"); + gtk_selection_data_set(selection_data, target, 8, (const guchar*)uri_list, + (gint)strlen(uri_list)); +} + +static void clipboard_uri_list_clear_cb(GtkClipboard* clipboard, gpointer user_data) { + (void)clipboard; + g_free(user_data); +} + +static gboolean set_files_to_clipboard(const gchar* content) { + if (content == NULL || *content == '\0') { + return FALSE; + } + + gchar** parts = g_strsplit(content, "\n", -1); + g_autoptr(GString) uri_list = g_string_new(NULL); + for (guint i = 0; parts[i] != NULL; i++) { + if (parts[i][0] == '\0' || !g_file_test(parts[i], G_FILE_TEST_EXISTS)) { + continue; + } + gchar* uri = g_filename_to_uri(parts[i], NULL, NULL); + if (uri != NULL) { + g_string_append(uri_list, uri); + g_string_append(uri_list, "\r\n"); + g_free(uri); + } + } + g_strfreev(parts); + + if (uri_list->len == 0) { + return FALSE; + } + + GtkClipboard* clipboard = gtk_clipboard_get(GDK_SELECTION_CLIPBOARD); + if (clipboard == NULL) { + return FALSE; + } + + static GtkTargetEntry targets[] = { + {(gchar*)"text/uri-list", 0, 0}, + }; + + gchar* uri_payload = g_string_free(g_steal_pointer(&uri_list), FALSE); + gboolean set_ok = gtk_clipboard_set_with_data( + clipboard, targets, G_N_ELEMENTS(targets), clipboard_uri_list_get_cb, + clipboard_uri_list_clear_cb, uri_payload); + if (!set_ok) { + g_free(uri_payload); + return FALSE; + } + + gtk_clipboard_store(clipboard); + return TRUE; +} + +static FlValue* get_media_info(void) { + return NULL; +} + +static void respond_success(FlMethodCall* method_call, FlValue* result) { + g_autoptr(GError) error = NULL; + if (!fl_method_call_respond_success(method_call, result, &error) && error != NULL) { + g_warning("Failed to respond to method call: %s", error->message); + } +} + +#ifdef GDK_WINDOWING_X11 +static gboolean paste_after_delay_cb(gpointer data) { + FlMethodCall* mc = FL_METHOD_CALL(data); + simulate_paste_x11(); + respond_success(mc, fl_value_new_bool(TRUE)); + g_object_unref(mc); + return G_SOURCE_REMOVE; +} +#endif + +static void listener_plugin_handle_method_call(ListenerPlugin* self, + FlMethodCall* method_call) { + const gchar* method = fl_method_call_get_name(method_call); + FlValue* args = fl_method_call_get_args(method_call); + + if (strcmp(method, "getCapabilities") == 0) { + g_autoptr(FlValue) caps = fl_value_new_map(); + fl_value_set_string_take(caps, "isX11", fl_value_new_bool(plugin_is_x11())); +#ifdef GDK_WINDOWING_X11 + Display* display = get_xdisplay(); + gboolean has_xtest = display != NULL && ensure_xtest(display); +#else + gboolean has_xtest = FALSE; +#endif + fl_value_set_string_take(caps, "hasXTest", fl_value_new_bool(has_xtest)); + respond_success(method_call, fl_value_ref(caps)); + return; + } + + if (strcmp(method, "setClipboardContent") == 0) { + FlValue* type_value = args != NULL ? fl_value_lookup_string(args, "type") : NULL; + gint64 type = type_value != NULL ? fl_value_get_int(type_value) : -1; + FlValue* content_value = args != NULL ? fl_value_lookup_string(args, "content") : NULL; + const gchar* content = content_value != NULL && + fl_value_get_type(content_value) == FL_VALUE_TYPE_STRING + ? fl_value_get_string(content_value) + : ""; + gboolean success = FALSE; + + switch (type) { + case CLIP_TYPE_TEXT: + case CLIP_TYPE_LINK: + success = set_text_to_clipboard(content); + break; + case CLIP_TYPE_IMAGE: + success = set_image_to_clipboard(content); + break; + case CLIP_TYPE_FILE: + case CLIP_TYPE_FOLDER: + case CLIP_TYPE_AUDIO: + case CLIP_TYPE_VIDEO: + success = set_files_to_clipboard(content); + break; + default: + success = FALSE; + break; + } + + if (success) { + self->last_write_tick_ms = now_ms(); + } + respond_success(method_call, fl_value_new_bool(success)); + return; + } + + if (strcmp(method, "getMediaInfo") == 0) { + respond_success(method_call, get_media_info()); + return; + } + + if (strcmp(method, "captureFrontmostApp") == 0) { +#ifdef GDK_WINDOWING_X11 + if (plugin_is_x11()) { + gchar* id = capture_frontmost_x11_identifier(); + FlValue* value = id != NULL ? fl_value_new_string(id) : NULL; + g_free(id); + respond_success(method_call, value); + return; + } +#endif + respond_success(method_call, NULL); + return; + } + + if (strcmp(method, "activateAndPaste") == 0) { +#ifdef GDK_WINDOWING_X11 + if (plugin_is_x11()) { + FlValue* id_value = args != NULL ? fl_value_lookup_string(args, "bundleId") : NULL; + FlValue* delay_value = args != NULL ? fl_value_lookup_string(args, "delayMs") : NULL; + const gchar* identifier = id_value != NULL && + fl_value_get_type(id_value) == FL_VALUE_TYPE_STRING + ? fl_value_get_string(id_value) + : NULL; + gint64 delay_ms = delay_value != NULL ? fl_value_get_int(delay_value) : 0; + gboolean activated = FALSE; + + if (identifier != NULL && g_str_has_prefix(identifier, "x11:0x")) { + Window window = (Window)g_ascii_strtoull(identifier + 6, NULL, 16); + activated = request_activate_x11_window(window); + } + + if (activated && delay_ms > 0) { + FlMethodCall* held_call = FL_METHOD_CALL(g_object_ref(method_call)); + guint timer_id = g_timeout_add((guint)delay_ms, paste_after_delay_cb, held_call); + if (timer_id != 0) { + return; // held_call will be released by paste_after_delay_cb + } + // Timer registration failed — release the ref and fall through to immediate paste. + g_object_unref(held_call); + g_warning("activateAndPaste: g_timeout_add failed, pasting immediately"); + } + + if (activated) { + simulate_paste_x11(); + } + respond_success(method_call, fl_value_new_bool(activated)); + return; + } +#endif + respond_success(method_call, fl_value_new_bool(FALSE)); + return; + } + + if (strcmp(method, "getCursorAndScreenInfo") == 0) { + respond_success(method_call, get_cursor_and_screen_info()); + return; + } + + if (strcmp(method, "checkAccessibility") == 0 || + strcmp(method, "requestAccessibility") == 0 || + strcmp(method, "openAccessibilitySettings") == 0) { + respond_success(method_call, fl_value_new_bool(TRUE)); + return; + } + + g_autoptr(FlMethodResponse) response = + FL_METHOD_RESPONSE(fl_method_not_implemented_response_new()); + fl_method_call_respond(method_call, response, NULL); +} + +static FlMethodErrorResponse* stream_listen_cb(FlEventChannel* channel, + FlValue* args, + gpointer user_data) { + (void)channel; + (void)args; + ListenerPlugin* self = LISTENER_PLUGIN(user_data); + self->is_listening = TRUE; + ensure_polling(self); + return NULL; +} + +static FlMethodErrorResponse* stream_cancel_cb(FlEventChannel* channel, + FlValue* args, + gpointer user_data) { + (void)channel; + (void)args; + ListenerPlugin* self = LISTENER_PLUGIN(user_data); + self->is_listening = FALSE; + stop_polling(self); + return NULL; +} + +static void method_call_cb(FlMethodChannel* channel, + FlMethodCall* method_call, + gpointer user_data) { + (void)channel; + listener_plugin_handle_method_call(LISTENER_PLUGIN(user_data), method_call); +} + +FlMethodResponse* get_platform_version(void) { + struct utsname uname_data = {}; + uname(&uname_data); + g_autofree gchar* version = g_strdup_printf("Linux %s", uname_data.version); + g_autoptr(FlValue) result = fl_value_new_string(version); + return FL_METHOD_RESPONSE(fl_method_success_response_new(result)); +} + +static void listener_plugin_dispose(GObject* object) { + ListenerPlugin* self = LISTENER_PLUGIN(object); + stop_polling(self); + self->is_listening = FALSE; + + g_clear_object(&self->event_channel); + g_clear_object(&self->method_channel); + g_free(self->last_content_hash); + self->last_content_hash = NULL; + + G_OBJECT_CLASS(listener_plugin_parent_class)->dispose(object); +} + +static void listener_plugin_class_init(ListenerPluginClass* klass) { + G_OBJECT_CLASS(klass)->dispose = listener_plugin_dispose; +} + +static void listener_plugin_init(ListenerPlugin* self) { + self->last_content_hash = NULL; + self->last_change_tick_ms = 0; + self->last_write_tick_ms = 0; + self->is_listening = FALSE; + self->poll_timer_id = 0; +} + +void listener_plugin_register_with_registrar(FlPluginRegistrar* registrar) { + ListenerPlugin* plugin = LISTENER_PLUGIN( + g_object_new(listener_plugin_get_type(), NULL)); + + FlBinaryMessenger* messenger = fl_plugin_registrar_get_messenger(registrar); + g_autoptr(FlStandardMethodCodec) codec = fl_standard_method_codec_new(); + + plugin->event_channel = fl_event_channel_new(messenger, kClipboardChannelName, + FL_METHOD_CODEC(codec)); + fl_event_channel_set_stream_handlers(plugin->event_channel, stream_listen_cb, + stream_cancel_cb, g_object_ref(plugin), + g_object_unref); + + plugin->method_channel = fl_method_channel_new( + messenger, kClipboardWriterChannelName, FL_METHOD_CODEC(codec)); + fl_method_channel_set_method_call_handler(plugin->method_channel, + method_call_cb, + g_object_ref(plugin), + g_object_unref); + + g_object_unref(plugin); +} From 126ca5e8d47e5cd87088922712acbf5512bdc8f9 Mon Sep 17 00:00:00 2001 From: rgdevment Date: Fri, 24 Apr 2026 12:20:22 -0400 Subject: [PATCH 03/31] feat: enhance Linux hotkey registration with detailed error handling and response structure --- app/lib/shell/linux_hotkey_registration.dart | 314 +++++++++++------- app/lib/shell/linux_shell.dart | 259 ++++++++------- app/linux/runner/copypaste_linux_shell.c | 64 +++- .../shell/linux_hotkey_registration_test.dart | 276 +++++++++------ listener/linux/listener_plugin.c | 2 +- 5 files changed, 558 insertions(+), 357 deletions(-) diff --git a/app/lib/shell/linux_hotkey_registration.dart b/app/lib/shell/linux_hotkey_registration.dart index 2a165323..5a5f9a5d 100644 --- a/app/lib/shell/linux_hotkey_registration.dart +++ b/app/lib/shell/linux_hotkey_registration.dart @@ -1,129 +1,185 @@ -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, - ); -} +import 'package:flutter/foundation.dart'; + +import 'linux_shell.dart'; + +enum HotkeyRegistrationStatus { registered, fallbackRegistered, failed } + +enum HotkeyFailureReason { + unsupportedKey, + noModifier, + grabFailed, + noX11, + channelError, + unknown, +} + +HotkeyFailureReason _reasonFromCode(String? code) { + switch (code) { + case 'unsupportedKey': + return HotkeyFailureReason.unsupportedKey; + case 'noModifier': + return HotkeyFailureReason.noModifier; + case 'grabFailed': + return HotkeyFailureReason.grabFailed; + case 'noX11': + return HotkeyFailureReason.noX11; + case 'channelError': + return HotkeyFailureReason.channelError; + default: + return HotkeyFailureReason.unknown; + } +} + +final Set _supportedLinuxVirtualKeys = { + for (var k = 0x41; k <= 0x5A; k++) k, + for (var k = 0x30; k <= 0x39; k++) k, + for (var k = 0x70; k <= 0x87; k++) k, + 0x08, 0x09, 0x0D, 0x1B, 0x20, + 0x21, 0x22, 0x23, 0x24, + 0x25, 0x26, 0x27, 0x28, + 0x2D, 0x2E, + 0xBA, 0xBB, 0xBC, 0xBD, 0xBE, 0xBF, 0xC0, + 0xDB, 0xDC, 0xDD, 0xDE, +}; + +bool isLinuxSupportedVirtualKey(int virtualKey) => + _supportedLinuxVirtualKeys.contains(virtualKey); + +@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, + this.failureReason, + }); + + final HotkeyRegistrationStatus status; + final HotkeyBinding requestedBinding; + final HotkeyBinding? effectiveBinding; + final HotkeyFailureReason? failureReason; + + 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 (!isLinuxSupportedVirtualKey(requestedBinding.virtualKey)) { + return HotkeyRegistrationResult( + status: HotkeyRegistrationStatus.failed, + requestedBinding: requestedBinding, + failureReason: HotkeyFailureReason.unsupportedKey, + ); + } + + final primary = await api.registerHotkey(requestedBinding); + if (primary.success) { + return HotkeyRegistrationResult( + status: HotkeyRegistrationStatus.registered, + requestedBinding: requestedBinding, + effectiveBinding: requestedBinding, + ); + } + + if (requestedBinding == fallbackBinding) { + return HotkeyRegistrationResult( + status: HotkeyRegistrationStatus.failed, + requestedBinding: requestedBinding, + failureReason: _reasonFromCode(primary.errorCode), + ); + } + + final fallback = await api.registerHotkey(fallbackBinding); + if (fallback.success) { + return HotkeyRegistrationResult( + status: HotkeyRegistrationStatus.fallbackRegistered, + requestedBinding: requestedBinding, + effectiveBinding: fallbackBinding, + failureReason: _reasonFromCode(primary.errorCode), + ); + } + + return HotkeyRegistrationResult( + status: HotkeyRegistrationStatus.failed, + requestedBinding: requestedBinding, + failureReason: _reasonFromCode(fallback.errorCode ?? primary.errorCode), + ); +} diff --git a/app/lib/shell/linux_shell.dart b/app/lib/shell/linux_shell.dart index 4b3b252f..02aec0cb 100644 --- a/app/lib/shell/linux_shell.dart +++ b/app/lib/shell/linux_shell.dart @@ -1,120 +1,139 @@ -// coverage:ignore-file -import 'dart:async'; - -import 'package:core/core.dart'; -import 'package:flutter/services.dart'; - -class LinuxShell { - LinuxShell._(); - - static const MethodChannel _methodChannel = MethodChannel( - 'copypaste/linux_shell', - ); - static const EventChannel _eventChannel = EventChannel( - 'copypaste/linux_shell/events', - ); - - static StreamController? _eventsController; - static StreamSubscription? _eventChannelSubscription; - - static Stream get events { - if (_eventsController == null) { - _eventsController = StreamController.broadcast(); - _eventChannelSubscription = _eventChannel.receiveBroadcastStream().listen( - (dynamic event) { - if (event is! Map) return; - final map = Map.from(event); - final type = map['type'] as String? ?? ''; - if (type.isNotEmpty) _eventsController?.add(type); - }, - onError: (Object error) => _eventsController?.addError(error), - ); - } - return _eventsController!.stream; - } - - static Future dispose() async { - await _eventChannelSubscription?.cancel(); - _eventChannelSubscription = null; - await _eventsController?.close(); - _eventsController = null; - } - - static Future initTray({ - required String iconPath, - required String showHideLabel, - required String exitLabel, - required String tooltip, - }) async { - final result = await _methodChannel.invokeMethod('initTray', { - 'iconPath': iconPath, - 'showHideLabel': showHideLabel, - 'exitLabel': exitLabel, - 'tooltip': tooltip, - }); - return result ?? false; - } - - static Future updateTray({ - required String iconPath, - required String showHideLabel, - required String exitLabel, - required String tooltip, - }) async { - final result = await _methodChannel.invokeMethod('updateTray', { - 'iconPath': iconPath, - 'showHideLabel': showHideLabel, - 'exitLabel': exitLabel, - 'tooltip': tooltip, - }); - return result ?? false; - } - - static Future destroyTray() async { - try { - await _methodChannel.invokeMethod('destroyTray'); - } catch (e) { - AppLogger.error('LinuxShell.destroyTray failed: $e'); - } - } - - static Future registerHotkey({ - required int virtualKey, - required bool useCtrl, - required bool useWin, - required bool useAlt, - required bool useShift, - }) async { - try { - final result = await _methodChannel.invokeMethod('registerHotkey', { - 'virtualKey': virtualKey, - 'useCtrl': useCtrl, - 'useWin': useWin, - 'useAlt': useAlt, - 'useShift': useShift, - }); - return result ?? false; - } catch (e) { - AppLogger.error('LinuxShell.registerHotkey failed: $e'); - return false; - } - } - - static Future unregisterHotkey() async { - try { - await _methodChannel.invokeMethod('unregisterHotkey'); - } catch (e) { - AppLogger.error('LinuxShell.unregisterHotkey failed: $e'); - } - } - - /// Raises and focuses the GTK window using the X11 hotkey event timestamp, - /// bypassing GNOME's focus-stealing prevention. - static Future focusWindow() async { - try { - await _methodChannel.invokeMethod('focusWindow'); - } catch (e) { - AppLogger.error('LinuxShell.focusWindow failed: $e'); - } - } -} +// coverage:ignore-file +import 'dart:async'; + +import 'package:core/core.dart'; +import 'package:flutter/services.dart'; + +class LinuxShell { + LinuxShell._(); + + static const MethodChannel _methodChannel = MethodChannel( + 'copypaste/linux_shell', + ); + static const EventChannel _eventChannel = EventChannel( + 'copypaste/linux_shell/events', + ); + + static StreamController? _eventsController; + static StreamSubscription? _eventChannelSubscription; + + static Stream get events { + if (_eventsController == null) { + _eventsController = StreamController.broadcast(); + _eventChannelSubscription = _eventChannel.receiveBroadcastStream().listen( + (dynamic event) { + if (event is! Map) return; + final map = Map.from(event); + final type = map['type'] as String? ?? ''; + if (type.isNotEmpty) _eventsController?.add(type); + }, + onError: (Object error) => _eventsController?.addError(error), + ); + } + return _eventsController!.stream; + } + + static Future dispose() async { + await _eventChannelSubscription?.cancel(); + _eventChannelSubscription = null; + await _eventsController?.close(); + _eventsController = null; + } + + static Future initTray({ + required String iconPath, + required String showHideLabel, + required String exitLabel, + required String tooltip, + }) async { + final result = await _methodChannel.invokeMethod('initTray', { + 'iconPath': iconPath, + 'showHideLabel': showHideLabel, + 'exitLabel': exitLabel, + 'tooltip': tooltip, + }); + return result ?? false; + } + + static Future updateTray({ + required String iconPath, + required String showHideLabel, + required String exitLabel, + required String tooltip, + }) async { + final result = await _methodChannel.invokeMethod('updateTray', { + 'iconPath': iconPath, + 'showHideLabel': showHideLabel, + 'exitLabel': exitLabel, + 'tooltip': tooltip, + }); + return result ?? false; + } + + static Future destroyTray() async { + try { + await _methodChannel.invokeMethod('destroyTray'); + } catch (e) { + AppLogger.error('LinuxShell.destroyTray failed: $e'); + } + } + + static Future registerHotkey({ + required int virtualKey, + required bool useCtrl, + required bool useWin, + required bool useAlt, + required bool useShift, + }) async { + try { + final result = await _methodChannel.invokeMethod('registerHotkey', { + 'virtualKey': virtualKey, + 'useCtrl': useCtrl, + 'useWin': useWin, + 'useAlt': useAlt, + 'useShift': useShift, + }); + if (result is Map) { + final map = Map.from(result); + final success = map['success'] == true; + final code = map['errorCode']; + return HotkeyRegisterResponse( + success: success, + errorCode: code is String ? code : null, + ); + } + if (result is bool) { + return HotkeyRegisterResponse(success: result); + } + return const HotkeyRegisterResponse(success: false, errorCode: 'unknown'); + } catch (e) { + AppLogger.error('LinuxShell.registerHotkey failed: $e'); + return const HotkeyRegisterResponse(success: false, errorCode: 'channelError'); + } + } + + static Future unregisterHotkey() async { + try { + await _methodChannel.invokeMethod('unregisterHotkey'); + } catch (e) { + AppLogger.error('LinuxShell.unregisterHotkey failed: $e'); + } + } + + /// Raises and focuses the GTK window using the X11 hotkey event timestamp, + /// bypassing GNOME's focus-stealing prevention. + static Future focusWindow() async { + try { + await _methodChannel.invokeMethod('focusWindow'); + } catch (e) { + AppLogger.error('LinuxShell.focusWindow failed: $e'); + } + } +} + +class HotkeyRegisterResponse { + const HotkeyRegisterResponse({required this.success, this.errorCode}); + + final bool success; + final String? errorCode; +} diff --git a/app/linux/runner/copypaste_linux_shell.c b/app/linux/runner/copypaste_linux_shell.c index 43d92e5a..4dcd3d24 100644 --- a/app/linux/runner/copypaste_linux_shell.c +++ b/app/linux/runner/copypaste_linux_shell.c @@ -269,6 +269,15 @@ static gboolean destroy_tray(CopyPasteLinuxShell* shell) { return TRUE; } +static FlValue* make_hotkey_result(gboolean success, const char* error_code) { + FlValue* map = fl_value_new_map(); + fl_value_set_string_take(map, "success", fl_value_new_bool(success)); + if (error_code != NULL) { + fl_value_set_string_take(map, "errorCode", fl_value_new_string(error_code)); + } + return map; +} + #ifdef GDK_WINDOWING_X11 static guint modifier_combinations[] = {0, LockMask, Mod2Mask, LockMask | Mod2Mask}; static int (*previous_x11_error_handler)(Display*, XErrorEvent*) = NULL; @@ -322,7 +331,41 @@ static KeySym virtual_key_to_keysym(gint64 virtual_key) { if (virtual_key >= 0x41 && virtual_key <= 0x5A) { return (KeySym)(XK_A + (virtual_key - 0x41)); } - return NoSymbol; + if (virtual_key >= 0x30 && virtual_key <= 0x39) { + return (KeySym)(XK_0 + (virtual_key - 0x30)); + } + if (virtual_key >= 0x70 && virtual_key <= 0x87) { + return (KeySym)(XK_F1 + (virtual_key - 0x70)); + } + switch (virtual_key) { + case 0x08: return XK_BackSpace; + case 0x09: return XK_Tab; + case 0x0D: return XK_Return; + case 0x1B: return XK_Escape; + case 0x20: return XK_space; + case 0x21: return XK_Page_Up; + case 0x22: return XK_Page_Down; + case 0x23: return XK_End; + case 0x24: return XK_Home; + case 0x25: return XK_Left; + case 0x26: return XK_Up; + case 0x27: return XK_Right; + case 0x28: return XK_Down; + case 0x2D: return XK_Insert; + case 0x2E: return XK_Delete; + case 0xBA: return XK_semicolon; + case 0xBB: return XK_equal; + case 0xBC: return XK_comma; + case 0xBD: return XK_minus; + case 0xBE: return XK_period; + case 0xBF: return XK_slash; + case 0xC0: return XK_grave; + case 0xDB: return XK_bracketleft; + case 0xDC: return XK_backslash; + case 0xDD: return XK_bracketright; + case 0xDE: return XK_apostrophe; + default: return NoSymbol; + } } static guint compute_modifier_mask(FlValue* args) { @@ -367,9 +410,9 @@ static void unregister_hotkey(CopyPasteLinuxShell* shell) { shell->hotkey_modifiers = 0; } -static gboolean register_hotkey(CopyPasteLinuxShell* shell, FlValue* args) { +static FlValue* register_hotkey(CopyPasteLinuxShell* shell, FlValue* args) { if (!shell_is_x11() || shell->xdisplay == NULL) { - return FALSE; + return make_hotkey_result(FALSE, "noX11"); } unregister_hotkey(shell); @@ -380,19 +423,19 @@ static gboolean register_hotkey(CopyPasteLinuxShell* shell, FlValue* args) { KeySym keysym = virtual_key_to_keysym(virtual_key); if (keysym == NoSymbol) { g_warning("registerHotkey: unsupported virtual key 0x%llx", (unsigned long long)virtual_key); - return FALSE; + return make_hotkey_result(FALSE, "unsupportedKey"); } guint modifiers = compute_modifier_mask(args); if (modifiers == 0) { g_warning("registerHotkey: no modifier keys specified"); - return FALSE; + return make_hotkey_result(FALSE, "noModifier"); } KeyCode keycode = XKeysymToKeycode(shell->xdisplay, keysym); if (keycode == 0) { g_warning("registerHotkey: no keycode for keysym %lu", (unsigned long)keysym); - return FALSE; + return make_hotkey_result(FALSE, "unsupportedKey"); } for (guint i = 0; i < G_N_ELEMENTS(modifier_combinations); i++) { @@ -402,11 +445,10 @@ static gboolean register_hotkey(CopyPasteLinuxShell* shell, FlValue* args) { modifiers | modifier_combinations[i]); ungrab_hotkey_variants(shell->xdisplay, shell->root_window, keycode, modifiers); - return FALSE; + return make_hotkey_result(FALSE, "grabFailed"); } } - // Flush pending requests before reading window attributes to avoid stale state. XSync(shell->xdisplay, False); XWindowAttributes attrs; if (XGetWindowAttributes(shell->xdisplay, shell->root_window, &attrs) != 0) { @@ -420,7 +462,7 @@ static gboolean register_hotkey(CopyPasteLinuxShell* shell, FlValue* args) { shell->hotkey_registered = TRUE; shell->hotkey_keycode = keycode; shell->hotkey_modifiers = modifiers; - return TRUE; + return make_hotkey_result(TRUE, NULL); } static GdkFilterReturn x11_event_filter(GdkXEvent* xevent, @@ -637,9 +679,9 @@ static void shell_method_call_cb(FlMethodChannel* channel, if (strcmp(method, "registerHotkey") == 0) { #ifdef GDK_WINDOWING_X11 - respond_method_success(method_call, fl_value_new_bool(register_hotkey(shell, args))); + respond_method_success(method_call, register_hotkey(shell, args)); #else - respond_method_success(method_call, fl_value_new_bool(FALSE)); + respond_method_success(method_call, make_hotkey_result(FALSE, "noX11")); #endif return; } diff --git a/app/test/shell/linux_hotkey_registration_test.dart b/app/test/shell/linux_hotkey_registration_test.dart index 27e002a2..c3bc6110 100644 --- a/app/test/shell/linux_hotkey_registration_test.dart +++ b/app/test/shell/linux_hotkey_registration_test.dart @@ -1,96 +1,180 @@ -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]); - }, - ); - }); -} +import 'package:flutter_test/flutter_test.dart'; + +import 'package:copypaste/shell/linux_hotkey_registration.dart'; +import 'package:copypaste/shell/linux_shell.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 const HotkeyRegisterResponse(success: false, errorCode: 'unknown'); + } + return responses.removeAt(0); + } +} + +HotkeyRegisterResponse _ok() => const HotkeyRegisterResponse(success: true); +HotkeyRegisterResponse _fail(String code) => + HotkeyRegisterResponse(success: false, errorCode: code); + +void main() { + group('isLinuxSupportedVirtualKey', () { + test('accepts A-Z, 0-9, F-keys, navigation, symbols', () { + expect(isLinuxSupportedVirtualKey(0x41), isTrue); + expect(isLinuxSupportedVirtualKey(0x5A), isTrue); + expect(isLinuxSupportedVirtualKey(0x30), isTrue); + expect(isLinuxSupportedVirtualKey(0x70), isTrue); + expect(isLinuxSupportedVirtualKey(0x87), isTrue); + expect(isLinuxSupportedVirtualKey(0x20), isTrue); + expect(isLinuxSupportedVirtualKey(0x25), isTrue); + expect(isLinuxSupportedVirtualKey(0xC0), isTrue); + }); + + test('rejects unmapped virtual keys', () { + expect(isLinuxSupportedVirtualKey(0x00), isFalse); + expect(isLinuxSupportedVirtualKey(0x90), isFalse); + expect(isLinuxSupportedVirtualKey(0xFF), isFalse); + }); + }); + + group('registerLinuxHotkeyWithFallback', () { + const requested = HotkeyBinding( + virtualKey: 0x56, + keyName: 'V', + useCtrl: true, + useWin: false, + useAlt: true, + useShift: false, + ); + + test('short-circuits when requested key is unsupported (no remote call)', + () async { + const unsupported = HotkeyBinding( + virtualKey: 0x99, + keyName: '?', + useCtrl: true, + useWin: false, + useAlt: true, + useShift: false, + ); + final api = _FakeLinuxHotkeyBindingApi([]); + + final result = await registerLinuxHotkeyWithFallback( + api: api, + requestedBinding: unsupported, + ); + + expect(result.status, HotkeyRegistrationStatus.failed); + expect(result.failureReason, HotkeyFailureReason.unsupportedKey); + expect(api.attempts, isEmpty); + }); + + test('registers requested binding when available', () async { + final api = _FakeLinuxHotkeyBindingApi([_ok()]); + + final result = await registerLinuxHotkeyWithFallback( + api: api, + requestedBinding: requested, + ); + + expect(result.status, HotkeyRegistrationStatus.registered); + expect(result.effectiveBinding, requested); + expect(result.failureReason, isNull); + expect(api.attempts, [requested]); + }); + + test('falls back when requested binding fails with grabFailed', () async { + final api = _FakeLinuxHotkeyBindingApi([ + _fail('grabFailed'), + _ok(), + ]); + + final result = await registerLinuxHotkeyWithFallback( + api: api, + requestedBinding: requested, + ); + + expect(result.status, HotkeyRegistrationStatus.fallbackRegistered); + expect(result.effectiveBinding, kLinuxTemporaryFallbackHotkey); + expect(result.failureReason, HotkeyFailureReason.grabFailed); + expect(api.attempts, [ + requested, + kLinuxTemporaryFallbackHotkey, + ]); + }); + + test('fails cleanly when requested and fallback both fail', () async { + final api = _FakeLinuxHotkeyBindingApi([ + _fail('grabFailed'), + _fail('grabFailed'), + ]); + + final result = await registerLinuxHotkeyWithFallback( + api: api, + requestedBinding: requested, + ); + + expect(result.status, HotkeyRegistrationStatus.failed); + expect(result.effectiveBinding, isNull); + expect(result.failureReason, HotkeyFailureReason.grabFailed); + }); + + test('does not retry when requested binding equals temporary fallback', + () async { + final api = _FakeLinuxHotkeyBindingApi([ + _fail('grabFailed'), + ]); + + final result = await registerLinuxHotkeyWithFallback( + api: api, + requestedBinding: kLinuxTemporaryFallbackHotkey, + ); + + expect(result.status, HotkeyRegistrationStatus.failed); + expect(result.failureReason, HotkeyFailureReason.grabFailed); + expect(api.attempts, [kLinuxTemporaryFallbackHotkey]); + }); + + test('maps unknown error code to HotkeyFailureReason.unknown', () async { + final api = _FakeLinuxHotkeyBindingApi([ + _fail('something_weird'), + _fail('something_weird'), + ]); + + final result = await registerLinuxHotkeyWithFallback( + api: api, + requestedBinding: requested, + ); + + expect(result.failureReason, HotkeyFailureReason.unknown); + }); + + test('maps noModifier and noX11 error codes', () async { + final api1 = _FakeLinuxHotkeyBindingApi([ + _fail('noModifier'), + _fail('noModifier'), + ]); + final r1 = await registerLinuxHotkeyWithFallback( + api: api1, + requestedBinding: requested, + ); + expect(r1.failureReason, HotkeyFailureReason.noModifier); + + final api2 = _FakeLinuxHotkeyBindingApi([ + _fail('noX11'), + _fail('noX11'), + ]); + final r2 = await registerLinuxHotkeyWithFallback( + api: api2, + requestedBinding: requested, + ); + expect(r2.failureReason, HotkeyFailureReason.noX11); + }); + }); +} diff --git a/listener/linux/listener_plugin.c b/listener/linux/listener_plugin.c index 66cd69b2..72abe8bb 100644 --- a/listener/linux/listener_plugin.c +++ b/listener/linux/listener_plugin.c @@ -585,7 +585,7 @@ static FlValue* build_image_event(GtkClipboard* clipboard, gchar* buffer = NULL; gsize buffer_size = 0; g_autoptr(GError) error = NULL; - gboolean ok = gdk_pixbuf_save_to_buffer(pixbuf, &buffer, &buffer_size, "bmp", + gboolean ok = gdk_pixbuf_save_to_buffer(pixbuf, &buffer, &buffer_size, "png", &error, NULL); g_object_unref(pixbuf); if (!ok || buffer == NULL || buffer_size == 0) { From 40a3790a379e31460bff7bfafe28efc06999a290 Mon Sep 17 00:00:00 2001 From: rgdevment Date: Fri, 24 Apr 2026 12:51:21 -0400 Subject: [PATCH 04/31] feat: Enhance Linux tray functionality and add capability warnings - Refactor LinuxShell to return detailed TrayResponse for tray initialization and updates. - Update TrayIcon to check for LinuxGuard capabilities before showing tray. - Modify Linux shell implementation to handle tray initialization and destruction with error codes. - Introduce LinuxCapabilitiesBanner to display warnings for missing capabilities on Linux. - Add dismissible flags in AppConfig for Linux capability warnings. - Implement tests for Linux capability banners and AppConfig dismiss flags. --- app/lib/l10n/app_en.arb | 29 + app/lib/l10n/app_es.arb | 8 + app/lib/l10n/app_localizations.dart | 48 + app/lib/l10n/app_localizations_en.dart | 30 + app/lib/l10n/app_localizations_es.dart | 30 + app/lib/main.dart | 54 +- .../screens/linux_capabilities_banner.dart | 131 ++ app/lib/screens/main_screen.dart | 1702 +++++++++-------- app/lib/shell/linux_shell.dart | 39 +- app/lib/shell/tray_icon.dart | 225 +-- app/linux/runner/copypaste_linux_shell.c | 74 +- .../linux_capabilities_banner_test.dart | 129 ++ core/lib/config/app_config.dart | 32 + core/test/app_config_test.dart | 41 + 14 files changed, 1548 insertions(+), 1024 deletions(-) create mode 100644 app/lib/screens/linux_capabilities_banner.dart create mode 100644 app/test/screens/linux_capabilities_banner_test.dart diff --git a/app/lib/l10n/app_en.arb b/app/lib/l10n/app_en.arb index 52d86ee5..6f5ee380 100644 --- a/app/lib/l10n/app_en.arb +++ b/app/lib/l10n/app_en.arb @@ -541,6 +541,35 @@ } }, + "linuxHotkeyGrabFailedWarning": "The shortcut {hotkey} is being used by another application. Change it in Settings → Shortcuts.", + "@linuxHotkeyGrabFailedWarning": { + "description": "Shown when XGrabKey fails because another app already owns the shortcut", + "placeholders": { + "hotkey": { "type": "String" } + } + }, + + "linuxAppindicatorBannerTitle": "System tray icon unavailable", + "@linuxAppindicatorBannerTitle": { "description": "Title of the AppIndicator missing banner" }, + + "linuxAppindicatorBannerBody": "Your desktop does not expose an AppIndicator host, so the CopyPaste tray icon will not appear. Install a tray extension for your distribution and restart CopyPaste.", + "@linuxAppindicatorBannerBody": { "description": "Body of the AppIndicator missing banner" }, + + "linuxClipboardManagerBannerTitle": "No clipboard manager detected", + "@linuxClipboardManagerBannerTitle": { "description": "Title of the missing clipboard manager banner" }, + + "linuxClipboardManagerBannerBody": "Without a clipboard manager (gpaste, klipper, clipman, copyq, …) clipboard data may be lost when CopyPaste quits. Install one and restart your session.", + "@linuxClipboardManagerBannerBody": { "description": "Body of the missing clipboard manager banner" }, + + "linuxXtestBannerTitle": "Automatic paste-back disabled", + "@linuxXtestBannerTitle": { "description": "Title of the missing XTest banner" }, + + "linuxXtestBannerBody": "The X11 XTest extension is not available, so CopyPaste cannot inject Ctrl+V automatically. Items are still copied to the clipboard — paste manually with Ctrl+V.", + "@linuxXtestBannerBody": { "description": "Body of the missing XTest banner" }, + + "linuxBannerDismiss": "Dismiss", + "@linuxBannerDismiss": { "description": "Action to dismiss a Linux capability banner" }, + "wakeupHint": "CopyPaste runs in the background — press {hotkey} or click the tray icon to open it anytime.", "@wakeupHint": { "description": "In-app snackbar shown inside the window when it is raised by a second launch attempt", diff --git a/app/lib/l10n/app_es.arb b/app/lib/l10n/app_es.arb index f3a544f4..29f9369f 100644 --- a/app/lib/l10n/app_es.arb +++ b/app/lib/l10n/app_es.arb @@ -247,6 +247,14 @@ "waylandUnsupportedClose": "Cerrar", "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.", + "linuxHotkeyGrabFailedWarning": "El atajo {hotkey} est\u00e1 siendo usado por otra aplicaci\u00f3n. C\u00e1mbialo en Configuraci\u00f3n \u2192 Atajos.", + "linuxAppindicatorBannerTitle": "\u00cdcono de bandeja no disponible", + "linuxAppindicatorBannerBody": "Tu escritorio no expone un host de AppIndicator, por lo que el \u00edcono de CopyPaste no aparecer\u00e1 en la bandeja. Instala una extensi\u00f3n de bandeja para tu distribuci\u00f3n y reinicia CopyPaste.", + "linuxClipboardManagerBannerTitle": "No se detect\u00f3 un gestor de portapapeles", + "linuxClipboardManagerBannerBody": "Sin un gestor de portapapeles (gpaste, klipper, clipman, copyq, \u2026) los datos copiados pueden perderse cuando CopyPaste se cierra. Instala uno y reinicia tu sesi\u00f3n.", + "linuxXtestBannerTitle": "Pegado autom\u00e1tico deshabilitado", + "linuxXtestBannerBody": "La extensi\u00f3n XTest de X11 no est\u00e1 disponible, por lo que CopyPaste no puede inyectar Ctrl+V autom\u00e1ticamente. Los elementos siguen copi\u00e1ndose al portapapeles \u2014 p\u00e9galos manualmente con Ctrl+V.", + "linuxBannerDismiss": "Descartar", "wakeupHint": "CopyPaste se ejecuta en segundo plano \u2014 presiona {hotkey} o haz clic en el \u00edcono de la bandeja para abrirlo cuando quieras.", diff --git a/app/lib/l10n/app_localizations.dart b/app/lib/l10n/app_localizations.dart index 8cc5961a..a0876d62 100644 --- a/app/lib/l10n/app_localizations.dart +++ b/app/lib/l10n/app_localizations.dart @@ -1346,6 +1346,54 @@ abstract class AppLocalizations { /// **'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); + /// Shown when XGrabKey fails because another app already owns the shortcut + /// + /// In en, this message translates to: + /// **'The shortcut {hotkey} is being used by another application. Change it in Settings → Shortcuts.'** + String linuxHotkeyGrabFailedWarning(String hotkey); + + /// Title of the AppIndicator missing banner + /// + /// In en, this message translates to: + /// **'System tray icon unavailable'** + String get linuxAppindicatorBannerTitle; + + /// Body of the AppIndicator missing banner + /// + /// In en, this message translates to: + /// **'Your desktop does not expose an AppIndicator host, so the CopyPaste tray icon will not appear. Install a tray extension for your distribution and restart CopyPaste.'** + String get linuxAppindicatorBannerBody; + + /// Title of the missing clipboard manager banner + /// + /// In en, this message translates to: + /// **'No clipboard manager detected'** + String get linuxClipboardManagerBannerTitle; + + /// Body of the missing clipboard manager banner + /// + /// In en, this message translates to: + /// **'Without a clipboard manager (gpaste, klipper, clipman, copyq, …) clipboard data may be lost when CopyPaste quits. Install one and restart your session.'** + String get linuxClipboardManagerBannerBody; + + /// Title of the missing XTest banner + /// + /// In en, this message translates to: + /// **'Automatic paste-back disabled'** + String get linuxXtestBannerTitle; + + /// Body of the missing XTest banner + /// + /// In en, this message translates to: + /// **'The X11 XTest extension is not available, so CopyPaste cannot inject Ctrl+V automatically. Items are still copied to the clipboard — paste manually with Ctrl+V.'** + String get linuxXtestBannerBody; + + /// Action to dismiss a Linux capability banner + /// + /// In en, this message translates to: + /// **'Dismiss'** + String get linuxBannerDismiss; + /// In-app snackbar shown inside the window when it is raised by a second launch attempt /// /// 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 36ae0039..829ef7e5 100644 --- a/app/lib/l10n/app_localizations_en.dart +++ b/app/lib/l10n/app_localizations_en.dart @@ -685,6 +685,36 @@ class AppLocalizationsEn extends AppLocalizations { return 'The shortcut $requested is unavailable on this X11 desktop, and the temporary fallback $fallback also failed. Open Settings to choose another shortcut.'; } + @override + String linuxHotkeyGrabFailedWarning(String hotkey) { + return 'The shortcut $hotkey is being used by another application. Change it in Settings → Shortcuts.'; + } + + @override + String get linuxAppindicatorBannerTitle => 'System tray icon unavailable'; + + @override + String get linuxAppindicatorBannerBody => + 'Your desktop does not expose an AppIndicator host, so the CopyPaste tray icon will not appear. Install a tray extension for your distribution and restart CopyPaste.'; + + @override + String get linuxClipboardManagerBannerTitle => + 'No clipboard manager detected'; + + @override + String get linuxClipboardManagerBannerBody => + 'Without a clipboard manager (gpaste, klipper, clipman, copyq, …) clipboard data may be lost when CopyPaste quits. Install one and restart your session.'; + + @override + String get linuxXtestBannerTitle => 'Automatic paste-back disabled'; + + @override + String get linuxXtestBannerBody => + 'The X11 XTest extension is not available, so CopyPaste cannot inject Ctrl+V automatically. Items are still copied to the clipboard — paste manually with Ctrl+V.'; + + @override + String get linuxBannerDismiss => 'Dismiss'; + @override String wakeupHint(String hotkey) { return 'CopyPaste runs in the background — press $hotkey or click the tray icon to open it anytime.'; diff --git a/app/lib/l10n/app_localizations_es.dart b/app/lib/l10n/app_localizations_es.dart index 4b929b84..c78c498a 100644 --- a/app/lib/l10n/app_localizations_es.dart +++ b/app/lib/l10n/app_localizations_es.dart @@ -689,6 +689,36 @@ class AppLocalizationsEs extends AppLocalizations { 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.'; } + @override + String linuxHotkeyGrabFailedWarning(String hotkey) { + return 'El atajo $hotkey está siendo usado por otra aplicación. Cámbialo en Configuración → Atajos.'; + } + + @override + String get linuxAppindicatorBannerTitle => 'Ícono de bandeja no disponible'; + + @override + String get linuxAppindicatorBannerBody => + 'Tu escritorio no expone un host de AppIndicator, por lo que el ícono de CopyPaste no aparecerá en la bandeja. Instala una extensión de bandeja para tu distribución y reinicia CopyPaste.'; + + @override + String get linuxClipboardManagerBannerTitle => + 'No se detectó un gestor de portapapeles'; + + @override + String get linuxClipboardManagerBannerBody => + 'Sin un gestor de portapapeles (gpaste, klipper, clipman, copyq, …) los datos copiados pueden perderse cuando CopyPaste se cierra. Instala uno y reinicia tu sesión.'; + + @override + String get linuxXtestBannerTitle => 'Pegado automático deshabilitado'; + + @override + String get linuxXtestBannerBody => + 'La extensión XTest de X11 no está disponible, por lo que CopyPaste no puede inyectar Ctrl+V automáticamente. Los elementos siguen copiándose al portapapeles — pégalos manualmente con Ctrl+V.'; + + @override + String get linuxBannerDismiss => 'Descartar'; + @override String wakeupHint(String hotkey) { return 'CopyPaste se ejecuta en segundo plano — presiona $hotkey o haz clic en el ícono de la bandeja para abrirlo cuando quieras.'; diff --git a/app/lib/main.dart b/app/lib/main.dart index 6ffe90a6..17630f1a 100644 --- a/app/lib/main.dart +++ b/app/lib/main.dart @@ -461,13 +461,20 @@ class _CopyPasteAppState extends State '${result.requestedBinding.label()} -> ' '${result.effectiveBinding?.label()}', ); - _showLinuxNotice( - (l) => l.linuxHotkeyFallbackWarning( - result.requestedBinding.label(), - result.effectiveBinding?.label() ?? - kLinuxTemporaryFallbackHotkey.label(), - ), - ); + if (result.failureReason == HotkeyFailureReason.grabFailed) { + _showLinuxNotice( + (l) => + l.linuxHotkeyGrabFailedWarning(result.requestedBinding.label()), + ); + } else { + _showLinuxNotice( + (l) => l.linuxHotkeyFallbackWarning( + result.requestedBinding.label(), + result.effectiveBinding?.label() ?? + kLinuxTemporaryFallbackHotkey.label(), + ), + ); + } return; } @@ -475,12 +482,19 @@ class _CopyPasteAppState extends State AppLogger.error( 'Linux hotkey registration failed for ${result.requestedBinding.label()}', ); - _showLinuxNotice( - (l) => l.linuxHotkeyConflictWarning( - result.requestedBinding.label(), - kLinuxTemporaryFallbackHotkey.label(), - ), - ); + if (result.failureReason == HotkeyFailureReason.grabFailed) { + _showLinuxNotice( + (l) => + l.linuxHotkeyGrabFailedWarning(result.requestedBinding.label()), + ); + } else { + _showLinuxNotice( + (l) => l.linuxHotkeyConflictWarning( + result.requestedBinding.label(), + kLinuxTemporaryFallbackHotkey.label(), + ), + ); + } } } @@ -714,6 +728,14 @@ class _CopyPasteAppState extends State if (mounted) setState(() {}); } + Future _updateLinuxConfig(AppConfig Function(AppConfig) update) async { + final next = update(_config); + if (identical(next, _config)) return; + _config = next; + if (mounted) setState(() {}); + await _config.save('${widget.storage.configPath}/${AppConfig.fileName}'); + } + Future _toggleWindow() async { _programmaticRestore = true; await _appWindow.toggle(); @@ -1223,6 +1245,12 @@ class _CopyPasteAppState extends State current: AppConfig.appVersion, state: _manifestState, ), + appConfig: Platform.isLinux ? _config : null, + linuxCapabilities: Platform.isLinux + ? LinuxCapabilitiesService.current + : null, + onLinuxConfigUpdate: + Platform.isLinux ? _updateLinuxConfig : null, ); }, ), diff --git a/app/lib/screens/linux_capabilities_banner.dart b/app/lib/screens/linux_capabilities_banner.dart new file mode 100644 index 00000000..a5f711bd --- /dev/null +++ b/app/lib/screens/linux_capabilities_banner.dart @@ -0,0 +1,131 @@ +import 'package:core/core.dart'; +import 'package:flutter/material.dart'; + +import '../l10n/app_localizations.dart'; +import '../services/linux_capabilities.dart'; +import '../theme/theme_provider.dart'; + +typedef LinuxBannerDismissCallback = Future Function( + AppConfig Function(AppConfig) update, +); + +class LinuxCapabilitiesBanner extends StatelessWidget { + const LinuxCapabilitiesBanner({ + super.key, + required this.config, + required this.capabilities, + required this.onDismiss, + }); + + final AppConfig config; + final LinuxCapabilities capabilities; + final LinuxBannerDismissCallback onDismiss; + + _BannerKind? _resolveActiveBanner() { + if (!capabilities.isUsable) return null; + if (!capabilities.hasAppIndicator && + !config.linuxAppindicatorWarningDismissed) { + return _BannerKind.appIndicator; + } + if (!capabilities.hasXTest && !config.linuxXtestWarningDismissed) { + return _BannerKind.xtest; + } + if (!capabilities.hasClipboardManager && + !config.linuxClipboardManagerWarningDismissed) { + return _BannerKind.clipboardManager; + } + return null; + } + + @override + Widget build(BuildContext context) { + final kind = _resolveActiveBanner(); + if (kind == null) return const SizedBox.shrink(); + + final l = AppLocalizations.of(context); + final colors = CopyPasteTheme.colorsOf(context); + final (title, body) = switch (kind) { + _BannerKind.appIndicator => ( + l.linuxAppindicatorBannerTitle, + l.linuxAppindicatorBannerBody, + ), + _BannerKind.xtest => ( + l.linuxXtestBannerTitle, + l.linuxXtestBannerBody, + ), + _BannerKind.clipboardManager => ( + l.linuxClipboardManagerBannerTitle, + l.linuxClipboardManagerBannerBody, + ), + }; + + return Container( + padding: const EdgeInsets.symmetric(horizontal: 14, vertical: 8), + color: colors.primary.withValues(alpha: 0.10), + child: Row( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Icon( + Icons.warning_amber_rounded, + size: 16, + color: colors.primary, + ), + const SizedBox(width: 10), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + title, + style: TextStyle( + fontSize: 12, + fontWeight: FontWeight.w600, + color: colors.onSurface, + ), + ), + const SizedBox(height: 2), + Text( + body, + style: TextStyle( + fontSize: 11, + color: colors.onSurfaceMuted, + ), + ), + ], + ), + ), + const SizedBox(width: 8), + GestureDetector( + onTap: () => _dismiss(kind), + child: MouseRegion( + cursor: SystemMouseCursors.click, + child: Tooltip( + message: l.linuxBannerDismiss, + child: Icon( + Icons.close_rounded, + size: 16, + color: colors.onSurfaceMuted, + ), + ), + ), + ), + ], + ), + ); + } + + Future _dismiss(_BannerKind kind) { + return onDismiss((c) { + switch (kind) { + case _BannerKind.appIndicator: + return c.copyWith(linuxAppindicatorWarningDismissed: true); + case _BannerKind.xtest: + return c.copyWith(linuxXtestWarningDismissed: true); + case _BannerKind.clipboardManager: + return c.copyWith(linuxClipboardManagerWarningDismissed: true); + } + }); + } +} + +enum _BannerKind { appIndicator, xtest, clipboardManager } diff --git a/app/lib/screens/main_screen.dart b/app/lib/screens/main_screen.dart index 6c46ee96..e5b65f8e 100644 --- a/app/lib/screens/main_screen.dart +++ b/app/lib/screens/main_screen.dart @@ -1,843 +1,859 @@ -import 'dart:async'; -import 'dart:io'; - -import 'package:core/core.dart'; -import 'package:flutter/material.dart'; -import 'package:flutter/services.dart'; - -import '../helpers/url_helper.dart'; -import '../l10n/app_localizations.dart'; -import '../services/auto_update_service.dart'; -import '../services/release_manifest_service.dart'; -import '../theme/app_theme_data.dart'; -import '../theme/theme_provider.dart'; -import '../widgets/clipboard_card.dart'; -import '../widgets/empty_state.dart'; -import '../widgets/filter_bar.dart'; -import '../widgets/filter_tab_bar.dart'; -import '../widgets/label_color_dialog.dart'; -import '../widgets/title_bar.dart'; - -enum ClipboardTab { recent, pinned } - -class MainScreen extends StatefulWidget { - const MainScreen({ - required this.clipboardService, - required this.onPaste, - required this.onPastePlain, - required this.onExit, - required this.onSettings, - this.resetScrollOnShow = true, - this.resetSearchOnShow = true, - this.resetFiltersOnShow = true, - this.cardMinLines = 2, - this.cardMaxLines = 5, - this.colorLabels = const {}, - this.showHint = false, - this.onDismissHint, - this.updateVersion, - this.updateSeverity, - super.key, - }); - - final ClipboardService clipboardService; - final void Function(ClipboardItem item) onPaste; - final void Function(ClipboardItem item) onPastePlain; - final VoidCallback onExit; - final VoidCallback onSettings; - final bool resetScrollOnShow; - final bool resetSearchOnShow; - final bool resetFiltersOnShow; - final int cardMinLines; - final int cardMaxLines; - final Map colorLabels; - final bool showHint; - final VoidCallback? onDismissHint; - final String? updateVersion; - final ManifestSeverity? updateSeverity; - - @override - State createState() => MainScreenState(); -} - -class MainScreenState extends State { - final _scrollController = ScrollController(); - final _searchController = TextEditingController(); - final _focusNode = FocusNode(); - final _searchFocusNode = FocusNode(); - final _filterBarKey = GlobalKey(); - final _cardKeys = {}; - - ClipboardTab _currentTab = ClipboardTab.recent; - List _items = []; - bool _loading = false; - bool _pendingReload = false; - int _selectedIndex = -1; - int _expandedIndex = -1; - Timer? _reloadDebounce; - - String _searchQuery = ''; - List _typeFilters = []; - List _colorFilters = []; - - StreamSubscription? _addedSub; - StreamSubscription? _reactivatedSub; - - static const int _pageSize = 30; - int _currentPage = 0; - bool _hasMore = true; - - bool _isFirstRender = true; - - @override - void initState() { - super.initState(); - _addedSub = widget.clipboardService.onItemAdded.listen((_) => _reload()); - _reactivatedSub = widget.clipboardService.onItemReactivated.listen( - (_) => _reload(), - ); - _searchFocusNode.onKeyEvent = _onSearchKeyEvent; - _scrollController.addListener(_onScroll); - _loadItems(); - } - - @override - void dispose() { - _addedSub?.cancel(); - _reactivatedSub?.cancel(); - _reloadDebounce?.cancel(); - _scrollController.dispose(); - _searchController.dispose(); - _focusNode.dispose(); - _searchFocusNode.dispose(); - super.dispose(); - } - - void onWindowShow() { - if (widget.resetFiltersOnShow) { - _typeFilters = []; - _colorFilters = []; - _currentTab = ClipboardTab.recent; - } - _reload(); - if (widget.resetScrollOnShow && _scrollController.hasClients) { - _scrollController.jumpTo(0); - } - if (widget.resetSearchOnShow) { - _searchController.clear(); - _searchQuery = ''; - } - _searchFocusNode.requestFocus(); - } - - void onWindowHide() { - _selectedIndex = -1; - _expandedIndex = -1; - if (_items.length > _pageSize) { - _items = _items.sublist(0, _pageSize); - _currentPage = 0; - _hasMore = true; - } - setState(() {}); - } - - Future _loadItems() async { - if (_loading) return; - _pendingReload = false; - setState(() => _loading = true); - - try { - final items = await widget.clipboardService.getHistoryAdvanced( - query: _searchQuery.isEmpty ? null : _searchQuery, - types: _typeFilters.isEmpty ? null : _typeFilters, - colors: _colorFilters.isEmpty ? null : _colorFilters, - isPinned: _currentTab == ClipboardTab.pinned ? true : null, - limit: _pageSize, - skip: _currentPage * _pageSize, - ); - - setState(() { - if (_currentPage == 0) { - _items = items; - final activeIds = items.map((e) => e.id).toSet(); - _cardKeys.removeWhere((id, _) => !activeIds.contains(id)); - } else { - _items.addAll(items); - } - _hasMore = items.length >= _pageSize; - _loading = false; - }); - } catch (e) { - AppLogger.error('Failed to load items: $e'); - setState(() => _loading = false); - } - - if (_pendingReload) { - _currentPage = 0; - _hasMore = true; - _pendingReload = false; - setState(() {}); - await _loadItems(); - } - } - - void _reload() { - _reloadDebounce?.cancel(); - _reloadDebounce = Timer(const Duration(milliseconds: 80), () { - if (_loading) { - _pendingReload = true; - return; - } - _currentPage = 0; - _hasMore = true; - _loadItems(); - }); - } - - void _onScroll() { - if (!_hasMore || _loading) return; - final max = _scrollController.position.maxScrollExtent; - if (_scrollController.offset >= max - 100) { - _currentPage++; - _loadItems(); - } - } - - void _onSearchChanged(String query) { - _searchQuery = query; - _selectedIndex = -1; - _reload(); - } - - void _onTabChanged(ClipboardTab tab) { - if (_currentTab == tab) return; - setState(() { - _currentTab = tab; - _selectedIndex = -1; - }); - _reload(); - } - - void _onTypeFilterChanged(List types) { - _typeFilters = types; - _selectedIndex = -1; - _reload(); - } - - void _onColorFilterChanged(List colors) { - _colorFilters = colors; - _selectedIndex = -1; - _reload(); - } - - void _clearFilters() { - _typeFilters = []; - _colorFilters = []; - _searchController.clear(); - _searchQuery = ''; - _selectedIndex = -1; - _reload(); - } - - Future _onItemTap(ClipboardItem item) async { - widget.onPaste(item); - } - - Future _onItemPin(ClipboardItem item) async { - await widget.clipboardService.updatePin(item.id, !item.isPinned); - _reload(); - } - - Future _onItemDelete(ClipboardItem item) async { - await widget.clipboardService.removeItem(item.id); - _reload(); - } - - Future _onItemOpen(ClipboardItem item) async { - bool opened = false; - try { - switch (item.type) { - case ClipboardContentType.image: - opened = await _openImageInTemp(item); - case ClipboardContentType.file: - case ClipboardContentType.folder: - case ClipboardContentType.audio: - case ClipboardContentType.video: - await UrlHelper.open(item.content.split('\n').first.trim()); - opened = true; - case ClipboardContentType.link: - await UrlHelper.open(item.content.trim()); - opened = true; - case ClipboardContentType.email: - await UrlHelper.open('mailto:${item.content.trim()}'); - opened = true; - case ClipboardContentType.phone: - await UrlHelper.open('tel:${item.content.trim()}'); - opened = true; - default: - break; - } - } catch (_) {} - if (opened) { - await widget.clipboardService.recordPaste(item.id); - _reload(); - } - } - - Future _openImageInTemp(ClipboardItem item) async { - final src = File(item.content); - if (!src.existsSync()) return false; - final name = item.content.split(Platform.pathSeparator).last; - final tmp = await Directory.systemTemp.createTemp('copypaste_'); - final dest = File('${tmp.path}${Platform.pathSeparator}$name'); - await src.copy(dest.path); - await UrlHelper.open(dest.path); - return true; - } - - Future _onItemLabelColor( - ClipboardItem item, - String? label, - CardColor color, - ) async { - await widget.clipboardService.updateLabelAndColor(item.id, label, color); - _reload(); - } - - KeyEventResult _onSearchKeyEvent(FocusNode node, KeyEvent event) { - if (event is! KeyDownEvent && event is! KeyRepeatEvent) { - return KeyEventResult.ignored; - } - if (event.logicalKey == LogicalKeyboardKey.arrowDown && _items.isNotEmpty) { - setState(() => _selectedIndex = 0); - _focusNode.requestFocus(); - _ensureVisible(0); - return KeyEventResult.handled; - } - return KeyEventResult.ignored; - } - - KeyEventResult _onKeyEvent(FocusNode node, KeyEvent event) { - if (event is! KeyDownEvent && event is! KeyRepeatEvent) { - return KeyEventResult.ignored; - } - - final key = event.logicalKey; - final ctrl = - HardwareKeyboard.instance.isControlPressed || - (Platform.isMacOS && HardwareKeyboard.instance.isMetaPressed); - final alt = HardwareKeyboard.instance.isAltPressed; - - if (key == LogicalKeyboardKey.escape) { - if (_searchQuery.isNotEmpty || - _typeFilters.isNotEmpty || - _colorFilters.isNotEmpty) { - _searchController.clear(); - _onSearchChanged(''); - _clearFilters(); - return KeyEventResult.handled; - } - widget.onExit(); - return KeyEventResult.handled; - } - - if (alt && key == LogicalKeyboardKey.keyC) { - _searchFocusNode.requestFocus(); - setState(() => _selectedIndex = -1); - return KeyEventResult.handled; - } - - if (alt && - (key == LogicalKeyboardKey.keyG || key == LogicalKeyboardKey.keyT)) { - _filterBarKey.currentState?.openMenu(); - return KeyEventResult.handled; - } - - if (ctrl && key == LogicalKeyboardKey.digit1) { - _onTabChanged(ClipboardTab.recent); - return KeyEventResult.handled; - } - - if (ctrl && key == LogicalKeyboardKey.digit2) { - _onTabChanged(ClipboardTab.pinned); - return KeyEventResult.handled; - } - - if (key == LogicalKeyboardKey.tab && - HardwareKeyboard.instance.isShiftPressed) { - _searchFocusNode.requestFocus(); - setState(() => _selectedIndex = -1); - return KeyEventResult.handled; - } - - if (key == LogicalKeyboardKey.arrowDown) { - if (_selectedIndex < _items.length - 1) { - setState(() => _selectedIndex++); - _ensureVisible(_selectedIndex); - } - return KeyEventResult.handled; - } - - if (key == LogicalKeyboardKey.arrowUp) { - if (_selectedIndex > 0) { - setState(() => _selectedIndex--); - _ensureVisible(_selectedIndex); - } else if (_selectedIndex == 0) { - setState(() => _selectedIndex = -1); - _searchFocusNode.requestFocus(); - } - return KeyEventResult.handled; - } - - if (key == LogicalKeyboardKey.enter && _selectedIndex >= 0) { - _onItemTap(_items[_selectedIndex]); - return KeyEventResult.handled; - } - - if (key == LogicalKeyboardKey.delete && _selectedIndex >= 0) { - _onItemDelete(_items[_selectedIndex]); - return KeyEventResult.handled; - } - - if (key == LogicalKeyboardKey.keyP && _selectedIndex >= 0) { - _onItemPin(_items[_selectedIndex]); - return KeyEventResult.handled; - } - - if (key == LogicalKeyboardKey.keyE && _selectedIndex >= 0) { - _editSelectedItem(); - return KeyEventResult.handled; - } - - if (key == LogicalKeyboardKey.arrowRight && _selectedIndex >= 0) { - setState(() { - _expandedIndex = _expandedIndex == _selectedIndex ? -1 : _selectedIndex; - }); - return KeyEventResult.handled; - } - - return KeyEventResult.ignored; - } - - void _editSelectedItem() { - if (_selectedIndex < 0 || _selectedIndex >= _items.length) return; - final item = _items[_selectedIndex]; - _showEditDialog(item); - } - - Future _showEditDialog(ClipboardItem item) async { - if (!mounted) return; - final result = await LabelColorDialog.show( - context, - currentLabel: item.label, - currentColor: item.cardColor, - ); - if (result != null) { - await _onItemLabelColor(item, result.label, result.color); - } - } - - void _ensureVisible(int index) { - if (index < 0 || index >= _items.length) return; - final item = _items[index]; - WidgetsBinding.instance.addPostFrameCallback((_) { - final ctx = _cardKeys[item.id]?.currentContext; - if (ctx != null) { - Scrollable.ensureVisible( - ctx, - duration: const Duration(milliseconds: 120), - curve: Curves.easeOut, - ); - } - }); - } - - bool get _isEmpty => _items.isEmpty && !_loading; - - @override - Widget build(BuildContext context) { - final colors = CopyPasteTheme.colorsOf(context); - final theme = CopyPasteTheme.of(context); - final hasColorFilters = _colorFilters.isNotEmpty; - - return Focus( - focusNode: _focusNode, - onKeyEvent: _onKeyEvent, - descendantsAreTraversable: false, - child: Column( - children: [ - TitleBar( - searchController: _searchController, - searchFocusNode: _searchFocusNode, - onSearchChanged: _onSearchChanged, - trailing: FilterBar( - key: _filterBarKey, - selectedTypes: _typeFilters, - selectedColors: _colorFilters, - colorLabels: widget.colorLabels, - onTypesChanged: _onTypeFilterChanged, - onColorsChanged: _onColorFilterChanged, - onClear: hasColorFilters ? _clearFilters : null, - ), - ), - FilterTabBar( - selectedTypes: _typeFilters, - onTypesChanged: _onTypeFilterChanged, - isPinnedMode: _currentTab == ClipboardTab.pinned, - onPinnedModeChanged: (pinned) { - _onTabChanged(pinned ? ClipboardTab.pinned : ClipboardTab.recent); - }, - ), - if (widget.showHint) _buildHintBanner(colors), - Expanded( - child: _isEmpty - ? const EmptyState() - : _buildRealList(theme, _items), - ), - Divider(height: 1, thickness: 0.5, color: colors.divider), - _buildBottomBar(theme, colors), - ], - ), - ); - } - - Widget _buildHintBanner(AppThemeColorScheme colors) { - final l = AppLocalizations.of(context); - return Container( - padding: const EdgeInsets.symmetric(horizontal: 14, vertical: 8), - color: colors.primary.withValues(alpha: 0.06), - child: Row( - children: [ - Icon( - Icons.lightbulb_outline_rounded, - size: 14, - color: colors.primary, - ), - const SizedBox(width: 8), - Expanded( - child: Text.rich( - TextSpan( - children: [ - TextSpan( - text: l.hintBannerText, - style: TextStyle(fontSize: 11, color: colors.onSurface), - ), - const TextSpan(text: ' '), - WidgetSpan( - alignment: PlaceholderAlignment.baseline, - baseline: TextBaseline.alphabetic, - child: GestureDetector( - onTap: () { - widget.onDismissHint?.call(); - widget.onSettings(); - }, - child: MouseRegion( - cursor: SystemMouseCursors.click, - child: Text( - l.hintBannerAction, - style: TextStyle( - fontSize: 11, - color: colors.primary, - decoration: TextDecoration.underline, - decorationColor: colors.primary.withValues( - alpha: 0.5, - ), - ), - ), - ), - ), - ), - ], - ), - ), - ), - GestureDetector( - onTap: widget.onDismissHint, - child: Icon( - Icons.close_rounded, - size: 14, - color: colors.onSurfaceMuted, - ), - ), - ], - ), - ); - } - - Widget _buildRealList(AppThemeData theme, List items) { - final animate = _isFirstRender; - if (_isFirstRender) { - _isFirstRender = false; - } - return ListView.builder( - controller: _scrollController, - padding: theme.spacing.listPadding.copyWith(top: 6, bottom: 8), - itemCount: items.length, - itemBuilder: (context, index) { - final item = items[index]; - final cardKey = _cardKeys.putIfAbsent(item.id, GlobalKey.new); - final card = Padding( - key: cardKey, - padding: EdgeInsets.only(bottom: theme.spacing.cardGap), - child: ClipboardCard( - item: item, - isSelected: index == _selectedIndex, - isExpanded: index == _expandedIndex, - cardMinLines: widget.cardMinLines, - cardMaxLines: widget.cardMaxLines, - onTap: () => _onItemTap(item), - onPin: () => _onItemPin(item), - onDelete: () => _onItemDelete(item), - onLabelColor: (label, color) => - _onItemLabelColor(item, label, color), - onPastePlain: () => widget.onPastePlain(item), - onOpen: () => _onItemOpen(item), - onRequestThumbnailRefresh: - widget.clipboardService.requestThumbnailIfStale, - onSelect: () { - setState(() => _selectedIndex = index); - _focusNode.requestFocus(); - }, - onExpandToggle: () { - setState(() { - _expandedIndex = _expandedIndex == index ? -1 : index; - }); - }, - ), - ); - - if (animate && index < _pageSize) { - return _StaggeredFadeIn(index: index, child: card); - } - return card; - }, - ); - } - - Widget _buildBottomBar(AppThemeData theme, AppThemeColorScheme colors) { - final l = AppLocalizations.of(context); - final updateVersion = widget.updateVersion; - final severity = widget.updateSeverity; - final isImportant = severity != null && severity != ManifestSeverity.patch; - final badgeColor = isImportant ? colors.accentRed : colors.primary; - final badgeText = isImportant - ? l.updateBadgeImportant(updateVersion ?? '') - : l.updateBadge(updateVersion ?? ''); - - return Container( - height: theme.spacing.bottomBarHeight, - padding: const EdgeInsets.symmetric(horizontal: 14), - child: Row( - children: [ - if (updateVersion != null) - Tooltip( - message: AutoUpdateService.isStoreBuild - ? l.updateTooltipStore(updateVersion) - : l.updateTooltipGeneric(updateVersion), - child: InkWell( - borderRadius: BorderRadius.circular(4), - onTap: () => _showUpdateDialog(context, updateVersion), - child: Row( - mainAxisSize: MainAxisSize.min, - children: [ - Icon( - Icons.system_update_outlined, - size: 13, - color: badgeColor, - ), - const SizedBox(width: 5), - Text( - badgeText, - style: theme.typography.branding.copyWith( - color: badgeColor, - letterSpacing: 0.3, - ), - ), - ], - ), - ), - ) - else - Opacity( - opacity: 0.35, - child: Row( - mainAxisSize: MainAxisSize.min, - children: [ - Image.asset( - 'assets/icons/icon_notification.png', - width: 12, - height: 12, - color: colors.onSurface, - colorBlendMode: BlendMode.srcIn, - ), - const SizedBox(width: 5), - Text( - 'CopyPaste', - style: theme.typography.branding.copyWith( - color: colors.onSurface, - letterSpacing: 0.3, - ), - ), - ], - ), - ), - const Spacer(), - _BottomBarAction( - icon: Icons.bug_report_outlined, - iconSize: 14, - opacity: 0.4, - onTap: () => - UrlHelper.open('https://github.com/rgdevment/CopyPaste/issues'), - ), - const SizedBox(width: 2), - _BottomBarAction( - icon: theme.icons.settings, - iconSize: 14, - opacity: 0.4, - onTap: widget.onSettings, - ), - ], - ), - ); - } - - void _showUpdateDialog(BuildContext context, String version) { - final l = AppLocalizations.of(context); - showDialog( - context: context, - builder: (dialogCtx) => AlertDialog( - title: Text(l.updateDialogTitle), - content: SizedBox( - width: double.maxFinite, - child: Text( - AutoUpdateService.isStoreBuild - ? l.updateAvailableStore(version) - : Platform.isMacOS - ? l.updateAvailableMac(version) - : Platform.isLinux - ? l.updateAvailableLinux(version) - : l.updateAvailableWindows(version), - ), - ), - actionsOverflowButtonSpacing: 8, - actions: [ - TextButton( - onPressed: () => Navigator.of(dialogCtx).pop(), - child: Text(l.updateDismiss), - ), - if (!AutoUpdateService.isStoreBuild) - FilledButton( - onPressed: () { - Navigator.of(dialogCtx).pop(); - UrlHelper.open( - 'https://github.com/rgdevment/CopyPaste/releases/latest', - ); - }, - child: Text(l.updateViewRelease), - ), - ], - ), - ); - } -} - -class _StaggeredFadeIn extends StatefulWidget { - const _StaggeredFadeIn({required this.index, required this.child}); - - final int index; - final Widget child; - - @override - State<_StaggeredFadeIn> createState() => _StaggeredFadeInState(); -} - -class _StaggeredFadeInState extends State<_StaggeredFadeIn> - with SingleTickerProviderStateMixin { - late final AnimationController _controller; - late final Animation _opacity; - late final Animation _offset; - Timer? _delayTimer; - - @override - void initState() { - super.initState(); - _controller = AnimationController( - vsync: this, - duration: const Duration(milliseconds: 80), - ); - _opacity = CurvedAnimation(parent: _controller, curve: Curves.easeOut); - _offset = Tween( - begin: const Offset(0, -4), - end: Offset.zero, - ).animate(CurvedAnimation(parent: _controller, curve: Curves.easeOut)); - - _delayTimer = Timer(Duration(milliseconds: 20 * widget.index), () { - if (mounted) _controller.forward(); - }); - } - - @override - void dispose() { - _delayTimer?.cancel(); - _controller.dispose(); - super.dispose(); - } - - @override - Widget build(BuildContext context) { - return AnimatedBuilder( - animation: _controller, - builder: (context, child) { - return Transform.translate( - offset: _offset.value, - child: Opacity(opacity: _opacity.value, child: child), - ); - }, - child: widget.child, - ); - } -} - -class _BottomBarAction extends StatefulWidget { - const _BottomBarAction({ - required this.icon, - required this.iconSize, - required this.opacity, - required this.onTap, - }); - - final IconData icon; - final double iconSize; - final double opacity; - final VoidCallback onTap; - - @override - State<_BottomBarAction> createState() => _BottomBarActionState(); -} - -class _BottomBarActionState extends State<_BottomBarAction> { - bool _hovering = false; - - @override - Widget build(BuildContext context) { - final colors = CopyPasteTheme.colorsOf(context); - - return MouseRegion( - onEnter: (_) => setState(() => _hovering = true), - onExit: (_) => setState(() => _hovering = false), - child: GestureDetector( - onTap: widget.onTap, - child: Padding( - padding: const EdgeInsets.symmetric(horizontal: 4, vertical: 2), - child: Icon( - widget.icon, - size: widget.iconSize, - color: colors.onSurface.withValues( - alpha: _hovering ? widget.opacity + 0.25 : widget.opacity, - ), - ), - ), - ), - ); - } -} +import 'dart:async'; +import 'dart:io'; + +import 'package:core/core.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter/services.dart'; + +import '../helpers/url_helper.dart'; +import '../l10n/app_localizations.dart'; +import '../services/auto_update_service.dart'; +import '../services/linux_capabilities.dart'; +import '../services/release_manifest_service.dart'; +import '../theme/app_theme_data.dart'; +import '../theme/theme_provider.dart'; +import '../widgets/clipboard_card.dart'; +import '../widgets/empty_state.dart'; +import '../widgets/filter_bar.dart'; +import '../widgets/filter_tab_bar.dart'; +import '../widgets/label_color_dialog.dart'; +import '../widgets/title_bar.dart'; +import 'linux_capabilities_banner.dart'; + +enum ClipboardTab { recent, pinned } + +class MainScreen extends StatefulWidget { + const MainScreen({ + required this.clipboardService, + required this.onPaste, + required this.onPastePlain, + required this.onExit, + required this.onSettings, + this.resetScrollOnShow = true, + this.resetSearchOnShow = true, + this.resetFiltersOnShow = true, + this.cardMinLines = 2, + this.cardMaxLines = 5, + this.colorLabels = const {}, + this.showHint = false, + this.onDismissHint, + this.updateVersion, + this.updateSeverity, + this.appConfig, + this.linuxCapabilities, + this.onLinuxConfigUpdate, + super.key, + }); + + final ClipboardService clipboardService; + final void Function(ClipboardItem item) onPaste; + final void Function(ClipboardItem item) onPastePlain; + final VoidCallback onExit; + final VoidCallback onSettings; + final bool resetScrollOnShow; + final bool resetSearchOnShow; + final bool resetFiltersOnShow; + final int cardMinLines; + final int cardMaxLines; + final Map colorLabels; + final bool showHint; + final VoidCallback? onDismissHint; + final String? updateVersion; + final ManifestSeverity? updateSeverity; + final AppConfig? appConfig; + final LinuxCapabilities? linuxCapabilities; + final Future Function(AppConfig Function(AppConfig))? onLinuxConfigUpdate; + + @override + State createState() => MainScreenState(); +} + +class MainScreenState extends State { + final _scrollController = ScrollController(); + final _searchController = TextEditingController(); + final _focusNode = FocusNode(); + final _searchFocusNode = FocusNode(); + final _filterBarKey = GlobalKey(); + final _cardKeys = {}; + + ClipboardTab _currentTab = ClipboardTab.recent; + List _items = []; + bool _loading = false; + bool _pendingReload = false; + int _selectedIndex = -1; + int _expandedIndex = -1; + Timer? _reloadDebounce; + + String _searchQuery = ''; + List _typeFilters = []; + List _colorFilters = []; + + StreamSubscription? _addedSub; + StreamSubscription? _reactivatedSub; + + static const int _pageSize = 30; + int _currentPage = 0; + bool _hasMore = true; + + bool _isFirstRender = true; + + @override + void initState() { + super.initState(); + _addedSub = widget.clipboardService.onItemAdded.listen((_) => _reload()); + _reactivatedSub = widget.clipboardService.onItemReactivated.listen( + (_) => _reload(), + ); + _searchFocusNode.onKeyEvent = _onSearchKeyEvent; + _scrollController.addListener(_onScroll); + _loadItems(); + } + + @override + void dispose() { + _addedSub?.cancel(); + _reactivatedSub?.cancel(); + _reloadDebounce?.cancel(); + _scrollController.dispose(); + _searchController.dispose(); + _focusNode.dispose(); + _searchFocusNode.dispose(); + super.dispose(); + } + + void onWindowShow() { + if (widget.resetFiltersOnShow) { + _typeFilters = []; + _colorFilters = []; + _currentTab = ClipboardTab.recent; + } + _reload(); + if (widget.resetScrollOnShow && _scrollController.hasClients) { + _scrollController.jumpTo(0); + } + if (widget.resetSearchOnShow) { + _searchController.clear(); + _searchQuery = ''; + } + _searchFocusNode.requestFocus(); + } + + void onWindowHide() { + _selectedIndex = -1; + _expandedIndex = -1; + if (_items.length > _pageSize) { + _items = _items.sublist(0, _pageSize); + _currentPage = 0; + _hasMore = true; + } + setState(() {}); + } + + Future _loadItems() async { + if (_loading) return; + _pendingReload = false; + setState(() => _loading = true); + + try { + final items = await widget.clipboardService.getHistoryAdvanced( + query: _searchQuery.isEmpty ? null : _searchQuery, + types: _typeFilters.isEmpty ? null : _typeFilters, + colors: _colorFilters.isEmpty ? null : _colorFilters, + isPinned: _currentTab == ClipboardTab.pinned ? true : null, + limit: _pageSize, + skip: _currentPage * _pageSize, + ); + + setState(() { + if (_currentPage == 0) { + _items = items; + final activeIds = items.map((e) => e.id).toSet(); + _cardKeys.removeWhere((id, _) => !activeIds.contains(id)); + } else { + _items.addAll(items); + } + _hasMore = items.length >= _pageSize; + _loading = false; + }); + } catch (e) { + AppLogger.error('Failed to load items: $e'); + setState(() => _loading = false); + } + + if (_pendingReload) { + _currentPage = 0; + _hasMore = true; + _pendingReload = false; + setState(() {}); + await _loadItems(); + } + } + + void _reload() { + _reloadDebounce?.cancel(); + _reloadDebounce = Timer(const Duration(milliseconds: 80), () { + if (_loading) { + _pendingReload = true; + return; + } + _currentPage = 0; + _hasMore = true; + _loadItems(); + }); + } + + void _onScroll() { + if (!_hasMore || _loading) return; + final max = _scrollController.position.maxScrollExtent; + if (_scrollController.offset >= max - 100) { + _currentPage++; + _loadItems(); + } + } + + void _onSearchChanged(String query) { + _searchQuery = query; + _selectedIndex = -1; + _reload(); + } + + void _onTabChanged(ClipboardTab tab) { + if (_currentTab == tab) return; + setState(() { + _currentTab = tab; + _selectedIndex = -1; + }); + _reload(); + } + + void _onTypeFilterChanged(List types) { + _typeFilters = types; + _selectedIndex = -1; + _reload(); + } + + void _onColorFilterChanged(List colors) { + _colorFilters = colors; + _selectedIndex = -1; + _reload(); + } + + void _clearFilters() { + _typeFilters = []; + _colorFilters = []; + _searchController.clear(); + _searchQuery = ''; + _selectedIndex = -1; + _reload(); + } + + Future _onItemTap(ClipboardItem item) async { + widget.onPaste(item); + } + + Future _onItemPin(ClipboardItem item) async { + await widget.clipboardService.updatePin(item.id, !item.isPinned); + _reload(); + } + + Future _onItemDelete(ClipboardItem item) async { + await widget.clipboardService.removeItem(item.id); + _reload(); + } + + Future _onItemOpen(ClipboardItem item) async { + bool opened = false; + try { + switch (item.type) { + case ClipboardContentType.image: + opened = await _openImageInTemp(item); + case ClipboardContentType.file: + case ClipboardContentType.folder: + case ClipboardContentType.audio: + case ClipboardContentType.video: + await UrlHelper.open(item.content.split('\n').first.trim()); + opened = true; + case ClipboardContentType.link: + await UrlHelper.open(item.content.trim()); + opened = true; + case ClipboardContentType.email: + await UrlHelper.open('mailto:${item.content.trim()}'); + opened = true; + case ClipboardContentType.phone: + await UrlHelper.open('tel:${item.content.trim()}'); + opened = true; + default: + break; + } + } catch (_) {} + if (opened) { + await widget.clipboardService.recordPaste(item.id); + _reload(); + } + } + + Future _openImageInTemp(ClipboardItem item) async { + final src = File(item.content); + if (!src.existsSync()) return false; + final name = item.content.split(Platform.pathSeparator).last; + final tmp = await Directory.systemTemp.createTemp('copypaste_'); + final dest = File('${tmp.path}${Platform.pathSeparator}$name'); + await src.copy(dest.path); + await UrlHelper.open(dest.path); + return true; + } + + Future _onItemLabelColor( + ClipboardItem item, + String? label, + CardColor color, + ) async { + await widget.clipboardService.updateLabelAndColor(item.id, label, color); + _reload(); + } + + KeyEventResult _onSearchKeyEvent(FocusNode node, KeyEvent event) { + if (event is! KeyDownEvent && event is! KeyRepeatEvent) { + return KeyEventResult.ignored; + } + if (event.logicalKey == LogicalKeyboardKey.arrowDown && _items.isNotEmpty) { + setState(() => _selectedIndex = 0); + _focusNode.requestFocus(); + _ensureVisible(0); + return KeyEventResult.handled; + } + return KeyEventResult.ignored; + } + + KeyEventResult _onKeyEvent(FocusNode node, KeyEvent event) { + if (event is! KeyDownEvent && event is! KeyRepeatEvent) { + return KeyEventResult.ignored; + } + + final key = event.logicalKey; + final ctrl = + HardwareKeyboard.instance.isControlPressed || + (Platform.isMacOS && HardwareKeyboard.instance.isMetaPressed); + final alt = HardwareKeyboard.instance.isAltPressed; + + if (key == LogicalKeyboardKey.escape) { + if (_searchQuery.isNotEmpty || + _typeFilters.isNotEmpty || + _colorFilters.isNotEmpty) { + _searchController.clear(); + _onSearchChanged(''); + _clearFilters(); + return KeyEventResult.handled; + } + widget.onExit(); + return KeyEventResult.handled; + } + + if (alt && key == LogicalKeyboardKey.keyC) { + _searchFocusNode.requestFocus(); + setState(() => _selectedIndex = -1); + return KeyEventResult.handled; + } + + if (alt && + (key == LogicalKeyboardKey.keyG || key == LogicalKeyboardKey.keyT)) { + _filterBarKey.currentState?.openMenu(); + return KeyEventResult.handled; + } + + if (ctrl && key == LogicalKeyboardKey.digit1) { + _onTabChanged(ClipboardTab.recent); + return KeyEventResult.handled; + } + + if (ctrl && key == LogicalKeyboardKey.digit2) { + _onTabChanged(ClipboardTab.pinned); + return KeyEventResult.handled; + } + + if (key == LogicalKeyboardKey.tab && + HardwareKeyboard.instance.isShiftPressed) { + _searchFocusNode.requestFocus(); + setState(() => _selectedIndex = -1); + return KeyEventResult.handled; + } + + if (key == LogicalKeyboardKey.arrowDown) { + if (_selectedIndex < _items.length - 1) { + setState(() => _selectedIndex++); + _ensureVisible(_selectedIndex); + } + return KeyEventResult.handled; + } + + if (key == LogicalKeyboardKey.arrowUp) { + if (_selectedIndex > 0) { + setState(() => _selectedIndex--); + _ensureVisible(_selectedIndex); + } else if (_selectedIndex == 0) { + setState(() => _selectedIndex = -1); + _searchFocusNode.requestFocus(); + } + return KeyEventResult.handled; + } + + if (key == LogicalKeyboardKey.enter && _selectedIndex >= 0) { + _onItemTap(_items[_selectedIndex]); + return KeyEventResult.handled; + } + + if (key == LogicalKeyboardKey.delete && _selectedIndex >= 0) { + _onItemDelete(_items[_selectedIndex]); + return KeyEventResult.handled; + } + + if (key == LogicalKeyboardKey.keyP && _selectedIndex >= 0) { + _onItemPin(_items[_selectedIndex]); + return KeyEventResult.handled; + } + + if (key == LogicalKeyboardKey.keyE && _selectedIndex >= 0) { + _editSelectedItem(); + return KeyEventResult.handled; + } + + if (key == LogicalKeyboardKey.arrowRight && _selectedIndex >= 0) { + setState(() { + _expandedIndex = _expandedIndex == _selectedIndex ? -1 : _selectedIndex; + }); + return KeyEventResult.handled; + } + + return KeyEventResult.ignored; + } + + void _editSelectedItem() { + if (_selectedIndex < 0 || _selectedIndex >= _items.length) return; + final item = _items[_selectedIndex]; + _showEditDialog(item); + } + + Future _showEditDialog(ClipboardItem item) async { + if (!mounted) return; + final result = await LabelColorDialog.show( + context, + currentLabel: item.label, + currentColor: item.cardColor, + ); + if (result != null) { + await _onItemLabelColor(item, result.label, result.color); + } + } + + void _ensureVisible(int index) { + if (index < 0 || index >= _items.length) return; + final item = _items[index]; + WidgetsBinding.instance.addPostFrameCallback((_) { + final ctx = _cardKeys[item.id]?.currentContext; + if (ctx != null) { + Scrollable.ensureVisible( + ctx, + duration: const Duration(milliseconds: 120), + curve: Curves.easeOut, + ); + } + }); + } + + bool get _isEmpty => _items.isEmpty && !_loading; + + @override + Widget build(BuildContext context) { + final colors = CopyPasteTheme.colorsOf(context); + final theme = CopyPasteTheme.of(context); + final hasColorFilters = _colorFilters.isNotEmpty; + + return Focus( + focusNode: _focusNode, + onKeyEvent: _onKeyEvent, + descendantsAreTraversable: false, + child: Column( + children: [ + TitleBar( + searchController: _searchController, + searchFocusNode: _searchFocusNode, + onSearchChanged: _onSearchChanged, + trailing: FilterBar( + key: _filterBarKey, + selectedTypes: _typeFilters, + selectedColors: _colorFilters, + colorLabels: widget.colorLabels, + onTypesChanged: _onTypeFilterChanged, + onColorsChanged: _onColorFilterChanged, + onClear: hasColorFilters ? _clearFilters : null, + ), + ), + FilterTabBar( + selectedTypes: _typeFilters, + onTypesChanged: _onTypeFilterChanged, + isPinnedMode: _currentTab == ClipboardTab.pinned, + onPinnedModeChanged: (pinned) { + _onTabChanged(pinned ? ClipboardTab.pinned : ClipboardTab.recent); + }, + ), + if (widget.showHint) _buildHintBanner(colors), + if (widget.appConfig != null && + widget.linuxCapabilities != null && + widget.onLinuxConfigUpdate != null) + LinuxCapabilitiesBanner( + config: widget.appConfig!, + capabilities: widget.linuxCapabilities!, + onDismiss: widget.onLinuxConfigUpdate!, + ), + Expanded( + child: _isEmpty + ? const EmptyState() + : _buildRealList(theme, _items), + ), + Divider(height: 1, thickness: 0.5, color: colors.divider), + _buildBottomBar(theme, colors), + ], + ), + ); + } + + Widget _buildHintBanner(AppThemeColorScheme colors) { + final l = AppLocalizations.of(context); + return Container( + padding: const EdgeInsets.symmetric(horizontal: 14, vertical: 8), + color: colors.primary.withValues(alpha: 0.06), + child: Row( + children: [ + Icon( + Icons.lightbulb_outline_rounded, + size: 14, + color: colors.primary, + ), + const SizedBox(width: 8), + Expanded( + child: Text.rich( + TextSpan( + children: [ + TextSpan( + text: l.hintBannerText, + style: TextStyle(fontSize: 11, color: colors.onSurface), + ), + const TextSpan(text: ' '), + WidgetSpan( + alignment: PlaceholderAlignment.baseline, + baseline: TextBaseline.alphabetic, + child: GestureDetector( + onTap: () { + widget.onDismissHint?.call(); + widget.onSettings(); + }, + child: MouseRegion( + cursor: SystemMouseCursors.click, + child: Text( + l.hintBannerAction, + style: TextStyle( + fontSize: 11, + color: colors.primary, + decoration: TextDecoration.underline, + decorationColor: colors.primary.withValues( + alpha: 0.5, + ), + ), + ), + ), + ), + ), + ], + ), + ), + ), + GestureDetector( + onTap: widget.onDismissHint, + child: Icon( + Icons.close_rounded, + size: 14, + color: colors.onSurfaceMuted, + ), + ), + ], + ), + ); + } + + Widget _buildRealList(AppThemeData theme, List items) { + final animate = _isFirstRender; + if (_isFirstRender) { + _isFirstRender = false; + } + return ListView.builder( + controller: _scrollController, + padding: theme.spacing.listPadding.copyWith(top: 6, bottom: 8), + itemCount: items.length, + itemBuilder: (context, index) { + final item = items[index]; + final cardKey = _cardKeys.putIfAbsent(item.id, GlobalKey.new); + final card = Padding( + key: cardKey, + padding: EdgeInsets.only(bottom: theme.spacing.cardGap), + child: ClipboardCard( + item: item, + isSelected: index == _selectedIndex, + isExpanded: index == _expandedIndex, + cardMinLines: widget.cardMinLines, + cardMaxLines: widget.cardMaxLines, + onTap: () => _onItemTap(item), + onPin: () => _onItemPin(item), + onDelete: () => _onItemDelete(item), + onLabelColor: (label, color) => + _onItemLabelColor(item, label, color), + onPastePlain: () => widget.onPastePlain(item), + onOpen: () => _onItemOpen(item), + onRequestThumbnailRefresh: + widget.clipboardService.requestThumbnailIfStale, + onSelect: () { + setState(() => _selectedIndex = index); + _focusNode.requestFocus(); + }, + onExpandToggle: () { + setState(() { + _expandedIndex = _expandedIndex == index ? -1 : index; + }); + }, + ), + ); + + if (animate && index < _pageSize) { + return _StaggeredFadeIn(index: index, child: card); + } + return card; + }, + ); + } + + Widget _buildBottomBar(AppThemeData theme, AppThemeColorScheme colors) { + final l = AppLocalizations.of(context); + final updateVersion = widget.updateVersion; + final severity = widget.updateSeverity; + final isImportant = severity != null && severity != ManifestSeverity.patch; + final badgeColor = isImportant ? colors.accentRed : colors.primary; + final badgeText = isImportant + ? l.updateBadgeImportant(updateVersion ?? '') + : l.updateBadge(updateVersion ?? ''); + + return Container( + height: theme.spacing.bottomBarHeight, + padding: const EdgeInsets.symmetric(horizontal: 14), + child: Row( + children: [ + if (updateVersion != null) + Tooltip( + message: AutoUpdateService.isStoreBuild + ? l.updateTooltipStore(updateVersion) + : l.updateTooltipGeneric(updateVersion), + child: InkWell( + borderRadius: BorderRadius.circular(4), + onTap: () => _showUpdateDialog(context, updateVersion), + child: Row( + mainAxisSize: MainAxisSize.min, + children: [ + Icon( + Icons.system_update_outlined, + size: 13, + color: badgeColor, + ), + const SizedBox(width: 5), + Text( + badgeText, + style: theme.typography.branding.copyWith( + color: badgeColor, + letterSpacing: 0.3, + ), + ), + ], + ), + ), + ) + else + Opacity( + opacity: 0.35, + child: Row( + mainAxisSize: MainAxisSize.min, + children: [ + Image.asset( + 'assets/icons/icon_notification.png', + width: 12, + height: 12, + color: colors.onSurface, + colorBlendMode: BlendMode.srcIn, + ), + const SizedBox(width: 5), + Text( + 'CopyPaste', + style: theme.typography.branding.copyWith( + color: colors.onSurface, + letterSpacing: 0.3, + ), + ), + ], + ), + ), + const Spacer(), + _BottomBarAction( + icon: Icons.bug_report_outlined, + iconSize: 14, + opacity: 0.4, + onTap: () => + UrlHelper.open('https://github.com/rgdevment/CopyPaste/issues'), + ), + const SizedBox(width: 2), + _BottomBarAction( + icon: theme.icons.settings, + iconSize: 14, + opacity: 0.4, + onTap: widget.onSettings, + ), + ], + ), + ); + } + + void _showUpdateDialog(BuildContext context, String version) { + final l = AppLocalizations.of(context); + showDialog( + context: context, + builder: (dialogCtx) => AlertDialog( + title: Text(l.updateDialogTitle), + content: SizedBox( + width: double.maxFinite, + child: Text( + AutoUpdateService.isStoreBuild + ? l.updateAvailableStore(version) + : Platform.isMacOS + ? l.updateAvailableMac(version) + : Platform.isLinux + ? l.updateAvailableLinux(version) + : l.updateAvailableWindows(version), + ), + ), + actionsOverflowButtonSpacing: 8, + actions: [ + TextButton( + onPressed: () => Navigator.of(dialogCtx).pop(), + child: Text(l.updateDismiss), + ), + if (!AutoUpdateService.isStoreBuild) + FilledButton( + onPressed: () { + Navigator.of(dialogCtx).pop(); + UrlHelper.open( + 'https://github.com/rgdevment/CopyPaste/releases/latest', + ); + }, + child: Text(l.updateViewRelease), + ), + ], + ), + ); + } +} + +class _StaggeredFadeIn extends StatefulWidget { + const _StaggeredFadeIn({required this.index, required this.child}); + + final int index; + final Widget child; + + @override + State<_StaggeredFadeIn> createState() => _StaggeredFadeInState(); +} + +class _StaggeredFadeInState extends State<_StaggeredFadeIn> + with SingleTickerProviderStateMixin { + late final AnimationController _controller; + late final Animation _opacity; + late final Animation _offset; + Timer? _delayTimer; + + @override + void initState() { + super.initState(); + _controller = AnimationController( + vsync: this, + duration: const Duration(milliseconds: 80), + ); + _opacity = CurvedAnimation(parent: _controller, curve: Curves.easeOut); + _offset = Tween( + begin: const Offset(0, -4), + end: Offset.zero, + ).animate(CurvedAnimation(parent: _controller, curve: Curves.easeOut)); + + _delayTimer = Timer(Duration(milliseconds: 20 * widget.index), () { + if (mounted) _controller.forward(); + }); + } + + @override + void dispose() { + _delayTimer?.cancel(); + _controller.dispose(); + super.dispose(); + } + + @override + Widget build(BuildContext context) { + return AnimatedBuilder( + animation: _controller, + builder: (context, child) { + return Transform.translate( + offset: _offset.value, + child: Opacity(opacity: _opacity.value, child: child), + ); + }, + child: widget.child, + ); + } +} + +class _BottomBarAction extends StatefulWidget { + const _BottomBarAction({ + required this.icon, + required this.iconSize, + required this.opacity, + required this.onTap, + }); + + final IconData icon; + final double iconSize; + final double opacity; + final VoidCallback onTap; + + @override + State<_BottomBarAction> createState() => _BottomBarActionState(); +} + +class _BottomBarActionState extends State<_BottomBarAction> { + bool _hovering = false; + + @override + Widget build(BuildContext context) { + final colors = CopyPasteTheme.colorsOf(context); + + return MouseRegion( + onEnter: (_) => setState(() => _hovering = true), + onExit: (_) => setState(() => _hovering = false), + child: GestureDetector( + onTap: widget.onTap, + child: Padding( + padding: const EdgeInsets.symmetric(horizontal: 4, vertical: 2), + child: Icon( + widget.icon, + size: widget.iconSize, + color: colors.onSurface.withValues( + alpha: _hovering ? widget.opacity + 0.25 : widget.opacity, + ), + ), + ), + ), + ); + } +} diff --git a/app/lib/shell/linux_shell.dart b/app/lib/shell/linux_shell.dart index 02aec0cb..77cc58bc 100644 --- a/app/lib/shell/linux_shell.dart +++ b/app/lib/shell/linux_shell.dart @@ -40,34 +40,54 @@ class LinuxShell { _eventsController = null; } - static Future initTray({ + static Future initTray({ required String iconPath, required String showHideLabel, required String exitLabel, required String tooltip, }) async { - final result = await _methodChannel.invokeMethod('initTray', { + return _invokeTrayMethod('initTray', { 'iconPath': iconPath, 'showHideLabel': showHideLabel, 'exitLabel': exitLabel, 'tooltip': tooltip, }); - return result ?? false; } - static Future updateTray({ + static Future updateTray({ required String iconPath, required String showHideLabel, required String exitLabel, required String tooltip, }) async { - final result = await _methodChannel.invokeMethod('updateTray', { + return _invokeTrayMethod('updateTray', { 'iconPath': iconPath, 'showHideLabel': showHideLabel, 'exitLabel': exitLabel, 'tooltip': tooltip, }); - return result ?? false; + } + + static Future _invokeTrayMethod( + String method, Map args) async { + try { + final result = await _methodChannel.invokeMethod(method, args); + if (result is Map) { + final map = Map.from(result); + final code = map['errorCode']; + return TrayResponse( + success: map['success'] == true, + errorCode: code is String ? code : null, + ); + } + if (result is bool) { + return TrayResponse(success: result); + } + return const TrayResponse(success: false, errorCode: 'unknown'); + } catch (e) { + AppLogger.error('LinuxShell.$method failed: $e'); + return const TrayResponse(success: false, errorCode: 'channelError'); + } } static Future destroyTray() async { @@ -137,3 +157,10 @@ class HotkeyRegisterResponse { final bool success; final String? errorCode; } + +class TrayResponse { + const TrayResponse({required this.success, this.errorCode}); + + final bool success; + final String? errorCode; +} diff --git a/app/lib/shell/tray_icon.dart b/app/lib/shell/tray_icon.dart index 2490a188..c96491bf 100644 --- a/app/lib/shell/tray_icon.dart +++ b/app/lib/shell/tray_icon.dart @@ -1,111 +1,114 @@ -// coverage:ignore-file -import 'dart:async'; -import 'dart:io'; - -import 'package:tray_manager/tray_manager.dart'; - -import 'linux_shell.dart'; - -class TrayIcon with TrayListener { - TrayIcon({required this.onToggle, required this.onExit}); - - final void Function() onToggle; - final Future Function() onExit; - - StreamSubscription? _linuxEventsSubscription; - - static String get _iconPath { - if (Platform.isMacOS) return 'assets/icons/icon_mac_tray.png'; - if (Platform.isLinux) return 'assets/icons/icon_tray_64.png'; - return 'assets/icons/icon_tray.ico'; - } - - Future init() async { - if (Platform.isLinux) { - _linuxEventsSubscription ??= LinuxShell.events.listen((event) { - switch (event) { - case 'toggle': - onToggle(); - case 'exit': - onExit(); - } - }); - await LinuxShell.initTray( - iconPath: _iconPath, - showHideLabel: 'Show/Hide', - exitLabel: 'Exit', - tooltip: 'CopyPaste', - ); - return; - } - - trayManager.addListener(this); - await trayManager.setIcon(_iconPath); - await trayManager.setContextMenu( - Menu( - items: [ - MenuItem(key: 'toggle', label: 'Show/Hide'), - MenuItem.separator(), - MenuItem(key: 'exit', label: 'Exit'), - ], - ), - ); - } - - Future rebuild({ - required String showHideLabel, - required String exitLabel, - required String tooltip, - }) async { - if (Platform.isLinux) { - await LinuxShell.updateTray( - iconPath: _iconPath, - showHideLabel: showHideLabel, - exitLabel: exitLabel, - tooltip: tooltip, - ); - return; - } - - await trayManager.setToolTip(tooltip); - await trayManager.setContextMenu( - Menu( - items: [ - MenuItem(key: 'toggle', label: showHideLabel), - MenuItem.separator(), - MenuItem(key: 'exit', label: exitLabel), - ], - ), - ); - } - - @override - void onTrayIconMouseDown() => onToggle(); - - @override - void onTrayIconRightMouseDown() { - trayManager.popUpContextMenu(); - } - - @override - void onTrayMenuItemClick(MenuItem menuItem) { - switch (menuItem.key) { - case 'toggle': - onToggle(); - case 'exit': - onExit(); - } - } - - Future dispose() async { - if (Platform.isLinux) { - await _linuxEventsSubscription?.cancel(); - _linuxEventsSubscription = null; - await LinuxShell.destroyTray(); - return; - } - - trayManager.removeListener(this); - await trayManager.destroy(); - } -} +// coverage:ignore-file +import 'dart:async'; +import 'dart:io'; + +import 'package:tray_manager/tray_manager.dart'; + +import '../services/linux_guard.dart'; +import 'linux_shell.dart'; + +class TrayIcon with TrayListener { + TrayIcon({required this.onToggle, required this.onExit}); + + final void Function() onToggle; + final Future Function() onExit; + + StreamSubscription? _linuxEventsSubscription; + + static String get _iconPath { + if (Platform.isMacOS) return 'assets/icons/icon_mac_tray.png'; + if (Platform.isLinux) return 'assets/icons/icon_tray_64.png'; + return 'assets/icons/icon_tray.ico'; + } + + Future init() async { + if (Platform.isLinux) { + if (!LinuxGuard.canShowTray) return; + _linuxEventsSubscription ??= LinuxShell.events.listen((event) { + switch (event) { + case 'toggle': + onToggle(); + case 'exit': + onExit(); + } + }); + await LinuxShell.initTray( + iconPath: _iconPath, + showHideLabel: 'Show/Hide', + exitLabel: 'Exit', + tooltip: 'CopyPaste', + ); + return; + } + + trayManager.addListener(this); + await trayManager.setIcon(_iconPath); + await trayManager.setContextMenu( + Menu( + items: [ + MenuItem(key: 'toggle', label: 'Show/Hide'), + MenuItem.separator(), + MenuItem(key: 'exit', label: 'Exit'), + ], + ), + ); + } + + Future rebuild({ + required String showHideLabel, + required String exitLabel, + required String tooltip, + }) async { + if (Platform.isLinux) { + if (!LinuxGuard.canShowTray) return; + await LinuxShell.updateTray( + iconPath: _iconPath, + showHideLabel: showHideLabel, + exitLabel: exitLabel, + tooltip: tooltip, + ); + return; + } + + await trayManager.setToolTip(tooltip); + await trayManager.setContextMenu( + Menu( + items: [ + MenuItem(key: 'toggle', label: showHideLabel), + MenuItem.separator(), + MenuItem(key: 'exit', label: exitLabel), + ], + ), + ); + } + + @override + void onTrayIconMouseDown() => onToggle(); + + @override + void onTrayIconRightMouseDown() { + trayManager.popUpContextMenu(); + } + + @override + void onTrayMenuItemClick(MenuItem menuItem) { + switch (menuItem.key) { + case 'toggle': + onToggle(); + case 'exit': + onExit(); + } + } + + Future dispose() async { + if (Platform.isLinux) { + await _linuxEventsSubscription?.cancel(); + _linuxEventsSubscription = null; + await LinuxShell.destroyTray(); + return; + } + + trayManager.removeListener(this); + await trayManager.destroy(); + } +} diff --git a/app/linux/runner/copypaste_linux_shell.c b/app/linux/runner/copypaste_linux_shell.c index 4dcd3d24..1ae631d2 100644 --- a/app/linux/runner/copypaste_linux_shell.c +++ b/app/linux/runner/copypaste_linux_shell.c @@ -27,8 +27,6 @@ struct _CopyPasteLinuxShell { #ifdef HAVE_APPINDICATOR AppIndicator* app_indicator; -#else - GtkStatusIcon* tray_icon; #endif GtkWidget* tray_menu; GtkWidget* toggle_item; @@ -128,27 +126,6 @@ static void tray_exit_cb(GtkMenuItem* item, gpointer user_data) { send_shell_event((CopyPasteLinuxShell*)user_data, "exit"); } -#ifndef HAVE_APPINDICATOR -static void tray_activate_cb(GtkStatusIcon* status_icon, gpointer user_data) { - (void)status_icon; - send_shell_event((CopyPasteLinuxShell*)user_data, "toggle"); -} - -static void tray_popup_menu_cb(GtkStatusIcon* status_icon, - guint button, - guint activate_time, - gpointer user_data) { - CopyPasteLinuxShell* shell = (CopyPasteLinuxShell*)user_data; - if (shell->tray_menu == NULL) { - return; - } - - gtk_menu_popup(GTK_MENU(shell->tray_menu), NULL, NULL, - gtk_status_icon_position_menu, status_icon, button, - activate_time); -} -#endif - static void rebuild_tray_menu(CopyPasteLinuxShell* shell, const gchar* show_hide_label, const gchar* exit_label) { @@ -197,7 +174,17 @@ static void parse_tray_args(FlValue* args, : "Exit"; } -static gboolean init_tray(CopyPasteLinuxShell* shell, FlValue* args) { +static FlValue* make_tray_result(gboolean success, const char* error_code) { + FlValue* map = fl_value_new_map(); + fl_value_set_string_take(map, "success", fl_value_new_bool(success)); + if (error_code != NULL) { + fl_value_set_string_take(map, "errorCode", fl_value_new_string(error_code)); + } + return map; +} + +static FlValue* init_tray(CopyPasteLinuxShell* shell, FlValue* args) { +#ifdef HAVE_APPINDICATOR const gchar* icon_path; const gchar* tooltip; const gchar* show_hide; @@ -207,13 +194,16 @@ static gboolean init_tray(CopyPasteLinuxShell* shell, FlValue* args) { g_free(shell->resolved_icon_path); shell->resolved_icon_path = resolve_asset_path(icon_path); -#ifdef HAVE_APPINDICATOR if (shell->app_indicator == NULL) { shell->app_indicator = app_indicator_new( "com.rgdevment.copypaste", "copypaste", APP_INDICATOR_CATEGORY_APPLICATION_STATUS); } + if (shell->app_indicator == NULL) { + return make_tray_result(FALSE, "noAppIndicator"); + } + if (shell->resolved_icon_path != NULL) { g_autofree gchar* icon_dir = g_path_get_dirname(shell->resolved_icon_path); @@ -229,28 +219,15 @@ static gboolean init_tray(CopyPasteLinuxShell* shell, FlValue* args) { rebuild_tray_menu(shell, show_hide, exit_label); app_indicator_set_menu(shell->app_indicator, GTK_MENU(shell->tray_menu)); app_indicator_set_status(shell->app_indicator, APP_INDICATOR_STATUS_ACTIVE); + return make_tray_result(TRUE, NULL); #else - if (shell->tray_icon == NULL) { - shell->tray_icon = gtk_status_icon_new(); - g_signal_connect(shell->tray_icon, "activate", - G_CALLBACK(tray_activate_cb), shell); - g_signal_connect(shell->tray_icon, "popup-menu", - G_CALLBACK(tray_popup_menu_cb), shell); - } - - if (shell->resolved_icon_path != NULL) { - gtk_status_icon_set_from_file(shell->tray_icon, shell->resolved_icon_path); - } - - gtk_status_icon_set_tooltip_text(shell->tray_icon, tooltip); - gtk_status_icon_set_visible(shell->tray_icon, TRUE); - rebuild_tray_menu(shell, show_hide, exit_label); + (void)shell; + (void)args; + return make_tray_result(FALSE, "noAppIndicator"); #endif - - return TRUE; } -static gboolean destroy_tray(CopyPasteLinuxShell* shell) { +static FlValue* destroy_tray(CopyPasteLinuxShell* shell) { destroy_tray_menu(shell); #ifdef HAVE_APPINDICATOR @@ -258,15 +235,10 @@ static gboolean destroy_tray(CopyPasteLinuxShell* shell) { app_indicator_set_status(shell->app_indicator, APP_INDICATOR_STATUS_PASSIVE); g_clear_object(&shell->app_indicator); } -#else - if (shell->tray_icon != NULL) { - gtk_status_icon_set_visible(shell->tray_icon, FALSE); - g_clear_object(&shell->tray_icon); - } #endif g_clear_pointer(&shell->resolved_icon_path, g_free); - return TRUE; + return make_tray_result(TRUE, NULL); } static FlValue* make_hotkey_result(gboolean success, const char* error_code) { @@ -668,12 +640,12 @@ static void shell_method_call_cb(FlMethodChannel* channel, } if (strcmp(method, "initTray") == 0 || strcmp(method, "updateTray") == 0) { - respond_method_success(method_call, fl_value_new_bool(init_tray(shell, args))); + respond_method_success(method_call, init_tray(shell, args)); return; } if (strcmp(method, "destroyTray") == 0) { - respond_method_success(method_call, fl_value_new_bool(destroy_tray(shell))); + respond_method_success(method_call, destroy_tray(shell)); return; } diff --git a/app/test/screens/linux_capabilities_banner_test.dart b/app/test/screens/linux_capabilities_banner_test.dart new file mode 100644 index 00000000..ad7bc32c --- /dev/null +++ b/app/test/screens/linux_capabilities_banner_test.dart @@ -0,0 +1,129 @@ +import 'dart:io'; + +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; + +import 'package:copypaste/l10n/app_localizations.dart'; +import 'package:copypaste/screens/linux_capabilities_banner.dart'; +import 'package:copypaste/services/linux_capabilities.dart'; +import 'package:copypaste/shell/linux_session.dart'; +import 'package:copypaste/theme/compact_theme.dart'; +import 'package:copypaste/theme/theme_provider.dart'; +import 'package:core/core.dart'; + +LinuxCapabilities _caps({ + bool hasAppIndicator = true, + bool hasXTest = true, + bool hasClipboardManager = true, +}) { + return LinuxCapabilities( + session: const LinuxSessionInfo( + sessionType: 'x11', + hasDisplay: true, + hasWaylandDisplay: false, + hasWaylandSocket: false, + desktopEnv: 'gnome', + wmName: 'mutter', + ), + isX11: true, + hasXTest: hasXTest, + hasAppIndicator: hasAppIndicator, + hasClipboardManager: hasClipboardManager, + hasEwmh: true, + detectedDesktopEnv: 'gnome', + detectedWmName: 'mutter', + detectionTimedOut: false, + ); +} + +Widget _wrap(Widget child) { + return MaterialApp( + locale: const Locale('en'), + localizationsDelegates: AppLocalizations.localizationsDelegates, + supportedLocales: AppLocalizations.supportedLocales, + home: CopyPasteTheme( + themeData: CompactTheme(), + child: Scaffold(body: child), + ), + ); +} + +void main() { + group('LinuxCapabilitiesBanner', () { + testWidgets('renders nothing when not on Linux', (tester) async { + if (Platform.isLinux) return; + await tester.pumpWidget( + _wrap( + LinuxCapabilitiesBanner( + config: const AppConfig(), + capabilities: _caps(hasAppIndicator: false), + onDismiss: (_) async {}, + ), + ), + ); + expect(find.byType(Icon), findsNothing); + }); + + testWidgets( + 'renders AppIndicator banner when missing and not dismissed', + (tester) async { + if (!Platform.isLinux) return; + await tester.pumpWidget( + _wrap( + LinuxCapabilitiesBanner( + config: const AppConfig(), + capabilities: _caps(hasAppIndicator: false), + onDismiss: (_) async {}, + ), + ), + ); + expect(find.byIcon(Icons.warning_amber_rounded), findsOneWidget); + expect(find.byIcon(Icons.close_rounded), findsOneWidget); + }, + ); + + testWidgets( + 'renders nothing when capability missing but already dismissed', + (tester) async { + if (!Platform.isLinux) return; + await tester.pumpWidget( + _wrap( + LinuxCapabilitiesBanner( + config: const AppConfig( + linuxAppindicatorWarningDismissed: true, + linuxXtestWarningDismissed: true, + linuxClipboardManagerWarningDismissed: true, + ), + capabilities: _caps( + hasAppIndicator: false, + hasXTest: false, + hasClipboardManager: false, + ), + onDismiss: (_) async {}, + ), + ), + ); + expect(find.byIcon(Icons.warning_amber_rounded), findsNothing); + }, + ); + + testWidgets('dismiss callback fires when close icon tapped', (tester) async { + if (!Platform.isLinux) return; + AppConfig? captured; + await tester.pumpWidget( + _wrap( + LinuxCapabilitiesBanner( + config: const AppConfig(), + capabilities: _caps(hasAppIndicator: false), + onDismiss: (update) async { + captured = update(const AppConfig()); + }, + ), + ), + ); + await tester.tap(find.byIcon(Icons.close_rounded)); + await tester.pump(); + expect(captured?.linuxAppindicatorWarningDismissed, isTrue); + }); + }); +} diff --git a/core/lib/config/app_config.dart b/core/lib/config/app_config.dart index 24942a61..18367f7b 100644 --- a/core/lib/config/app_config.dart +++ b/core/lib/config/app_config.dart @@ -45,6 +45,9 @@ class AppConfig { this.generateAudioThumbnails = true, this.maxImageProcessingSizeMB = 25, this.imagesQuotaMB = 0, + this.linuxAppindicatorWarningDismissed = false, + this.linuxClipboardManagerWarningDismissed = false, + this.linuxXtestWarningDismissed = false, }); factory AppConfig.fromJson(Map json) { @@ -127,6 +130,15 @@ class AppConfig { json['maxImageProcessingSizeMB'] as int? ?? defaults.maxImageProcessingSizeMB, imagesQuotaMB: json['imagesQuotaMB'] as int? ?? defaults.imagesQuotaMB, + linuxAppindicatorWarningDismissed: + json['linuxAppindicatorWarningDismissed'] as bool? ?? + defaults.linuxAppindicatorWarningDismissed, + linuxClipboardManagerWarningDismissed: + json['linuxClipboardManagerWarningDismissed'] as bool? ?? + defaults.linuxClipboardManagerWarningDismissed, + linuxXtestWarningDismissed: + json['linuxXtestWarningDismissed'] as bool? ?? + defaults.linuxXtestWarningDismissed, ); } @@ -199,6 +211,11 @@ class AppConfig { // owned bytes drop back below the limit. Pinned items are never purged. final int imagesQuotaMB; + // Linux capability warning banners (dismissible). + final bool linuxAppindicatorWarningDismissed; + final bool linuxClipboardManagerWarningDismissed; + final bool linuxXtestWarningDismissed; + AppConfig copyWith({ String? preferredLanguage, bool? runOnStartup, @@ -238,6 +255,9 @@ class AppConfig { bool? generateAudioThumbnails, int? maxImageProcessingSizeMB, int? imagesQuotaMB, + bool? linuxAppindicatorWarningDismissed, + bool? linuxClipboardManagerWarningDismissed, + bool? linuxXtestWarningDismissed, }) => AppConfig( preferredLanguage: preferredLanguage ?? this.preferredLanguage, runOnStartup: runOnStartup ?? this.runOnStartup, @@ -288,6 +308,14 @@ class AppConfig { maxImageProcessingSizeMB: maxImageProcessingSizeMB ?? this.maxImageProcessingSizeMB, imagesQuotaMB: imagesQuotaMB ?? this.imagesQuotaMB, + linuxAppindicatorWarningDismissed: + linuxAppindicatorWarningDismissed ?? + this.linuxAppindicatorWarningDismissed, + linuxClipboardManagerWarningDismissed: + linuxClipboardManagerWarningDismissed ?? + this.linuxClipboardManagerWarningDismissed, + linuxXtestWarningDismissed: + linuxXtestWarningDismissed ?? this.linuxXtestWarningDismissed, ); Map toJson() => { @@ -330,6 +358,10 @@ class AppConfig { 'generateAudioThumbnails': generateAudioThumbnails, 'maxImageProcessingSizeMB': maxImageProcessingSizeMB, 'imagesQuotaMB': imagesQuotaMB, + 'linuxAppindicatorWarningDismissed': linuxAppindicatorWarningDismissed, + 'linuxClipboardManagerWarningDismissed': + linuxClipboardManagerWarningDismissed, + 'linuxXtestWarningDismissed': linuxXtestWarningDismissed, }; static Future load(String configPath) async { diff --git a/core/test/app_config_test.dart b/core/test/app_config_test.dart index ef9b4fa5..446c7907 100644 --- a/core/test/app_config_test.dart +++ b/core/test/app_config_test.dart @@ -223,6 +223,47 @@ void main() { ); }); + test('linux capability dismiss flags default to false', () { + const config = AppConfig(); + expect(config.linuxAppindicatorWarningDismissed, isFalse); + expect(config.linuxClipboardManagerWarningDismissed, isFalse); + expect(config.linuxXtestWarningDismissed, isFalse); + }); + + test('linux capability dismiss flags round-trip via JSON', () { + const config = AppConfig( + linuxAppindicatorWarningDismissed: true, + linuxClipboardManagerWarningDismissed: true, + linuxXtestWarningDismissed: true, + ); + final restored = AppConfig.fromJson(config.toJson()); + expect(restored.linuxAppindicatorWarningDismissed, isTrue); + expect(restored.linuxClipboardManagerWarningDismissed, isTrue); + expect(restored.linuxXtestWarningDismissed, isTrue); + }); + + test('copyWith updates linux capability dismiss flags individually', () { + const config = AppConfig(); + expect( + config + .copyWith(linuxAppindicatorWarningDismissed: true) + .linuxAppindicatorWarningDismissed, + isTrue, + ); + expect( + config + .copyWith(linuxClipboardManagerWarningDismissed: true) + .linuxClipboardManagerWarningDismissed, + isTrue, + ); + expect( + config + .copyWith(linuxXtestWarningDismissed: true) + .linuxXtestWarningDismissed, + isTrue, + ); + }); + test('toJson omits lastBackupDateUtc when null', () { const config = AppConfig(); expect(config.toJson().containsKey('lastBackupDateUtc'), isFalse); From 450dfcd5612ed339ca178124e835daf21273b57e Mon Sep 17 00:00:00 2001 From: rgdevment Date: Fri, 24 Apr 2026 13:07:35 -0400 Subject: [PATCH 05/31] refact: X11 clipboard activation and pasting logic - Introduced `make_paste_result` function to standardize result formatting. - Enhanced `activate_and_paste_x11` to handle focus confirmation with a timeout. - Replaced direct X11 window activation with EWMH `_NET_ACTIVE_WINDOW` message. - Improved error handling for focus transfer and XTest availability. - Updated tests for `ClipboardWriter.activateAndPaste` to reflect new response structure. - Removed deprecated delay handling in favor of immediate paste logic. --- app/lib/l10n/app_en.arb | 5 + app/lib/l10n/app_es.arb | 1 + app/lib/l10n/app_localizations.dart | 6 + app/lib/l10n/app_localizations_en.dart | 4 + app/lib/l10n/app_localizations_es.dart | 4 + app/lib/main.dart | 5 +- app/lib/shell/app_window.dart | 900 ++++++++++--------- app/lib/shell/focus_manager.dart | 454 +++++----- app/lib/shell/linux_shell.dart | 19 + app/linux/runner/copypaste_linux_shell.c | 23 + app/linux/runner/my_application.cc | 266 +++--- app/test/shell/linux_shell_test.dart | 72 ++ listener/lib/clipboard_writer.dart | 357 ++++---- listener/linux/listener_plugin.c | 174 ++-- listener/test/clipboard_writer_test.dart | 1047 +++++++++++----------- 15 files changed, 1780 insertions(+), 1557 deletions(-) create mode 100644 app/test/shell/linux_shell_test.dart diff --git a/app/lib/l10n/app_en.arb b/app/lib/l10n/app_en.arb index 6f5ee380..e587fa0b 100644 --- a/app/lib/l10n/app_en.arb +++ b/app/lib/l10n/app_en.arb @@ -549,6 +549,11 @@ } }, + "linuxPasteFocusTimeoutWarning": "The clipboard has your content. Paste manually with Ctrl+V.", + "@linuxPasteFocusTimeoutWarning": { + "description": "Shown when the X11 paste flow could not regain focus on the previous window in time" + }, + "linuxAppindicatorBannerTitle": "System tray icon unavailable", "@linuxAppindicatorBannerTitle": { "description": "Title of the AppIndicator missing banner" }, diff --git a/app/lib/l10n/app_es.arb b/app/lib/l10n/app_es.arb index 29f9369f..3cb2a3df 100644 --- a/app/lib/l10n/app_es.arb +++ b/app/lib/l10n/app_es.arb @@ -248,6 +248,7 @@ "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.", "linuxHotkeyGrabFailedWarning": "El atajo {hotkey} est\u00e1 siendo usado por otra aplicaci\u00f3n. C\u00e1mbialo en Configuraci\u00f3n \u2192 Atajos.", + "linuxPasteFocusTimeoutWarning": "El portapapeles tiene tu contenido. P\u00e9galo manualmente con Ctrl+V.", "linuxAppindicatorBannerTitle": "\u00cdcono de bandeja no disponible", "linuxAppindicatorBannerBody": "Tu escritorio no expone un host de AppIndicator, por lo que el \u00edcono de CopyPaste no aparecer\u00e1 en la bandeja. Instala una extensi\u00f3n de bandeja para tu distribuci\u00f3n y reinicia CopyPaste.", "linuxClipboardManagerBannerTitle": "No se detect\u00f3 un gestor de portapapeles", diff --git a/app/lib/l10n/app_localizations.dart b/app/lib/l10n/app_localizations.dart index a0876d62..cb3b8cbf 100644 --- a/app/lib/l10n/app_localizations.dart +++ b/app/lib/l10n/app_localizations.dart @@ -1352,6 +1352,12 @@ abstract class AppLocalizations { /// **'The shortcut {hotkey} is being used by another application. Change it in Settings → Shortcuts.'** String linuxHotkeyGrabFailedWarning(String hotkey); + /// Shown when the X11 paste flow could not regain focus on the previous window in time + /// + /// In en, this message translates to: + /// **'The clipboard has your content. Paste manually with Ctrl+V.'** + String get linuxPasteFocusTimeoutWarning; + /// Title of the AppIndicator missing banner /// /// 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 829ef7e5..8cb04ac2 100644 --- a/app/lib/l10n/app_localizations_en.dart +++ b/app/lib/l10n/app_localizations_en.dart @@ -690,6 +690,10 @@ class AppLocalizationsEn extends AppLocalizations { return 'The shortcut $hotkey is being used by another application. Change it in Settings → Shortcuts.'; } + @override + String get linuxPasteFocusTimeoutWarning => + 'The clipboard has your content. Paste manually with Ctrl+V.'; + @override String get linuxAppindicatorBannerTitle => 'System tray icon unavailable'; diff --git a/app/lib/l10n/app_localizations_es.dart b/app/lib/l10n/app_localizations_es.dart index c78c498a..629ac2ef 100644 --- a/app/lib/l10n/app_localizations_es.dart +++ b/app/lib/l10n/app_localizations_es.dart @@ -694,6 +694,10 @@ class AppLocalizationsEs extends AppLocalizations { return 'El atajo $hotkey está siendo usado por otra aplicación. Cámbialo en Configuración → Atajos.'; } + @override + String get linuxPasteFocusTimeoutWarning => + 'El portapapeles tiene tu contenido. Pégalo manualmente con Ctrl+V.'; + @override String get linuxAppindicatorBannerTitle => 'Ícono de bandeja no disponible'; diff --git a/app/lib/main.dart b/app/lib/main.dart index 17630f1a..4202a1a4 100644 --- a/app/lib/main.dart +++ b/app/lib/main.dart @@ -770,11 +770,14 @@ class _CopyPasteAppState extends State if (!ok) return; await _appWindow.hide(); try { - await _focusManager.restoreAndPaste( + final response = await _focusManager.restoreAndPaste( delayBeforeFocusMs: _config.delayBeforeFocusMs, maxFocusVerifyAttempts: _config.maxFocusVerifyAttempts, delayBeforePasteMs: _config.delayBeforePasteMs, ); + if (Platform.isLinux && response.isFocusTimeout) { + _showLinuxNotice((l) => l.linuxPasteFocusTimeoutWarning); + } } on PlatformException catch (e) { if (e.code == 'ACCESSIBILITY_DENIED' && mounted) { _enterPermissionGate(); diff --git a/app/lib/shell/app_window.dart b/app/lib/shell/app_window.dart index af6851c0..f5291200 100644 --- a/app/lib/shell/app_window.dart +++ b/app/lib/shell/app_window.dart @@ -1,445 +1,455 @@ -// coverage:ignore-file -import 'dart:ffi' hide Size; -import 'dart:io'; -import 'dart:ui' show Color, Offset, Size; - -import 'package:core/core.dart'; -import 'package:ffi/ffi.dart'; -import 'package:flutter_acrylic/flutter_acrylic.dart'; -import 'package:listener/listener.dart'; -import 'package:window_manager/window_manager.dart'; - -import 'linux_shell.dart'; - -typedef _SystemParametersInfoWNative = - Int32 Function( - Uint32 uiAction, - Uint32 uiParam, - Pointer lpvParam, - Uint32 fWinIni, - ); -typedef _SystemParametersInfoWDart = - int Function(int uiAction, int uiParam, Pointer lpvParam, int fWinIni); - -typedef _GetCursorPosNative = Int32 Function(Pointer lpPoint); -typedef _GetCursorPosDart = int Function(Pointer lpPoint); - -typedef _MonitorFromPointNative = - IntPtr Function(Int32 x, Int32 y, Uint32 dwFlags); -typedef _MonitorFromPointDart = int Function(int x, int y, int dwFlags); - -typedef _GetMonitorInfoWNative = Int32 Function(IntPtr hMonitor, Pointer lpmi); -typedef _GetMonitorInfoWDart = int Function(int hMonitor, Pointer lpmi); - -class _Win32Pos { - _Win32Pos._(); - static _Win32Pos? _instance; - static _Win32Pos get instance => _instance ??= _Win32Pos._(); - - late final _u32 = DynamicLibrary.open('user32.dll'); - late final spiFunc = _u32 - .lookupFunction<_SystemParametersInfoWNative, _SystemParametersInfoWDart>( - 'SystemParametersInfoW', - ); - late final getCursorPosFunc = _u32 - .lookupFunction<_GetCursorPosNative, _GetCursorPosDart>('GetCursorPos'); - late final monitorFromPointFunc = _u32 - .lookupFunction<_MonitorFromPointNative, _MonitorFromPointDart>( - 'MonitorFromPoint', - ); - late final getMonitorInfoFunc = _u32 - .lookupFunction<_GetMonitorInfoWNative, _GetMonitorInfoWDart>( - 'GetMonitorInfoW', - ); -} - -class AppWindow { - AppWindow({ - this.onVisibilityChanged, - this.showInTaskbar = true, - double popupWidth = 360, - double popupHeight = 500, - }) : _popupWidth = popupWidth, - _popupHeight = popupHeight; - - bool showInTaskbar; - - static const double _settingsWidth = 820; - static const double _settingsHeight = 680; - - final void Function(bool visible)? onVisibilityChanged; - double _popupWidth; - double _popupHeight; - bool _visible = false; - bool _ready = false; - bool _settingsMode = false; - - bool get isVisible => _visible; - bool get isReady => _ready; - bool get isSettingsMode => _settingsMode; - - void updatePopupSize(double width, double height) { - _popupWidth = width; - _popupHeight = height; - } - - Future init({bool startVisible = false}) async { - AppLogger.info( - 'AppWindow.init: startVisible=$startVisible, ' - 'showInTaskbar=$showInTaskbar, ' - 'size=${_popupWidth}x$_popupHeight', - ); - try { - await windowManager - .waitUntilReadyToShow(null, () async { - await _configureWindow(startVisible); - }) - .timeout(const Duration(seconds: 5)); - AppLogger.info('AppWindow.init: waitUntilReadyToShow completed'); - } catch (e) { - AppLogger.warn( - 'AppWindow.init: waitUntilReadyToShow failed ($e), ' - 'attempting direct configuration', - ); - try { - await _configureWindow(startVisible); - AppLogger.info('AppWindow.init: direct configuration succeeded'); - } catch (e2) { - AppLogger.error('Window configuration failed: $e2'); - } - } - _visible = startVisible; - _ready = true; - AppLogger.info('AppWindow.init: done, ready=$_ready, visible=$_visible'); - } - - Future _configureWindow(bool startVisible) async { - await windowManager.setTitle('CopyPaste'); - await windowManager.setSize(Size(_popupWidth, _popupHeight)); - await windowManager.setMinimumSize(Size(_popupWidth, 400)); - await windowManager.setMaximumSize(Size(_popupWidth, 900)); - await windowManager.setTitleBarStyle( - TitleBarStyle.hidden, - windowButtonVisibility: !Platform.isMacOS, - ); - await windowManager.setAlwaysOnTop(true); - await windowManager.setResizable(false); - await windowManager.setMaximizable(false); - await windowManager.setPreventClose(true); - final inTaskbar = showInTaskbar && Platform.isWindows; - await windowManager.setSkipTaskbar(!inTaskbar); - if (Platform.isWindows || Platform.isMacOS) { - await windowManager.setBackgroundColor(const Color(0x00000000)); - AppLogger.info('_configureWindow: applying initial effect'); - await applyEffect(); - } - if (startVisible) { - 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(); - } - } - - bool _isDark = false; - - Future applyEffect({bool? dark}) async { - if (dark != null) _isDark = dark; - try { - if (Platform.isWindows) { - await Window.setEffect( - effect: WindowEffect.mica, - color: const Color(0x00000000), - dark: _isDark, - ).timeout(const Duration(seconds: 2)); - } else if (Platform.isMacOS) { - await Window.setEffect( - effect: WindowEffect.sidebar, - color: const Color(0x00000000), - dark: _isDark, - ).timeout(const Duration(seconds: 2)); - } - } catch (e) { - // Effect failure is non-fatal — app runs without the acrylic effect. - AppLogger.warn('applyEffect: window effect unavailable (non-fatal): $e'); - } - } - - Future _positionNearCursor() async { - if (Platform.isWindows) { - await _positionNearCursorWindows(); - } else if (Platform.isMacOS || Platform.isLinux) { - await _positionNearCursorNative(); - } else { - await windowManager.center(); - } - } - - Future _positionNearCursorWindows() async { - try { - final cursor = _getCursorPosWin32(); - if (cursor == null) { - await windowManager.center(); - return; - } - final workArea = _getWorkAreaForPointWin32(cursor.$1, cursor.$2); - if (workArea == null) { - await windowManager.center(); - return; - } - await _applyPosition(cursor.$1, cursor.$2, workArea); - } catch (e) { - AppLogger.warn('_positionNearCursorWindows: fallback to center: $e'); - await windowManager.center(); - } - } - - Future _positionNearCursorNative() async { - try { - final info = await ClipboardWriter.getCursorAndScreenInfo(); - if (info == null) { - await windowManager.center(); - return; - } - final cursorX = info['cursorX'] ?? 0; - final cursorY = info['cursorY'] ?? 0; - final workArea = ( - info['waLeft'] ?? 0, - info['waTop'] ?? 0, - info['waRight'] ?? 1440, - info['waBottom'] ?? 900, - ); - await _applyPosition(cursorX, cursorY, workArea); - } catch (e) { - AppLogger.warn('_positionNearCursorNative: fallback to center: $e'); - await windowManager.center(); - } - } - - Future _applyPosition( - double cursorX, - double cursorY, - (double, double, double, double) workArea, - ) async { - final waLeft = workArea.$1; - final waTop = workArea.$2; - final waRight = workArea.$3; - final waBottom = workArea.$4; - - double x; - double y; - - if (cursorX + _popupWidth + 12 <= waRight) { - x = cursorX + 12; - } else if (cursorX - _popupWidth - 12 >= waLeft) { - x = cursorX - _popupWidth - 12; - } else { - x = waRight - _popupWidth - 12; - } - - y = cursorY - _popupHeight / 2; - if (y < waTop + 8) y = waTop + 8; - if (y + _popupHeight > waBottom - 8) y = waBottom - _popupHeight - 8; - - x = x.clamp(waLeft, waRight - _popupWidth); - y = y.clamp(waTop, waBottom - _popupHeight); - - await windowManager.setPosition(Offset(x, y)); - } - - static (double, double)? _getCursorPosWin32() { - final w = _Win32Pos.instance; - final pt = calloc(2); - try { - final result = w.getCursorPosFunc(pt); - if (result == 0) return null; - return (pt[0].toDouble(), pt[1].toDouble()); - } finally { - calloc.free(pt); - } - } - - static (double, double, double, double)? _getWorkAreaForPointWin32( - double x, - double y, - ) { - const monitorDefaultToNearest = 0x00000002; - final w = _Win32Pos.instance; - final hMonitor = w.monitorFromPointFunc( - x.toInt(), - y.toInt(), - monitorDefaultToNearest, - ); - if (hMonitor == 0) return _getWorkAreaWin32(); - - final mi = calloc(10); - try { - mi[0] = 40; - final result = w.getMonitorInfoFunc(hMonitor, mi); - if (result == 0) return _getWorkAreaWin32(); - return ( - mi[5].toDouble(), - mi[6].toDouble(), - mi[7].toDouble(), - mi[8].toDouble(), - ); - } finally { - calloc.free(mi); - } - } - - static (double, double, double, double)? _getWorkAreaWin32() { - const spiGetWorkArea = 0x0030; - final w = _Win32Pos.instance; - final rect = calloc(4); - try { - final result = w.spiFunc(spiGetWorkArea, 0, rect, 0); - if (result == 0) return null; - return ( - rect[0].toDouble(), - rect[1].toDouble(), - rect[2].toDouble(), - rect[3].toDouble(), - ); - } finally { - calloc.free(rect); - } - } - - Future show() async { - AppLogger.info('AppWindow.show: starting'); - if (Platform.isLinux) { - // On X11/GTK, show the window first (so it gets realized/mapped by the WM), - // then set the position (avoids WM initial-placement overriding our offset), - // then focus via gtk_window_present_with_time so GNOME doesn't block focus - // and show a spurious "está preparado" notification. - await windowManager.setSkipTaskbar(false); - await windowManager.show(); - await _positionNearCursor(); - await LinuxShell.focusWindow(); - } else { - await _positionNearCursor(); - if (Platform.isWindows) { - await windowManager.setSkipTaskbar(false); - } - await windowManager.show(); - await windowManager.focus(); - AppLogger.info('AppWindow.show: window shown and focused'); - if (Platform.isWindows) { - await applyEffect(); - } - } - _visible = true; - onVisibilityChanged?.call(true); - } - - Future hide() async { - if (!_visible) return; - _visible = false; - if (showInTaskbar && Platform.isWindows) { - await windowManager.minimize(); - } else { - await windowManager.hide(); - if (!Platform.isMacOS) { - await windowManager.setSkipTaskbar(true); - } - } - onVisibilityChanged?.call(false); - } - - Future toggle() async { - if (_visible) { - await hide(); - } else { - await show(); - } - } - - Future hideIfNotPinned() async { - if (_visible && !_settingsMode) { - await hide(); - } - } - - Future enterSettingsMode() async { - _settingsMode = true; - await windowManager.setResizable(true); - // GTK processes geometry hints asynchronously — wait one frame before - // applying new constraints so the WM doesn't reject the resize. - if (Platform.isLinux) { - await Future.delayed(const Duration(milliseconds: 50)); - } - await windowManager.setMinimumSize( - const Size(_settingsWidth, _settingsHeight), - ); - await windowManager.setMaximumSize(const Size(1200, 900)); - await windowManager.setSize(const Size(_settingsWidth, _settingsHeight)); - await windowManager.center(); - // Settings mode implies the window must be visible and focused. Without - // this, transitioning from gate/onboarding (which hides the window on - // exit) leaves Settings invisible behind other windows. - if (!await windowManager.isVisible()) { - await windowManager.show(); - } - await windowManager.focus(); - _visible = true; - } - - Future exitSettingsMode() async { - _settingsMode = false; - // On Linux the window may still be in resizable=true state from settings - // mode. Reset it explicitly and wait for GTK to process before resizing. - if (Platform.isLinux) { - await windowManager.setResizable(true); - await Future.delayed(const Duration(milliseconds: 50)); - } - await windowManager.setMinimumSize(Size(_popupWidth, 400)); - await windowManager.setMaximumSize(Size(_popupWidth, 900)); - await windowManager.setSize(Size(_popupWidth, _popupHeight)); - // Wait for GTK to process the resize before locking with setResizable(false). - // Without this delay the WM may freeze the window at the old (large) size. - if (Platform.isLinux) { - await Future.delayed(const Duration(milliseconds: 100)); - } - await windowManager.setResizable(false); - await _positionNearCursor(); - } - - static const double _gateWidth = 480; - static const double _gateHeight = 540; - - bool _gateMode = false; - bool get isGateMode => _gateMode; - - Future enterGateMode() async { - AppLogger.info('AppWindow.enterGateMode: starting'); - _gateMode = true; - await windowManager.setResizable(false); - await windowManager.setMinimumSize(const Size(_gateWidth, _gateHeight)); - await windowManager.setMaximumSize(const Size(_gateWidth, _gateHeight)); - await windowManager.setSize(const Size(_gateWidth, _gateHeight)); - await windowManager.setAlwaysOnTop(false); - await windowManager.setSkipTaskbar(false); - await windowManager.center(); - await windowManager.show(); - await windowManager.focus(); - _visible = true; - AppLogger.info('AppWindow.enterGateMode: done'); - } - - Future exitGateMode() async { - _gateMode = false; - await windowManager.setAlwaysOnTop(true); - await windowManager.setSkipTaskbar(!(showInTaskbar && Platform.isWindows)); - await windowManager.setMinimumSize(Size(_popupWidth, 400)); - await windowManager.setMaximumSize(Size(_popupWidth, 900)); - await windowManager.setSize(Size(_popupWidth, _popupHeight)); - await windowManager.hide(); - _visible = false; - } -} +// coverage:ignore-file +import 'dart:ffi' hide Size; +import 'dart:io'; +import 'dart:ui' show Color, Offset, Size; + +import 'package:core/core.dart'; +import 'package:ffi/ffi.dart'; +import 'package:flutter_acrylic/flutter_acrylic.dart'; +import 'package:listener/listener.dart'; +import 'package:window_manager/window_manager.dart'; + +import 'linux_shell.dart'; + +typedef _SystemParametersInfoWNative = + Int32 Function( + Uint32 uiAction, + Uint32 uiParam, + Pointer lpvParam, + Uint32 fWinIni, + ); +typedef _SystemParametersInfoWDart = + int Function(int uiAction, int uiParam, Pointer lpvParam, int fWinIni); + +typedef _GetCursorPosNative = Int32 Function(Pointer lpPoint); +typedef _GetCursorPosDart = int Function(Pointer lpPoint); + +typedef _MonitorFromPointNative = + IntPtr Function(Int32 x, Int32 y, Uint32 dwFlags); +typedef _MonitorFromPointDart = int Function(int x, int y, int dwFlags); + +typedef _GetMonitorInfoWNative = Int32 Function(IntPtr hMonitor, Pointer lpmi); +typedef _GetMonitorInfoWDart = int Function(int hMonitor, Pointer lpmi); + +class _Win32Pos { + _Win32Pos._(); + static _Win32Pos? _instance; + static _Win32Pos get instance => _instance ??= _Win32Pos._(); + + late final _u32 = DynamicLibrary.open('user32.dll'); + late final spiFunc = _u32 + .lookupFunction<_SystemParametersInfoWNative, _SystemParametersInfoWDart>( + 'SystemParametersInfoW', + ); + late final getCursorPosFunc = _u32 + .lookupFunction<_GetCursorPosNative, _GetCursorPosDart>('GetCursorPos'); + late final monitorFromPointFunc = _u32 + .lookupFunction<_MonitorFromPointNative, _MonitorFromPointDart>( + 'MonitorFromPoint', + ); + late final getMonitorInfoFunc = _u32 + .lookupFunction<_GetMonitorInfoWNative, _GetMonitorInfoWDart>( + 'GetMonitorInfoW', + ); +} + +class AppWindow { + AppWindow({ + this.onVisibilityChanged, + this.showInTaskbar = true, + double popupWidth = 360, + double popupHeight = 500, + }) : _popupWidth = popupWidth, + _popupHeight = popupHeight; + + bool showInTaskbar; + + static const double _settingsWidth = 820; + static const double _settingsHeight = 680; + + final void Function(bool visible)? onVisibilityChanged; + double _popupWidth; + double _popupHeight; + bool _visible = false; + bool _ready = false; + bool _settingsMode = false; + + bool get isVisible => _visible; + bool get isReady => _ready; + bool get isSettingsMode => _settingsMode; + + void updatePopupSize(double width, double height) { + _popupWidth = width; + _popupHeight = height; + } + + Future init({bool startVisible = false}) async { + AppLogger.info( + 'AppWindow.init: startVisible=$startVisible, ' + 'showInTaskbar=$showInTaskbar, ' + 'size=${_popupWidth}x$_popupHeight', + ); + try { + await windowManager + .waitUntilReadyToShow(null, () async { + await _configureWindow(startVisible); + }) + .timeout(const Duration(seconds: 5)); + AppLogger.info('AppWindow.init: waitUntilReadyToShow completed'); + } catch (e) { + AppLogger.warn( + 'AppWindow.init: waitUntilReadyToShow failed ($e), ' + 'attempting direct configuration', + ); + try { + await _configureWindow(startVisible); + AppLogger.info('AppWindow.init: direct configuration succeeded'); + } catch (e2) { + AppLogger.error('Window configuration failed: $e2'); + } + } + _visible = startVisible; + _ready = true; + AppLogger.info('AppWindow.init: done, ready=$_ready, visible=$_visible'); + } + + Future _configureWindow(bool startVisible) async { + await windowManager.setTitle('CopyPaste'); + await windowManager.setSize(Size(_popupWidth, _popupHeight)); + await windowManager.setMinimumSize(Size(_popupWidth, 400)); + await windowManager.setMaximumSize(Size(_popupWidth, 900)); + await windowManager.setTitleBarStyle( + TitleBarStyle.hidden, + windowButtonVisibility: !Platform.isMacOS, + ); + await windowManager.setAlwaysOnTop(true); + await windowManager.setResizable(false); + await windowManager.setMaximizable(false); + await windowManager.setPreventClose(true); + final inTaskbar = showInTaskbar && Platform.isWindows; + await windowManager.setSkipTaskbar(!inTaskbar); + if (Platform.isWindows || Platform.isMacOS) { + await windowManager.setBackgroundColor(const Color(0x00000000)); + AppLogger.info('_configureWindow: applying initial effect'); + await applyEffect(); + } + if (startVisible) { + 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(); + } + } + + bool _isDark = false; + + Future applyEffect({bool? dark}) async { + if (dark != null) _isDark = dark; + try { + if (Platform.isWindows) { + await Window.setEffect( + effect: WindowEffect.mica, + color: const Color(0x00000000), + dark: _isDark, + ).timeout(const Duration(seconds: 2)); + } else if (Platform.isMacOS) { + await Window.setEffect( + effect: WindowEffect.sidebar, + color: const Color(0x00000000), + dark: _isDark, + ).timeout(const Duration(seconds: 2)); + } + } catch (e) { + // Effect failure is non-fatal — app runs without the acrylic effect. + AppLogger.warn('applyEffect: window effect unavailable (non-fatal): $e'); + } + } + + Future _positionNearCursor() async { + if (Platform.isWindows) { + await _positionNearCursorWindows(); + } else if (Platform.isMacOS || Platform.isLinux) { + await _positionNearCursorNative(); + } else { + await windowManager.center(); + } + } + + Future _positionNearCursorWindows() async { + try { + final cursor = _getCursorPosWin32(); + if (cursor == null) { + await windowManager.center(); + return; + } + final workArea = _getWorkAreaForPointWin32(cursor.$1, cursor.$2); + if (workArea == null) { + await windowManager.center(); + return; + } + await _applyPosition(cursor.$1, cursor.$2, workArea); + } catch (e) { + AppLogger.warn('_positionNearCursorWindows: fallback to center: $e'); + await windowManager.center(); + } + } + + Future _positionNearCursorNative() async { + try { + final info = await ClipboardWriter.getCursorAndScreenInfo(); + if (info == null) { + await windowManager.center(); + return; + } + final cursorX = info['cursorX'] ?? 0; + final cursorY = info['cursorY'] ?? 0; + final workArea = ( + info['waLeft'] ?? 0, + info['waTop'] ?? 0, + info['waRight'] ?? 1440, + info['waBottom'] ?? 900, + ); + await _applyPosition(cursorX, cursorY, workArea); + } catch (e) { + AppLogger.warn('_positionNearCursorNative: fallback to center: $e'); + await windowManager.center(); + } + } + + Future _applyPosition( + double cursorX, + double cursorY, + (double, double, double, double) workArea, + ) async { + final waLeft = workArea.$1; + final waTop = workArea.$2; + final waRight = workArea.$3; + final waBottom = workArea.$4; + + double x; + double y; + + if (cursorX + _popupWidth + 12 <= waRight) { + x = cursorX + 12; + } else if (cursorX - _popupWidth - 12 >= waLeft) { + x = cursorX - _popupWidth - 12; + } else { + x = waRight - _popupWidth - 12; + } + + y = cursorY - _popupHeight / 2; + if (y < waTop + 8) y = waTop + 8; + if (y + _popupHeight > waBottom - 8) y = waBottom - _popupHeight - 8; + + x = x.clamp(waLeft, waRight - _popupWidth); + y = y.clamp(waTop, waBottom - _popupHeight); + + await windowManager.setPosition(Offset(x, y)); + } + + static (double, double)? _getCursorPosWin32() { + final w = _Win32Pos.instance; + final pt = calloc(2); + try { + final result = w.getCursorPosFunc(pt); + if (result == 0) return null; + return (pt[0].toDouble(), pt[1].toDouble()); + } finally { + calloc.free(pt); + } + } + + static (double, double, double, double)? _getWorkAreaForPointWin32( + double x, + double y, + ) { + const monitorDefaultToNearest = 0x00000002; + final w = _Win32Pos.instance; + final hMonitor = w.monitorFromPointFunc( + x.toInt(), + y.toInt(), + monitorDefaultToNearest, + ); + if (hMonitor == 0) return _getWorkAreaWin32(); + + final mi = calloc(10); + try { + mi[0] = 40; + final result = w.getMonitorInfoFunc(hMonitor, mi); + if (result == 0) return _getWorkAreaWin32(); + return ( + mi[5].toDouble(), + mi[6].toDouble(), + mi[7].toDouble(), + mi[8].toDouble(), + ); + } finally { + calloc.free(mi); + } + } + + static (double, double, double, double)? _getWorkAreaWin32() { + const spiGetWorkArea = 0x0030; + final w = _Win32Pos.instance; + final rect = calloc(4); + try { + final result = w.spiFunc(spiGetWorkArea, 0, rect, 0); + if (result == 0) return null; + return ( + rect[0].toDouble(), + rect[1].toDouble(), + rect[2].toDouble(), + rect[3].toDouble(), + ); + } finally { + calloc.free(rect); + } + } + + Future show() async { + AppLogger.info('AppWindow.show: starting'); + if (Platform.isLinux) { + // On X11/GTK, show the window first (so it gets realized/mapped by the WM), + // then set the position (avoids WM initial-placement overriding our offset), + // then focus via gtk_window_present_with_time so GNOME doesn't block focus + // and show a spurious "está preparado" notification. + await windowManager.setSkipTaskbar(false); + await windowManager.show(); + await _positionNearCursor(); + await LinuxShell.focusWindow(); + } else { + await _positionNearCursor(); + if (Platform.isWindows) { + await windowManager.setSkipTaskbar(false); + } + await windowManager.show(); + await windowManager.focus(); + AppLogger.info('AppWindow.show: window shown and focused'); + if (Platform.isWindows) { + await applyEffect(); + } + } + _visible = true; + onVisibilityChanged?.call(true); + } + + Future hide() async { + if (!_visible) return; + _visible = false; + 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; + } + } + onVisibilityChanged?.call(false); + } + + Future toggle() async { + if (_visible) { + await hide(); + } else { + await show(); + } + } + + Future hideIfNotPinned() async { + if (_visible && !_settingsMode) { + await hide(); + } + } + + Future enterSettingsMode() async { + _settingsMode = true; + await windowManager.setResizable(true); + // GTK processes geometry hints asynchronously — wait one frame before + // applying new constraints so the WM doesn't reject the resize. + if (Platform.isLinux) { + await Future.delayed(const Duration(milliseconds: 50)); + } + await windowManager.setMinimumSize( + const Size(_settingsWidth, _settingsHeight), + ); + await windowManager.setMaximumSize(const Size(1200, 900)); + await windowManager.setSize(const Size(_settingsWidth, _settingsHeight)); + await windowManager.center(); + // Settings mode implies the window must be visible and focused. Without + // this, transitioning from gate/onboarding (which hides the window on + // exit) leaves Settings invisible behind other windows. + if (!await windowManager.isVisible()) { + await windowManager.show(); + } + await windowManager.focus(); + _visible = true; + } + + Future exitSettingsMode() async { + _settingsMode = false; + // On Linux the window may still be in resizable=true state from settings + // mode. Reset it explicitly and wait for GTK to process before resizing. + if (Platform.isLinux) { + await windowManager.setResizable(true); + await Future.delayed(const Duration(milliseconds: 50)); + } + await windowManager.setMinimumSize(Size(_popupWidth, 400)); + await windowManager.setMaximumSize(Size(_popupWidth, 900)); + await windowManager.setSize(Size(_popupWidth, _popupHeight)); + // Wait for GTK to process the resize before locking with setResizable(false). + // Without this delay the WM may freeze the window at the old (large) size. + if (Platform.isLinux) { + await Future.delayed(const Duration(milliseconds: 100)); + } + await windowManager.setResizable(false); + await _positionNearCursor(); + } + + static const double _gateWidth = 480; + static const double _gateHeight = 540; + + bool _gateMode = false; + bool get isGateMode => _gateMode; + + Future enterGateMode() async { + AppLogger.info('AppWindow.enterGateMode: starting'); + _gateMode = true; + await windowManager.setResizable(false); + await windowManager.setMinimumSize(const Size(_gateWidth, _gateHeight)); + await windowManager.setMaximumSize(const Size(_gateWidth, _gateHeight)); + await windowManager.setSize(const Size(_gateWidth, _gateHeight)); + await windowManager.setAlwaysOnTop(false); + await windowManager.setSkipTaskbar(false); + await windowManager.center(); + await windowManager.show(); + await windowManager.focus(); + _visible = true; + AppLogger.info('AppWindow.enterGateMode: done'); + } + + Future exitGateMode() async { + _gateMode = false; + await windowManager.setAlwaysOnTop(true); + await windowManager.setSkipTaskbar(!(showInTaskbar && Platform.isWindows)); + await windowManager.setMinimumSize(Size(_popupWidth, 400)); + await windowManager.setMaximumSize(Size(_popupWidth, 900)); + await windowManager.setSize(Size(_popupWidth, _popupHeight)); + await windowManager.hide(); + _visible = false; + } +} diff --git a/app/lib/shell/focus_manager.dart b/app/lib/shell/focus_manager.dart index 63ead254..40ad1021 100644 --- a/app/lib/shell/focus_manager.dart +++ b/app/lib/shell/focus_manager.dart @@ -1,225 +1,229 @@ -// coverage:ignore-file -import 'dart:ffi'; -import 'dart:io'; - -import 'package:ffi/ffi.dart'; -import 'package:listener/listener.dart'; - -typedef _GetForegroundWindowNative = IntPtr Function(); -typedef _GetForegroundWindowDart = int Function(); - -typedef _IsWindowNative = Int32 Function(IntPtr hWnd); -typedef _IsWindowDart = int Function(int hWnd); - -typedef _IsWindowVisibleNative = Int32 Function(IntPtr hWnd); -typedef _IsWindowVisibleDart = int Function(int hWnd); - -typedef _SetForegroundWindowNative = Int32 Function(IntPtr hWnd); -typedef _SetForegroundWindowDart = int Function(int hWnd); - -typedef _BringWindowToTopNative = Int32 Function(IntPtr hWnd); -typedef _BringWindowToTopDart = int Function(int hWnd); - -typedef _ShowWindowNative = Int32 Function(IntPtr hWnd, Int32 nCmdShow); -typedef _ShowWindowDart = int Function(int hWnd, int nCmdShow); - -typedef _GetWindowLongPtrNative = IntPtr Function(IntPtr hWnd, Int32 nIndex); -typedef _GetWindowLongPtrDart = int Function(int hWnd, int nIndex); - -typedef _GetWindowThreadProcessIdNative = - Uint32 Function(IntPtr hWnd, Pointer lpdwProcessId); -typedef _GetWindowThreadProcessIdDart = - int Function(int hWnd, Pointer lpdwProcessId); - -typedef _GetCurrentThreadIdNative = Uint32 Function(); -typedef _GetCurrentThreadIdDart = int Function(); - -typedef _AttachThreadInputNative = - Int32 Function(Uint32 idAttach, Uint32 idAttachTo, Int32 fAttach); -typedef _AttachThreadInputDart = - int Function(int idAttach, int idAttachTo, int fAttach); - -typedef _KeybdEventNative = - Void Function(Uint8 bVk, Uint8 bScan, Uint32 dwFlags, IntPtr dwExtraInfo); -typedef _KeybdEventDart = - void Function(int bVk, int bScan, int dwFlags, int dwExtraInfo); - -class _Win32 { - _Win32._() { - assert(Platform.isWindows, '_Win32 requires Windows'); - } - static _Win32? _instance; - static _Win32 get instance => _instance ??= _Win32._(); - - static const int swRestore = 9; - static const int gwlStyle = -16; - static const int wsMinimize = 0x20000000; - static const int keyeventfKeyup = 0x0002; - static const int vkControl = 0x11; - static const int vkV = 0x56; - - late final _user32 = DynamicLibrary.open('user32.dll'); - late final _kernel32 = DynamicLibrary.open('kernel32.dll'); - - late final getForegroundWindow = _user32 - .lookupFunction<_GetForegroundWindowNative, _GetForegroundWindowDart>( - 'GetForegroundWindow', - ); - late final isWindow = _user32.lookupFunction<_IsWindowNative, _IsWindowDart>( - 'IsWindow', - ); - late final isWindowVisible = _user32 - .lookupFunction<_IsWindowVisibleNative, _IsWindowVisibleDart>( - 'IsWindowVisible', - ); - late final setForegroundWindow = _user32 - .lookupFunction<_SetForegroundWindowNative, _SetForegroundWindowDart>( - 'SetForegroundWindow', - ); - late final bringWindowToTop = _user32 - .lookupFunction<_BringWindowToTopNative, _BringWindowToTopDart>( - 'BringWindowToTop', - ); - late final showWindow = _user32 - .lookupFunction<_ShowWindowNative, _ShowWindowDart>('ShowWindow'); - late final getWindowLongPtr = _user32 - .lookupFunction<_GetWindowLongPtrNative, _GetWindowLongPtrDart>( - 'GetWindowLongPtrW', - ); - late final getWindowThreadProcessId = _user32 - .lookupFunction< - _GetWindowThreadProcessIdNative, - _GetWindowThreadProcessIdDart - >('GetWindowThreadProcessId'); - late final getCurrentThreadId = _kernel32 - .lookupFunction<_GetCurrentThreadIdNative, _GetCurrentThreadIdDart>( - 'GetCurrentThreadId', - ); - late final attachThreadInput = _user32 - .lookupFunction<_AttachThreadInputNative, _AttachThreadInputDart>( - 'AttachThreadInput', - ); - late final keybdEvent = _user32 - .lookupFunction<_KeybdEventNative, _KeybdEventDart>('keybd_event'); -} - -class WindowFocusManager { - int _previousWindow = 0; - int _previousThreadId = 0; - String? _previousBundleId; - - Future capturePreviousWindow() async { - if (Platform.isWindows) { - _capturePreviousWindows(); - } else if (Platform.isMacOS || Platform.isLinux) { - _previousBundleId = await ClipboardWriter.captureFrontmostApp(); - } - } - - Future restoreAndPaste({ - required int delayBeforeFocusMs, - required int maxFocusVerifyAttempts, - required int delayBeforePasteMs, - }) async { - if (Platform.isWindows && _previousWindow == 0) return; - if ((Platform.isMacOS || Platform.isLinux) && _previousBundleId == null) { - return; - } - - try { - await Future.delayed(Duration(milliseconds: delayBeforeFocusMs)); - - if (Platform.isMacOS || Platform.isLinux) { - await ClipboardWriter.activateAndPaste( - bundleId: _previousBundleId!, - delayMs: delayBeforePasteMs, - ); - return; - } - - if (!_restorePreviousWindows()) return; - - final focused = await _waitForFocusWindows(maxFocusVerifyAttempts); - if (!focused) { - await Future.delayed(Duration(milliseconds: delayBeforePasteMs)); - } else { - await Future.delayed(const Duration(milliseconds: 30)); - } - - _simulatePasteWindows(); - } finally { - clear(); - } - } - - void clear() { - _previousWindow = 0; - _previousThreadId = 0; - _previousBundleId = null; - } - - void _capturePreviousWindows() { - final w = _Win32.instance; - final hwnd = w.getForegroundWindow(); - if (hwnd != 0 && w.isWindow(hwnd) != 0 && w.isWindowVisible(hwnd) != 0) { - _previousWindow = hwnd; - final pidPtr = calloc(); - try { - _previousThreadId = w.getWindowThreadProcessId(hwnd, pidPtr); - } finally { - calloc.free(pidPtr); - } - } else { - _previousWindow = 0; - _previousThreadId = 0; - } - } - - bool _restorePreviousWindows() { - if (_previousWindow == 0) return false; - final w = _Win32.instance; - if (w.isWindow(_previousWindow) == 0) { - _previousWindow = 0; - return false; - } - - final currentThreadId = w.getCurrentThreadId(); - var attached = false; - - if (currentThreadId != _previousThreadId && _previousThreadId != 0) { - attached = - w.attachThreadInput(currentThreadId, _previousThreadId, 1) != 0; - } - - try { - final style = w.getWindowLongPtr(_previousWindow, _Win32.gwlStyle); - if (style & _Win32.wsMinimize != 0) { - w.showWindow(_previousWindow, _Win32.swRestore); - } - - w.bringWindowToTop(_previousWindow); - return w.setForegroundWindow(_previousWindow) != 0; - } finally { - if (attached) { - w.attachThreadInput(currentThreadId, _previousThreadId, 0); - } - } - } - - Future _waitForFocusWindows(int maxAttempts) async { - final w = _Win32.instance; - for (var i = 0; i < maxAttempts; i++) { - if (w.getForegroundWindow() == _previousWindow) return true; - await Future.delayed(const Duration(milliseconds: 10)); - } - return false; - } - - void _simulatePasteWindows() { - final w = _Win32.instance; - w.keybdEvent(_Win32.vkControl, 0, 0, 0); - w.keybdEvent(_Win32.vkV, 0, 0, 0); - w.keybdEvent(_Win32.vkV, 0, _Win32.keyeventfKeyup, 0); - w.keybdEvent(_Win32.vkControl, 0, _Win32.keyeventfKeyup, 0); - } -} +// coverage:ignore-file +import 'dart:ffi'; +import 'dart:io'; + +import 'package:ffi/ffi.dart'; +import 'package:listener/listener.dart'; + +typedef _GetForegroundWindowNative = IntPtr Function(); +typedef _GetForegroundWindowDart = int Function(); + +typedef _IsWindowNative = Int32 Function(IntPtr hWnd); +typedef _IsWindowDart = int Function(int hWnd); + +typedef _IsWindowVisibleNative = Int32 Function(IntPtr hWnd); +typedef _IsWindowVisibleDart = int Function(int hWnd); + +typedef _SetForegroundWindowNative = Int32 Function(IntPtr hWnd); +typedef _SetForegroundWindowDart = int Function(int hWnd); + +typedef _BringWindowToTopNative = Int32 Function(IntPtr hWnd); +typedef _BringWindowToTopDart = int Function(int hWnd); + +typedef _ShowWindowNative = Int32 Function(IntPtr hWnd, Int32 nCmdShow); +typedef _ShowWindowDart = int Function(int hWnd, int nCmdShow); + +typedef _GetWindowLongPtrNative = IntPtr Function(IntPtr hWnd, Int32 nIndex); +typedef _GetWindowLongPtrDart = int Function(int hWnd, int nIndex); + +typedef _GetWindowThreadProcessIdNative = + Uint32 Function(IntPtr hWnd, Pointer lpdwProcessId); +typedef _GetWindowThreadProcessIdDart = + int Function(int hWnd, Pointer lpdwProcessId); + +typedef _GetCurrentThreadIdNative = Uint32 Function(); +typedef _GetCurrentThreadIdDart = int Function(); + +typedef _AttachThreadInputNative = + Int32 Function(Uint32 idAttach, Uint32 idAttachTo, Int32 fAttach); +typedef _AttachThreadInputDart = + int Function(int idAttach, int idAttachTo, int fAttach); + +typedef _KeybdEventNative = + Void Function(Uint8 bVk, Uint8 bScan, Uint32 dwFlags, IntPtr dwExtraInfo); +typedef _KeybdEventDart = + void Function(int bVk, int bScan, int dwFlags, int dwExtraInfo); + +class _Win32 { + _Win32._() { + assert(Platform.isWindows, '_Win32 requires Windows'); + } + static _Win32? _instance; + static _Win32 get instance => _instance ??= _Win32._(); + + static const int swRestore = 9; + static const int gwlStyle = -16; + static const int wsMinimize = 0x20000000; + static const int keyeventfKeyup = 0x0002; + static const int vkControl = 0x11; + static const int vkV = 0x56; + + late final _user32 = DynamicLibrary.open('user32.dll'); + late final _kernel32 = DynamicLibrary.open('kernel32.dll'); + + late final getForegroundWindow = _user32 + .lookupFunction<_GetForegroundWindowNative, _GetForegroundWindowDart>( + 'GetForegroundWindow', + ); + late final isWindow = _user32.lookupFunction<_IsWindowNative, _IsWindowDart>( + 'IsWindow', + ); + late final isWindowVisible = _user32 + .lookupFunction<_IsWindowVisibleNative, _IsWindowVisibleDart>( + 'IsWindowVisible', + ); + late final setForegroundWindow = _user32 + .lookupFunction<_SetForegroundWindowNative, _SetForegroundWindowDart>( + 'SetForegroundWindow', + ); + late final bringWindowToTop = _user32 + .lookupFunction<_BringWindowToTopNative, _BringWindowToTopDart>( + 'BringWindowToTop', + ); + late final showWindow = _user32 + .lookupFunction<_ShowWindowNative, _ShowWindowDart>('ShowWindow'); + late final getWindowLongPtr = _user32 + .lookupFunction<_GetWindowLongPtrNative, _GetWindowLongPtrDart>( + 'GetWindowLongPtrW', + ); + late final getWindowThreadProcessId = _user32 + .lookupFunction< + _GetWindowThreadProcessIdNative, + _GetWindowThreadProcessIdDart + >('GetWindowThreadProcessId'); + late final getCurrentThreadId = _kernel32 + .lookupFunction<_GetCurrentThreadIdNative, _GetCurrentThreadIdDart>( + 'GetCurrentThreadId', + ); + late final attachThreadInput = _user32 + .lookupFunction<_AttachThreadInputNative, _AttachThreadInputDart>( + 'AttachThreadInput', + ); + late final keybdEvent = _user32 + .lookupFunction<_KeybdEventNative, _KeybdEventDart>('keybd_event'); +} + +class WindowFocusManager { + int _previousWindow = 0; + int _previousThreadId = 0; + String? _previousBundleId; + + Future capturePreviousWindow() async { + if (Platform.isWindows) { + _capturePreviousWindows(); + } else if (Platform.isMacOS || Platform.isLinux) { + _previousBundleId = await ClipboardWriter.captureFrontmostApp(); + } + } + + Future restoreAndPaste({ + required int delayBeforeFocusMs, + required int maxFocusVerifyAttempts, + required int delayBeforePasteMs, + }) async { + if (Platform.isWindows && _previousWindow == 0) { + return const PasteResponse(success: false, errorCode: 'noPreviousWindow'); + } + if ((Platform.isMacOS || Platform.isLinux) && _previousBundleId == null) { + return const PasteResponse(success: false, errorCode: 'noPreviousWindow'); + } + + try { + await Future.delayed(Duration(milliseconds: delayBeforeFocusMs)); + + if (Platform.isMacOS || Platform.isLinux) { + return await ClipboardWriter.activateAndPaste( + bundleId: _previousBundleId!, + delayMs: delayBeforePasteMs, + ); + } + + if (!_restorePreviousWindows()) { + return const PasteResponse(success: false, errorCode: 'restoreFailed'); + } + + final focused = await _waitForFocusWindows(maxFocusVerifyAttempts); + if (!focused) { + await Future.delayed(Duration(milliseconds: delayBeforePasteMs)); + } else { + await Future.delayed(const Duration(milliseconds: 30)); + } + + _simulatePasteWindows(); + return const PasteResponse(success: true); + } finally { + clear(); + } + } + + void clear() { + _previousWindow = 0; + _previousThreadId = 0; + _previousBundleId = null; + } + + void _capturePreviousWindows() { + final w = _Win32.instance; + final hwnd = w.getForegroundWindow(); + if (hwnd != 0 && w.isWindow(hwnd) != 0 && w.isWindowVisible(hwnd) != 0) { + _previousWindow = hwnd; + final pidPtr = calloc(); + try { + _previousThreadId = w.getWindowThreadProcessId(hwnd, pidPtr); + } finally { + calloc.free(pidPtr); + } + } else { + _previousWindow = 0; + _previousThreadId = 0; + } + } + + bool _restorePreviousWindows() { + if (_previousWindow == 0) return false; + final w = _Win32.instance; + if (w.isWindow(_previousWindow) == 0) { + _previousWindow = 0; + return false; + } + + final currentThreadId = w.getCurrentThreadId(); + var attached = false; + + if (currentThreadId != _previousThreadId && _previousThreadId != 0) { + attached = + w.attachThreadInput(currentThreadId, _previousThreadId, 1) != 0; + } + + try { + final style = w.getWindowLongPtr(_previousWindow, _Win32.gwlStyle); + if (style & _Win32.wsMinimize != 0) { + w.showWindow(_previousWindow, _Win32.swRestore); + } + + w.bringWindowToTop(_previousWindow); + return w.setForegroundWindow(_previousWindow) != 0; + } finally { + if (attached) { + w.attachThreadInput(currentThreadId, _previousThreadId, 0); + } + } + } + + Future _waitForFocusWindows(int maxAttempts) async { + final w = _Win32.instance; + for (var i = 0; i < maxAttempts; i++) { + if (w.getForegroundWindow() == _previousWindow) return true; + await Future.delayed(const Duration(milliseconds: 10)); + } + return false; + } + + void _simulatePasteWindows() { + final w = _Win32.instance; + w.keybdEvent(_Win32.vkControl, 0, 0, 0); + w.keybdEvent(_Win32.vkV, 0, 0, 0); + w.keybdEvent(_Win32.vkV, 0, _Win32.keyeventfKeyup, 0); + w.keybdEvent(_Win32.vkControl, 0, _Win32.keyeventfKeyup, 0); + } +} diff --git a/app/lib/shell/linux_shell.dart b/app/lib/shell/linux_shell.dart index 77cc58bc..e89e6c30 100644 --- a/app/lib/shell/linux_shell.dart +++ b/app/lib/shell/linux_shell.dart @@ -40,6 +40,25 @@ class LinuxShell { _eventsController = null; } + static Future awaitEvent( + String type, { + Duration timeout = const Duration(milliseconds: 300), + }) async { + final completer = Completer(); + final sub = events.listen((event) { + if (event == type && !completer.isCompleted) completer.complete(true); + }); + final timer = Timer(timeout, () { + if (!completer.isCompleted) completer.complete(false); + }); + try { + return await completer.future; + } finally { + timer.cancel(); + await sub.cancel(); + } + } + static Future initTray({ required String iconPath, required String showHideLabel, diff --git a/app/linux/runner/copypaste_linux_shell.c b/app/linux/runner/copypaste_linux_shell.c index 1ae631d2..23f03cab 100644 --- a/app/linux/runner/copypaste_linux_shell.c +++ b/app/linux/runner/copypaste_linux_shell.c @@ -76,6 +76,22 @@ static void send_shell_event(CopyPasteLinuxShell* shell, const gchar* type) { } } +static gboolean window_unmap_event_cb(GtkWidget* widget, GdkEvent* event, + gpointer user_data) { + (void)widget; + (void)event; + send_shell_event((CopyPasteLinuxShell*)user_data, "unmapped"); + return FALSE; +} + +static gboolean window_map_event_cb(GtkWidget* widget, GdkEvent* event, + gpointer user_data) { + (void)widget; + (void)event; + send_shell_event((CopyPasteLinuxShell*)user_data, "mapped"); + return FALSE; +} + static gchar* resolve_asset_path(const gchar* asset_path) { if (asset_path == NULL || *asset_path == '\0') { return NULL; @@ -701,6 +717,13 @@ CopyPasteLinuxShell* copypaste_linux_shell_new(FlBinaryMessenger* messenger, fl_event_channel_set_stream_handlers(shell->event_channel, shell_listen_cb, shell_cancel_cb, shell, NULL); + if (shell->gtk_window != NULL) { + g_signal_connect(shell->gtk_window, "unmap-event", + G_CALLBACK(window_unmap_event_cb), shell); + g_signal_connect(shell->gtk_window, "map-event", + G_CALLBACK(window_map_event_cb), shell); + } + #ifdef GDK_WINDOWING_X11 if (shell_is_x11()) { GdkDisplay* display = gdk_display_get_default(); diff --git a/app/linux/runner/my_application.cc b/app/linux/runner/my_application.cc index 18560197..3aa75920 100644 --- a/app/linux/runner/my_application.cc +++ b/app/linux/runner/my_application.cc @@ -1,133 +1,133 @@ -#include "my_application.h" - -#include -#ifdef GDK_WINDOWING_X11 -#include -#endif - -#include "copypaste_linux_shell.h" -#include "flutter/generated_plugin_registrant.h" - -struct _MyApplication { - GtkApplication parent_instance; - char** dart_entrypoint_arguments; - CopyPasteLinuxShell* shell; -}; - -G_DEFINE_TYPE(MyApplication, my_application, GTK_TYPE_APPLICATION) - -static void my_application_activate(GApplication* application) { - MyApplication* self = MY_APPLICATION(application); - GtkWindow* window = - GTK_WINDOW(gtk_application_window_new(GTK_APPLICATION(application))); - - gboolean use_header_bar = TRUE; -#ifdef GDK_WINDOWING_X11 - GdkScreen* screen = gtk_window_get_screen(window); - if (GDK_IS_X11_SCREEN(screen)) { - const gchar* wm_name = gdk_x11_screen_get_window_manager_name(screen); - if (g_strcmp0(wm_name, "GNOME Shell") != 0) { - use_header_bar = FALSE; - } - } -#endif - if (use_header_bar) { - GtkHeaderBar* header_bar = GTK_HEADER_BAR(gtk_header_bar_new()); - gtk_widget_show(GTK_WIDGET(header_bar)); - gtk_header_bar_set_title(header_bar, "CopyPaste"); - gtk_header_bar_set_show_close_button(header_bar, TRUE); - gtk_window_set_titlebar(window, GTK_WIDGET(header_bar)); - } else { - gtk_window_set_title(window, "CopyPaste"); - } - - gtk_window_set_default_size(window, 368, 500); - - g_autoptr(FlDartProject) project = fl_dart_project_new(); - fl_dart_project_set_dart_entrypoint_arguments( - project, self->dart_entrypoint_arguments); - - FlView* view = fl_view_new(project); - GdkRGBA background_color; - gdk_rgba_parse(&background_color, "#1a1a2e"); - fl_view_set_background_color(view, &background_color); - gtk_widget_show(GTK_WIDGET(view)); - gtk_container_add(GTK_CONTAINER(window), GTK_WIDGET(view)); - - gtk_widget_realize(GTK_WIDGET(window)); - - fl_register_plugins(FL_PLUGIN_REGISTRY(view)); - - FlBinaryMessenger* messenger = - fl_engine_get_binary_messenger(fl_view_get_engine(view)); - self->shell = copypaste_linux_shell_new(messenger, window); - - gtk_widget_grab_focus(GTK_WIDGET(view)); - - // Complete the XDG startup notification immediately so desktop environments - // (GNOME Shell, KDE Plasma, etc.) don't show "CopyPaste is ready" when the - // window is first made visible on hotkey press. - gdk_notify_startup_complete(); -} - -static gboolean my_application_local_command_line(GApplication* application, - gchar*** arguments, - int* exit_status) { - MyApplication* self = MY_APPLICATION(application); - self->dart_entrypoint_arguments = g_strdupv(*arguments + 1); - - g_autoptr(GError) error = nullptr; - if (!g_application_register(application, nullptr, &error)) { - g_warning("Failed to register: %s", error->message); - *exit_status = 1; - return TRUE; - } - - g_application_activate(application); - *exit_status = 0; - - return TRUE; -} - -static void my_application_startup(GApplication* application) { - G_APPLICATION_CLASS(my_application_parent_class)->startup(application); -} - -static void my_application_shutdown(GApplication* application) { - MyApplication* self = MY_APPLICATION(application); - if (self->shell != nullptr) { - copypaste_linux_shell_dispose(self->shell); - self->shell = nullptr; - } - - G_APPLICATION_CLASS(my_application_parent_class)->shutdown(application); -} - -static void my_application_dispose(GObject* object) { - MyApplication* self = MY_APPLICATION(object); - g_clear_pointer(&self->dart_entrypoint_arguments, g_strfreev); - G_OBJECT_CLASS(my_application_parent_class)->dispose(object); -} - -static void my_application_class_init(MyApplicationClass* klass) { - G_APPLICATION_CLASS(klass)->activate = my_application_activate; - G_APPLICATION_CLASS(klass)->local_command_line = - my_application_local_command_line; - G_APPLICATION_CLASS(klass)->startup = my_application_startup; - G_APPLICATION_CLASS(klass)->shutdown = my_application_shutdown; - G_OBJECT_CLASS(klass)->dispose = my_application_dispose; -} - -static void my_application_init(MyApplication* self) { self->shell = nullptr; } - -MyApplication* my_application_new() { - // Set the program name to the application ID, which helps various systems - // like GTK and desktop environments map this running application to its - // corresponding .desktop file. This ensures better integration by allowing - // the application to be recognized beyond its binary name. - g_set_prgname(APPLICATION_ID); - - return MY_APPLICATION(g_object_new(my_application_get_type(), - "application-id", APPLICATION_ID, "flags", - G_APPLICATION_NON_UNIQUE, nullptr)); -} +#include "my_application.h" + +#include +#ifdef GDK_WINDOWING_X11 +#include +#endif + +#include "copypaste_linux_shell.h" +#include "flutter/generated_plugin_registrant.h" + +struct _MyApplication { + GtkApplication parent_instance; + char** dart_entrypoint_arguments; + CopyPasteLinuxShell* shell; +}; + +G_DEFINE_TYPE(MyApplication, my_application, GTK_TYPE_APPLICATION) + +static void my_application_activate(GApplication* application) { + MyApplication* self = MY_APPLICATION(application); + GtkWindow* window = + GTK_WINDOW(gtk_application_window_new(GTK_APPLICATION(application))); + + gboolean use_header_bar = TRUE; +#ifdef GDK_WINDOWING_X11 + GdkScreen* screen = gtk_window_get_screen(window); + if (GDK_IS_X11_SCREEN(screen)) { + const gchar* wm_name = gdk_x11_screen_get_window_manager_name(screen); + if (g_strcmp0(wm_name, "GNOME Shell") != 0) { + use_header_bar = FALSE; + } + } +#endif + if (use_header_bar) { + GtkHeaderBar* header_bar = GTK_HEADER_BAR(gtk_header_bar_new()); + gtk_widget_show(GTK_WIDGET(header_bar)); + gtk_header_bar_set_title(header_bar, "CopyPaste"); + gtk_header_bar_set_show_close_button(header_bar, TRUE); + gtk_window_set_titlebar(window, GTK_WIDGET(header_bar)); + } else { + gtk_window_set_title(window, "CopyPaste"); + } + + gtk_window_set_default_size(window, 368, 500); + + g_autoptr(FlDartProject) project = fl_dart_project_new(); + fl_dart_project_set_dart_entrypoint_arguments( + project, self->dart_entrypoint_arguments); + + // Complete the XDG startup notification before any widget becomes visible + // so desktop environments don't show a "CopyPaste is starting…" cursor or + // launcher pulse when the window first maps via hotkey. + gdk_notify_startup_complete(); + + FlView* view = fl_view_new(project); + GdkRGBA background_color; + gdk_rgba_parse(&background_color, "#1a1a2e"); + fl_view_set_background_color(view, &background_color); + gtk_widget_show(GTK_WIDGET(view)); + gtk_container_add(GTK_CONTAINER(window), GTK_WIDGET(view)); + + gtk_widget_realize(GTK_WIDGET(window)); + + fl_register_plugins(FL_PLUGIN_REGISTRY(view)); + + FlBinaryMessenger* messenger = + fl_engine_get_binary_messenger(fl_view_get_engine(view)); + self->shell = copypaste_linux_shell_new(messenger, window); + + gtk_widget_grab_focus(GTK_WIDGET(view)); +} + +static gboolean my_application_local_command_line(GApplication* application, + gchar*** arguments, + int* exit_status) { + MyApplication* self = MY_APPLICATION(application); + self->dart_entrypoint_arguments = g_strdupv(*arguments + 1); + + g_autoptr(GError) error = nullptr; + if (!g_application_register(application, nullptr, &error)) { + g_warning("Failed to register: %s", error->message); + *exit_status = 1; + return TRUE; + } + + g_application_activate(application); + *exit_status = 0; + + return TRUE; +} + +static void my_application_startup(GApplication* application) { + G_APPLICATION_CLASS(my_application_parent_class)->startup(application); +} + +static void my_application_shutdown(GApplication* application) { + MyApplication* self = MY_APPLICATION(application); + if (self->shell != nullptr) { + copypaste_linux_shell_dispose(self->shell); + self->shell = nullptr; + } + + G_APPLICATION_CLASS(my_application_parent_class)->shutdown(application); +} + +static void my_application_dispose(GObject* object) { + MyApplication* self = MY_APPLICATION(object); + g_clear_pointer(&self->dart_entrypoint_arguments, g_strfreev); + G_OBJECT_CLASS(my_application_parent_class)->dispose(object); +} + +static void my_application_class_init(MyApplicationClass* klass) { + G_APPLICATION_CLASS(klass)->activate = my_application_activate; + G_APPLICATION_CLASS(klass)->local_command_line = + my_application_local_command_line; + G_APPLICATION_CLASS(klass)->startup = my_application_startup; + G_APPLICATION_CLASS(klass)->shutdown = my_application_shutdown; + G_OBJECT_CLASS(klass)->dispose = my_application_dispose; +} + +static void my_application_init(MyApplication* self) { self->shell = nullptr; } + +MyApplication* my_application_new() { + // Set the program name to the application ID, which helps various systems + // like GTK and desktop environments map this running application to its + // corresponding .desktop file. This ensures better integration by allowing + // the application to be recognized beyond its binary name. + g_set_prgname(APPLICATION_ID); + + return MY_APPLICATION(g_object_new(my_application_get_type(), + "application-id", APPLICATION_ID, "flags", + G_APPLICATION_NON_UNIQUE, nullptr)); +} diff --git a/app/test/shell/linux_shell_test.dart b/app/test/shell/linux_shell_test.dart new file mode 100644 index 00000000..8bccc5ec --- /dev/null +++ b/app/test/shell/linux_shell_test.dart @@ -0,0 +1,72 @@ +import 'dart:async'; + +import 'package:copypaste/shell/linux_shell.dart'; +import 'package:flutter/services.dart'; +import 'package:flutter_test/flutter_test.dart'; + +void main() { + TestWidgetsFlutterBinding.ensureInitialized(); + + const eventChannelName = 'copypaste/linux_shell/events'; + StreamController? controller; + + Future emit(Object event) async { + final messenger = + TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger; + final data = const StandardMethodCodec().encodeSuccessEnvelope(event); + await messenger.handlePlatformMessage(eventChannelName, data, (_) {}); + } + + setUp(() { + controller = StreamController.broadcast(); + TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger + .setMockStreamHandler( + const EventChannel(eventChannelName), + MockStreamHandler.inline( + onListen: (_, sink) { + controller!.stream.listen(sink.success, onError: sink.error); + }, + onCancel: (_) {}, + ), + ); + }); + + tearDown(() async { + await LinuxShell.dispose(); + await controller?.close(); + controller = null; + TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger + .setMockStreamHandler(const EventChannel(eventChannelName), null); + }); + + group('LinuxShell.awaitEvent', () { + test('completes true when matching event arrives', () async { + final future = LinuxShell.awaitEvent( + 'unmapped', + timeout: const Duration(seconds: 1), + ); + await Future.delayed(const Duration(milliseconds: 20)); + await emit({'type': 'unmapped'}); + expect(await future, isTrue); + }); + + test('completes false on timeout when event never arrives', () async { + final result = await LinuxShell.awaitEvent( + 'unmapped', + timeout: const Duration(milliseconds: 50), + ); + expect(result, isFalse); + }); + + test('ignores non-matching events and times out', () async { + final future = LinuxShell.awaitEvent( + 'unmapped', + timeout: const Duration(milliseconds: 80), + ); + await Future.delayed(const Duration(milliseconds: 10)); + await emit({'type': 'mapped'}); + await emit({'type': 'hotkey'}); + expect(await future, isFalse); + }); + }); +} diff --git a/listener/lib/clipboard_writer.dart b/listener/lib/clipboard_writer.dart index fe716172..afe84d02 100644 --- a/listener/lib/clipboard_writer.dart +++ b/listener/lib/clipboard_writer.dart @@ -1,168 +1,189 @@ -import 'dart:convert'; - -import 'package:core/core.dart'; -import 'package:flutter/services.dart'; - -class ClipboardWriter { - static const MethodChannel _channel = MethodChannel( - 'copypaste/clipboard_writer', - ); - - static Future setText( - String content, { - String? metadata, - bool plainText = false, - }) async { - final args = { - 'type': 0, - 'content': content, - 'plainText': plainText, - }; - - if (!plainText && metadata != null && metadata.isNotEmpty) { - try { - final json = jsonDecode(metadata) as Map; - final rtfB64 = json['rtf'] as String?; - if (rtfB64 != null && rtfB64.isNotEmpty) { - args['rtf'] = base64Decode(rtfB64); - } - final htmlB64 = json['html'] as String?; - if (htmlB64 != null && htmlB64.isNotEmpty) { - args['html'] = base64Decode(htmlB64); - } - } catch (e) { - AppLogger.error('ClipboardWriter metadata parse error: $e'); - } - } - - final result = await _channel.invokeMethod( - 'setClipboardContent', - args, - ); - return result ?? false; - } - - static Future setImage(String imagePath) async { - final result = await _channel.invokeMethod( - 'setClipboardContent', - {'type': 1, 'content': imagePath}, - ); - return result ?? false; - } - - static Future setFiles(String content, int typeValue) async { - final result = await _channel.invokeMethod( - 'setClipboardContent', - {'type': typeValue, 'content': content}, - ); - return result ?? false; - } - - static Future setFromItem({ - required int typeValue, - required String content, - String? metadata, - bool plainText = false, - }) async { - switch (typeValue) { - case 0: - case 4: - return setText(content, metadata: metadata, plainText: plainText); - case 1: - return setImage(content); - case 2: - case 3: - case 5: - case 6: - return setFiles(content, typeValue); - default: - return setText(content, plainText: true); - } - } - - static Future?> getMediaInfo(String path) async { - try { - final result = await _channel.invokeMapMethod( - 'getMediaInfo', - {'path': path}, - ); - return result; - } catch (e) { - AppLogger.error('ClipboardWriter.getMediaInfo failed: $e'); - return null; - } - } - - static Future captureFrontmostApp() async { - try { - return await _channel.invokeMethod('captureFrontmostApp'); - } catch (e) { - AppLogger.error('ClipboardWriter.captureFrontmostApp failed: $e'); - return null; - } - } - - static Future activateAndPaste({ - required String bundleId, - required int delayMs, - }) async { - try { - final result = await _channel.invokeMethod( - 'activateAndPaste', - {'bundleId': bundleId, 'delayMs': delayMs}, - ); - 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; - } - } - - static Future?> getCursorAndScreenInfo() async { - try { - final result = await _channel.invokeMapMethod( - 'getCursorAndScreenInfo', - ); - if (result == null) return null; - return result.map((k, v) => MapEntry(k, (v as num).toDouble())); - } catch (e) { - AppLogger.error('ClipboardWriter.getCursorAndScreenInfo failed: $e'); - return null; - } - } - - static Future checkAccessibility() async { - try { - final result = await _channel.invokeMethod('checkAccessibility'); - return result ?? false; - } catch (e) { - AppLogger.error('ClipboardWriter.checkAccessibility failed: $e'); - return false; - } - } - - static Future requestAccessibility() async { - try { - final result = await _channel.invokeMethod('requestAccessibility'); - return result ?? false; - } catch (e) { - AppLogger.error('ClipboardWriter.requestAccessibility failed: $e'); - return false; - } - } - - static Future openAccessibilitySettings() async { - try { - await _channel.invokeMethod('openAccessibilitySettings'); - } catch (e) { - AppLogger.error('ClipboardWriter.openAccessibilitySettings failed: $e'); - } - } -} +import 'dart:convert'; + +import 'package:core/core.dart'; +import 'package:flutter/services.dart'; + +class ClipboardWriter { + static const MethodChannel _channel = MethodChannel( + 'copypaste/clipboard_writer', + ); + + static Future setText( + String content, { + String? metadata, + bool plainText = false, + }) async { + final args = { + 'type': 0, + 'content': content, + 'plainText': plainText, + }; + + if (!plainText && metadata != null && metadata.isNotEmpty) { + try { + final json = jsonDecode(metadata) as Map; + final rtfB64 = json['rtf'] as String?; + if (rtfB64 != null && rtfB64.isNotEmpty) { + args['rtf'] = base64Decode(rtfB64); + } + final htmlB64 = json['html'] as String?; + if (htmlB64 != null && htmlB64.isNotEmpty) { + args['html'] = base64Decode(htmlB64); + } + } catch (e) { + AppLogger.error('ClipboardWriter metadata parse error: $e'); + } + } + + final result = await _channel.invokeMethod( + 'setClipboardContent', + args, + ); + return result ?? false; + } + + static Future setImage(String imagePath) async { + final result = await _channel.invokeMethod( + 'setClipboardContent', + {'type': 1, 'content': imagePath}, + ); + return result ?? false; + } + + static Future setFiles(String content, int typeValue) async { + final result = await _channel.invokeMethod( + 'setClipboardContent', + {'type': typeValue, 'content': content}, + ); + return result ?? false; + } + + static Future setFromItem({ + required int typeValue, + required String content, + String? metadata, + bool plainText = false, + }) async { + switch (typeValue) { + case 0: + case 4: + return setText(content, metadata: metadata, plainText: plainText); + case 1: + return setImage(content); + case 2: + case 3: + case 5: + case 6: + return setFiles(content, typeValue); + default: + return setText(content, plainText: true); + } + } + + static Future?> getMediaInfo(String path) async { + try { + final result = await _channel.invokeMapMethod( + 'getMediaInfo', + {'path': path}, + ); + return result; + } catch (e) { + AppLogger.error('ClipboardWriter.getMediaInfo failed: $e'); + return null; + } + } + + static Future captureFrontmostApp() async { + try { + return await _channel.invokeMethod('captureFrontmostApp'); + } catch (e) { + AppLogger.error('ClipboardWriter.captureFrontmostApp failed: $e'); + return null; + } + } + + static Future activateAndPaste({ + required String bundleId, + required int delayMs, + int focusTimeoutMs = 250, + }) async { + try { + final result = await _channel.invokeMethod( + 'activateAndPaste', + { + 'bundleId': bundleId, + 'delayMs': delayMs, + 'focusTimeoutMs': focusTimeoutMs, + }, + ); + if (result is Map) { + final map = Map.from(result); + return PasteResponse( + success: map['success'] == true, + errorCode: map['errorCode'] as String?, + ); + } + return PasteResponse(success: result == true); + } on PlatformException catch (e) { + if (e.code == 'ACCESSIBILITY_DENIED') rethrow; + AppLogger.error( + 'ClipboardWriter.activateAndPaste platform failure ' + '[${e.code}]: ${e.message}', + ); + return const PasteResponse(success: false, errorCode: 'platformError'); + } catch (e) { + AppLogger.error('ClipboardWriter.activateAndPaste failed: $e'); + return const PasteResponse(success: false, errorCode: 'unknown'); + } + } + + static Future?> getCursorAndScreenInfo() async { + try { + final result = await _channel.invokeMapMethod( + 'getCursorAndScreenInfo', + ); + if (result == null) return null; + return result.map((k, v) => MapEntry(k, (v as num).toDouble())); + } catch (e) { + AppLogger.error('ClipboardWriter.getCursorAndScreenInfo failed: $e'); + return null; + } + } + + static Future checkAccessibility() async { + try { + final result = await _channel.invokeMethod('checkAccessibility'); + return result ?? false; + } catch (e) { + AppLogger.error('ClipboardWriter.checkAccessibility failed: $e'); + return false; + } + } + + static Future requestAccessibility() async { + try { + final result = await _channel.invokeMethod('requestAccessibility'); + return result ?? false; + } catch (e) { + AppLogger.error('ClipboardWriter.requestAccessibility failed: $e'); + return false; + } + } + + static Future openAccessibilitySettings() async { + try { + await _channel.invokeMethod('openAccessibilitySettings'); + } catch (e) { + AppLogger.error('ClipboardWriter.openAccessibilitySettings failed: $e'); + } + } +} + +class PasteResponse { + const PasteResponse({required this.success, this.errorCode}); + + final bool success; + final String? errorCode; + + bool get isFocusTimeout => errorCode == 'focusTimeout'; +} diff --git a/listener/linux/listener_plugin.c b/listener/linux/listener_plugin.c index 72abe8bb..0d1bb1c8 100644 --- a/listener/linux/listener_plugin.c +++ b/listener/linux/listener_plugin.c @@ -306,67 +306,115 @@ static int activate_noop_error_handler(Display* display, XErrorEvent* event) { return 0; } -static gboolean request_activate_x11_window(Window window) { - Display* display = get_xdisplay(); - if (display == NULL || window == 0) { +static FlValue* make_paste_result(gboolean success, const gchar* error_code) { + FlValue* result = fl_value_new_map(); + fl_value_set_string_take(result, "success", fl_value_new_bool(success)); + if (error_code != NULL) { + fl_value_set_string_take(result, "errorCode", + fl_value_new_string(error_code)); + } + return result; +} + +static gboolean inject_paste_keystroke_x11(Display* display) { + if (!ensure_xtest(display)) { + return FALSE; + } + KeyCode ctrl = XKeysymToKeycode(display, XK_Control_L); + KeyCode v = XKeysymToKeycode(display, XK_v); + if (ctrl == 0 || v == 0) { return FALSE; } + XTestFakeKeyEvent(display, ctrl, True, CurrentTime); + XTestFakeKeyEvent(display, v, True, CurrentTime); + XTestFakeKeyEvent(display, v, False, CurrentTime); + XTestFakeKeyEvent(display, ctrl, False, CurrentTime); + XFlush(display); + return TRUE; +} + +// Synchronous activate-then-paste: sends EWMH _NET_ACTIVE_WINDOW, raises and +// focuses the target window, then waits up to timeout_ms for the X server to +// confirm focus before injecting Ctrl+V via XTest. +// +// Returns an FlValue map: {success: bool, errorCode?: string}. +// errorCode values: +// - "noX11" — display unavailable +// - "invalidWindow"— window id is 0 +// - "noXTest" — XTest extension missing +// - "focusTimeout" — focus did not transfer within timeout +static FlValue* activate_and_paste_x11(Window window, gint timeout_ms) { + Display* display = get_xdisplay(); + if (display == NULL) { + return make_paste_result(FALSE, "noX11"); + } + if (window == 0) { + return make_paste_result(FALSE, "invalidWindow"); + } + if (!ensure_xtest(display)) { + return make_paste_result(FALSE, "noXTest"); + } + + XWindowAttributes prev_attrs; + long prev_event_mask = 0; + gboolean restored_mask = FALSE; + int (*prev_handler)(Display*, XErrorEvent*) = + XSetErrorHandler(activate_noop_error_handler); + + if (XGetWindowAttributes(display, window, &prev_attrs) != 0) { + prev_event_mask = prev_attrs.your_event_mask; + XSelectInput(display, window, prev_event_mask | FocusChangeMask); + restored_mask = TRUE; + } - // 1. Send the EWMH _NET_ACTIVE_WINDOW message (honours ICCCM; most WMs). - // source=2 (pager) is more trusted than 1 (application) on WMs that - // apply focus-stealing prevention (KDE Plasma, some GNOME configs). XEvent event; memset(&event, 0, sizeof(event)); event.xclient.type = ClientMessage; event.xclient.window = window; event.xclient.message_type = atom_net_active_window(display); event.xclient.format = 32; - event.xclient.data.l[0] = 2; // pager source — more likely to bypass focus-steal guards + event.xclient.data.l[0] = 2; // pager source — bypasses focus-steal guards event.xclient.data.l[1] = CurrentTime; - event.xclient.data.l[2] = 0; - event.xclient.data.l[3] = 0; - event.xclient.data.l[4] = 0; - - Status status = XSendEvent(display, DefaultRootWindow(display), False, - SubstructureNotifyMask | SubstructureRedirectMask, - &event); + XSendEvent(display, DefaultRootWindow(display), False, + SubstructureNotifyMask | SubstructureRedirectMask, &event); - // 2. Raise the window and attempt a direct input focus as a fallback for WMs - // that ignore _NET_ACTIVE_WINDOW (tiling WMs, minimal WMs). - // Trap X errors: XSetInputFocus produces BadMatch on unmapped/invisible windows. XRaiseWindow(display, window); - XSync(display, False); - int (*prev_handler)(Display*, XErrorEvent*) = XSetErrorHandler(activate_noop_error_handler); XSetInputFocus(display, window, RevertToParent, CurrentTime); XSync(display, False); - XSetErrorHandler(prev_handler); - XFlush(display); - return status != 0; -} + // Poll for FocusIn on the target window (250 ms default budget). + gint64 deadline_us = g_get_monotonic_time() + ((gint64)timeout_ms * 1000); + XEvent received; + gboolean focus_in_received = FALSE; + while (g_get_monotonic_time() < deadline_us) { + if (XCheckTypedWindowEvent(display, window, FocusIn, &received)) { + focus_in_received = TRUE; + break; + } + g_usleep(5000); // 5 ms — keeps the loop responsive without busy-spin + } -static gboolean simulate_paste_x11(void) { - Display* display = get_xdisplay(); - if (display == NULL) { - return FALSE; + // Verify with XGetInputFocus regardless of whether FocusIn arrived (some WMs + // refocus a child of the requested window — that's still acceptable). + Window focused = None; + int revert_to = 0; + XGetInputFocus(display, &focused, &revert_to); + gboolean focus_ok = focus_in_received || focused == window; + + if (restored_mask) { + XSelectInput(display, window, prev_event_mask); } + XSetErrorHandler(prev_handler); - if (!ensure_xtest(display)) { - return FALSE; + if (!focus_ok) { + return make_paste_result(FALSE, "focusTimeout"); } - KeyCode ctrl = XKeysymToKeycode(display, XK_Control_L); - KeyCode v = XKeysymToKeycode(display, XK_v); - if (ctrl == 0 || v == 0) { - return FALSE; + if (!inject_paste_keystroke_x11(display)) { + return make_paste_result(FALSE, "noXTest"); } - XTestFakeKeyEvent(display, ctrl, True, CurrentTime); - XTestFakeKeyEvent(display, v, True, CurrentTime); - XTestFakeKeyEvent(display, v, False, CurrentTime); - XTestFakeKeyEvent(display, ctrl, False, CurrentTime); - XFlush(display); - return TRUE; + return make_paste_result(TRUE, NULL); } #endif @@ -890,16 +938,6 @@ static void respond_success(FlMethodCall* method_call, FlValue* result) { } } -#ifdef GDK_WINDOWING_X11 -static gboolean paste_after_delay_cb(gpointer data) { - FlMethodCall* mc = FL_METHOD_CALL(data); - simulate_paste_x11(); - respond_success(mc, fl_value_new_bool(TRUE)); - g_object_unref(mc); - return G_SOURCE_REMOVE; -} -#endif - static void listener_plugin_handle_method_call(ListenerPlugin* self, FlMethodCall* method_call) { const gchar* method = fl_method_call_get_name(method_call); @@ -978,38 +1016,30 @@ static void listener_plugin_handle_method_call(ListenerPlugin* self, #ifdef GDK_WINDOWING_X11 if (plugin_is_x11()) { FlValue* id_value = args != NULL ? fl_value_lookup_string(args, "bundleId") : NULL; - FlValue* delay_value = args != NULL ? fl_value_lookup_string(args, "delayMs") : NULL; + FlValue* timeout_value = + args != NULL ? fl_value_lookup_string(args, "focusTimeoutMs") : NULL; const gchar* identifier = id_value != NULL && fl_value_get_type(id_value) == FL_VALUE_TYPE_STRING ? fl_value_get_string(id_value) : NULL; - gint64 delay_ms = delay_value != NULL ? fl_value_get_int(delay_value) : 0; - gboolean activated = FALSE; - - if (identifier != NULL && g_str_has_prefix(identifier, "x11:0x")) { - Window window = (Window)g_ascii_strtoull(identifier + 6, NULL, 16); - activated = request_activate_x11_window(window); + gint timeout_ms = + timeout_value != NULL && fl_value_get_type(timeout_value) == FL_VALUE_TYPE_INT + ? (gint)fl_value_get_int(timeout_value) + : 250; + if (timeout_ms < 50) timeout_ms = 50; + if (timeout_ms > 2000) timeout_ms = 2000; + + if (identifier == NULL || !g_str_has_prefix(identifier, "x11:0x")) { + respond_success(method_call, make_paste_result(FALSE, "invalidWindow")); + return; } - if (activated && delay_ms > 0) { - FlMethodCall* held_call = FL_METHOD_CALL(g_object_ref(method_call)); - guint timer_id = g_timeout_add((guint)delay_ms, paste_after_delay_cb, held_call); - if (timer_id != 0) { - return; // held_call will be released by paste_after_delay_cb - } - // Timer registration failed — release the ref and fall through to immediate paste. - g_object_unref(held_call); - g_warning("activateAndPaste: g_timeout_add failed, pasting immediately"); - } - - if (activated) { - simulate_paste_x11(); - } - respond_success(method_call, fl_value_new_bool(activated)); + Window window = (Window)g_ascii_strtoull(identifier + 6, NULL, 16); + respond_success(method_call, activate_and_paste_x11(window, timeout_ms)); return; } #endif - respond_success(method_call, fl_value_new_bool(FALSE)); + respond_success(method_call, make_paste_result(FALSE, "noX11")); return; } diff --git a/listener/test/clipboard_writer_test.dart b/listener/test/clipboard_writer_test.dart index d30952bf..737add5c 100644 --- a/listener/test/clipboard_writer_test.dart +++ b/listener/test/clipboard_writer_test.dart @@ -1,513 +1,534 @@ -import 'dart:convert'; - -import 'package:flutter/services.dart'; -import 'package:flutter_test/flutter_test.dart'; -import 'package:listener/clipboard_writer.dart'; - -void main() { - TestWidgetsFlutterBinding.ensureInitialized(); - - const channel = MethodChannel('copypaste/clipboard_writer'); - - setUp(() { - TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger - .setMockMethodCallHandler(channel, (call) async { - switch (call.method) { - case 'setClipboardContent': - return true; - case 'getMediaInfo': - return {'width': 1920, 'height': 1080}; - default: - return null; - } - }); - }); - - tearDown(() { - TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger - .setMockMethodCallHandler(channel, null); - }); - - group('ClipboardWriter.setText', () { - test('returns true on success', () async { - final result = await ClipboardWriter.setText('hello'); - expect(result, isTrue); - }); - - test('sends plain text flag', () async { - MethodCall? captured; - TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger - .setMockMethodCallHandler(channel, (call) async { - captured = call; - return true; - }); - await ClipboardWriter.setText('hi', plainText: true); - expect(captured!.arguments['plainText'], isTrue); - }); - - test('sends rtf decoded from base64 in metadata', () async { - MethodCall? captured; - TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger - .setMockMethodCallHandler(channel, (call) async { - captured = call; - return true; - }); - final rtfBytes = utf8.encode('{\\rtf1 hello}'); - final meta = jsonEncode({'rtf': base64Encode(rtfBytes)}); - await ClipboardWriter.setText('hello', metadata: meta); - expect(captured!.arguments['rtf'], isNotNull); - }); - - test('sends html decoded from base64 in metadata', () async { - MethodCall? captured; - TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger - .setMockMethodCallHandler(channel, (call) async { - captured = call; - return true; - }); - final htmlBytes = utf8.encode('hello'); - final meta = jsonEncode({'html': base64Encode(htmlBytes)}); - await ClipboardWriter.setText('hello', metadata: meta); - expect(captured!.arguments['html'], isNotNull); - }); - - test('handles invalid metadata JSON gracefully', () async { - final result = await ClipboardWriter.setText( - 'test', - metadata: 'not valid json {{{', - ); - expect(result, isTrue); - }); - - test('skips rtf/html when plainText is true', () async { - MethodCall? captured; - TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger - .setMockMethodCallHandler(channel, (call) async { - captured = call; - return true; - }); - final rtfBytes = utf8.encode('{\\rtf1 hello}'); - final meta = jsonEncode({'rtf': base64Encode(rtfBytes)}); - await ClipboardWriter.setText('hi', metadata: meta, plainText: true); - expect(captured!.arguments.containsKey('rtf'), isFalse); - }); - - test('returns false when channel returns null', () async { - TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger - .setMockMethodCallHandler(channel, (_) async => null); - final result = await ClipboardWriter.setText('test'); - expect(result, isFalse); - }); - }); - - group('ClipboardWriter.setImage', () { - test('returns true on success', () async { - final result = await ClipboardWriter.setImage('/path/to/image.png'); - expect(result, isTrue); - }); - - test('sends type 1 and correct path', () async { - MethodCall? captured; - TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger - .setMockMethodCallHandler(channel, (call) async { - captured = call; - return true; - }); - await ClipboardWriter.setImage('/img/photo.png'); - expect(captured!.arguments['type'], equals(1)); - expect(captured!.arguments['content'], equals('/img/photo.png')); - }); - }); - - group('ClipboardWriter.setFiles', () { - test('returns true on success', () async { - final result = await ClipboardWriter.setFiles('/path/to/file.txt', 2); - expect(result, isTrue); - }); - - test('sends provided typeValue', () async { - MethodCall? captured; - TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger - .setMockMethodCallHandler(channel, (call) async { - captured = call; - return true; - }); - await ClipboardWriter.setFiles('/file.mp3', 5); - expect(captured!.arguments['type'], equals(5)); - }); - }); - - group('ClipboardWriter.setFromItem', () { - test('type 0 (text) calls setText', () async { - final result = await ClipboardWriter.setFromItem( - typeValue: 0, - content: 'text content', - ); - expect(result, isTrue); - }); - - test('type 4 (link) calls setText', () async { - final result = await ClipboardWriter.setFromItem( - typeValue: 4, - content: 'https://example.com', - ); - expect(result, isTrue); - }); - - test('type 1 (image) calls setImage', () async { - MethodCall? captured; - TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger - .setMockMethodCallHandler(channel, (call) async { - captured = call; - return true; - }); - await ClipboardWriter.setFromItem(typeValue: 1, content: '/path/img.png'); - expect(captured!.arguments['type'], equals(1)); - }); - - test('type 2 (file) calls setFiles', () async { - MethodCall? captured; - TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger - .setMockMethodCallHandler(channel, (call) async { - captured = call; - return true; - }); - await ClipboardWriter.setFromItem(typeValue: 2, content: '/file.txt'); - expect(captured!.arguments['type'], equals(2)); - }); - - test('type 3 (folder) calls setFiles', () async { - MethodCall? captured; - TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger - .setMockMethodCallHandler(channel, (call) async { - captured = call; - return true; - }); - await ClipboardWriter.setFromItem(typeValue: 3, content: '/folder/'); - expect(captured!.arguments['type'], equals(3)); - }); - - test('type 5 (audio) calls setFiles', () async { - MethodCall? captured; - TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger - .setMockMethodCallHandler(channel, (call) async { - captured = call; - return true; - }); - await ClipboardWriter.setFromItem(typeValue: 5, content: '/audio.mp3'); - expect(captured!.arguments['type'], equals(5)); - }); - - test('type 6 (video) calls setFiles', () async { - MethodCall? captured; - TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger - .setMockMethodCallHandler(channel, (call) async { - captured = call; - return true; - }); - await ClipboardWriter.setFromItem(typeValue: 6, content: '/video.mp4'); - expect(captured!.arguments['type'], equals(6)); - }); - - test('unknown type defaults to plainText setText', () async { - MethodCall? captured; - TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger - .setMockMethodCallHandler(channel, (call) async { - captured = call; - return true; - }); - await ClipboardWriter.setFromItem(typeValue: 99, content: 'fallback'); - expect(captured!.arguments['plainText'], isTrue); - }); - }); - - group('ClipboardWriter.getMediaInfo', () { - test('returns map on success', () async { - final result = await ClipboardWriter.getMediaInfo('/path/video.mp4'); - expect(result, isNotNull); - expect(result!['width'], equals(1920)); - }); - - test('returns null when channel throws', () async { - TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger - .setMockMethodCallHandler(channel, (_) async { - throw PlatformException(code: 'ERROR', message: 'fail'); - }); - final result = await ClipboardWriter.getMediaInfo('/bad/path'); - expect(result, isNull); - }); - }); - - // ── macOS-specific methods ────────────────────────────────────────────── - - group('ClipboardWriter.captureFrontmostApp', () { - test('returns bundle id on success', () async { - TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger - .setMockMethodCallHandler(channel, (call) async { - if (call.method == 'captureFrontmostApp') { - return 'com.apple.finder'; - } - return null; - }); - final result = await ClipboardWriter.captureFrontmostApp(); - expect(result, equals('com.apple.finder')); - }); - - test('returns null when channel returns null', () async { - TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger - .setMockMethodCallHandler(channel, (call) async => null); - final result = await ClipboardWriter.captureFrontmostApp(); - expect(result, isNull); - }); - - test('returns null when channel throws', () async { - TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger - .setMockMethodCallHandler(channel, (_) async { - throw PlatformException(code: 'UNAVAILABLE'); - }); - final result = await ClipboardWriter.captureFrontmostApp(); - expect(result, isNull); - }); - }); - - group('ClipboardWriter.activateAndPaste', () { - test('returns true on success', () async { - TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger - .setMockMethodCallHandler(channel, (call) async { - if (call.method == 'activateAndPaste') return true; - return null; - }); - final result = await ClipboardWriter.activateAndPaste( - bundleId: 'com.apple.safari', - delayMs: 150, - ); - expect(result, isTrue); - }); - - test('sends bundleId and delayMs as arguments', () async { - MethodCall? captured; - TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger - .setMockMethodCallHandler(channel, (call) async { - captured = call; - return true; - }); - await ClipboardWriter.activateAndPaste( - bundleId: 'com.example.app', - delayMs: 200, - ); - expect(captured!.method, equals('activateAndPaste')); - expect(captured!.arguments['bundleId'], equals('com.example.app')); - expect(captured!.arguments['delayMs'], equals(200)); - }); - - test('returns false when channel returns null', () async { - TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger - .setMockMethodCallHandler(channel, (_) async => null); - final result = await ClipboardWriter.activateAndPaste( - bundleId: 'com.test', - delayMs: 0, - ); - expect(result, isFalse); - }); - - test('rethrows when channel throws ACCESSIBILITY_DENIED', () async { - TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger - .setMockMethodCallHandler(channel, (_) async { - throw PlatformException(code: 'ACCESSIBILITY_DENIED'); - }); - expect( - () => - ClipboardWriter.activateAndPaste(bundleId: 'com.test', delayMs: 0), - throwsA( - isA().having( - (e) => e.code, - 'code', - 'ACCESSIBILITY_DENIED', - ), - ), - ); - }); - - test('returns false when channel throws other error', () async { - TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger - .setMockMethodCallHandler(channel, (_) async { - throw PlatformException(code: 'UNKNOWN_ERROR'); - }); - final result = await ClipboardWriter.activateAndPaste( - bundleId: 'com.test', - delayMs: 0, - ); - expect(result, isFalse); - }); - }); - - group('ClipboardWriter.getCursorAndScreenInfo', () { - test('returns typed map on success', () async { - TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger - .setMockMethodCallHandler(channel, (call) async { - if (call.method == 'getCursorAndScreenInfo') { - return { - 'cursorX': 100.0, - 'cursorY': 200.0, - 'waLeft': 0.0, - 'waTop': 25.0, - 'waRight': 1440.0, - 'waBottom': 900.0, - }; - } - return null; - }); - final result = await ClipboardWriter.getCursorAndScreenInfo(); - expect(result, isNotNull); - expect(result!['cursorX'], equals(100.0)); - expect(result['cursorY'], equals(200.0)); - expect(result['waLeft'], equals(0.0)); - expect(result['waTop'], equals(25.0)); - expect(result['waRight'], equals(1440.0)); - expect(result['waBottom'], equals(900.0)); - }); - - test('converts integer values to double', () async { - TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger - .setMockMethodCallHandler(channel, (call) async { - if (call.method == 'getCursorAndScreenInfo') { - return { - 'cursorX': 50, - 'cursorY': 75, - 'waLeft': 0, - 'waTop': 0, - 'waRight': 1280, - 'waBottom': 800, - }; - } - return null; - }); - final result = await ClipboardWriter.getCursorAndScreenInfo(); - expect(result, isNotNull); - expect(result!['cursorX'], isA()); - expect(result['cursorX'], equals(50.0)); - }); - - test('returns null when channel returns null', () async { - TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger - .setMockMethodCallHandler(channel, (call) async => null); - final result = await ClipboardWriter.getCursorAndScreenInfo(); - expect(result, isNull); - }); - - test('returns null when channel throws', () async { - TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger - .setMockMethodCallHandler(channel, (_) async { - throw PlatformException(code: 'ERROR'); - }); - final result = await ClipboardWriter.getCursorAndScreenInfo(); - expect(result, isNull); - }); - }); - - group('ClipboardWriter.checkAccessibility', () { - test('returns true when accessibility is granted', () async { - TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger - .setMockMethodCallHandler(channel, (call) async { - if (call.method == 'checkAccessibility') return true; - return null; - }); - final result = await ClipboardWriter.checkAccessibility(); - expect(result, isTrue); - }); - - test('returns false when accessibility is denied', () async { - TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger - .setMockMethodCallHandler(channel, (call) async { - if (call.method == 'checkAccessibility') return false; - return null; - }); - final result = await ClipboardWriter.checkAccessibility(); - expect(result, isFalse); - }); - - test('returns false when channel returns null', () async { - TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger - .setMockMethodCallHandler(channel, (_) async => null); - final result = await ClipboardWriter.checkAccessibility(); - expect(result, isFalse); - }); - - test('returns false when channel throws', () async { - TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger - .setMockMethodCallHandler(channel, (_) async { - throw PlatformException(code: 'ERROR'); - }); - final result = await ClipboardWriter.checkAccessibility(); - expect(result, isFalse); - }); - }); - - group('ClipboardWriter.requestAccessibility', () { - test('returns true when user grants permission', () async { - TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger - .setMockMethodCallHandler(channel, (call) async { - if (call.method == 'requestAccessibility') return true; - return null; - }); - final result = await ClipboardWriter.requestAccessibility(); - expect(result, isTrue); - }); - - test('returns false when user denies permission', () async { - TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger - .setMockMethodCallHandler(channel, (call) async { - if (call.method == 'requestAccessibility') return false; - return null; - }); - final result = await ClipboardWriter.requestAccessibility(); - expect(result, isFalse); - }); - - test('returns false when channel returns null', () async { - TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger - .setMockMethodCallHandler(channel, (_) async => null); - final result = await ClipboardWriter.requestAccessibility(); - expect(result, isFalse); - }); - - test('returns false when channel throws', () async { - TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger - .setMockMethodCallHandler(channel, (_) async { - throw PlatformException(code: 'ERROR'); - }); - final result = await ClipboardWriter.requestAccessibility(); - expect(result, isFalse); - }); - }); - - group('ClipboardWriter.openAccessibilitySettings', () { - test('completes without error on success', () async { - TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger - .setMockMethodCallHandler(channel, (call) async { - if (call.method == 'openAccessibilitySettings') return true; - return null; - }); - await expectLater(ClipboardWriter.openAccessibilitySettings(), completes); - }); - - test('completes without error even when channel throws', () async { - TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger - .setMockMethodCallHandler(channel, (_) async { - throw PlatformException(code: 'ERROR'); - }); - await expectLater(ClipboardWriter.openAccessibilitySettings(), completes); - }); - - test('invokes correct method name', () async { - MethodCall? captured; - TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger - .setMockMethodCallHandler(channel, (call) async { - captured = call; - return null; - }); - await ClipboardWriter.openAccessibilitySettings(); - expect(captured!.method, equals('openAccessibilitySettings')); - }); - }); -} +import 'dart:convert'; + +import 'package:flutter/services.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:listener/clipboard_writer.dart'; + +void main() { + TestWidgetsFlutterBinding.ensureInitialized(); + + const channel = MethodChannel('copypaste/clipboard_writer'); + + setUp(() { + TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger + .setMockMethodCallHandler(channel, (call) async { + switch (call.method) { + case 'setClipboardContent': + return true; + case 'getMediaInfo': + return {'width': 1920, 'height': 1080}; + default: + return null; + } + }); + }); + + tearDown(() { + TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger + .setMockMethodCallHandler(channel, null); + }); + + group('ClipboardWriter.setText', () { + test('returns true on success', () async { + final result = await ClipboardWriter.setText('hello'); + expect(result, isTrue); + }); + + test('sends plain text flag', () async { + MethodCall? captured; + TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger + .setMockMethodCallHandler(channel, (call) async { + captured = call; + return true; + }); + await ClipboardWriter.setText('hi', plainText: true); + expect(captured!.arguments['plainText'], isTrue); + }); + + test('sends rtf decoded from base64 in metadata', () async { + MethodCall? captured; + TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger + .setMockMethodCallHandler(channel, (call) async { + captured = call; + return true; + }); + final rtfBytes = utf8.encode('{\\rtf1 hello}'); + final meta = jsonEncode({'rtf': base64Encode(rtfBytes)}); + await ClipboardWriter.setText('hello', metadata: meta); + expect(captured!.arguments['rtf'], isNotNull); + }); + + test('sends html decoded from base64 in metadata', () async { + MethodCall? captured; + TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger + .setMockMethodCallHandler(channel, (call) async { + captured = call; + return true; + }); + final htmlBytes = utf8.encode('hello'); + final meta = jsonEncode({'html': base64Encode(htmlBytes)}); + await ClipboardWriter.setText('hello', metadata: meta); + expect(captured!.arguments['html'], isNotNull); + }); + + test('handles invalid metadata JSON gracefully', () async { + final result = await ClipboardWriter.setText( + 'test', + metadata: 'not valid json {{{', + ); + expect(result, isTrue); + }); + + test('skips rtf/html when plainText is true', () async { + MethodCall? captured; + TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger + .setMockMethodCallHandler(channel, (call) async { + captured = call; + return true; + }); + final rtfBytes = utf8.encode('{\\rtf1 hello}'); + final meta = jsonEncode({'rtf': base64Encode(rtfBytes)}); + await ClipboardWriter.setText('hi', metadata: meta, plainText: true); + expect(captured!.arguments.containsKey('rtf'), isFalse); + }); + + test('returns false when channel returns null', () async { + TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger + .setMockMethodCallHandler(channel, (_) async => null); + final result = await ClipboardWriter.setText('test'); + expect(result, isFalse); + }); + }); + + group('ClipboardWriter.setImage', () { + test('returns true on success', () async { + final result = await ClipboardWriter.setImage('/path/to/image.png'); + expect(result, isTrue); + }); + + test('sends type 1 and correct path', () async { + MethodCall? captured; + TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger + .setMockMethodCallHandler(channel, (call) async { + captured = call; + return true; + }); + await ClipboardWriter.setImage('/img/photo.png'); + expect(captured!.arguments['type'], equals(1)); + expect(captured!.arguments['content'], equals('/img/photo.png')); + }); + }); + + group('ClipboardWriter.setFiles', () { + test('returns true on success', () async { + final result = await ClipboardWriter.setFiles('/path/to/file.txt', 2); + expect(result, isTrue); + }); + + test('sends provided typeValue', () async { + MethodCall? captured; + TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger + .setMockMethodCallHandler(channel, (call) async { + captured = call; + return true; + }); + await ClipboardWriter.setFiles('/file.mp3', 5); + expect(captured!.arguments['type'], equals(5)); + }); + }); + + group('ClipboardWriter.setFromItem', () { + test('type 0 (text) calls setText', () async { + final result = await ClipboardWriter.setFromItem( + typeValue: 0, + content: 'text content', + ); + expect(result, isTrue); + }); + + test('type 4 (link) calls setText', () async { + final result = await ClipboardWriter.setFromItem( + typeValue: 4, + content: 'https://example.com', + ); + expect(result, isTrue); + }); + + test('type 1 (image) calls setImage', () async { + MethodCall? captured; + TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger + .setMockMethodCallHandler(channel, (call) async { + captured = call; + return true; + }); + await ClipboardWriter.setFromItem(typeValue: 1, content: '/path/img.png'); + expect(captured!.arguments['type'], equals(1)); + }); + + test('type 2 (file) calls setFiles', () async { + MethodCall? captured; + TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger + .setMockMethodCallHandler(channel, (call) async { + captured = call; + return true; + }); + await ClipboardWriter.setFromItem(typeValue: 2, content: '/file.txt'); + expect(captured!.arguments['type'], equals(2)); + }); + + test('type 3 (folder) calls setFiles', () async { + MethodCall? captured; + TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger + .setMockMethodCallHandler(channel, (call) async { + captured = call; + return true; + }); + await ClipboardWriter.setFromItem(typeValue: 3, content: '/folder/'); + expect(captured!.arguments['type'], equals(3)); + }); + + test('type 5 (audio) calls setFiles', () async { + MethodCall? captured; + TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger + .setMockMethodCallHandler(channel, (call) async { + captured = call; + return true; + }); + await ClipboardWriter.setFromItem(typeValue: 5, content: '/audio.mp3'); + expect(captured!.arguments['type'], equals(5)); + }); + + test('type 6 (video) calls setFiles', () async { + MethodCall? captured; + TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger + .setMockMethodCallHandler(channel, (call) async { + captured = call; + return true; + }); + await ClipboardWriter.setFromItem(typeValue: 6, content: '/video.mp4'); + expect(captured!.arguments['type'], equals(6)); + }); + + test('unknown type defaults to plainText setText', () async { + MethodCall? captured; + TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger + .setMockMethodCallHandler(channel, (call) async { + captured = call; + return true; + }); + await ClipboardWriter.setFromItem(typeValue: 99, content: 'fallback'); + expect(captured!.arguments['plainText'], isTrue); + }); + }); + + group('ClipboardWriter.getMediaInfo', () { + test('returns map on success', () async { + final result = await ClipboardWriter.getMediaInfo('/path/video.mp4'); + expect(result, isNotNull); + expect(result!['width'], equals(1920)); + }); + + test('returns null when channel throws', () async { + TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger + .setMockMethodCallHandler(channel, (_) async { + throw PlatformException(code: 'ERROR', message: 'fail'); + }); + final result = await ClipboardWriter.getMediaInfo('/bad/path'); + expect(result, isNull); + }); + }); + + // ── macOS-specific methods ────────────────────────────────────────────── + + group('ClipboardWriter.captureFrontmostApp', () { + test('returns bundle id on success', () async { + TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger + .setMockMethodCallHandler(channel, (call) async { + if (call.method == 'captureFrontmostApp') { + return 'com.apple.finder'; + } + return null; + }); + final result = await ClipboardWriter.captureFrontmostApp(); + expect(result, equals('com.apple.finder')); + }); + + test('returns null when channel returns null', () async { + TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger + .setMockMethodCallHandler(channel, (call) async => null); + final result = await ClipboardWriter.captureFrontmostApp(); + expect(result, isNull); + }); + + test('returns null when channel throws', () async { + TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger + .setMockMethodCallHandler(channel, (_) async { + throw PlatformException(code: 'UNAVAILABLE'); + }); + final result = await ClipboardWriter.captureFrontmostApp(); + expect(result, isNull); + }); + }); + + group('ClipboardWriter.activateAndPaste', () { + test('returns success on bool true', () async { + TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger + .setMockMethodCallHandler(channel, (call) async { + if (call.method == 'activateAndPaste') return true; + return null; + }); + final result = await ClipboardWriter.activateAndPaste( + bundleId: 'com.apple.safari', + delayMs: 150, + ); + expect(result.success, isTrue); + expect(result.errorCode, isNull); + }); + + test('parses Map response with success and errorCode', () async { + TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger + .setMockMethodCallHandler(channel, (_) async { + return { + 'success': false, + 'errorCode': 'focusTimeout', + }; + }); + final result = await ClipboardWriter.activateAndPaste( + bundleId: 'x11:0xabc', + delayMs: 0, + ); + expect(result.success, isFalse); + expect(result.errorCode, equals('focusTimeout')); + expect(result.isFocusTimeout, isTrue); + }); + + test('sends bundleId, delayMs and focusTimeoutMs as arguments', () async { + MethodCall? captured; + TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger + .setMockMethodCallHandler(channel, (call) async { + captured = call; + return true; + }); + await ClipboardWriter.activateAndPaste( + bundleId: 'com.example.app', + delayMs: 200, + focusTimeoutMs: 350, + ); + expect(captured!.method, equals('activateAndPaste')); + expect(captured!.arguments['bundleId'], equals('com.example.app')); + expect(captured!.arguments['delayMs'], equals(200)); + expect(captured!.arguments['focusTimeoutMs'], equals(350)); + }); + + test('returns failure when channel returns null', () async { + TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger + .setMockMethodCallHandler(channel, (_) async => null); + final result = await ClipboardWriter.activateAndPaste( + bundleId: 'com.test', + delayMs: 0, + ); + expect(result.success, isFalse); + }); + + test('rethrows when channel throws ACCESSIBILITY_DENIED', () async { + TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger + .setMockMethodCallHandler(channel, (_) async { + throw PlatformException(code: 'ACCESSIBILITY_DENIED'); + }); + expect( + () => + ClipboardWriter.activateAndPaste(bundleId: 'com.test', delayMs: 0), + throwsA( + isA().having( + (e) => e.code, + 'code', + 'ACCESSIBILITY_DENIED', + ), + ), + ); + }); + + test('returns platformError on other PlatformException', () async { + TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger + .setMockMethodCallHandler(channel, (_) async { + throw PlatformException(code: 'UNKNOWN_ERROR'); + }); + final result = await ClipboardWriter.activateAndPaste( + bundleId: 'com.test', + delayMs: 0, + ); + expect(result.success, isFalse); + expect(result.errorCode, equals('platformError')); + }); + }); + + group('ClipboardWriter.getCursorAndScreenInfo', () { + test('returns typed map on success', () async { + TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger + .setMockMethodCallHandler(channel, (call) async { + if (call.method == 'getCursorAndScreenInfo') { + return { + 'cursorX': 100.0, + 'cursorY': 200.0, + 'waLeft': 0.0, + 'waTop': 25.0, + 'waRight': 1440.0, + 'waBottom': 900.0, + }; + } + return null; + }); + final result = await ClipboardWriter.getCursorAndScreenInfo(); + expect(result, isNotNull); + expect(result!['cursorX'], equals(100.0)); + expect(result['cursorY'], equals(200.0)); + expect(result['waLeft'], equals(0.0)); + expect(result['waTop'], equals(25.0)); + expect(result['waRight'], equals(1440.0)); + expect(result['waBottom'], equals(900.0)); + }); + + test('converts integer values to double', () async { + TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger + .setMockMethodCallHandler(channel, (call) async { + if (call.method == 'getCursorAndScreenInfo') { + return { + 'cursorX': 50, + 'cursorY': 75, + 'waLeft': 0, + 'waTop': 0, + 'waRight': 1280, + 'waBottom': 800, + }; + } + return null; + }); + final result = await ClipboardWriter.getCursorAndScreenInfo(); + expect(result, isNotNull); + expect(result!['cursorX'], isA()); + expect(result['cursorX'], equals(50.0)); + }); + + test('returns null when channel returns null', () async { + TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger + .setMockMethodCallHandler(channel, (call) async => null); + final result = await ClipboardWriter.getCursorAndScreenInfo(); + expect(result, isNull); + }); + + test('returns null when channel throws', () async { + TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger + .setMockMethodCallHandler(channel, (_) async { + throw PlatformException(code: 'ERROR'); + }); + final result = await ClipboardWriter.getCursorAndScreenInfo(); + expect(result, isNull); + }); + }); + + group('ClipboardWriter.checkAccessibility', () { + test('returns true when accessibility is granted', () async { + TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger + .setMockMethodCallHandler(channel, (call) async { + if (call.method == 'checkAccessibility') return true; + return null; + }); + final result = await ClipboardWriter.checkAccessibility(); + expect(result, isTrue); + }); + + test('returns false when accessibility is denied', () async { + TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger + .setMockMethodCallHandler(channel, (call) async { + if (call.method == 'checkAccessibility') return false; + return null; + }); + final result = await ClipboardWriter.checkAccessibility(); + expect(result, isFalse); + }); + + test('returns false when channel returns null', () async { + TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger + .setMockMethodCallHandler(channel, (_) async => null); + final result = await ClipboardWriter.checkAccessibility(); + expect(result, isFalse); + }); + + test('returns false when channel throws', () async { + TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger + .setMockMethodCallHandler(channel, (_) async { + throw PlatformException(code: 'ERROR'); + }); + final result = await ClipboardWriter.checkAccessibility(); + expect(result, isFalse); + }); + }); + + group('ClipboardWriter.requestAccessibility', () { + test('returns true when user grants permission', () async { + TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger + .setMockMethodCallHandler(channel, (call) async { + if (call.method == 'requestAccessibility') return true; + return null; + }); + final result = await ClipboardWriter.requestAccessibility(); + expect(result, isTrue); + }); + + test('returns false when user denies permission', () async { + TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger + .setMockMethodCallHandler(channel, (call) async { + if (call.method == 'requestAccessibility') return false; + return null; + }); + final result = await ClipboardWriter.requestAccessibility(); + expect(result, isFalse); + }); + + test('returns false when channel returns null', () async { + TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger + .setMockMethodCallHandler(channel, (_) async => null); + final result = await ClipboardWriter.requestAccessibility(); + expect(result, isFalse); + }); + + test('returns false when channel throws', () async { + TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger + .setMockMethodCallHandler(channel, (_) async { + throw PlatformException(code: 'ERROR'); + }); + final result = await ClipboardWriter.requestAccessibility(); + expect(result, isFalse); + }); + }); + + group('ClipboardWriter.openAccessibilitySettings', () { + test('completes without error on success', () async { + TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger + .setMockMethodCallHandler(channel, (call) async { + if (call.method == 'openAccessibilitySettings') return true; + return null; + }); + await expectLater(ClipboardWriter.openAccessibilitySettings(), completes); + }); + + test('completes without error even when channel throws', () async { + TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger + .setMockMethodCallHandler(channel, (_) async { + throw PlatformException(code: 'ERROR'); + }); + await expectLater(ClipboardWriter.openAccessibilitySettings(), completes); + }); + + test('invokes correct method name', () async { + MethodCall? captured; + TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger + .setMockMethodCallHandler(channel, (call) async { + captured = call; + return null; + }); + await ClipboardWriter.openAccessibilitySettings(); + expect(captured!.method, equals('openAccessibilitySettings')); + }); + }); +} From 9f131cf0386aab9b4345dc0ab5c7f809229f6d9e Mon Sep 17 00:00:00 2001 From: rgdevment Date: Fri, 24 Apr 2026 13:33:06 -0400 Subject: [PATCH 06/31] refact: Remove outdated comments and improve Linux clipboard handling --- app/lib/shell/app_window.dart | 14 ----- app/lib/shell/linux_shell.dart | 2 - app/linux/runner/my_application.cc | 3 -- app/test/shell/linux_shell_test.dart | 2 +- listener/linux/listener_plugin.c | 69 ++++++++++++++++++------ listener/test/clipboard_writer_test.dart | 2 - 6 files changed, 54 insertions(+), 38 deletions(-) diff --git a/app/lib/shell/app_window.dart b/app/lib/shell/app_window.dart index f5291200..85efbe64 100644 --- a/app/lib/shell/app_window.dart +++ b/app/lib/shell/app_window.dart @@ -165,7 +165,6 @@ class AppWindow { ).timeout(const Duration(seconds: 2)); } } catch (e) { - // Effect failure is non-fatal — app runs without the acrylic effect. AppLogger.warn('applyEffect: window effect unavailable (non-fatal): $e'); } } @@ -314,10 +313,6 @@ class AppWindow { Future show() async { AppLogger.info('AppWindow.show: starting'); if (Platform.isLinux) { - // On X11/GTK, show the window first (so it gets realized/mapped by the WM), - // then set the position (avoids WM initial-placement overriding our offset), - // then focus via gtk_window_present_with_time so GNOME doesn't block focus - // and show a spurious "está preparado" notification. await windowManager.setSkipTaskbar(false); await windowManager.show(); await _positionNearCursor(); @@ -379,8 +374,6 @@ class AppWindow { Future enterSettingsMode() async { _settingsMode = true; await windowManager.setResizable(true); - // GTK processes geometry hints asynchronously — wait one frame before - // applying new constraints so the WM doesn't reject the resize. if (Platform.isLinux) { await Future.delayed(const Duration(milliseconds: 50)); } @@ -390,9 +383,6 @@ class AppWindow { await windowManager.setMaximumSize(const Size(1200, 900)); await windowManager.setSize(const Size(_settingsWidth, _settingsHeight)); await windowManager.center(); - // Settings mode implies the window must be visible and focused. Without - // this, transitioning from gate/onboarding (which hides the window on - // exit) leaves Settings invisible behind other windows. if (!await windowManager.isVisible()) { await windowManager.show(); } @@ -402,8 +392,6 @@ class AppWindow { Future exitSettingsMode() async { _settingsMode = false; - // On Linux the window may still be in resizable=true state from settings - // mode. Reset it explicitly and wait for GTK to process before resizing. if (Platform.isLinux) { await windowManager.setResizable(true); await Future.delayed(const Duration(milliseconds: 50)); @@ -411,8 +399,6 @@ class AppWindow { await windowManager.setMinimumSize(Size(_popupWidth, 400)); await windowManager.setMaximumSize(Size(_popupWidth, 900)); await windowManager.setSize(Size(_popupWidth, _popupHeight)); - // Wait for GTK to process the resize before locking with setResizable(false). - // Without this delay the WM may freeze the window at the old (large) size. if (Platform.isLinux) { await Future.delayed(const Duration(milliseconds: 100)); } diff --git a/app/lib/shell/linux_shell.dart b/app/lib/shell/linux_shell.dart index e89e6c30..ac84a8e3 100644 --- a/app/lib/shell/linux_shell.dart +++ b/app/lib/shell/linux_shell.dart @@ -159,8 +159,6 @@ class LinuxShell { } } - /// Raises and focuses the GTK window using the X11 hotkey event timestamp, - /// bypassing GNOME's focus-stealing prevention. static Future focusWindow() async { try { await _methodChannel.invokeMethod('focusWindow'); diff --git a/app/linux/runner/my_application.cc b/app/linux/runner/my_application.cc index 3aa75920..34c27c56 100644 --- a/app/linux/runner/my_application.cc +++ b/app/linux/runner/my_application.cc @@ -47,9 +47,6 @@ static void my_application_activate(GApplication* application) { fl_dart_project_set_dart_entrypoint_arguments( project, self->dart_entrypoint_arguments); - // Complete the XDG startup notification before any widget becomes visible - // so desktop environments don't show a "CopyPaste is starting…" cursor or - // launcher pulse when the window first maps via hotkey. gdk_notify_startup_complete(); FlView* view = fl_view_new(project); diff --git a/app/test/shell/linux_shell_test.dart b/app/test/shell/linux_shell_test.dart index 8bccc5ec..ebdba604 100644 --- a/app/test/shell/linux_shell_test.dart +++ b/app/test/shell/linux_shell_test.dart @@ -24,7 +24,7 @@ void main() { const EventChannel(eventChannelName), MockStreamHandler.inline( onListen: (_, sink) { - controller!.stream.listen(sink.success, onError: sink.error); + controller!.stream.listen(sink.success); }, onCancel: (_) {}, ), diff --git a/listener/linux/listener_plugin.c b/listener/linux/listener_plugin.c index 0d1bb1c8..fccd51f2 100644 --- a/listener/linux/listener_plugin.c +++ b/listener/linux/listener_plugin.c @@ -38,7 +38,8 @@ static const gchar* kClipboardChannelName = "copypaste/clipboard"; static const gchar* kClipboardWriterChannelName = "copypaste/clipboard_writer"; static const guint64 kClipboardDebounceMs = 500; -static const guint kClipboardPollIntervalMs = 250; +static const guint kClipboardPollIntervalMs = 1500; +static const guint kClipboardOwnerDebounceMs = 80; static const guint64 kClipboardWriteIgnoreMs = 700; typedef struct { @@ -58,6 +59,8 @@ struct _ListenerPlugin { gboolean is_listening; guint poll_timer_id; + gulong owner_change_handler_id; + guint owner_debounce_timer_id; gchar* last_content_hash; guint64 last_change_tick_ms; guint64 last_write_tick_ms; @@ -333,16 +336,6 @@ static gboolean inject_paste_keystroke_x11(Display* display) { return TRUE; } -// Synchronous activate-then-paste: sends EWMH _NET_ACTIVE_WINDOW, raises and -// focuses the target window, then waits up to timeout_ms for the X server to -// confirm focus before injecting Ctrl+V via XTest. -// -// Returns an FlValue map: {success: bool, errorCode?: string}. -// errorCode values: -// - "noX11" — display unavailable -// - "invalidWindow"— window id is 0 -// - "noXTest" — XTest extension missing -// - "focusTimeout" — focus did not transfer within timeout static FlValue* activate_and_paste_x11(Window window, gint timeout_ms) { Display* display = get_xdisplay(); if (display == NULL) { @@ -373,7 +366,7 @@ static FlValue* activate_and_paste_x11(Window window, gint timeout_ms) { event.xclient.window = window; event.xclient.message_type = atom_net_active_window(display); event.xclient.format = 32; - event.xclient.data.l[0] = 2; // pager source — bypasses focus-steal guards + event.xclient.data.l[0] = 2; event.xclient.data.l[1] = CurrentTime; XSendEvent(display, DefaultRootWindow(display), False, SubstructureNotifyMask | SubstructureRedirectMask, &event); @@ -382,7 +375,6 @@ static FlValue* activate_and_paste_x11(Window window, gint timeout_ms) { XSetInputFocus(display, window, RevertToParent, CurrentTime); XSync(display, False); - // Poll for FocusIn on the target window (250 ms default budget). gint64 deadline_us = g_get_monotonic_time() + ((gint64)timeout_ms * 1000); XEvent received; gboolean focus_in_received = FALSE; @@ -391,11 +383,9 @@ static FlValue* activate_and_paste_x11(Window window, gint timeout_ms) { focus_in_received = TRUE; break; } - g_usleep(5000); // 5 ms — keeps the loop responsive without busy-spin + g_usleep(5000); } - // Verify with XGetInputFocus regardless of whether FocusIn arrived (some WMs - // refocus a child of the requested window — that's still acceptable). Window focused = None; int revert_to = 0; XGetInputFocus(display, &focused, &revert_to); @@ -702,11 +692,44 @@ static gboolean clipboard_poll_cb(gpointer user_data) { return G_SOURCE_CONTINUE; } +static gboolean owner_debounce_cb(gpointer user_data) { + ListenerPlugin* self = LISTENER_PLUGIN(user_data); + self->owner_debounce_timer_id = 0; + if (self->is_listening) { + process_clipboard(self); + } + return G_SOURCE_REMOVE; +} + +static void on_owner_change(GtkClipboard* clipboard, + GdkEvent* event, + gpointer user_data) { + (void)clipboard; + (void)event; + ListenerPlugin* self = LISTENER_PLUGIN(user_data); + if (!self->is_listening) { + return; + } + if (self->owner_debounce_timer_id != 0) { + g_source_remove(self->owner_debounce_timer_id); + self->owner_debounce_timer_id = 0; + } + self->owner_debounce_timer_id = g_timeout_add( + kClipboardOwnerDebounceMs, owner_debounce_cb, self); +} + static void ensure_polling(ListenerPlugin* self) { if (self->poll_timer_id == 0) { self->poll_timer_id = g_timeout_add(kClipboardPollIntervalMs, clipboard_poll_cb, self); } + if (self->owner_change_handler_id == 0) { + GtkClipboard* clipboard = gtk_clipboard_get(GDK_SELECTION_CLIPBOARD); + if (clipboard != NULL) { + self->owner_change_handler_id = g_signal_connect( + clipboard, "owner-change", G_CALLBACK(on_owner_change), self); + } + } } static void stop_polling(ListenerPlugin* self) { @@ -714,6 +737,17 @@ static void stop_polling(ListenerPlugin* self) { g_source_remove(self->poll_timer_id); self->poll_timer_id = 0; } + if (self->owner_debounce_timer_id != 0) { + g_source_remove(self->owner_debounce_timer_id); + self->owner_debounce_timer_id = 0; + } + if (self->owner_change_handler_id != 0) { + GtkClipboard* clipboard = gtk_clipboard_get(GDK_SELECTION_CLIPBOARD); + if (clipboard != NULL) { + g_signal_handler_disconnect(clipboard, self->owner_change_handler_id); + } + self->owner_change_handler_id = 0; + } } static FlValue* get_cursor_and_screen_info(void) { @@ -1068,6 +1102,7 @@ static FlMethodErrorResponse* stream_listen_cb(FlEventChannel* channel, ListenerPlugin* self = LISTENER_PLUGIN(user_data); self->is_listening = TRUE; ensure_polling(self); + process_clipboard(self); return NULL; } @@ -1120,6 +1155,8 @@ static void listener_plugin_init(ListenerPlugin* self) { self->last_write_tick_ms = 0; self->is_listening = FALSE; self->poll_timer_id = 0; + self->owner_change_handler_id = 0; + self->owner_debounce_timer_id = 0; } void listener_plugin_register_with_registrar(FlPluginRegistrar* registrar) { diff --git a/listener/test/clipboard_writer_test.dart b/listener/test/clipboard_writer_test.dart index 737add5c..a1bfef5f 100644 --- a/listener/test/clipboard_writer_test.dart +++ b/listener/test/clipboard_writer_test.dart @@ -238,8 +238,6 @@ void main() { }); }); - // ── macOS-specific methods ────────────────────────────────────────────── - group('ClipboardWriter.captureFrontmostApp', () { test('returns bundle id on success', () async { TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger From d2ac70bfaa71ed781ae4e2b8b18ed17a24e14c15 Mon Sep 17 00:00:00 2001 From: rgdevment Date: Fri, 24 Apr 2026 13:43:31 -0400 Subject: [PATCH 07/31] feat: Implement Linux cursor monitoring and input focus retrieval --- app/lib/main.dart | 6 +- app/lib/shell/app_window.dart | 43 +++++++- app/lib/shell/linux_shell.dart | 68 ++++++++++++ app/linux/runner/copypaste_linux_shell.c | 130 +++++++++++++++++++++++ app/test/shell/linux_shell_test.dart | 67 ++++++++++++ 5 files changed, 306 insertions(+), 8 deletions(-) diff --git a/app/lib/main.dart b/app/lib/main.dart index 4202a1a4..73447f0c 100644 --- a/app/lib/main.dart +++ b/app/lib/main.dart @@ -977,11 +977,11 @@ class _CopyPasteAppState extends State if (_appWindow.isGateMode) return; if (!_config.hideOnDeactivate) return; if (Platform.isLinux) { - // On Linux/GTK, window-move and other WM operations briefly steal focus. - // Delay the hide so we can cancel it if focus returns quickly (e.g. drag). _blurHideTimer?.cancel(); - _blurHideTimer = Timer(const Duration(milliseconds: 300), () { + _blurHideTimer = Timer(const Duration(milliseconds: 500), () async { _blurHideTimer = null; + final focus = await LinuxShell.getInputFocus(); + if (focus != null && focus.ownsFocus) return; unawaited(_appWindow.hideIfNotPinned()); }); } else { diff --git a/app/lib/shell/app_window.dart b/app/lib/shell/app_window.dart index 85efbe64..47c3e7d8 100644 --- a/app/lib/shell/app_window.dart +++ b/app/lib/shell/app_window.dart @@ -172,13 +172,35 @@ class AppWindow { Future _positionNearCursor() async { if (Platform.isWindows) { await _positionNearCursorWindows(); - } else if (Platform.isMacOS || Platform.isLinux) { + } else if (Platform.isLinux) { + await _positionNearCursorLinux(); + } else if (Platform.isMacOS) { await _positionNearCursorNative(); } else { await windowManager.center(); } } + Future _positionNearCursorLinux() async { + try { + final info = await LinuxShell.getCursorMonitor(); + if (info == null) { + await _positionNearCursorNative(); + return; + } + final workArea = ( + info.x, + info.y, + info.x + info.width, + info.y + info.height, + ); + await _applyPosition(info.cursorX, info.cursorY, workArea); + } catch (e) { + AppLogger.warn('_positionNearCursorLinux: fallback to native: $e'); + await _positionNearCursorNative(); + } + } + Future _positionNearCursorWindows() async { try { final cursor = _getCursorPosWin32(); @@ -374,14 +396,21 @@ class AppWindow { Future enterSettingsMode() async { _settingsMode = true; await windowManager.setResizable(true); + Future? configureFuture; if (Platform.isLinux) { - await Future.delayed(const Duration(milliseconds: 50)); + configureFuture = LinuxShell.awaitEvent( + 'configureNotify', + timeout: const Duration(milliseconds: 250), + ); } await windowManager.setMinimumSize( const Size(_settingsWidth, _settingsHeight), ); await windowManager.setMaximumSize(const Size(1200, 900)); await windowManager.setSize(const Size(_settingsWidth, _settingsHeight)); + if (configureFuture != null) { + await configureFuture; + } await windowManager.center(); if (!await windowManager.isVisible()) { await windowManager.show(); @@ -392,15 +421,19 @@ class AppWindow { Future exitSettingsMode() async { _settingsMode = false; + Future? configureFuture; if (Platform.isLinux) { await windowManager.setResizable(true); - await Future.delayed(const Duration(milliseconds: 50)); + configureFuture = LinuxShell.awaitEvent( + 'configureNotify', + timeout: const Duration(milliseconds: 250), + ); } await windowManager.setMinimumSize(Size(_popupWidth, 400)); await windowManager.setMaximumSize(Size(_popupWidth, 900)); await windowManager.setSize(Size(_popupWidth, _popupHeight)); - if (Platform.isLinux) { - await Future.delayed(const Duration(milliseconds: 100)); + if (configureFuture != null) { + await configureFuture; } await windowManager.setResizable(false); await _positionNearCursor(); diff --git a/app/lib/shell/linux_shell.dart b/app/lib/shell/linux_shell.dart index ac84a8e3..c7931183 100644 --- a/app/lib/shell/linux_shell.dart +++ b/app/lib/shell/linux_shell.dart @@ -166,6 +166,74 @@ class LinuxShell { AppLogger.error('LinuxShell.focusWindow failed: $e'); } } + + static Future getCursorMonitor() async { + try { + final result = + await _methodChannel.invokeMethod('getCursorMonitor'); + if (result is! Map) return null; + return CursorMonitorInfo( + cursorX: (result['cursorX'] as num?)?.toDouble() ?? 0, + cursorY: (result['cursorY'] as num?)?.toDouble() ?? 0, + x: (result['x'] as num?)?.toDouble() ?? 0, + y: (result['y'] as num?)?.toDouble() ?? 0, + width: (result['width'] as num?)?.toDouble() ?? 0, + height: (result['height'] as num?)?.toDouble() ?? 0, + scaleFactor: (result['scaleFactor'] as num?)?.toDouble() ?? 1.0, + ); + } catch (e) { + AppLogger.error('LinuxShell.getCursorMonitor failed: $e'); + return null; + } + } + + static Future getInputFocus() async { + try { + final result = + await _methodChannel.invokeMethod('getInputFocus'); + if (result is! Map) return null; + return InputFocusInfo( + ownsFocus: result['ownsFocus'] as bool? ?? false, + focusWindow: (result['focusWindow'] as num?)?.toInt() ?? 0, + ownWindow: (result['ownWindow'] as num?)?.toInt() ?? 0, + ); + } catch (e) { + AppLogger.error('LinuxShell.getInputFocus failed: $e'); + return null; + } + } +} + +class CursorMonitorInfo { + const CursorMonitorInfo({ + required this.cursorX, + required this.cursorY, + required this.x, + required this.y, + required this.width, + required this.height, + required this.scaleFactor, + }); + + final double cursorX; + final double cursorY; + final double x; + final double y; + final double width; + final double height; + final double scaleFactor; +} + +class InputFocusInfo { + const InputFocusInfo({ + required this.ownsFocus, + required this.focusWindow, + required this.ownWindow, + }); + + final bool ownsFocus; + final int focusWindow; + final int ownWindow; } class HotkeyRegisterResponse { diff --git a/app/linux/runner/copypaste_linux_shell.c b/app/linux/runner/copypaste_linux_shell.c index 23f03cab..c929fad0 100644 --- a/app/linux/runner/copypaste_linux_shell.c +++ b/app/linux/runner/copypaste_linux_shell.c @@ -92,6 +92,14 @@ static gboolean window_map_event_cb(GtkWidget* widget, GdkEvent* event, return FALSE; } +static gboolean window_configure_event_cb(GtkWidget* widget, GdkEvent* event, + gpointer user_data) { + (void)widget; + (void)event; + send_shell_event((CopyPasteLinuxShell*)user_data, "configureNotify"); + return FALSE; +} + static gchar* resolve_asset_path(const gchar* asset_path) { if (asset_path == NULL || *asset_path == '\0') { return NULL; @@ -642,6 +650,116 @@ static FlValue* build_capabilities(void) { return caps; } +static FlValue* build_cursor_monitor(void) { + GdkDisplay* display = gdk_display_get_default(); + if (display == NULL) { + return fl_value_new_null(); + } + GdkSeat* seat = gdk_display_get_default_seat(display); + if (seat == NULL) { + return fl_value_new_null(); + } + GdkDevice* pointer = gdk_seat_get_pointer(seat); + if (pointer == NULL) { + return fl_value_new_null(); + } + gint cursor_x = 0; + gint cursor_y = 0; + GdkScreen* screen = NULL; + gdk_device_get_position(pointer, &screen, &cursor_x, &cursor_y); + + GdkMonitor* monitor = + gdk_display_get_monitor_at_point(display, cursor_x, cursor_y); + if (monitor == NULL) { + monitor = gdk_display_get_primary_monitor(display); + } + if (monitor == NULL) { + return fl_value_new_null(); + } + + GdkRectangle workarea = {0, 0, 0, 0}; + gdk_monitor_get_workarea(monitor, &workarea); + gint scale = gdk_monitor_get_scale_factor(monitor); + if (scale <= 0) { + scale = 1; + } + + FlValue* result = fl_value_new_map(); + fl_value_set_string_take(result, "cursorX", + fl_value_new_float((double)cursor_x)); + fl_value_set_string_take(result, "cursorY", + fl_value_new_float((double)cursor_y)); + fl_value_set_string_take(result, "x", + fl_value_new_float((double)workarea.x)); + fl_value_set_string_take(result, "y", + fl_value_new_float((double)workarea.y)); + fl_value_set_string_take(result, "width", + fl_value_new_float((double)workarea.width)); + fl_value_set_string_take(result, "height", + fl_value_new_float((double)workarea.height)); + fl_value_set_string_take(result, "scaleFactor", + fl_value_new_float((double)scale)); + return result; +} + +static FlValue* build_input_focus(CopyPasteLinuxShell* shell) { + FlValue* result = fl_value_new_map(); + fl_value_set_string_take(result, "ownsFocus", fl_value_new_bool(FALSE)); + fl_value_set_string_take(result, "focusWindow", fl_value_new_int(0)); + fl_value_set_string_take(result, "ownWindow", fl_value_new_int(0)); +#ifdef GDK_WINDOWING_X11 + if (!shell_is_x11() || shell->xdisplay == NULL) { + return result; + } + Window focused = None; + int revert_to = 0; + XGetInputFocus(shell->xdisplay, &focused, &revert_to); + fl_value_set_string_take(result, "focusWindow", + fl_value_new_int((gint64)focused)); + + if (shell->gtk_window == NULL) { + return result; + } + GdkWindow* gdk_window = + gtk_widget_get_window(GTK_WIDGET(shell->gtk_window)); + if (gdk_window == NULL) { + return result; + } + Window own = gdk_x11_window_get_xid(gdk_window); + fl_value_set_string_take(result, "ownWindow", fl_value_new_int((gint64)own)); + + gboolean owns = (focused == own); + if (!owns && focused != None && focused != PointerRoot) { + Window root = None; + Window parent = None; + Window* children = NULL; + unsigned int nchildren = 0; + Window cursor = focused; + for (int depth = 0; depth < 8; depth++) { + if (XQueryTree(shell->xdisplay, cursor, &root, &parent, &children, + &nchildren) == 0) { + break; + } + if (children != NULL) { + XFree(children); + } + if (parent == None || parent == root) { + break; + } + if (parent == own) { + owns = TRUE; + break; + } + cursor = parent; + } + } + fl_value_set_string_take(result, "ownsFocus", fl_value_new_bool(owns)); +#else + (void)shell; +#endif + return result; +} + static void shell_method_call_cb(FlMethodChannel* channel, FlMethodCall* method_call, gpointer user_data) { @@ -696,6 +814,16 @@ static void shell_method_call_cb(FlMethodChannel* channel, return; } + if (strcmp(method, "getCursorMonitor") == 0) { + respond_method_success(method_call, build_cursor_monitor()); + return; + } + + if (strcmp(method, "getInputFocus") == 0) { + respond_method_success(method_call, build_input_focus(shell)); + return; + } + g_autoptr(FlMethodResponse) response = FL_METHOD_RESPONSE(fl_method_not_implemented_response_new()); fl_method_call_respond(method_call, response, NULL); @@ -722,6 +850,8 @@ CopyPasteLinuxShell* copypaste_linux_shell_new(FlBinaryMessenger* messenger, G_CALLBACK(window_unmap_event_cb), shell); g_signal_connect(shell->gtk_window, "map-event", G_CALLBACK(window_map_event_cb), shell); + g_signal_connect(shell->gtk_window, "configure-event", + G_CALLBACK(window_configure_event_cb), shell); } #ifdef GDK_WINDOWING_X11 diff --git a/app/test/shell/linux_shell_test.dart b/app/test/shell/linux_shell_test.dart index ebdba604..fde3ffd7 100644 --- a/app/test/shell/linux_shell_test.dart +++ b/app/test/shell/linux_shell_test.dart @@ -69,4 +69,71 @@ void main() { expect(await future, isFalse); }); }); + + group('LinuxShell.getCursorMonitor', () { + const channel = MethodChannel('copypaste/linux_shell'); + + tearDown(() { + TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger + .setMockMethodCallHandler(channel, null); + }); + + test('parses Map response into CursorMonitorInfo', () async { + TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger + .setMockMethodCallHandler(channel, (call) async { + if (call.method != 'getCursorMonitor') return null; + return { + 'cursorX': 800.0, + 'cursorY': 450.0, + 'x': 0.0, + 'y': 0.0, + 'width': 1920.0, + 'height': 1080.0, + 'scaleFactor': 2.0, + }; + }); + final info = await LinuxShell.getCursorMonitor(); + expect(info, isNotNull); + expect(info!.cursorX, equals(800.0)); + expect(info.width, equals(1920.0)); + expect(info.scaleFactor, equals(2.0)); + }); + + test('returns null when channel returns null', () async { + TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger + .setMockMethodCallHandler(channel, (_) async => null); + expect(await LinuxShell.getCursorMonitor(), isNull); + }); + }); + + group('LinuxShell.getInputFocus', () { + const channel = MethodChannel('copypaste/linux_shell'); + + tearDown(() { + TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger + .setMockMethodCallHandler(channel, null); + }); + + test('parses Map response into InputFocusInfo', () async { + TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger + .setMockMethodCallHandler(channel, (call) async { + if (call.method != 'getInputFocus') return null; + return { + 'ownsFocus': true, + 'focusWindow': 0xabc, + 'ownWindow': 0xabc, + }; + }); + final info = await LinuxShell.getInputFocus(); + expect(info, isNotNull); + expect(info!.ownsFocus, isTrue); + expect(info.focusWindow, equals(0xabc)); + }); + + test('returns null when channel returns null', () async { + TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger + .setMockMethodCallHandler(channel, (_) async => null); + expect(await LinuxShell.getInputFocus(), isNull); + }); + }); } From b223bb80e1129e816c45eda432f5cb706fa9af5e Mon Sep 17 00:00:00 2001 From: rgdevment Date: Fri, 24 Apr 2026 13:43:50 -0400 Subject: [PATCH 08/31] refact: Clean up code formatting and improve readability across multiple files --- app/lib/main.dart | 5 +- .../screens/linux_capabilities_banner.dart | 33 +++----- app/lib/screens/main_screen.dart | 3 +- app/lib/services/linux_capabilities.dart | 34 ++++++--- app/lib/shell/linux_hotkey_registration.dart | 32 ++++++-- app/lib/shell/linux_session.dart | 8 +- app/lib/shell/linux_shell.dart | 32 ++++---- .../linux_capabilities_banner_test.dart | 35 ++++----- .../services/linux_capabilities_test.dart | 11 ++- .../shell/linux_hotkey_registration_test.dart | 76 ++++++++++--------- 10 files changed, 152 insertions(+), 117 deletions(-) diff --git a/app/lib/main.dart b/app/lib/main.dart index 73447f0c..f66ac083 100644 --- a/app/lib/main.dart +++ b/app/lib/main.dart @@ -1252,8 +1252,9 @@ class _CopyPasteAppState extends State linuxCapabilities: Platform.isLinux ? LinuxCapabilitiesService.current : null, - onLinuxConfigUpdate: - Platform.isLinux ? _updateLinuxConfig : null, + onLinuxConfigUpdate: Platform.isLinux + ? _updateLinuxConfig + : null, ); }, ), diff --git a/app/lib/screens/linux_capabilities_banner.dart b/app/lib/screens/linux_capabilities_banner.dart index a5f711bd..6e64de41 100644 --- a/app/lib/screens/linux_capabilities_banner.dart +++ b/app/lib/screens/linux_capabilities_banner.dart @@ -5,9 +5,8 @@ import '../l10n/app_localizations.dart'; import '../services/linux_capabilities.dart'; import '../theme/theme_provider.dart'; -typedef LinuxBannerDismissCallback = Future Function( - AppConfig Function(AppConfig) update, -); +typedef LinuxBannerDismissCallback = + Future Function(AppConfig Function(AppConfig) update); class LinuxCapabilitiesBanner extends StatelessWidget { const LinuxCapabilitiesBanner({ @@ -46,17 +45,14 @@ class LinuxCapabilitiesBanner extends StatelessWidget { final colors = CopyPasteTheme.colorsOf(context); final (title, body) = switch (kind) { _BannerKind.appIndicator => ( - l.linuxAppindicatorBannerTitle, - l.linuxAppindicatorBannerBody, - ), - _BannerKind.xtest => ( - l.linuxXtestBannerTitle, - l.linuxXtestBannerBody, - ), + l.linuxAppindicatorBannerTitle, + l.linuxAppindicatorBannerBody, + ), + _BannerKind.xtest => (l.linuxXtestBannerTitle, l.linuxXtestBannerBody), _BannerKind.clipboardManager => ( - l.linuxClipboardManagerBannerTitle, - l.linuxClipboardManagerBannerBody, - ), + l.linuxClipboardManagerBannerTitle, + l.linuxClipboardManagerBannerBody, + ), }; return Container( @@ -65,11 +61,7 @@ class LinuxCapabilitiesBanner extends StatelessWidget { child: Row( crossAxisAlignment: CrossAxisAlignment.start, children: [ - Icon( - Icons.warning_amber_rounded, - size: 16, - color: colors.primary, - ), + Icon(Icons.warning_amber_rounded, size: 16, color: colors.primary), const SizedBox(width: 10), Expanded( child: Column( @@ -86,10 +78,7 @@ class LinuxCapabilitiesBanner extends StatelessWidget { const SizedBox(height: 2), Text( body, - style: TextStyle( - fontSize: 11, - color: colors.onSurfaceMuted, - ), + style: TextStyle(fontSize: 11, color: colors.onSurfaceMuted), ), ], ), diff --git a/app/lib/screens/main_screen.dart b/app/lib/screens/main_screen.dart index e5b65f8e..56cd80b4 100644 --- a/app/lib/screens/main_screen.dart +++ b/app/lib/screens/main_screen.dart @@ -62,7 +62,8 @@ class MainScreen extends StatefulWidget { final ManifestSeverity? updateSeverity; final AppConfig? appConfig; final LinuxCapabilities? linuxCapabilities; - final Future Function(AppConfig Function(AppConfig))? onLinuxConfigUpdate; + final Future Function(AppConfig Function(AppConfig))? + onLinuxConfigUpdate; @override State createState() => MainScreenState(); diff --git a/app/lib/services/linux_capabilities.dart b/app/lib/services/linux_capabilities.dart index 6b47e468..5230425e 100644 --- a/app/lib/services/linux_capabilities.dart +++ b/app/lib/services/linux_capabilities.dart @@ -87,8 +87,9 @@ class _DefaultLinuxCapabilitiesChannel implements LinuxCapabilitiesChannel { const _DefaultLinuxCapabilitiesChannel(); static const MethodChannel _shell = MethodChannel('copypaste/linux_shell'); - static const MethodChannel _listener = - MethodChannel('copypaste/clipboard_writer'); + static const MethodChannel _listener = MethodChannel( + 'copypaste/clipboard_writer', + ); @override Future?> invokeShell(String method) async { @@ -129,7 +130,9 @@ class LinuxCapabilitiesService { } final session = detectLinuxSession(); - final base = LinuxCapabilities.unsupported.copyWith().copyWithSession(session); + final base = LinuxCapabilities.unsupported.copyWith().copyWithSession( + session, + ); if (!session.isX11) { _cache = base; @@ -142,13 +145,17 @@ class LinuxCapabilitiesService { Map? listenerCaps; try { - final results = await Future.wait([ - channel.invokeShell('getCapabilities').catchError((_) => null), - channel.invokeListener('getCapabilities').catchError((_) => null), - ]).timeout(timeout, onTimeout: () { - timedOut = true; - return [null, null]; - }); + final results = + await Future.wait([ + channel.invokeShell('getCapabilities').catchError((_) => null), + channel.invokeListener('getCapabilities').catchError((_) => null), + ]).timeout( + timeout, + onTimeout: () { + timedOut = true; + return [null, null]; + }, + ); shellCaps = results[0]; listenerCaps = results[1]; } catch (e) { @@ -172,8 +179,11 @@ class LinuxCapabilitiesService { return result; } - static bool _readBool(Map? map, String key, - {bool fallback = false}) { + static bool _readBool( + Map? map, + String key, { + bool fallback = false, + }) { if (map == null) return fallback; final value = map[key]; return value is bool ? value : fallback; diff --git a/app/lib/shell/linux_hotkey_registration.dart b/app/lib/shell/linux_hotkey_registration.dart index 5a5f9a5d..25d596a6 100644 --- a/app/lib/shell/linux_hotkey_registration.dart +++ b/app/lib/shell/linux_hotkey_registration.dart @@ -34,12 +34,32 @@ final Set _supportedLinuxVirtualKeys = { for (var k = 0x41; k <= 0x5A; k++) k, for (var k = 0x30; k <= 0x39; k++) k, for (var k = 0x70; k <= 0x87; k++) k, - 0x08, 0x09, 0x0D, 0x1B, 0x20, - 0x21, 0x22, 0x23, 0x24, - 0x25, 0x26, 0x27, 0x28, - 0x2D, 0x2E, - 0xBA, 0xBB, 0xBC, 0xBD, 0xBE, 0xBF, 0xC0, - 0xDB, 0xDC, 0xDD, 0xDE, + 0x08, + 0x09, + 0x0D, + 0x1B, + 0x20, + 0x21, + 0x22, + 0x23, + 0x24, + 0x25, + 0x26, + 0x27, + 0x28, + 0x2D, + 0x2E, + 0xBA, + 0xBB, + 0xBC, + 0xBD, + 0xBE, + 0xBF, + 0xC0, + 0xDB, + 0xDC, + 0xDD, + 0xDE, }; bool isLinuxSupportedVirtualKey(int virtualKey) => diff --git a/app/lib/shell/linux_session.dart b/app/lib/shell/linux_session.dart index 2ddd7a73..c3606e97 100644 --- a/app/lib/shell/linux_session.dart +++ b/app/lib/shell/linux_session.dart @@ -33,7 +33,9 @@ class LinuxSessionInfo { bool get isX11 { if (sessionType == 'x11') return true; - if (sessionType == 'wayland' || sessionType == 'mir' || sessionType == 'tty') { + if (sessionType == 'wayland' || + sessionType == 'mir' || + sessionType == 'tty') { return false; } if (hasDisplay && !hasWaylandDisplay && !hasWaylandSocket) return true; @@ -41,7 +43,9 @@ class LinuxSessionInfo { } bool get isXWayland => - hasDisplay && (hasWaylandDisplay || hasWaylandSocket) && sessionType == 'wayland'; + hasDisplay && + (hasWaylandDisplay || hasWaylandSocket) && + sessionType == 'wayland'; bool get isUsable => isX11 || isWayland; diff --git a/app/lib/shell/linux_shell.dart b/app/lib/shell/linux_shell.dart index c7931183..5553f788 100644 --- a/app/lib/shell/linux_shell.dart +++ b/app/lib/shell/linux_shell.dart @@ -88,7 +88,9 @@ class LinuxShell { } static Future _invokeTrayMethod( - String method, Map args) async { + String method, + Map args, + ) async { try { final result = await _methodChannel.invokeMethod(method, args); if (result is Map) { @@ -125,13 +127,14 @@ class LinuxShell { required bool useShift, }) async { try { - final result = await _methodChannel.invokeMethod('registerHotkey', { - 'virtualKey': virtualKey, - 'useCtrl': useCtrl, - 'useWin': useWin, - 'useAlt': useAlt, - 'useShift': useShift, - }); + final result = await _methodChannel + .invokeMethod('registerHotkey', { + 'virtualKey': virtualKey, + 'useCtrl': useCtrl, + 'useWin': useWin, + 'useAlt': useAlt, + 'useShift': useShift, + }); if (result is Map) { final map = Map.from(result); final success = map['success'] == true; @@ -147,7 +150,10 @@ class LinuxShell { return const HotkeyRegisterResponse(success: false, errorCode: 'unknown'); } catch (e) { AppLogger.error('LinuxShell.registerHotkey failed: $e'); - return const HotkeyRegisterResponse(success: false, errorCode: 'channelError'); + return const HotkeyRegisterResponse( + success: false, + errorCode: 'channelError', + ); } } @@ -169,8 +175,9 @@ class LinuxShell { static Future getCursorMonitor() async { try { - final result = - await _methodChannel.invokeMethod('getCursorMonitor'); + final result = await _methodChannel.invokeMethod( + 'getCursorMonitor', + ); if (result is! Map) return null; return CursorMonitorInfo( cursorX: (result['cursorX'] as num?)?.toDouble() ?? 0, @@ -189,8 +196,7 @@ class LinuxShell { static Future getInputFocus() async { try { - final result = - await _methodChannel.invokeMethod('getInputFocus'); + final result = await _methodChannel.invokeMethod('getInputFocus'); if (result is! Map) return null; return InputFocusInfo( ownsFocus: result['ownsFocus'] as bool? ?? false, diff --git a/app/test/screens/linux_capabilities_banner_test.dart b/app/test/screens/linux_capabilities_banner_test.dart index ad7bc32c..a24c222f 100644 --- a/app/test/screens/linux_capabilities_banner_test.dart +++ b/app/test/screens/linux_capabilities_banner_test.dart @@ -64,23 +64,22 @@ void main() { expect(find.byType(Icon), findsNothing); }); - testWidgets( - 'renders AppIndicator banner when missing and not dismissed', - (tester) async { - if (!Platform.isLinux) return; - await tester.pumpWidget( - _wrap( - LinuxCapabilitiesBanner( - config: const AppConfig(), - capabilities: _caps(hasAppIndicator: false), - onDismiss: (_) async {}, - ), + testWidgets('renders AppIndicator banner when missing and not dismissed', ( + tester, + ) async { + if (!Platform.isLinux) return; + await tester.pumpWidget( + _wrap( + LinuxCapabilitiesBanner( + config: const AppConfig(), + capabilities: _caps(hasAppIndicator: false), + onDismiss: (_) async {}, ), - ); - expect(find.byIcon(Icons.warning_amber_rounded), findsOneWidget); - expect(find.byIcon(Icons.close_rounded), findsOneWidget); - }, - ); + ), + ); + expect(find.byIcon(Icons.warning_amber_rounded), findsOneWidget); + expect(find.byIcon(Icons.close_rounded), findsOneWidget); + }); testWidgets( 'renders nothing when capability missing but already dismissed', @@ -107,7 +106,9 @@ void main() { }, ); - testWidgets('dismiss callback fires when close icon tapped', (tester) async { + testWidgets('dismiss callback fires when close icon tapped', ( + tester, + ) async { if (!Platform.isLinux) return; AppConfig? captured; await tester.pumpWidget( diff --git a/app/test/services/linux_capabilities_test.dart b/app/test/services/linux_capabilities_test.dart index 1fd5a55b..f8a96d29 100644 --- a/app/test/services/linux_capabilities_test.dart +++ b/app/test/services/linux_capabilities_test.dart @@ -63,10 +63,7 @@ void main() { 'desktopEnv': 'GNOME', 'wmName': 'Mutter', }, - listenerResponse: const { - 'isX11': true, - 'hasXTest': true, - }, + listenerResponse: const {'isX11': true, 'hasXTest': true}, ); final caps = await LinuxCapabilitiesService.detect(channel: channel); expect(caps.hasXTest, isTrue); @@ -155,8 +152,10 @@ void main() { test('canShowTray requires X11 + AppIndicator', () { if (!Platform.isLinux) return; LinuxCapabilitiesService.resetForTesting( - LinuxCapabilities.unsupported - .copyWith(isX11: true, hasAppIndicator: true), + LinuxCapabilities.unsupported.copyWith( + isX11: true, + hasAppIndicator: true, + ), ); expect(LinuxGuard.canShowTray, isTrue); LinuxCapabilitiesService.resetForTesting( diff --git a/app/test/shell/linux_hotkey_registration_test.dart b/app/test/shell/linux_hotkey_registration_test.dart index c3bc6110..fd1efa43 100644 --- a/app/test/shell/linux_hotkey_registration_test.dart +++ b/app/test/shell/linux_hotkey_registration_test.dart @@ -53,27 +53,29 @@ void main() { useShift: false, ); - test('short-circuits when requested key is unsupported (no remote call)', - () async { - const unsupported = HotkeyBinding( - virtualKey: 0x99, - keyName: '?', - useCtrl: true, - useWin: false, - useAlt: true, - useShift: false, - ); - final api = _FakeLinuxHotkeyBindingApi([]); - - final result = await registerLinuxHotkeyWithFallback( - api: api, - requestedBinding: unsupported, - ); - - expect(result.status, HotkeyRegistrationStatus.failed); - expect(result.failureReason, HotkeyFailureReason.unsupportedKey); - expect(api.attempts, isEmpty); - }); + test( + 'short-circuits when requested key is unsupported (no remote call)', + () async { + const unsupported = HotkeyBinding( + virtualKey: 0x99, + keyName: '?', + useCtrl: true, + useWin: false, + useAlt: true, + useShift: false, + ); + final api = _FakeLinuxHotkeyBindingApi([]); + + final result = await registerLinuxHotkeyWithFallback( + api: api, + requestedBinding: unsupported, + ); + + expect(result.status, HotkeyRegistrationStatus.failed); + expect(result.failureReason, HotkeyFailureReason.unsupportedKey); + expect(api.attempts, isEmpty); + }, + ); test('registers requested binding when available', () async { final api = _FakeLinuxHotkeyBindingApi([_ok()]); @@ -125,21 +127,23 @@ void main() { expect(result.failureReason, HotkeyFailureReason.grabFailed); }); - test('does not retry when requested binding equals temporary fallback', - () async { - final api = _FakeLinuxHotkeyBindingApi([ - _fail('grabFailed'), - ]); - - final result = await registerLinuxHotkeyWithFallback( - api: api, - requestedBinding: kLinuxTemporaryFallbackHotkey, - ); - - expect(result.status, HotkeyRegistrationStatus.failed); - expect(result.failureReason, HotkeyFailureReason.grabFailed); - expect(api.attempts, [kLinuxTemporaryFallbackHotkey]); - }); + test( + 'does not retry when requested binding equals temporary fallback', + () async { + final api = _FakeLinuxHotkeyBindingApi([ + _fail('grabFailed'), + ]); + + final result = await registerLinuxHotkeyWithFallback( + api: api, + requestedBinding: kLinuxTemporaryFallbackHotkey, + ); + + expect(result.status, HotkeyRegistrationStatus.failed); + expect(result.failureReason, HotkeyFailureReason.grabFailed); + expect(api.attempts, [kLinuxTemporaryFallbackHotkey]); + }, + ); test('maps unknown error code to HotkeyFailureReason.unknown', () async { final api = _FakeLinuxHotkeyBindingApi([ From 396978874a651e2b546859ed8eae38a1cffb686f Mon Sep 17 00:00:00 2001 From: rgdevment Date: Fri, 24 Apr 2026 13:53:34 -0400 Subject: [PATCH 09/31] feat: enhance Linux release workflow and startup helper - Updated release-linux.yml to include validation for .desktop files and compute SHA-256 checksums for artifacts. - Modified settings_screen.dart to display 'Super' key label for Linux. - Improved startup_helper.dart to handle autostart entries for Linux more effectively, including support for Wayland sessions. - Adjusted copypaste_linux_shell.c to correctly add and remove event filters for the root window. - Refined listener_plugin.c to prevent RTF and HTML data from being set if the text is a URL. - Added flatpak command to release-manifest.json for better package management. --- .github/workflows/release-linux.yml | 446 ++++++++------- app/lib/screens/settings_screen.dart | 6 +- app/lib/shell/startup_helper.dart | 693 ++++++++++++----------- app/linux/runner/copypaste_linux_shell.c | 6 +- listener/linux/listener_plugin.c | 24 +- release-manifest.json | 3 +- 6 files changed, 605 insertions(+), 573 deletions(-) diff --git a/.github/workflows/release-linux.yml b/.github/workflows/release-linux.yml index f5160b05..c129b94e 100644 --- a/.github/workflows/release-linux.yml +++ b/.github/workflows/release-linux.yml @@ -1,212 +1,234 @@ -name: Release (Linux) - -on: - workflow_call: - inputs: - version: - required: true - type: string - workflow_dispatch: - inputs: - version: - description: "Version to use (e.g. 2.1.0). Defaults to 2.0.0-dev" - required: false - default: "2.0.0-dev" - -permissions: - contents: read - -jobs: - build-linux: - runs-on: ubuntu-22.04 - timeout-minutes: 45 - name: Build Linux (AppImage, deb, rpm) - - steps: - - uses: actions/checkout@v6 - with: - ref: ${{ github.ref_name }} - - - name: Resolve version - id: get_version - run: | - VERSION="${{ inputs.version }}" - - IS_PRERELEASE="false" - if [[ "$VERSION" == *-* ]]; then - IS_PRERELEASE="true" - fi - - echo "VERSION=$VERSION" >> "$GITHUB_OUTPUT" - echo "IS_PRERELEASE=$IS_PRERELEASE" >> "$GITHUB_OUTPUT" - echo "Version: $VERSION PreRelease: $IS_PRERELEASE" - - - uses: subosito/flutter-action@v2 - with: - channel: stable - cache: true - - - name: Cache pub dependencies - uses: actions/cache@v5 - with: - path: ~/.pub-cache - key: ${{ runner.os }}-pub-${{ hashFiles('**/pubspec.yaml') }} - restore-keys: ${{ runner.os }}-pub- - - - name: Install Linux build dependencies - run: | - sudo apt-get update - sudo apt-get install -y \ - clang \ - cmake \ - desktop-file-utils \ - libayatana-appindicator3-dev \ - libfuse2 \ - libgtk-3-dev \ - libkeybinder-3.0-dev \ - liblzma-dev \ - libx11-dev \ - libxtst-dev \ - lld-14 \ - ninja-build \ - patchelf \ - pkg-config \ - rpm - - - name: Verify Linux toolchain preflight - run: | - set -euo pipefail - test -x /usr/lib/llvm-14/bin/ld.lld || test -x /usr/bin/ld.lld-14 || test -x /usr/bin/ld.lld - pkg-config --modversion gtk+-3.0 - pkg-config --modversion keybinder-3.0 - pkg-config --modversion ayatana-appindicator3-0.1 - pkg-config --modversion x11 - pkg-config --modversion xtst - - - name: Install Fastforge - run: dart pub global activate fastforge - - - name: Install appimagetool - run: | - wget -qO /usr/local/bin/appimagetool \ - "https://github.com/AppImage/AppImageKit/releases/download/continuous/appimagetool-x86_64.AppImage" - chmod +x /usr/local/bin/appimagetool - - - name: Install Melos - run: dart pub global activate melos - - - name: Get dependencies - run: melos bootstrap - - - name: Update pubspec version from tag - run: | - VERSION="${{ steps.get_version.outputs.VERSION }}" - sed -i "s/^version:.*/version: $VERSION/" app/pubspec.yaml - echo "Updated pubspec.yaml version to: $VERSION" - - - name: Package AppImage - env: - APPIMAGE_EXTRACT_AND_RUN: "1" - run: | - cd app - fastforge package \ - --platform linux \ - --targets appimage \ - --build-dart-define "APP_VERSION=${{ steps.get_version.outputs.VERSION }}" - - - name: Rename AppImage - run: | - VERSION="${{ steps.get_version.outputs.VERSION }}" - APPIMAGE=$(find app/dist app/build -type f -name "*.AppImage" 2>/dev/null | head -n 1) - if [[ -z "$APPIMAGE" ]]; then - echo "::error::No AppImage generated" - exit 1 - fi - mkdir -p app/dist - DEST="app/dist/CopyPaste_${VERSION}_x86_64.AppImage" - mv "$APPIMAGE" "$DEST" - chmod +x "$DEST" - echo "Renamed to: $(basename "$DEST")" - - - name: Package deb - run: | - cd app - fastforge package \ - --platform linux \ - --targets deb \ - --build-dart-define "APP_VERSION=${{ steps.get_version.outputs.VERSION }}" \ - --skip-clean - - - name: Rename deb - run: | - VERSION="${{ steps.get_version.outputs.VERSION }}" - DEB=$(find app/dist app/build -type f -name "*.deb" 2>/dev/null | head -n 1) - if [[ -z "$DEB" ]]; then - echo "::error::No deb package generated" - exit 1 - fi - mkdir -p app/dist - DEST="app/dist/CopyPaste_${VERSION}_amd64.deb" - mv "$DEB" "$DEST" - echo "Renamed to: $(basename "$DEST")" - - - name: Normalize version for RPM - id: rpm_version - run: | - VERSION="${{ steps.get_version.outputs.VERSION }}" - RPM_VERSION="${VERSION%%-*}" - echo "RPM_VERSION=$RPM_VERSION" >> "$GITHUB_OUTPUT" - echo "RPM version (normalized): $RPM_VERSION" - - - name: Package rpm - run: | - RPM_VERSION="${{ steps.rpm_version.outputs.RPM_VERSION }}" - cd app - sed -i "s/^version:.*/version: $RPM_VERSION/" pubspec.yaml - fastforge package \ - --platform linux \ - --targets rpm \ - --build-dart-define "APP_VERSION=${{ steps.get_version.outputs.VERSION }}" \ - --skip-clean - VERSION="${{ steps.get_version.outputs.VERSION }}" - sed -i "s/^version:.*/version: $VERSION/" pubspec.yaml - - - name: Rename rpm - run: | - VERSION="${{ steps.get_version.outputs.VERSION }}" - RPM=$(find app/dist app/build -type f -name "*.rpm" 2>/dev/null | head -n 1) - if [[ -z "$RPM" ]]; then - echo "::error::No rpm package generated" - exit 1 - fi - mkdir -p app/dist - DEST="app/dist/CopyPaste_${VERSION}_x86_64.rpm" - mv "$RPM" "$DEST" - echo "Renamed to: $(basename "$DEST")" - - - name: Publish deb to Cloudsmith - if: github.event_name == 'push' - env: - CLOUDSMITH_API_KEY: ${{ secrets.CLOUDSMITH_API_KEY }} - run: | - pip install cloudsmith-cli - cloudsmith push deb rgdevment/copypaste/any-distro/any-version \ - app/dist/CopyPaste_${{ steps.get_version.outputs.VERSION }}_amd64.deb --republish - - - name: Publish rpm to Cloudsmith - if: github.event_name == 'push' - env: - CLOUDSMITH_API_KEY: ${{ secrets.CLOUDSMITH_API_KEY }} - run: | - cloudsmith push rpm rgdevment/copypaste/any-distro/any-version \ - app/dist/CopyPaste_${{ steps.get_version.outputs.VERSION }}_x86_64.rpm --republish - - - name: Upload artifact - uses: actions/upload-artifact@v6 - with: - name: release-linux - path: | - app/dist/*.AppImage - app/dist/*.deb - app/dist/*.rpm - retention-days: 5 +name: Release (Linux) + +on: + workflow_call: + inputs: + version: + required: true + type: string + workflow_dispatch: + inputs: + version: + description: "Version to use (e.g. 2.1.0). Defaults to 2.0.0-dev" + required: false + default: "2.0.0-dev" + +permissions: + contents: read + +jobs: + build-linux: + runs-on: ubuntu-22.04 + timeout-minutes: 45 + name: Build Linux (AppImage, deb, rpm) + + steps: + - uses: actions/checkout@v6 + with: + ref: ${{ github.ref_name }} + + - name: Resolve version + id: get_version + run: | + VERSION="${{ inputs.version }}" + + IS_PRERELEASE="false" + if [[ "$VERSION" == *-* ]]; then + IS_PRERELEASE="true" + fi + + echo "VERSION=$VERSION" >> "$GITHUB_OUTPUT" + echo "IS_PRERELEASE=$IS_PRERELEASE" >> "$GITHUB_OUTPUT" + echo "Version: $VERSION PreRelease: $IS_PRERELEASE" + + - uses: subosito/flutter-action@v2 + with: + channel: stable + cache: true + + - name: Cache pub dependencies + uses: actions/cache@v5 + with: + path: ~/.pub-cache + key: ${{ runner.os }}-pub-${{ hashFiles('**/pubspec.yaml') }} + restore-keys: ${{ runner.os }}-pub- + + - name: Install Linux build dependencies + run: | + sudo apt-get update + sudo apt-get install -y \ + clang \ + cmake \ + desktop-file-utils \ + libayatana-appindicator3-dev \ + libfuse2 \ + libgtk-3-dev \ + libkeybinder-3.0-dev \ + liblzma-dev \ + libx11-dev \ + libxtst-dev \ + lld-14 \ + ninja-build \ + patchelf \ + pkg-config \ + rpm + + - name: Verify Linux toolchain preflight + run: | + set -euo pipefail + test -x /usr/lib/llvm-14/bin/ld.lld || test -x /usr/bin/ld.lld-14 || test -x /usr/bin/ld.lld + pkg-config --modversion gtk+-3.0 + pkg-config --modversion keybinder-3.0 + pkg-config --modversion ayatana-appindicator3-0.1 + pkg-config --modversion x11 + pkg-config --modversion xtst + + - name: Install Fastforge + run: dart pub global activate fastforge + + - name: Install appimagetool + run: | + wget -qO /usr/local/bin/appimagetool \ + "https://github.com/AppImage/AppImageKit/releases/download/continuous/appimagetool-x86_64.AppImage" + chmod +x /usr/local/bin/appimagetool + + - name: Install Melos + run: dart pub global activate melos + + - name: Get dependencies + run: melos bootstrap + + - name: Update pubspec version from tag + run: | + VERSION="${{ steps.get_version.outputs.VERSION }}" + sed -i "s/^version:.*/version: $VERSION/" app/pubspec.yaml + echo "Updated pubspec.yaml version to: $VERSION" + + - name: Package AppImage + env: + APPIMAGE_EXTRACT_AND_RUN: "1" + run: | + cd app + fastforge package \ + --platform linux \ + --targets appimage \ + --build-dart-define "APP_VERSION=${{ steps.get_version.outputs.VERSION }}" + + - name: Rename AppImage + run: | + VERSION="${{ steps.get_version.outputs.VERSION }}" + APPIMAGE=$(find app/dist app/build -type f -name "*.AppImage" 2>/dev/null | head -n 1) + if [[ -z "$APPIMAGE" ]]; then + echo "::error::No AppImage generated" + exit 1 + fi + mkdir -p app/dist + DEST="app/dist/CopyPaste_${VERSION}_x86_64.AppImage" + mv "$APPIMAGE" "$DEST" + chmod +x "$DEST" + echo "Renamed to: $(basename "$DEST")" + + - name: Package deb + run: | + cd app + fastforge package \ + --platform linux \ + --targets deb \ + --build-dart-define "APP_VERSION=${{ steps.get_version.outputs.VERSION }}" \ + --skip-clean + + - name: Rename deb + run: | + VERSION="${{ steps.get_version.outputs.VERSION }}" + DEB=$(find app/dist app/build -type f -name "*.deb" 2>/dev/null | head -n 1) + if [[ -z "$DEB" ]]; then + echo "::error::No deb package generated" + exit 1 + fi + mkdir -p app/dist + DEST="app/dist/CopyPaste_${VERSION}_amd64.deb" + mv "$DEB" "$DEST" + echo "Renamed to: $(basename "$DEST")" + + - name: Normalize version for RPM + id: rpm_version + run: | + VERSION="${{ steps.get_version.outputs.VERSION }}" + RPM_VERSION="${VERSION%%-*}" + echo "RPM_VERSION=$RPM_VERSION" >> "$GITHUB_OUTPUT" + echo "RPM version (normalized): $RPM_VERSION" + + - name: Package rpm + run: | + RPM_VERSION="${{ steps.rpm_version.outputs.RPM_VERSION }}" + cd app + sed -i "s/^version:.*/version: $RPM_VERSION/" pubspec.yaml + fastforge package \ + --platform linux \ + --targets rpm \ + --build-dart-define "APP_VERSION=${{ steps.get_version.outputs.VERSION }}" \ + --skip-clean + VERSION="${{ steps.get_version.outputs.VERSION }}" + sed -i "s/^version:.*/version: $VERSION/" pubspec.yaml + + - name: Rename rpm + run: | + VERSION="${{ steps.get_version.outputs.VERSION }}" + RPM=$(find app/dist app/build -type f -name "*.rpm" 2>/dev/null | head -n 1) + if [[ -z "$RPM" ]]; then + echo "::error::No rpm package generated" + exit 1 + fi + mkdir -p app/dist + DEST="app/dist/CopyPaste_${VERSION}_x86_64.rpm" + mv "$RPM" "$DEST" + echo "Renamed to: $(basename "$DEST")" + + - name: Validate bundled .desktop file + run: | + set -euo pipefail + VERSION="${{ steps.get_version.outputs.VERSION }}" + APPIMAGE="app/dist/CopyPaste_${VERSION}_x86_64.AppImage" + WORKDIR="$(mktemp -d)" + (cd "$WORKDIR" && "$GITHUB_WORKSPACE/$APPIMAGE" --appimage-extract '*.desktop' >/dev/null) + DESKTOP=$(find "$WORKDIR/squashfs-root" -maxdepth 2 -name '*.desktop' | head -n 1) + if [[ -z "$DESKTOP" ]]; then + echo "::error::No .desktop file inside AppImage" + exit 1 + fi + desktop-file-validate "$DESKTOP" + echo "✓ desktop-file-validate passed" + + - name: Compute SHA-256 checksums + run: | + cd app/dist + sha256sum *.AppImage *.deb *.rpm > SHA256SUMS + cat SHA256SUMS + + - name: Publish deb to Cloudsmith + if: github.event_name == 'push' + env: + CLOUDSMITH_API_KEY: ${{ secrets.CLOUDSMITH_API_KEY }} + run: | + pip install cloudsmith-cli + cloudsmith push deb rgdevment/copypaste/any-distro/any-version \ + app/dist/CopyPaste_${{ steps.get_version.outputs.VERSION }}_amd64.deb --republish + + - name: Publish rpm to Cloudsmith + if: github.event_name == 'push' + env: + CLOUDSMITH_API_KEY: ${{ secrets.CLOUDSMITH_API_KEY }} + run: | + cloudsmith push rpm rgdevment/copypaste/any-distro/any-version \ + app/dist/CopyPaste_${{ steps.get_version.outputs.VERSION }}_x86_64.rpm --republish + + - name: Upload artifact + uses: actions/upload-artifact@v6 + with: + name: release-linux + path: | + app/dist/*.AppImage + app/dist/*.deb + app/dist/*.rpm + app/dist/SHA256SUMS + retention-days: 5 diff --git a/app/lib/screens/settings_screen.dart b/app/lib/screens/settings_screen.dart index c8f7173b..a081452a 100644 --- a/app/lib/screens/settings_screen.dart +++ b/app/lib/screens/settings_screen.dart @@ -1060,7 +1060,11 @@ class _SettingsScreenState extends State { }, ), _ModifierChip( - label: Platform.isMacOS ? 'Cmd' : 'Win', + label: Platform.isMacOS + ? 'Cmd' + : Platform.isLinux + ? 'Super' + : 'Win', selected: _hotkeyWin, colors: colors, onTap: () { diff --git a/app/lib/shell/startup_helper.dart b/app/lib/shell/startup_helper.dart index d82d0d79..3deace13 100644 --- a/app/lib/shell/startup_helper.dart +++ b/app/lib/shell/startup_helper.dart @@ -1,346 +1,347 @@ -// coverage:ignore-file -import 'dart:ffi'; -import 'dart:io'; - -import 'package:core/core.dart'; -import 'package:ffi/ffi.dart'; -import 'package:flutter/foundation.dart'; - -import 'linux_session.dart'; -import 'msix_startup_task.dart'; -import 'win_package_context.dart'; - -typedef _RegOpenKeyExNative = - Int32 Function( - IntPtr hKey, - Pointer lpSubKey, - Uint32 ulOptions, - Int32 samDesired, - Pointer phkResult, - ); -typedef _RegOpenKeyExDart = - int Function( - int hKey, - Pointer lpSubKey, - int ulOptions, - int samDesired, - Pointer phkResult, - ); - -typedef _RegSetValueExNative = - Int32 Function( - IntPtr hKey, - Pointer lpValueName, - Uint32 reserved, - Uint32 dwType, - Pointer lpData, - Uint32 cbData, - ); -typedef _RegSetValueExDart = - int Function( - int hKey, - Pointer lpValueName, - int reserved, - int dwType, - Pointer lpData, - int cbData, - ); - -typedef _RegDeleteValueNative = - Int32 Function(IntPtr hKey, Pointer lpValueName); -typedef _RegDeleteValueDart = - int Function(int hKey, Pointer lpValueName); - -typedef _RegCloseKeyNative = Int32 Function(IntPtr hKey); -typedef _RegCloseKeyDart = int Function(int hKey); - -class _Win32Registry { - _Win32Registry._() { - assert(Platform.isWindows, '_Win32Registry requires Windows'); - } - static _Win32Registry? _instance; - static _Win32Registry get instance => _instance ??= _Win32Registry._(); - - late final _advapi32 = DynamicLibrary.open('advapi32.dll'); - - late final regOpenKeyEx = _advapi32 - .lookupFunction<_RegOpenKeyExNative, _RegOpenKeyExDart>('RegOpenKeyExW'); - late final regSetValueEx = _advapi32 - .lookupFunction<_RegSetValueExNative, _RegSetValueExDart>( - 'RegSetValueExW', - ); - late final regDeleteValue = _advapi32 - .lookupFunction<_RegDeleteValueNative, _RegDeleteValueDart>( - 'RegDeleteValueW', - ); - late final regCloseKey = _advapi32 - .lookupFunction<_RegCloseKeyNative, _RegCloseKeyDart>('RegCloseKey'); -} - -class StartupHelper { - static const int _hkeyCurrentUser = 0x80000001; - static const int _keySetValue = 0x0002; - static const int _regSz = 1; - static const String _registryPath = - r'Software\Microsoft\Windows\CurrentVersion\Run'; - static const String _appName = 'CopyPaste'; - static const String _msixStartupTaskId = 'CopyPasteStartup'; - static const String _macOsPlistLabel = 'com.rgdevment.copypaste'; - - static Future apply( - bool runOnStartup, { - bool fromUserAction = false, - }) async { - if (Platform.isWindows) { - if (WinPackageContext.isMsix) { - // MSIX uses the StartupTask declared in AppxManifest. Make sure no - // stale HKCU\...\Run entry from a previous standalone install lingers, - // otherwise Windows shows it with a generic icon and the raw registry - // path in the Startup settings page. - _removeRegistryValue(); - await _applyMsixStartupTask( - runOnStartup, - fromUserAction: fromUserAction, - ); - } else { - if (runOnStartup) { - _setRegistryValue(Platform.resolvedExecutable); - } else { - _removeRegistryValue(); - } - } - } else if (Platform.isMacOS) { - if (runOnStartup) { - _installLaunchAgent(); - } else { - _removeLaunchAgent(); - } - } else if (Platform.isLinux) { - // Never install autostart on Wayland — the app would launch and immediately - // show the unsupported screen, which is a poor experience. - if (isWaylandSession()) { - _removeDesktopAutostart(); - AppLogger.info('Wayland session: autostart entry removed/skipped.'); - return; - } - if (runOnStartup) { - _installDesktopAutostart(); - } else { - _removeDesktopAutostart(); - } - } - } - - static Future openWindowsStartupSettings() async { - try { - await Process.start('explorer.exe', ['ms-settings:startupapps']); - } catch (e) { - AppLogger.error('openWindowsStartupSettings failed: $e'); - } - } - - static Future _applyMsixStartupTask( - bool runOnStartup, { - required bool fromUserAction, - }) async { - if (runOnStartup) { - final state = await MsixStartupTask.enable(_msixStartupTaskId); - AppLogger.info('MSIX StartupTask enable -> $state'); - // When the user has explicitly disabled the task from Settings, only - // the user can re-enable it. Surface the system page so they can act. - if (fromUserAction && state == MsixStartupTaskState.disabledByUser) { - await openWindowsStartupSettings(); - } - } else { - final state = await MsixStartupTask.disable(_msixStartupTaskId); - AppLogger.info('MSIX StartupTask disable -> $state'); - } - } - - // Detects executables running from a Flutter build folder (dev runs). - // Writing those paths to HKCU\...\Run produces stale entries that Windows - // renders with a generic icon and only the registry path text once the - // build folder is cleaned. - @visibleForTesting - static bool isDevBuildPath(String exePath) { - final normalized = exePath.replaceAll('/', r'\').toLowerCase(); - return normalized.contains(r'\build\windows\'); - } - - static void _setRegistryValue(String exePath) { - if (!exePath.toLowerCase().endsWith('.exe') || - !File(exePath).existsSync()) { - AppLogger.error( - 'Skipping startup registry write: executable not found at "$exePath".', - ); - _removeRegistryValue(); - return; - } - - if (isDevBuildPath(exePath)) { - AppLogger.info( - 'Skipping startup registry write: running from a Flutter build folder ("$exePath").', - ); - _removeRegistryValue(); - return; - } - - final r = _Win32Registry.instance; - final subKey = _registryPath.toNativeUtf16(allocator: malloc); - final hKeyPtr = calloc(); - - try { - final result = r.regOpenKeyEx( - _hkeyCurrentUser, - subKey, - 0, - _keySetValue, - hKeyPtr, - ); - if (result != 0) { - AppLogger.error('Failed to open registry key for set: $result'); - return; - } - - final hKey = hKeyPtr.value; - final valueName = _appName.toNativeUtf16(allocator: malloc); - final valueData = '"$exePath"'.toNativeUtf16(allocator: malloc); - final dataSize = ('"$exePath"'.length + 1) * 2; - - try { - final setResult = r.regSetValueEx( - hKey, - valueName, - 0, - _regSz, - valueData, - dataSize, - ); - if (setResult != 0) { - AppLogger.error('Failed to set registry value: $setResult'); - } - } finally { - malloc.free(valueName); - malloc.free(valueData); - r.regCloseKey(hKey); - } - } finally { - malloc.free(subKey); - calloc.free(hKeyPtr); - } - } - - static void _removeRegistryValue() { - final r = _Win32Registry.instance; - final subKey = _registryPath.toNativeUtf16(allocator: malloc); - final hKeyPtr = calloc(); - - try { - final result = r.regOpenKeyEx( - _hkeyCurrentUser, - subKey, - 0, - _keySetValue, - hKeyPtr, - ); - if (result != 0) { - AppLogger.error('Failed to open registry key for delete: $result'); - return; - } - - final hKey = hKeyPtr.value; - final valueName = _appName.toNativeUtf16(allocator: malloc); - - try { - r.regDeleteValue(hKey, valueName); - } finally { - malloc.free(valueName); - r.regCloseKey(hKey); - } - } finally { - malloc.free(subKey); - calloc.free(hKeyPtr); - } - } - - static String get _launchAgentPath { - final home = Platform.environment['HOME'] ?? '/tmp'; - return '$home/Library/LaunchAgents/$_macOsPlistLabel.plist'; - } - - static void _installLaunchAgent() { - try { - final exePath = Platform.resolvedExecutable; - final plist = - ''' - - - - Label - $_macOsPlistLabel - ProgramArguments - - $exePath - - RunAtLoad - - KeepAlive - - - -'''; - final agentDir = Directory( - '${Platform.environment['HOME']}/Library/LaunchAgents', - ); - if (!agentDir.existsSync()) agentDir.createSync(recursive: true); - File(_launchAgentPath).writeAsStringSync(plist); - } catch (e) { - AppLogger.error('Failed to install LaunchAgent: $e'); - } - } - - static void _removeLaunchAgent() { - try { - final file = File(_launchAgentPath); - if (file.existsSync()) file.deleteSync(); - } catch (e) { - AppLogger.error('Failed to remove LaunchAgent: $e'); - } - } - - static String get _desktopAutostartPath { - final home = Platform.environment['HOME'] ?? '/tmp'; - return '$home/.config/autostart/$_appName.desktop'; - } - - static void _installDesktopAutostart() { - try { - final exePath = Platform.resolvedExecutable; - final desktop = - '[Desktop Entry]\n' - 'Type=Application\n' - 'Name=$_appName\n' - 'Exec=$exePath\n' - 'X-GNOME-Autostart-enabled=true\n' - 'StartupNotify=false\n' - 'Terminal=false\n'; - final autostartDir = Directory( - '${Platform.environment['HOME']}/.config/autostart', - ); - if (!autostartDir.existsSync()) autostartDir.createSync(recursive: true); - File(_desktopAutostartPath).writeAsStringSync(desktop); - } catch (e) { - AppLogger.error('Failed to install autostart desktop entry: $e'); - } - } - - static void _removeDesktopAutostart() { - try { - final file = File(_desktopAutostartPath); - if (file.existsSync()) file.deleteSync(); - } catch (e) { - AppLogger.error('Failed to remove autostart desktop entry: $e'); - } - } -} +// coverage:ignore-file +import 'dart:ffi'; +import 'dart:io'; + +import 'package:core/core.dart'; +import 'package:ffi/ffi.dart'; +import 'package:flutter/foundation.dart'; + +import 'linux_session.dart'; +import 'msix_startup_task.dart'; +import 'win_package_context.dart'; + +typedef _RegOpenKeyExNative = + Int32 Function( + IntPtr hKey, + Pointer lpSubKey, + Uint32 ulOptions, + Int32 samDesired, + Pointer phkResult, + ); +typedef _RegOpenKeyExDart = + int Function( + int hKey, + Pointer lpSubKey, + int ulOptions, + int samDesired, + Pointer phkResult, + ); + +typedef _RegSetValueExNative = + Int32 Function( + IntPtr hKey, + Pointer lpValueName, + Uint32 reserved, + Uint32 dwType, + Pointer lpData, + Uint32 cbData, + ); +typedef _RegSetValueExDart = + int Function( + int hKey, + Pointer lpValueName, + int reserved, + int dwType, + Pointer lpData, + int cbData, + ); + +typedef _RegDeleteValueNative = + Int32 Function(IntPtr hKey, Pointer lpValueName); +typedef _RegDeleteValueDart = + int Function(int hKey, Pointer lpValueName); + +typedef _RegCloseKeyNative = Int32 Function(IntPtr hKey); +typedef _RegCloseKeyDart = int Function(int hKey); + +class _Win32Registry { + _Win32Registry._() { + assert(Platform.isWindows, '_Win32Registry requires Windows'); + } + static _Win32Registry? _instance; + static _Win32Registry get instance => _instance ??= _Win32Registry._(); + + late final _advapi32 = DynamicLibrary.open('advapi32.dll'); + + late final regOpenKeyEx = _advapi32 + .lookupFunction<_RegOpenKeyExNative, _RegOpenKeyExDart>('RegOpenKeyExW'); + late final regSetValueEx = _advapi32 + .lookupFunction<_RegSetValueExNative, _RegSetValueExDart>( + 'RegSetValueExW', + ); + late final regDeleteValue = _advapi32 + .lookupFunction<_RegDeleteValueNative, _RegDeleteValueDart>( + 'RegDeleteValueW', + ); + late final regCloseKey = _advapi32 + .lookupFunction<_RegCloseKeyNative, _RegCloseKeyDart>('RegCloseKey'); +} + +class StartupHelper { + static const int _hkeyCurrentUser = 0x80000001; + static const int _keySetValue = 0x0002; + static const int _regSz = 1; + static const String _registryPath = + r'Software\Microsoft\Windows\CurrentVersion\Run'; + static const String _appName = 'CopyPaste'; + static const String _msixStartupTaskId = 'CopyPasteStartup'; + static const String _macOsPlistLabel = 'com.rgdevment.copypaste'; + + static Future apply( + bool runOnStartup, { + bool fromUserAction = false, + }) async { + if (Platform.isWindows) { + if (WinPackageContext.isMsix) { + // MSIX uses the StartupTask declared in AppxManifest. Make sure no + // stale HKCU\...\Run entry from a previous standalone install lingers, + // otherwise Windows shows it with a generic icon and the raw registry + // path in the Startup settings page. + _removeRegistryValue(); + await _applyMsixStartupTask( + runOnStartup, + fromUserAction: fromUserAction, + ); + } else { + if (runOnStartup) { + _setRegistryValue(Platform.resolvedExecutable); + } else { + _removeRegistryValue(); + } + } + } else if (Platform.isMacOS) { + if (runOnStartup) { + _installLaunchAgent(); + } else { + _removeLaunchAgent(); + } + } else if (Platform.isLinux) { + // Never install autostart on Wayland — the app would launch and immediately + // show the unsupported screen, which is a poor experience. + if (isWaylandSession()) { + _removeDesktopAutostart(); + AppLogger.info('Wayland session: autostart entry removed/skipped.'); + return; + } + if (runOnStartup) { + _installDesktopAutostart(); + } else { + _removeDesktopAutostart(); + } + } + } + + static Future openWindowsStartupSettings() async { + try { + await Process.start('explorer.exe', ['ms-settings:startupapps']); + } catch (e) { + AppLogger.error('openWindowsStartupSettings failed: $e'); + } + } + + static Future _applyMsixStartupTask( + bool runOnStartup, { + required bool fromUserAction, + }) async { + if (runOnStartup) { + final state = await MsixStartupTask.enable(_msixStartupTaskId); + AppLogger.info('MSIX StartupTask enable -> $state'); + // When the user has explicitly disabled the task from Settings, only + // the user can re-enable it. Surface the system page so they can act. + if (fromUserAction && state == MsixStartupTaskState.disabledByUser) { + await openWindowsStartupSettings(); + } + } else { + final state = await MsixStartupTask.disable(_msixStartupTaskId); + AppLogger.info('MSIX StartupTask disable -> $state'); + } + } + + // Detects executables running from a Flutter build folder (dev runs). + // Writing those paths to HKCU\...\Run produces stale entries that Windows + // renders with a generic icon and only the registry path text once the + // build folder is cleaned. + @visibleForTesting + static bool isDevBuildPath(String exePath) { + final normalized = exePath.replaceAll('/', r'\').toLowerCase(); + return normalized.contains(r'\build\windows\'); + } + + static void _setRegistryValue(String exePath) { + if (!exePath.toLowerCase().endsWith('.exe') || + !File(exePath).existsSync()) { + AppLogger.error( + 'Skipping startup registry write: executable not found at "$exePath".', + ); + _removeRegistryValue(); + return; + } + + if (isDevBuildPath(exePath)) { + AppLogger.info( + 'Skipping startup registry write: running from a Flutter build folder ("$exePath").', + ); + _removeRegistryValue(); + return; + } + + final r = _Win32Registry.instance; + final subKey = _registryPath.toNativeUtf16(allocator: malloc); + final hKeyPtr = calloc(); + + try { + final result = r.regOpenKeyEx( + _hkeyCurrentUser, + subKey, + 0, + _keySetValue, + hKeyPtr, + ); + if (result != 0) { + AppLogger.error('Failed to open registry key for set: $result'); + return; + } + + final hKey = hKeyPtr.value; + final valueName = _appName.toNativeUtf16(allocator: malloc); + final valueData = '"$exePath"'.toNativeUtf16(allocator: malloc); + final dataSize = ('"$exePath"'.length + 1) * 2; + + try { + final setResult = r.regSetValueEx( + hKey, + valueName, + 0, + _regSz, + valueData, + dataSize, + ); + if (setResult != 0) { + AppLogger.error('Failed to set registry value: $setResult'); + } + } finally { + malloc.free(valueName); + malloc.free(valueData); + r.regCloseKey(hKey); + } + } finally { + malloc.free(subKey); + calloc.free(hKeyPtr); + } + } + + static void _removeRegistryValue() { + final r = _Win32Registry.instance; + final subKey = _registryPath.toNativeUtf16(allocator: malloc); + final hKeyPtr = calloc(); + + try { + final result = r.regOpenKeyEx( + _hkeyCurrentUser, + subKey, + 0, + _keySetValue, + hKeyPtr, + ); + if (result != 0) { + AppLogger.error('Failed to open registry key for delete: $result'); + return; + } + + final hKey = hKeyPtr.value; + final valueName = _appName.toNativeUtf16(allocator: malloc); + + try { + r.regDeleteValue(hKey, valueName); + } finally { + malloc.free(valueName); + r.regCloseKey(hKey); + } + } finally { + malloc.free(subKey); + calloc.free(hKeyPtr); + } + } + + static String get _launchAgentPath { + final home = Platform.environment['HOME'] ?? '/tmp'; + return '$home/Library/LaunchAgents/$_macOsPlistLabel.plist'; + } + + static void _installLaunchAgent() { + try { + final exePath = Platform.resolvedExecutable; + final plist = + ''' + + + + Label + $_macOsPlistLabel + ProgramArguments + + $exePath + + RunAtLoad + + KeepAlive + + + +'''; + final agentDir = Directory( + '${Platform.environment['HOME']}/Library/LaunchAgents', + ); + if (!agentDir.existsSync()) agentDir.createSync(recursive: true); + File(_launchAgentPath).writeAsStringSync(plist); + } catch (e) { + AppLogger.error('Failed to install LaunchAgent: $e'); + } + } + + static void _removeLaunchAgent() { + try { + final file = File(_launchAgentPath); + if (file.existsSync()) file.deleteSync(); + } catch (e) { + AppLogger.error('Failed to remove LaunchAgent: $e'); + } + } + + static String get _desktopAutostartPath { + final home = Platform.environment['HOME'] ?? '/tmp'; + return '$home/.config/autostart/$_appName.desktop'; + } + + static void _installDesktopAutostart() { + try { + final exePath = Platform.resolvedExecutable; + final desktop = + '[Desktop Entry]\n' + 'Type=Application\n' + 'Name=$_appName\n' + 'Exec=$exePath\n' + 'X-GNOME-Autostart-enabled=true\n' + 'StartupNotify=false\n' + 'Terminal=false\n' + 'OnlyShowIn=GNOME;KDE;XFCE;Cinnamon;MATE;LXDE;LXQt;Pantheon;Unity;Budgie;Deepin;\n'; + final autostartDir = Directory( + '${Platform.environment['HOME']}/.config/autostart', + ); + if (!autostartDir.existsSync()) autostartDir.createSync(recursive: true); + File(_desktopAutostartPath).writeAsStringSync(desktop); + } catch (e) { + AppLogger.error('Failed to install autostart desktop entry: $e'); + } + } + + static void _removeDesktopAutostart() { + try { + final file = File(_desktopAutostartPath); + if (file.existsSync()) file.deleteSync(); + } catch (e) { + AppLogger.error('Failed to remove autostart desktop entry: $e'); + } + } +} diff --git a/app/linux/runner/copypaste_linux_shell.c b/app/linux/runner/copypaste_linux_shell.c index c929fad0..526dd17d 100644 --- a/app/linux/runner/copypaste_linux_shell.c +++ b/app/linux/runner/copypaste_linux_shell.c @@ -859,7 +859,8 @@ CopyPasteLinuxShell* copypaste_linux_shell_new(FlBinaryMessenger* messenger, GdkDisplay* display = gdk_display_get_default(); shell->xdisplay = gdk_x11_display_get_xdisplay(display); shell->root_window = DefaultRootWindow(shell->xdisplay); - gdk_window_add_filter(NULL, x11_event_filter, shell); + GdkWindow* gdk_root = gdk_get_default_root_window(); + gdk_window_add_filter(gdk_root, x11_event_filter, shell); } #endif @@ -874,7 +875,8 @@ void copypaste_linux_shell_dispose(CopyPasteLinuxShell* shell) { #ifdef GDK_WINDOWING_X11 if (shell_is_x11()) { unregister_hotkey(shell); - gdk_window_remove_filter(NULL, x11_event_filter, shell); + GdkWindow* gdk_root = gdk_get_default_root_window(); + gdk_window_remove_filter(gdk_root, x11_event_filter, shell); } #endif diff --git a/listener/linux/listener_plugin.c b/listener/linux/listener_plugin.c index fccd51f2..c42e9ce4 100644 --- a/listener/linux/listener_plugin.c +++ b/listener/linux/listener_plugin.c @@ -595,17 +595,19 @@ static FlValue* build_text_event(GtkClipboard* clipboard, fl_value_set_string_take(event, "source", fl_value_new_string(source)); fl_value_set_string_take(event, "contentHash", fl_value_new_string(hash)); - const gchar* const rtf_targets[] = {"text/rtf", "application/rtf", - "Rich Text Format", NULL}; - const gchar* const html_targets[] = {"text/html", "HTML Format", NULL}; - - FlValue* rtf = get_selection_data_value(clipboard, rtf_targets); - if (rtf != NULL) { - fl_value_set_string_take(event, "rtf", rtf); - } - FlValue* html = get_selection_data_value(clipboard, html_targets); - if (html != NULL) { - fl_value_set_string_take(event, "html", html); + if (!is_url_text(text)) { + const gchar* const rtf_targets[] = {"text/rtf", "application/rtf", + "Rich Text Format", NULL}; + const gchar* const html_targets[] = {"text/html", "HTML Format", NULL}; + + FlValue* rtf = get_selection_data_value(clipboard, rtf_targets); + if (rtf != NULL) { + fl_value_set_string_take(event, "rtf", rtf); + } + FlValue* html = get_selection_data_value(clipboard, html_targets); + if (html != NULL) { + fl_value_set_string_take(event, "html", html); + } } g_free(text); diff --git a/release-manifest.json b/release-manifest.json index 1fbfd217..f7c24b35 100644 --- a/release-manifest.json +++ b/release-manifest.json @@ -22,7 +22,8 @@ }, "snap": { "command": "sudo snap refresh copypaste" - } + }, + "flatpak": null }, "releaseNotes": { "en": { From b88c8b53fba9bbcf635540a261a25d46c27255f3 Mon Sep 17 00:00:00 2001 From: rgdevment Date: Fri, 24 Apr 2026 13:57:52 -0400 Subject: [PATCH 10/31] feat: enhance onboarding logic for Linux and update hotkey label handling --- app/lib/main.dart | 10 +++++++--- app/lib/shell/linux_hotkey_registration.dart | 14 ++++++++++++-- 2 files changed, 19 insertions(+), 5 deletions(-) diff --git a/app/lib/main.dart b/app/lib/main.dart index f66ac083..03e57d3b 100644 --- a/app/lib/main.dart +++ b/app/lib/main.dart @@ -330,14 +330,18 @@ class _CopyPasteAppState extends State final isUpdate = _config.lastRunVersion != AppConfig.appVersion; final windowsNeedsOnboarding = Platform.isWindows && (!_config.hasSeenWindowsOnboarding || isUpdate); + final linuxNeedsOnboarding = + Platform.isLinux && !_config.hasCompletedOnboarding; + final desktopNeedsOnboarding = + windowsNeedsOnboarding || linuxNeedsOnboarding; final showOnStart = isFirstRun && (Platform.isLinux || (Platform.isMacOS && macosGranted) || Platform.isWindows) || - windowsNeedsOnboarding; + desktopNeedsOnboarding; await _appWindow.init(startVisible: showOnStart); - if (showOnStart && Platform.isWindows) { + if (showOnStart && (Platform.isWindows || linuxNeedsOnboarding)) { try { await _appWindow.enterGateMode(); } catch (e) { @@ -390,7 +394,7 @@ class _CopyPasteAppState extends State } } } else { - final shouldShowOnboarding = windowsNeedsOnboarding; + final shouldShowOnboarding = desktopNeedsOnboarding; if (shouldShowOnboarding) { if (isFirstRun) widget.storage.markAsInitialized(); if (mounted) setState(() => _showWindowsOnboarding = true); diff --git a/app/lib/shell/linux_hotkey_registration.dart b/app/lib/shell/linux_hotkey_registration.dart index 25d596a6..0e49cb8e 100644 --- a/app/lib/shell/linux_hotkey_registration.dart +++ b/app/lib/shell/linux_hotkey_registration.dart @@ -1,3 +1,5 @@ +import 'dart:io' show Platform; + import 'package:flutter/foundation.dart'; import 'linux_shell.dart'; @@ -86,8 +88,16 @@ class HotkeyBinding { 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 (useWin) { + if (isMac || Platform.isMacOS) { + parts.add('Cmd'); + } else if (Platform.isLinux) { + parts.add('Super'); + } else { + parts.add('Win'); + } + } + if (useAlt) parts.add(isMac || Platform.isMacOS ? 'Option' : 'Alt'); if (useShift) parts.add('Shift'); parts.add(keyName); return parts.join('+'); From 7e2b96d4f99c9c5dfec44dff37ff6551aafc7eb9 Mon Sep 17 00:00:00 2001 From: rgdevment Date: Fri, 24 Apr 2026 14:06:47 -0400 Subject: [PATCH 11/31] feat: update onboarding logic for Linux and introduce desktop onboarding screen --- app/lib/main.dart | 28 +- ...en.dart => desktop_onboarding_screen.dart} | 404 ++++++++--------- ...rt => desktop_onboarding_screen_test.dart} | 420 +++++++++--------- core/lib/config/app_config.dart | 20 +- core/test/app_config_test.dart | 24 +- 5 files changed, 449 insertions(+), 447 deletions(-) rename app/lib/screens/{windows_onboarding_screen.dart => desktop_onboarding_screen.dart} (93%) rename app/test/screens/{windows_onboarding_screen_test.dart => desktop_onboarding_screen_test.dart} (93%) diff --git a/app/lib/main.dart b/app/lib/main.dart index 03e57d3b..7b72a61b 100644 --- a/app/lib/main.dart +++ b/app/lib/main.dart @@ -36,7 +36,7 @@ import 'theme/compact_theme.dart'; import 'theme/theme_provider.dart'; import 'l10n/app_localizations.dart'; import 'screens/permission_gate_screen.dart'; -import 'screens/windows_onboarding_screen.dart'; +import 'screens/desktop_onboarding_screen.dart'; import 'screens/blocked_version_screen.dart'; // Re-exported so existing tests can import isWaylandSession from main.dart. @@ -225,7 +225,7 @@ class _CopyPasteAppState extends State StreamSubscription? _listenerSubscription; String? _lastTrayLocale; bool _showPermissionGate = false; - bool _showWindowsOnboarding = false; + bool _showOnboarding = false; bool _showWaylandUnsupported = false; bool _linuxPrefersDark = false; String? _availableUpdateVersion; @@ -329,9 +329,9 @@ class _CopyPasteAppState extends State final isUpdate = _config.lastRunVersion != AppConfig.appVersion; final windowsNeedsOnboarding = - Platform.isWindows && (!_config.hasSeenWindowsOnboarding || isUpdate); + Platform.isWindows && (!_config.hasSeenOnboarding || isUpdate); final linuxNeedsOnboarding = - Platform.isLinux && !_config.hasCompletedOnboarding; + Platform.isLinux && (!_config.hasCompletedOnboarding || isUpdate); final desktopNeedsOnboarding = windowsNeedsOnboarding || linuxNeedsOnboarding; final showOnStart = @@ -370,7 +370,7 @@ class _CopyPasteAppState extends State AppLogger.error('trayIcon.init failed: $e'); } - if (Platform.isWindows && !isFirstRun && _config.hasSeenWindowsOnboarding) { + if (Platform.isWindows && !isFirstRun && _config.hasSeenOnboarding) { WidgetsBinding.instance.addPostFrameCallback( (_) => unawaited(_showStartupBalloon()), ); @@ -397,7 +397,7 @@ class _CopyPasteAppState extends State final shouldShowOnboarding = desktopNeedsOnboarding; if (shouldShowOnboarding) { if (isFirstRun) widget.storage.markAsInitialized(); - if (mounted) setState(() => _showWindowsOnboarding = true); + if (mounted) setState(() => _showOnboarding = true); if (!_appWindow.isGateMode) { try { await _appWindow.enterGateMode(); @@ -650,14 +650,14 @@ class _CopyPasteAppState extends State } Future _showOnboardingFromWakeup() async { - if (_showWindowsOnboarding || _appWindow.isSettingsMode) { + if (_showOnboarding || _appWindow.isSettingsMode) { try { await windowManager.show(); await windowManager.focus(); } catch (_) {} return; } - setState(() => _showWindowsOnboarding = true); + setState(() => _showOnboarding = true); try { await _appWindow.enterGateMode(); } catch (e) { @@ -1054,7 +1054,7 @@ class _CopyPasteAppState extends State Future _onOnboardingDismissed(AppConfig fromOnboarding) async { _config = fromOnboarding.copyWith( - hasSeenWindowsOnboarding: true, + hasSeenOnboarding: true, hasCompletedOnboarding: true, lastRunVersion: AppConfig.appVersion, ); @@ -1062,7 +1062,7 @@ class _CopyPasteAppState extends State unawaited( _config.save('${widget.storage.configPath}/${AppConfig.fileName}'), ); - setState(() => _showWindowsOnboarding = false); + setState(() => _showOnboarding = false); await _appWindow.exitGateMode(); unawaited(_showStartupBalloon()); } @@ -1072,7 +1072,7 @@ class _CopyPasteAppState extends State AppConfig fromOnboarding, ) async { _config = fromOnboarding.copyWith( - hasSeenWindowsOnboarding: true, + hasSeenOnboarding: true, hasCompletedOnboarding: true, lastRunVersion: AppConfig.appVersion, ); @@ -1080,7 +1080,7 @@ class _CopyPasteAppState extends State unawaited( _config.save('${widget.storage.configPath}/${AppConfig.fileName}'), ); - setState(() => _showWindowsOnboarding = false); + setState(() => _showOnboarding = false); await _appWindow.exitGateMode(); await Future.delayed(const Duration(milliseconds: 150)); if (ctx.mounted) await _openSettings(ctx); @@ -1181,7 +1181,7 @@ class _CopyPasteAppState extends State ); } - if (_showWindowsOnboarding) { + if (_showOnboarding) { final binding = HotkeyBinding( virtualKey: _config.hotkeyVirtualKey, keyName: _config.hotkeyKeyName, @@ -1190,7 +1190,7 @@ class _CopyPasteAppState extends State useAlt: _config.hotkeyUseAlt, useShift: _config.hotkeyUseShift, ); - return WindowsOnboardingScreen( + return DesktopOnboardingScreen( hotkey: binding.label(), initialConfig: _config, onDismiss: (updated) => diff --git a/app/lib/screens/windows_onboarding_screen.dart b/app/lib/screens/desktop_onboarding_screen.dart similarity index 93% rename from app/lib/screens/windows_onboarding_screen.dart rename to app/lib/screens/desktop_onboarding_screen.dart index d88ca07c..0722a9aa 100644 --- a/app/lib/screens/windows_onboarding_screen.dart +++ b/app/lib/screens/desktop_onboarding_screen.dart @@ -1,202 +1,202 @@ -import 'package:core/core.dart'; -import 'package:flutter/material.dart'; - -import '../l10n/app_localizations.dart'; - -class WindowsOnboardingScreen extends StatefulWidget { - const WindowsOnboardingScreen({ - required this.hotkey, - required this.initialConfig, - required this.onDismiss, - required this.onSettings, - super.key, - }); - - final String hotkey; - final AppConfig initialConfig; - final void Function(AppConfig updated) onDismiss; - final void Function(AppConfig updated) onSettings; - - @override - State createState() => - _WindowsOnboardingScreenState(); -} - -class _WindowsOnboardingScreenState extends State { - AppConfig _buildConfig() => widget.initialConfig; - - @override - Widget build(BuildContext context) { - final l = AppLocalizations.of(context); - final cs = Theme.of(context).colorScheme; - final tt = Theme.of(context).textTheme; - - return Scaffold( - backgroundColor: cs.surface, - body: Center( - child: SizedBox( - width: 360, - child: SingleChildScrollView( - padding: const EdgeInsets.symmetric(horizontal: 32, vertical: 32), - child: Column( - mainAxisSize: MainAxisSize.min, - children: [ - ClipRRect( - borderRadius: BorderRadius.circular(20), - child: Image.asset( - 'assets/icons/icon_app_256.png', - width: 64, - height: 64, - ), - ), - const SizedBox(height: 14), - Text( - l.onboardingTitle, - style: tt.titleLarge?.copyWith( - fontWeight: FontWeight.w700, - letterSpacing: -0.3, - ), - textAlign: TextAlign.center, - ), - const SizedBox(height: 4), - Text( - l.onboardingSubtitle, - style: tt.bodyMedium?.copyWith(color: cs.onSurfaceVariant), - textAlign: TextAlign.center, - ), - const SizedBox(height: 14), - _PrivacyBadge(label: l.onboardingPrivacyBadge, colorScheme: cs), - const SizedBox(height: 20), - Divider(color: cs.outlineVariant, height: 1), - const SizedBox(height: 16), - Text( - l.onboardingDescription(widget.hotkey), - style: tt.bodyMedium?.copyWith( - color: cs.onSurfaceVariant, - height: 1.5, - ), - textAlign: TextAlign.center, - ), - const SizedBox(height: 8), - _HotkeyChip(hotkey: widget.hotkey, colorScheme: cs), - const SizedBox(height: 8), - Text( - l.onboardingTrayHint, - style: tt.bodySmall?.copyWith(color: cs.onSurfaceVariant), - textAlign: TextAlign.center, - ), - const SizedBox(height: 20), - Wrap( - alignment: WrapAlignment.center, - spacing: 10, - runSpacing: 8, - children: [ - OutlinedButton( - onPressed: () => widget.onSettings(_buildConfig()), - style: OutlinedButton.styleFrom( - padding: const EdgeInsets.symmetric( - horizontal: 20, - vertical: 12, - ), - ), - child: Text(l.onboardingSettingsButton), - ), - FilledButton( - onPressed: () => widget.onDismiss(_buildConfig()), - style: FilledButton.styleFrom( - padding: const EdgeInsets.symmetric( - horizontal: 20, - vertical: 12, - ), - ), - child: Text(l.onboardingDismissButton), - ), - ], - ), - ], - ), - ), - ), - ), - ); - } -} - -class _PrivacyBadge extends StatelessWidget { - const _PrivacyBadge({required this.label, required this.colorScheme}); - - final String label; - final ColorScheme colorScheme; - - @override - Widget build(BuildContext context) { - return Container( - padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 6), - decoration: BoxDecoration( - color: colorScheme.primary.withValues(alpha: 0.08), - borderRadius: BorderRadius.circular(20), - ), - child: Row( - mainAxisSize: MainAxisSize.min, - children: [ - Icon( - Icons.lock_outline_rounded, - size: 13, - color: colorScheme.primary, - ), - const SizedBox(width: 6), - Flexible( - child: Text( - label, - overflow: TextOverflow.ellipsis, - style: TextStyle( - fontSize: 12, - color: colorScheme.primary, - fontWeight: FontWeight.w500, - ), - ), - ), - ], - ), - ); - } -} - -class _HotkeyChip extends StatelessWidget { - const _HotkeyChip({required this.hotkey, required this.colorScheme}); - - final String hotkey; - final ColorScheme colorScheme; - - @override - Widget build(BuildContext context) { - return Container( - padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 6), - decoration: BoxDecoration( - color: colorScheme.surfaceContainerHighest, - borderRadius: BorderRadius.circular(8), - border: Border.all(color: colorScheme.outlineVariant), - ), - child: Row( - mainAxisSize: MainAxisSize.min, - children: [ - Icon( - Icons.keyboard_rounded, - size: 15, - color: colorScheme.onSurfaceVariant, - ), - const SizedBox(width: 7), - Text( - hotkey, - style: TextStyle( - fontSize: 13, - fontWeight: FontWeight.w600, - color: colorScheme.onSurface, - letterSpacing: 0.2, - ), - ), - ], - ), - ); - } -} +import 'package:core/core.dart'; +import 'package:flutter/material.dart'; + +import '../l10n/app_localizations.dart'; + +class DesktopOnboardingScreen extends StatefulWidget { + const DesktopOnboardingScreen({ + required this.hotkey, + required this.initialConfig, + required this.onDismiss, + required this.onSettings, + super.key, + }); + + final String hotkey; + final AppConfig initialConfig; + final void Function(AppConfig updated) onDismiss; + final void Function(AppConfig updated) onSettings; + + @override + State createState() => + _DesktopOnboardingScreenState(); +} + +class _DesktopOnboardingScreenState extends State { + AppConfig _buildConfig() => widget.initialConfig; + + @override + Widget build(BuildContext context) { + final l = AppLocalizations.of(context); + final cs = Theme.of(context).colorScheme; + final tt = Theme.of(context).textTheme; + + return Scaffold( + backgroundColor: cs.surface, + body: Center( + child: SizedBox( + width: 360, + child: SingleChildScrollView( + padding: const EdgeInsets.symmetric(horizontal: 32, vertical: 32), + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + ClipRRect( + borderRadius: BorderRadius.circular(20), + child: Image.asset( + 'assets/icons/icon_app_256.png', + width: 64, + height: 64, + ), + ), + const SizedBox(height: 14), + Text( + l.onboardingTitle, + style: tt.titleLarge?.copyWith( + fontWeight: FontWeight.w700, + letterSpacing: -0.3, + ), + textAlign: TextAlign.center, + ), + const SizedBox(height: 4), + Text( + l.onboardingSubtitle, + style: tt.bodyMedium?.copyWith(color: cs.onSurfaceVariant), + textAlign: TextAlign.center, + ), + const SizedBox(height: 14), + _PrivacyBadge(label: l.onboardingPrivacyBadge, colorScheme: cs), + const SizedBox(height: 20), + Divider(color: cs.outlineVariant, height: 1), + const SizedBox(height: 16), + Text( + l.onboardingDescription(widget.hotkey), + style: tt.bodyMedium?.copyWith( + color: cs.onSurfaceVariant, + height: 1.5, + ), + textAlign: TextAlign.center, + ), + const SizedBox(height: 8), + _HotkeyChip(hotkey: widget.hotkey, colorScheme: cs), + const SizedBox(height: 8), + Text( + l.onboardingTrayHint, + style: tt.bodySmall?.copyWith(color: cs.onSurfaceVariant), + textAlign: TextAlign.center, + ), + const SizedBox(height: 20), + Wrap( + alignment: WrapAlignment.center, + spacing: 10, + runSpacing: 8, + children: [ + OutlinedButton( + onPressed: () => widget.onSettings(_buildConfig()), + style: OutlinedButton.styleFrom( + padding: const EdgeInsets.symmetric( + horizontal: 20, + vertical: 12, + ), + ), + child: Text(l.onboardingSettingsButton), + ), + FilledButton( + onPressed: () => widget.onDismiss(_buildConfig()), + style: FilledButton.styleFrom( + padding: const EdgeInsets.symmetric( + horizontal: 20, + vertical: 12, + ), + ), + child: Text(l.onboardingDismissButton), + ), + ], + ), + ], + ), + ), + ), + ), + ); + } +} + +class _PrivacyBadge extends StatelessWidget { + const _PrivacyBadge({required this.label, required this.colorScheme}); + + final String label; + final ColorScheme colorScheme; + + @override + Widget build(BuildContext context) { + return Container( + padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 6), + decoration: BoxDecoration( + color: colorScheme.primary.withValues(alpha: 0.08), + borderRadius: BorderRadius.circular(20), + ), + child: Row( + mainAxisSize: MainAxisSize.min, + children: [ + Icon( + Icons.lock_outline_rounded, + size: 13, + color: colorScheme.primary, + ), + const SizedBox(width: 6), + Flexible( + child: Text( + label, + overflow: TextOverflow.ellipsis, + style: TextStyle( + fontSize: 12, + color: colorScheme.primary, + fontWeight: FontWeight.w500, + ), + ), + ), + ], + ), + ); + } +} + +class _HotkeyChip extends StatelessWidget { + const _HotkeyChip({required this.hotkey, required this.colorScheme}); + + final String hotkey; + final ColorScheme colorScheme; + + @override + Widget build(BuildContext context) { + return Container( + padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 6), + decoration: BoxDecoration( + color: colorScheme.surfaceContainerHighest, + borderRadius: BorderRadius.circular(8), + border: Border.all(color: colorScheme.outlineVariant), + ), + child: Row( + mainAxisSize: MainAxisSize.min, + children: [ + Icon( + Icons.keyboard_rounded, + size: 15, + color: colorScheme.onSurfaceVariant, + ), + const SizedBox(width: 7), + Text( + hotkey, + style: TextStyle( + fontSize: 13, + fontWeight: FontWeight.w600, + color: colorScheme.onSurface, + letterSpacing: 0.2, + ), + ), + ], + ), + ); + } +} diff --git a/app/test/screens/windows_onboarding_screen_test.dart b/app/test/screens/desktop_onboarding_screen_test.dart similarity index 93% rename from app/test/screens/windows_onboarding_screen_test.dart rename to app/test/screens/desktop_onboarding_screen_test.dart index a530bd65..44cd0c11 100644 --- a/app/test/screens/windows_onboarding_screen_test.dart +++ b/app/test/screens/desktop_onboarding_screen_test.dart @@ -1,210 +1,210 @@ -import 'package:core/core.dart'; -import 'package:flutter/material.dart'; -import 'package:flutter_test/flutter_test.dart'; - -import 'package:copypaste/l10n/app_localizations.dart'; -import 'package:copypaste/screens/windows_onboarding_screen.dart'; -import 'package:copypaste/theme/compact_theme.dart'; -import 'package:copypaste/theme/theme_provider.dart'; - -Widget _wrap(Widget child, {Locale locale = const Locale('en')}) { - return MaterialApp( - locale: locale, - localizationsDelegates: AppLocalizations.localizationsDelegates, - supportedLocales: AppLocalizations.supportedLocales, - home: CopyPasteTheme(themeData: CompactTheme(), child: child), - ); -} - -Future _pump( - WidgetTester tester, - Widget child, { - Locale locale = const Locale('en'), -}) async { - tester.view.physicalSize = const Size(1080, 1920); - tester.view.devicePixelRatio = 2.0; - addTearDown(tester.view.reset); - await tester.pumpWidget(_wrap(child, locale: locale)); - await tester.pump(); -} - -void main() { - const hotkey = 'Ctrl+Shift+V'; - - Widget screen({VoidCallback? onDismiss, VoidCallback? onSettings}) => - WindowsOnboardingScreen( - hotkey: hotkey, - initialConfig: const AppConfig(), - onDismiss: (_) => (onDismiss ?? () {})(), - onSettings: (_) => (onSettings ?? () {})(), - ); - - group('WindowsOnboardingScreen', () { - testWidgets('renders title and subtitle', (tester) async { - await _pump(tester, screen()); - - expect(find.text('Welcome to CopyPaste'), findsOneWidget); - expect(find.text('Everything you copy, saved.'), findsOneWidget); - }); - - testWidgets('renders privacy badge with lock icon', (tester) async { - await _pump(tester, screen()); - - expect(find.byIcon(Icons.lock_outline_rounded), findsOneWidget); - expect(find.text('No cloud · No tracking · 100% local'), findsOneWidget); - }); - - testWidgets('renders hotkey chip with keyboard icon', (tester) async { - await _pump(tester, screen()); - - expect(find.byIcon(Icons.keyboard_rounded), findsOneWidget); - expect(find.text(hotkey), findsOneWidget); - }); - - testWidgets('renders tray hint text', (tester) async { - await _pump(tester, screen()); - - expect( - find.text('Look for the CP icon next to your clock.'), - findsOneWidget, - ); - }); - - testWidgets('renders description containing the hotkey', (tester) async { - await _pump(tester, screen()); - - expect(find.textContaining(hotkey), findsWidgets); - }); - - testWidgets('tapping dismiss button invokes onDismiss', (tester) async { - var dismissed = false; - await _pump(tester, screen(onDismiss: () => dismissed = true)); - - await tester.tap(find.byType(FilledButton)); - await tester.pump(); - - expect(dismissed, isTrue); - }); - - testWidgets('tapping settings button invokes onSettings', (tester) async { - var opened = false; - await _pump(tester, screen(onSettings: () => opened = true)); - - await tester.tap(find.byType(OutlinedButton)); - await tester.pump(); - - expect(opened, isTrue); - }); - - testWidgets('renders both action buttons', (tester) async { - await _pump(tester, screen()); - - expect(find.byType(FilledButton), findsOneWidget); - expect(find.byType(OutlinedButton), findsOneWidget); - }); - - testWidgets('renders in dark mode without errors', (tester) async { - tester.view.physicalSize = const Size(1080, 1920); - tester.view.devicePixelRatio = 2.0; - addTearDown(tester.view.reset); - - await tester.pumpWidget( - MaterialApp( - locale: const Locale('en'), - localizationsDelegates: AppLocalizations.localizationsDelegates, - supportedLocales: AppLocalizations.supportedLocales, - theme: ThemeData(brightness: Brightness.dark), - home: CopyPasteTheme(themeData: CompactTheme(), child: screen()), - ), - ); - await tester.pump(); - - expect(find.text('Welcome to CopyPaste'), findsOneWidget); - }); - - testWidgets('renders in Spanish locale', (tester) async { - await _pump(tester, screen(), locale: const Locale('es')); - - expect(find.text('Bienvenido a CopyPaste'), findsOneWidget); - expect(find.text('Sin nube · Sin rastreo · 100% local'), findsOneWidget); - }); - - testWidgets('app icon is displayed', (tester) async { - await _pump(tester, screen()); - - expect(find.byType(Image), findsOneWidget); - }); - - testWidgets('uses different hotkey string when provided', (tester) async { - const customHotkey = 'Ctrl+Alt+V'; - tester.view.physicalSize = const Size(1080, 1920); - tester.view.devicePixelRatio = 2.0; - addTearDown(tester.view.reset); - - await tester.pumpWidget( - _wrap( - WindowsOnboardingScreen( - hotkey: customHotkey, - initialConfig: const AppConfig(), - onDismiss: (_) {}, - onSettings: (_) {}, - ), - ), - ); - await tester.pump(); - - expect(find.text(customHotkey), findsOneWidget); - }); - - testWidgets('no Switch or Slider rendered (personalize section removed)', ( - tester, - ) async { - await _pump(tester, screen()); - - expect(find.byType(Switch), findsNothing); - expect(find.byType(Slider), findsNothing); - }); - - testWidgets('dismiss callback receives unmodified initialConfig', ( - tester, - ) async { - const config = AppConfig(); - AppConfig? received; - await _pump( - tester, - WindowsOnboardingScreen( - hotkey: hotkey, - initialConfig: config, - onDismiss: (c) => received = c, - onSettings: (_) {}, - ), - ); - - await tester.tap(find.byType(FilledButton)); - await tester.pump(); - - expect(received, equals(config)); - }); - - testWidgets('settings callback receives unmodified initialConfig', ( - tester, - ) async { - const config = AppConfig(); - AppConfig? received; - await _pump( - tester, - WindowsOnboardingScreen( - hotkey: hotkey, - initialConfig: config, - onDismiss: (_) {}, - onSettings: (c) => received = c, - ), - ); - - await tester.tap(find.byType(OutlinedButton)); - await tester.pump(); - - expect(received, equals(config)); - }); - }); -} +import 'package:core/core.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; + +import 'package:copypaste/l10n/app_localizations.dart'; +import 'package:copypaste/screens/desktop_onboarding_screen.dart'; +import 'package:copypaste/theme/compact_theme.dart'; +import 'package:copypaste/theme/theme_provider.dart'; + +Widget _wrap(Widget child, {Locale locale = const Locale('en')}) { + return MaterialApp( + locale: locale, + localizationsDelegates: AppLocalizations.localizationsDelegates, + supportedLocales: AppLocalizations.supportedLocales, + home: CopyPasteTheme(themeData: CompactTheme(), child: child), + ); +} + +Future _pump( + WidgetTester tester, + Widget child, { + Locale locale = const Locale('en'), +}) async { + tester.view.physicalSize = const Size(1080, 1920); + tester.view.devicePixelRatio = 2.0; + addTearDown(tester.view.reset); + await tester.pumpWidget(_wrap(child, locale: locale)); + await tester.pump(); +} + +void main() { + const hotkey = 'Ctrl+Shift+V'; + + Widget screen({VoidCallback? onDismiss, VoidCallback? onSettings}) => + DesktopOnboardingScreen( + hotkey: hotkey, + initialConfig: const AppConfig(), + onDismiss: (_) => (onDismiss ?? () {})(), + onSettings: (_) => (onSettings ?? () {})(), + ); + + group('DesktopOnboardingScreen', () { + testWidgets('renders title and subtitle', (tester) async { + await _pump(tester, screen()); + + expect(find.text('Welcome to CopyPaste'), findsOneWidget); + expect(find.text('Everything you copy, saved.'), findsOneWidget); + }); + + testWidgets('renders privacy badge with lock icon', (tester) async { + await _pump(tester, screen()); + + expect(find.byIcon(Icons.lock_outline_rounded), findsOneWidget); + expect(find.text('No cloud · No tracking · 100% local'), findsOneWidget); + }); + + testWidgets('renders hotkey chip with keyboard icon', (tester) async { + await _pump(tester, screen()); + + expect(find.byIcon(Icons.keyboard_rounded), findsOneWidget); + expect(find.text(hotkey), findsOneWidget); + }); + + testWidgets('renders tray hint text', (tester) async { + await _pump(tester, screen()); + + expect( + find.text('Look for the CP icon next to your clock.'), + findsOneWidget, + ); + }); + + testWidgets('renders description containing the hotkey', (tester) async { + await _pump(tester, screen()); + + expect(find.textContaining(hotkey), findsWidgets); + }); + + testWidgets('tapping dismiss button invokes onDismiss', (tester) async { + var dismissed = false; + await _pump(tester, screen(onDismiss: () => dismissed = true)); + + await tester.tap(find.byType(FilledButton)); + await tester.pump(); + + expect(dismissed, isTrue); + }); + + testWidgets('tapping settings button invokes onSettings', (tester) async { + var opened = false; + await _pump(tester, screen(onSettings: () => opened = true)); + + await tester.tap(find.byType(OutlinedButton)); + await tester.pump(); + + expect(opened, isTrue); + }); + + testWidgets('renders both action buttons', (tester) async { + await _pump(tester, screen()); + + expect(find.byType(FilledButton), findsOneWidget); + expect(find.byType(OutlinedButton), findsOneWidget); + }); + + testWidgets('renders in dark mode without errors', (tester) async { + tester.view.physicalSize = const Size(1080, 1920); + tester.view.devicePixelRatio = 2.0; + addTearDown(tester.view.reset); + + await tester.pumpWidget( + MaterialApp( + locale: const Locale('en'), + localizationsDelegates: AppLocalizations.localizationsDelegates, + supportedLocales: AppLocalizations.supportedLocales, + theme: ThemeData(brightness: Brightness.dark), + home: CopyPasteTheme(themeData: CompactTheme(), child: screen()), + ), + ); + await tester.pump(); + + expect(find.text('Welcome to CopyPaste'), findsOneWidget); + }); + + testWidgets('renders in Spanish locale', (tester) async { + await _pump(tester, screen(), locale: const Locale('es')); + + expect(find.text('Bienvenido a CopyPaste'), findsOneWidget); + expect(find.text('Sin nube · Sin rastreo · 100% local'), findsOneWidget); + }); + + testWidgets('app icon is displayed', (tester) async { + await _pump(tester, screen()); + + expect(find.byType(Image), findsOneWidget); + }); + + testWidgets('uses different hotkey string when provided', (tester) async { + const customHotkey = 'Ctrl+Alt+V'; + tester.view.physicalSize = const Size(1080, 1920); + tester.view.devicePixelRatio = 2.0; + addTearDown(tester.view.reset); + + await tester.pumpWidget( + _wrap( + DesktopOnboardingScreen( + hotkey: customHotkey, + initialConfig: const AppConfig(), + onDismiss: (_) {}, + onSettings: (_) {}, + ), + ), + ); + await tester.pump(); + + expect(find.text(customHotkey), findsOneWidget); + }); + + testWidgets('no Switch or Slider rendered (personalize section removed)', ( + tester, + ) async { + await _pump(tester, screen()); + + expect(find.byType(Switch), findsNothing); + expect(find.byType(Slider), findsNothing); + }); + + testWidgets('dismiss callback receives unmodified initialConfig', ( + tester, + ) async { + const config = AppConfig(); + AppConfig? received; + await _pump( + tester, + DesktopOnboardingScreen( + hotkey: hotkey, + initialConfig: config, + onDismiss: (c) => received = c, + onSettings: (_) {}, + ), + ); + + await tester.tap(find.byType(FilledButton)); + await tester.pump(); + + expect(received, equals(config)); + }); + + testWidgets('settings callback receives unmodified initialConfig', ( + tester, + ) async { + const config = AppConfig(); + AppConfig? received; + await _pump( + tester, + DesktopOnboardingScreen( + hotkey: hotkey, + initialConfig: config, + onDismiss: (_) {}, + onSettings: (c) => received = c, + ), + ); + + await tester.tap(find.byType(OutlinedButton)); + await tester.pump(); + + expect(received, equals(config)); + }); + }); +} diff --git a/core/lib/config/app_config.dart b/core/lib/config/app_config.dart index 18367f7b..58132911 100644 --- a/core/lib/config/app_config.dart +++ b/core/lib/config/app_config.dart @@ -38,7 +38,7 @@ class AppConfig { this.themeMode = 'dark', this.accessibilityWasGranted = false, this.lastRunVersion = '', - this.hasSeenWindowsOnboarding = false, + this.hasSeenOnboarding = false, this.hasCompletedOnboarding = false, this.generateImageThumbnails = true, this.generateVideoThumbnails = true, @@ -110,12 +110,14 @@ class AppConfig { defaults.accessibilityWasGranted, lastRunVersion: json['lastRunVersion'] as String? ?? defaults.lastRunVersion, - hasSeenWindowsOnboarding: + hasSeenOnboarding: + json['hasSeenOnboarding'] as bool? ?? json['hasSeenWindowsOnboarding'] as bool? ?? - defaults.hasSeenWindowsOnboarding, + defaults.hasSeenOnboarding, hasCompletedOnboarding: json['hasCompletedOnboarding'] as bool? ?? - (json['hasSeenWindowsOnboarding'] as bool? ?? + (json['hasSeenOnboarding'] as bool? ?? + json['hasSeenWindowsOnboarding'] as bool? ?? defaults.hasCompletedOnboarding), generateImageThumbnails: json['generateImageThumbnails'] as bool? ?? @@ -197,7 +199,7 @@ class AppConfig { final String themeMode; final bool accessibilityWasGranted; final String lastRunVersion; - final bool hasSeenWindowsOnboarding; + final bool hasSeenOnboarding; final bool hasCompletedOnboarding; // Multimedia & thumbnails @@ -248,7 +250,7 @@ class AppConfig { String? themeMode, bool? accessibilityWasGranted, String? lastRunVersion, - bool? hasSeenWindowsOnboarding, + bool? hasSeenOnboarding, bool? hasCompletedOnboarding, bool? generateImageThumbnails, bool? generateVideoThumbnails, @@ -295,8 +297,8 @@ class AppConfig { accessibilityWasGranted: accessibilityWasGranted ?? this.accessibilityWasGranted, lastRunVersion: lastRunVersion ?? this.lastRunVersion, - hasSeenWindowsOnboarding: - hasSeenWindowsOnboarding ?? this.hasSeenWindowsOnboarding, + hasSeenOnboarding: + hasSeenOnboarding ?? this.hasSeenOnboarding, hasCompletedOnboarding: hasCompletedOnboarding ?? this.hasCompletedOnboarding, generateImageThumbnails: @@ -351,7 +353,7 @@ class AppConfig { 'themeMode': themeMode, 'accessibilityWasGranted': accessibilityWasGranted, 'lastRunVersion': lastRunVersion, - 'hasSeenWindowsOnboarding': hasSeenWindowsOnboarding, + 'hasSeenOnboarding': hasSeenOnboarding, 'hasCompletedOnboarding': hasCompletedOnboarding, 'generateImageThumbnails': generateImageThumbnails, 'generateVideoThumbnails': generateVideoThumbnails, diff --git a/core/test/app_config_test.dart b/core/test/app_config_test.dart index 446c7907..0cec01a5 100644 --- a/core/test/app_config_test.dart +++ b/core/test/app_config_test.dart @@ -196,29 +196,29 @@ void main() { expect(config.cardMaxLines, equals(5)); }); - test('hasSeenWindowsOnboarding defaults to false', () { + test('hasSeenOnboarding defaults to false', () { const config = AppConfig(); - expect(config.hasSeenWindowsOnboarding, isFalse); + expect(config.hasSeenOnboarding, isFalse); }); - test('hasSeenWindowsOnboarding round-trips via JSON', () { - const config = AppConfig(hasSeenWindowsOnboarding: true); + test('hasSeenOnboarding round-trips via JSON', () { + const config = AppConfig(hasSeenOnboarding: true); expect( - AppConfig.fromJson(config.toJson()).hasSeenWindowsOnboarding, + AppConfig.fromJson(config.toJson()).hasSeenOnboarding, isTrue, ); }); - test('hasSeenWindowsOnboarding absent in JSON defaults to false', () { - expect(AppConfig.fromJson({}).hasSeenWindowsOnboarding, isFalse); + test('hasSeenOnboarding absent in JSON defaults to false', () { + expect(AppConfig.fromJson({}).hasSeenOnboarding, isFalse); }); - test('copyWith hasSeenWindowsOnboarding updates value', () { + test('copyWith hasSeenOnboarding updates value', () { const config = AppConfig(); expect( config - .copyWith(hasSeenWindowsOnboarding: true) - .hasSeenWindowsOnboarding, + .copyWith(hasSeenOnboarding: true) + .hasSeenOnboarding, isTrue, ); }); @@ -387,7 +387,7 @@ void main() { themeMode: 'test', accessibilityWasGranted: true, lastRunVersion: 'v', - hasSeenWindowsOnboarding: true, + hasSeenOnboarding: true, ); final json = config.toJson(); expect(json['preferredLanguage'], 'fr'); @@ -419,7 +419,7 @@ void main() { expect(json['themeMode'], 'test'); expect(json['accessibilityWasGranted'], true); expect(json['lastRunVersion'], 'v'); - expect(json['hasSeenWindowsOnboarding'], true); + expect(json['hasSeenOnboarding'], true); }); }); From 6d9babb45a6e2f4301d2d23231899283dcdea682 Mon Sep 17 00:00:00 2001 From: rgdevment Date: Fri, 24 Apr 2026 14:16:32 -0400 Subject: [PATCH 12/31] feat: implement Linux native thumbnail provider and desktop notifier --- app/lib/main.dart | 8 +- app/lib/shell/desktop_notifier.dart | 64 +++++++++++ core/lib/config/app_config.dart | 3 +- core/test/app_config_test.dart | 9 +- .../lib/linux_native_thumbnail_provider.dart | 79 +++++++++++++ listener/lib/listener.dart | 1 + listener/linux/listener_plugin.c | 66 +++++++++++ .../linux_native_thumbnail_provider_test.dart | 104 ++++++++++++++++++ 8 files changed, 322 insertions(+), 12 deletions(-) create mode 100644 app/lib/shell/desktop_notifier.dart create mode 100644 listener/lib/linux_native_thumbnail_provider.dart create mode 100644 listener/test/linux_native_thumbnail_provider_test.dart diff --git a/app/lib/main.dart b/app/lib/main.dart index 7b72a61b..e47aa3ee 100644 --- a/app/lib/main.dart +++ b/app/lib/main.dart @@ -28,7 +28,7 @@ import 'shell/startup_helper.dart'; import 'shell/tray_icon.dart'; import 'shell/win_known_folders.dart'; import 'shell/win_package_context.dart'; -import 'shell/windows_balloon.dart'; +import 'shell/desktop_notifier.dart'; import 'screens/main_screen.dart'; import 'screens/settings_screen.dart'; import 'screens/wayland_unsupported_screen.dart'; @@ -120,6 +120,8 @@ Future _run() async { ? WindowsNativeThumbnailProvider() : Platform.isMacOS ? MacOSNativeThumbnailProvider() + : Platform.isLinux + ? LinuxNativeThumbnailProvider() : null; final clipboardService = ClipboardService( repo, @@ -687,7 +689,7 @@ class _CopyPasteAppState extends State ); final ctx = _navigatorKey.currentContext; final l = ctx != null && ctx.mounted ? AppLocalizations.of(ctx) : null; - await WindowsBalloon.show( + await DesktopNotifier.show( title: l?.balloonWakeupTitle ?? 'CopyPaste is already open', body: l?.balloonWakeupBody(binding.label()) ?? @@ -706,7 +708,7 @@ class _CopyPasteAppState extends State ); final ctx = _navigatorKey.currentContext; final l = ctx != null && ctx.mounted ? AppLocalizations.of(ctx) : null; - await WindowsBalloon.show( + await DesktopNotifier.show( title: 'CopyPaste', body: l?.balloonStartupBody(binding.label()) ?? diff --git a/app/lib/shell/desktop_notifier.dart b/app/lib/shell/desktop_notifier.dart new file mode 100644 index 00000000..02704840 --- /dev/null +++ b/app/lib/shell/desktop_notifier.dart @@ -0,0 +1,64 @@ +// coverage:ignore-file +import 'dart:async'; +import 'dart:io'; + +import 'windows_balloon.dart'; + +/// Cross-platform desktop notification helper for tray balloons. +/// +/// Routes to the most idiomatic native channel per OS: +/// - Windows → `WindowsBalloon` (Shell_NotifyIconW via FFI). +/// - Linux → `notify-send` (libnotify CLI shipped on every desktop; +/// talks D-Bus to `org.freedesktop.Notifications`, which all +/// modern DEs implement: GNOME Shell, KDE Plasma, Xfce…). +/// - macOS → no-op (Mac uses dock badges + window UI; balloons would +/// collide with the system Notification Center conventions). +/// +/// Always returns a Future that completes — never throws. +class DesktopNotifier { + DesktopNotifier._(); + + /// Shows a transient notification with [title] and [body]. + /// Returns true when the platform layer accepted the request. + static Future show({ + required String title, + required String body, + }) async { + if (Platform.isWindows) { + return WindowsBalloon.show(title: title, body: body); + } + if (Platform.isLinux) { + return _showLinux(title: title, body: body); + } + return false; + } + + /// Spawns `notify-send` to push a notification through D-Bus + /// (`org.freedesktop.Notifications`). Silent on systems without it. + /// + /// Flags: + /// --app-name=CopyPaste → grouping / branding in the shell. + /// --icon=copypaste → DE looks up the icon by name in the theme; + /// falls back gracefully if not installed. + /// --expire-time=7000 → matches Windows balloon dismiss window. + static Future _showLinux({ + required String title, + required String body, + }) async { + try { + final result = await Process.run('notify-send', [ + '--app-name=CopyPaste', + '--icon=copypaste', + '--expire-time=7000', + title, + body, + ]); + return result.exitCode == 0; + } on ProcessException { + // notify-send not installed (rare; ships with libnotify-bin). + return false; + } catch (_) { + return false; + } + } +} diff --git a/core/lib/config/app_config.dart b/core/lib/config/app_config.dart index 58132911..077dea9f 100644 --- a/core/lib/config/app_config.dart +++ b/core/lib/config/app_config.dart @@ -297,8 +297,7 @@ class AppConfig { accessibilityWasGranted: accessibilityWasGranted ?? this.accessibilityWasGranted, lastRunVersion: lastRunVersion ?? this.lastRunVersion, - hasSeenOnboarding: - hasSeenOnboarding ?? this.hasSeenOnboarding, + hasSeenOnboarding: hasSeenOnboarding ?? this.hasSeenOnboarding, hasCompletedOnboarding: hasCompletedOnboarding ?? this.hasCompletedOnboarding, generateImageThumbnails: diff --git a/core/test/app_config_test.dart b/core/test/app_config_test.dart index 0cec01a5..e32ff913 100644 --- a/core/test/app_config_test.dart +++ b/core/test/app_config_test.dart @@ -203,10 +203,7 @@ void main() { test('hasSeenOnboarding round-trips via JSON', () { const config = AppConfig(hasSeenOnboarding: true); - expect( - AppConfig.fromJson(config.toJson()).hasSeenOnboarding, - isTrue, - ); + expect(AppConfig.fromJson(config.toJson()).hasSeenOnboarding, isTrue); }); test('hasSeenOnboarding absent in JSON defaults to false', () { @@ -216,9 +213,7 @@ void main() { test('copyWith hasSeenOnboarding updates value', () { const config = AppConfig(); expect( - config - .copyWith(hasSeenOnboarding: true) - .hasSeenOnboarding, + config.copyWith(hasSeenOnboarding: true).hasSeenOnboarding, isTrue, ); }); diff --git a/listener/lib/linux_native_thumbnail_provider.dart b/listener/lib/linux_native_thumbnail_provider.dart new file mode 100644 index 00000000..c7515beb --- /dev/null +++ b/listener/lib/linux_native_thumbnail_provider.dart @@ -0,0 +1,79 @@ +// coverage:ignore-file +import 'dart:async'; +import 'dart:io' show Platform; +import 'dart:ui' as ui show PlatformDispatcher; + +import 'package:core/core.dart'; +import 'package:flutter/services.dart'; + +/// Linux-backed [NativeThumbnailProvider]. Bridges to the native handler +/// `getNativeThumbnail` exposed by the listener plugin, which uses +/// `gdk_pixbuf_new_from_file_at_size()` to decode the source image and +/// `gdk_pixbuf_save_to_buffer(... "png")` to encode PNG bytes. +/// +/// Backed by GdkPixbuf, which natively decodes PNG/JPEG/BMP/GIF/TIFF/ICO, +/// plus SVG (via librsvg-loader) and any other format with an installed +/// gdk-pixbuf-loader. Video/audio frames are not handled here (would +/// require libavformat); the Dart fallback covers those (returns null → +/// generic type icon). +/// +/// This provider is a no-op on non-Linux platforms. +/// +/// HiDPI: the requested [sizePx] is multiplied by the platform device +/// pixel ratio so the OS produces a bitmap large enough for the largest +/// connected display. The C side enforces a 64-px minimum heuristic to +/// reject icon-only fallbacks. +class LinuxNativeThumbnailProvider implements NativeThumbnailProvider { + LinuxNativeThumbnailProvider({MethodChannel? channel}) + : _channel = channel ?? const MethodChannel('copypaste/clipboard_writer'); + + final MethodChannel _channel; + + @override + Future request(String path, {int sizePx = 256}) async { + if (!Platform.isLinux) return null; + if (path.isEmpty || sizePx <= 0) return null; + + final scaled = (sizePx * _devicePixelRatio()).round().clamp(64, 1024); + + try { + final result = await _channel.invokeMethod( + 'getNativeThumbnail', + {'path': path, 'sizePx': scaled}, + ); + if (result is Uint8List && result.isNotEmpty) { + AppLogger.info( + '[NativeThumb] OK ${result.length}B for $path (size=$scaled)', + ); + return result; + } + if (result is List && result.isNotEmpty) { + AppLogger.info( + '[NativeThumb] OK ${result.length}B for $path (size=$scaled)', + ); + return Uint8List.fromList(result); + } + AppLogger.info('[NativeThumb] empty for $path (size=$scaled)'); + return null; + } on PlatformException catch (e, s) { + AppLogger.warn( + 'LinuxNativeThumbnailProvider: platform error: ${e.code} ${e.message}\n$s', + ); + return null; + } on MissingPluginException { + // Plugin not registered (e.g. running in a unit test host without the + // listener plugin loaded). Quiet fallback. + return null; + } + } + + double _devicePixelRatio() { + final views = ui.PlatformDispatcher.instance.views; + if (views.isEmpty) return 1.0; + var maxRatio = 1.0; + for (final view in views) { + if (view.devicePixelRatio > maxRatio) maxRatio = view.devicePixelRatio; + } + return maxRatio; + } +} diff --git a/listener/lib/listener.dart b/listener/lib/listener.dart index 96d5605b..b33f14bc 100644 --- a/listener/lib/listener.dart +++ b/listener/lib/listener.dart @@ -1,5 +1,6 @@ export 'clipboard_event.dart'; export 'clipboard_writer.dart'; +export 'linux_native_thumbnail_provider.dart'; export 'macos_native_thumbnail_provider.dart'; export 'windows_clipboard_listener.dart'; export 'windows_native_thumbnail_provider.dart'; diff --git a/listener/linux/listener_plugin.c b/listener/linux/listener_plugin.c index c42e9ce4..7c687ec5 100644 --- a/listener/linux/listener_plugin.c +++ b/listener/linux/listener_plugin.c @@ -967,6 +967,55 @@ static FlValue* get_media_info(void) { return NULL; } +// Generates a native PNG thumbnail for `path`, scaled so the longest side +// is `size_px`. Returns a Uint8 list FlValue with PNG bytes, or NULL when +// the file cannot be decoded by GdkPixbuf (e.g. video/audio without a +// loader). Caller takes ownership of the returned FlValue. +// +// Uses gdk_pixbuf_new_from_file_at_size() which preserves aspect ratio. +// Rejects results smaller than 64 px on the longest side (icon fallback). +static FlValue* get_native_thumbnail(const gchar* path, gint size_px) { + if (path == NULL || *path == '\0' || size_px <= 0) return NULL; + + GError* error = NULL; + GdkPixbuf* pixbuf = + gdk_pixbuf_new_from_file_at_size(path, size_px, size_px, &error); + if (pixbuf == NULL) { + if (error != NULL) { + g_warning("get_native_thumbnail: %s", error->message); + g_error_free(error); + } + return NULL; + } + + gint w = gdk_pixbuf_get_width(pixbuf); + gint h = gdk_pixbuf_get_height(pixbuf); + gint longest = w > h ? w : h; + if (longest < 64) { + g_object_unref(pixbuf); + return NULL; + } + + gchar* buffer = NULL; + gsize buffer_size = 0; + gboolean ok = gdk_pixbuf_save_to_buffer( + pixbuf, &buffer, &buffer_size, "png", &error, NULL); + g_object_unref(pixbuf); + if (!ok || buffer == NULL || buffer_size == 0) { + if (error != NULL) { + g_warning("get_native_thumbnail save: %s", error->message); + g_error_free(error); + } + g_free(buffer); + return NULL; + } + + FlValue* value = + fl_value_new_uint8_list((const uint8_t*)buffer, buffer_size); + g_free(buffer); + return value; +} + static void respond_success(FlMethodCall* method_call, FlValue* result) { g_autoptr(GError) error = NULL; if (!fl_method_call_respond_success(method_call, result, &error) && error != NULL) { @@ -1034,6 +1083,23 @@ static void listener_plugin_handle_method_call(ListenerPlugin* self, return; } + if (strcmp(method, "getNativeThumbnail") == 0) { + FlValue* path_value = + args != NULL ? fl_value_lookup_string(args, "path") : NULL; + FlValue* size_value = + args != NULL ? fl_value_lookup_string(args, "sizePx") : NULL; + const gchar* path = + path_value != NULL && fl_value_get_type(path_value) == FL_VALUE_TYPE_STRING + ? fl_value_get_string(path_value) + : NULL; + gint size_px = + size_value != NULL && fl_value_get_type(size_value) == FL_VALUE_TYPE_INT + ? (gint)fl_value_get_int(size_value) + : 256; + respond_success(method_call, get_native_thumbnail(path, size_px)); + return; + } + if (strcmp(method, "captureFrontmostApp") == 0) { #ifdef GDK_WINDOWING_X11 if (plugin_is_x11()) { diff --git a/listener/test/linux_native_thumbnail_provider_test.dart b/listener/test/linux_native_thumbnail_provider_test.dart new file mode 100644 index 00000000..e524b97a --- /dev/null +++ b/listener/test/linux_native_thumbnail_provider_test.dart @@ -0,0 +1,104 @@ +import 'package:flutter/services.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:listener/linux_native_thumbnail_provider.dart'; + +void main() { + TestWidgetsFlutterBinding.ensureInitialized(); + + const channel = MethodChannel('copypaste/clipboard_writer'); + + tearDown(() { + TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger + .setMockMethodCallHandler(channel, null); + }); + + group('LinuxNativeThumbnailProvider', () { + test('returns null on non-Linux hosts (or empty channel)', () async { + TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger + .setMockMethodCallHandler(channel, (call) async => null); + + final provider = LinuxNativeThumbnailProvider(); + final result = await provider.request('/tmp/missing.png', sizePx: 256); + expect(result, isNull); + }); + + test('returns Uint8List bytes when channel succeeds', () async { + final fakeBytes = Uint8List.fromList(List.generate(64, (i) => i)); + String? receivedPath; + int? receivedSize; + TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger + .setMockMethodCallHandler(channel, (call) async { + if (call.method != 'getNativeThumbnail') return null; + final args = call.arguments as Map; + receivedPath = args['path'] as String?; + receivedSize = args['sizePx'] as int?; + return fakeBytes; + }); + + final provider = LinuxNativeThumbnailProvider(); + final result = await provider.request('/tmp/photo.jpg', sizePx: 128); + + // Outside the platform guard this is a no-op on non-Linux hosts. + if (receivedPath != null) { + expect(result, equals(fakeBytes)); + expect(receivedPath, equals('/tmp/photo.jpg')); + expect(receivedSize, greaterThanOrEqualTo(128)); + } else { + expect(result, isNull); + } + }); + + test('treats empty list as null (no thumbnail available)', () async { + TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger + .setMockMethodCallHandler(channel, (call) async { + if (call.method == 'getNativeThumbnail') return Uint8List(0); + return null; + }); + + final provider = LinuxNativeThumbnailProvider(); + final result = await provider.request('/tmp/missing.bin'); + expect(result, isNull); + }); + + test('swallows PlatformException and returns null', () async { + TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger + .setMockMethodCallHandler(channel, (call) async { + if (call.method == 'getNativeThumbnail') { + throw PlatformException(code: 'boom', message: 'native failure'); + } + return null; + }); + + final provider = LinuxNativeThumbnailProvider(); + final result = await provider.request('/tmp/whatever.png'); + expect(result, isNull); + }); + + test( + 'rejects empty path / non-positive size before invoking channel', + () async { + var called = false; + TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger + .setMockMethodCallHandler(channel, (call) async { + called = true; + return null; + }); + + final provider = LinuxNativeThumbnailProvider(); + expect(await provider.request(''), isNull); + expect(await provider.request('x', sizePx: 0), isNull); + expect(await provider.request('x', sizePx: -1), isNull); + expect(called, isFalse); + }, + ); + + test('survives MissingPluginException (no listener registered)', () async { + TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger + .setMockMethodCallHandler(channel, null); + + final provider = LinuxNativeThumbnailProvider(); + final result = await provider.request('/tmp/anything.png'); + expect(result, isNull); + }); + }); +} From 5aa7ca2975d7e1dfbdf13947ee9f8ee41c019d1e Mon Sep 17 00:00:00 2001 From: rgdevment Date: Fri, 24 Apr 2026 14:25:01 -0400 Subject: [PATCH 13/31] feat: add tests for onboarding migration logic in AppConfig --- app/lib/shell/desktop_notifier.dart | 12 +- app/test/shell/desktop_notifier_test.dart | 128 ++++++++++++++++++++++ core/test/app_config_test.dart | 22 ++++ 3 files changed, 160 insertions(+), 2 deletions(-) create mode 100644 app/test/shell/desktop_notifier_test.dart diff --git a/app/lib/shell/desktop_notifier.dart b/app/lib/shell/desktop_notifier.dart index 02704840..9d328b3b 100644 --- a/app/lib/shell/desktop_notifier.dart +++ b/app/lib/shell/desktop_notifier.dart @@ -1,7 +1,8 @@ -// coverage:ignore-file import 'dart:async'; import 'dart:io'; +import 'package:flutter/foundation.dart'; + import 'windows_balloon.dart'; /// Cross-platform desktop notification helper for tray balloons. @@ -18,6 +19,12 @@ import 'windows_balloon.dart'; class DesktopNotifier { DesktopNotifier._(); + /// Injectable process runner. Override in tests to avoid spawning real + /// system processes. + @visibleForTesting + static Future Function(String, List)? + processRunnerOverride; + /// Shows a transient notification with [title] and [body]. /// Returns true when the platform layer accepted the request. static Future show({ @@ -45,8 +52,9 @@ class DesktopNotifier { required String title, required String body, }) async { + final runner = processRunnerOverride ?? Process.run; try { - final result = await Process.run('notify-send', [ + final result = await runner('notify-send', [ '--app-name=CopyPaste', '--icon=copypaste', '--expire-time=7000', diff --git a/app/test/shell/desktop_notifier_test.dart b/app/test/shell/desktop_notifier_test.dart new file mode 100644 index 00000000..e6337c23 --- /dev/null +++ b/app/test/shell/desktop_notifier_test.dart @@ -0,0 +1,128 @@ +import 'dart:io'; + +import 'package:flutter_test/flutter_test.dart'; + +import 'package:copypaste/shell/desktop_notifier.dart'; + +void main() { + tearDown(() { + DesktopNotifier.processRunnerOverride = null; + }); + + group('DesktopNotifier – macOS', () { + test('returns false on macOS (no-op)', () async { + if (!Platform.isMacOS) return; + final result = await DesktopNotifier.show(title: 'Test', body: 'Body'); + expect(result, isFalse); + }); + }); + + group('DesktopNotifier – Linux routing', () { + test('spawns notify-send with correct arguments', () async { + if (!Platform.isLinux) return; + + String? capturedExe; + List? capturedArgs; + DesktopNotifier.processRunnerOverride = + (String exe, List args) async { + capturedExe = exe; + capturedArgs = List.from(args); + return ProcessResult(0, 0, '', ''); + }; + + final result = await DesktopNotifier.show( + title: 'CopyPaste', + body: 'Running in the background.', + ); + + expect(result, isTrue); + expect(capturedExe, equals('notify-send')); + expect(capturedArgs, isNotNull); + expect(capturedArgs, contains('--app-name=CopyPaste')); + expect(capturedArgs, contains('--icon=copypaste')); + expect(capturedArgs, contains('--expire-time=7000')); + expect(capturedArgs, contains('CopyPaste')); + expect(capturedArgs, contains('Running in the background.')); + }); + + test('title and body are forwarded verbatim', () async { + if (!Platform.isLinux) return; + + const title = 'My Title'; + const body = 'My Body Line'; + String? gotTitle; + String? gotBody; + DesktopNotifier.processRunnerOverride = + (String exe, List args) async { + gotTitle = args[args.length - 2]; + gotBody = args[args.length - 1]; + return ProcessResult(0, 0, '', ''); + }; + + await DesktopNotifier.show(title: title, body: body); + expect(gotTitle, equals(title)); + expect(gotBody, equals(body)); + }); + + test('returns false when notify-send exits with non-zero code', () async { + if (!Platform.isLinux) return; + + DesktopNotifier.processRunnerOverride = + (String exe, List args) async { + return ProcessResult(0, 1, '', 'error'); + }; + + final result = await DesktopNotifier.show(title: 'Test', body: 'Body'); + expect(result, isFalse); + }); + + test( + 'returns false when notify-send is not installed (ProcessException)', + () async { + if (!Platform.isLinux) return; + + DesktopNotifier.processRunnerOverride = + (String exe, List args) async { + throw ProcessException(exe, args, 'No such file or directory', 2); + }; + + final result = await DesktopNotifier.show(title: 'Test', body: 'Body'); + expect(result, isFalse); + }, + ); + + test('returns false on unexpected exception (never throws)', () async { + if (!Platform.isLinux) return; + + DesktopNotifier.processRunnerOverride = + (String exe, List args) async { + throw StateError('unexpected'); + }; + + final result = await DesktopNotifier.show(title: 'Test', body: 'Body'); + expect(result, isFalse); + }); + }); + + group('DesktopNotifier – processRunnerOverride lifecycle', () { + test('override is invoked when set', () async { + if (!Platform.isLinux) return; + + var called = false; + DesktopNotifier.processRunnerOverride = + (String exe, List args) async { + called = true; + return ProcessResult(0, 0, '', ''); + }; + + await DesktopNotifier.show(title: 'T', body: 'B'); + expect(called, isTrue); + }); + + test('override resets to null after tearDown (isolation)', () { + // Confirms test isolation — override should be null at this point + // because tearDown clears it. + expect(DesktopNotifier.processRunnerOverride, isNull); + }); + }); +} diff --git a/core/test/app_config_test.dart b/core/test/app_config_test.dart index e32ff913..e46297a3 100644 --- a/core/test/app_config_test.dart +++ b/core/test/app_config_test.dart @@ -685,6 +685,28 @@ void main() { }, ); + test('hasSeenOnboarding migrates from legacy hasSeenWindowsOnboarding', () { + final c = AppConfig.fromJson({'hasSeenWindowsOnboarding': true}); + expect(c.hasSeenOnboarding, isTrue); + }); + + test('hasSeenOnboarding new key takes precedence over legacy', () { + final c = AppConfig.fromJson({ + 'hasSeenWindowsOnboarding': false, + 'hasSeenOnboarding': true, + }); + expect(c.hasSeenOnboarding, isTrue); + }); + + test( + 'both hasSeenOnboarding and hasCompletedOnboarding populated from legacy', + () { + final c = AppConfig.fromJson({'hasSeenWindowsOnboarding': true}); + expect(c.hasSeenOnboarding, isTrue); + expect(c.hasCompletedOnboarding, isTrue); + }, + ); + test( 'hasCompletedOnboarding stays false when neither legacy nor new is set', () { From 3bc3015fc5bb493958bb2e9b5e559d86db24644e Mon Sep 17 00:00:00 2001 From: rgdevment Date: Fri, 24 Apr 2026 15:02:37 -0400 Subject: [PATCH 14/31] tests: add coverage ignore comments to macOS and Windows native thumbnail provider files - Added coverage ignore comments at the top of `macos_native_thumbnail_provider.dart` and `windows_native_thumbnail_provider.dart` to exclude these files from code coverage analysis. --- app/lib/l10n/app_localizations_en.dart | 1663 +++++------ app/lib/l10n/app_localizations_es.dart | 1677 +++++------ app/lib/services/linux_capabilities.dart | 2 +- app/lib/services/linux_guard.dart | 2 +- .../services/linux_capabilities_test.dart | 78 + core/lib/config/storage_config.dart | 182 +- core/lib/repository/sqlite_repository.g.dart | 2595 +++++++++-------- core/lib/services/crash_logger.dart | 250 +- core/lib/services/support_service.dart | 316 +- core/test/support_service_test.dart | 497 ++-- .../lib/macos_native_thumbnail_provider.dart | 1 + .../windows_native_thumbnail_provider.dart | 147 +- 12 files changed, 3776 insertions(+), 3634 deletions(-) diff --git a/app/lib/l10n/app_localizations_en.dart b/app/lib/l10n/app_localizations_en.dart index 8cb04ac2..a1fcb1c1 100644 --- a/app/lib/l10n/app_localizations_en.dart +++ b/app/lib/l10n/app_localizations_en.dart @@ -1,831 +1,832 @@ -// ignore: unused_import -import 'package:intl/intl.dart' as intl; -import 'app_localizations.dart'; - -// ignore_for_file: type=lint - -/// The translations for English (`en`). -class AppLocalizationsEn extends AppLocalizations { - AppLocalizationsEn([String locale = 'en']) : super(locale); - - @override - String get searchPlaceholder => 'Search clipboard…'; - - @override - String get emptyState => 'No items in this section'; - - @override - String get emptyStateSubtitle => 'Copy something to get started'; - - @override - String get hintBannerText => - 'CopyPaste is active and running in the background. Look for it in the system tray or just use your shortcut. Feel free to customize your experience in'; - - @override - String get hintBannerAction => 'Settings'; - - @override - String get settingsTitle => 'Settings'; - - @override - String get sectionShortcuts => 'KEYBOARD SHORTCUTS'; - - @override - String get sectionStorage => 'STORAGE'; - - @override - String get settingRunOnStartup => 'Run on startup'; - - @override - String get settingLanguage => 'Interface language'; - - @override - String get hotkeyWillApply => 'Hotkey will apply immediately'; - - @override - String get sectionSupport => 'SUPPORT'; - - @override - String get supportExportLogs => 'Export logs'; - - @override - String get supportExportLogsSubtitle => - 'Save a zip with app logs for a bug report. Your clipboard content is never included.'; - - @override - String get supportOpenLogsFolder => 'Open logs folder'; - - @override - String get supportOpenLogsFolderSubtitle => - 'Browse the raw log files in your file manager.'; - - @override - String get supportGitHub => 'Report a bug on GitHub'; - - @override - String get supportExportSuccess => 'Logs saved to Downloads.'; - - @override - String get supportShowInFiles => 'Show'; - - @override - String get supportExportEmpty => 'No log files found.'; - - @override - String get supportExportError => 'Failed to export logs.'; - - @override - String get sectionReset => 'RESET & CLEAN INSTALL'; - - @override - String get resetSoftLabel => 'Soft Reset'; - - @override - String get resetSoftSubtitle => - 'Resets all settings to defaults and marks app as fresh install. Clipboard history is preserved.'; - - @override - String get resetHardLabel => 'Hard Reset'; - - @override - String get resetHardSubtitle => - 'Deletes all clipboard history, images, and settings. This cannot be undone.'; - - @override - String get resetSoftConfirmTitle => 'Soft reset?'; - - @override - String get resetSoftConfirmMessage => - 'All settings will return to defaults and the app will restart as if freshly installed. Your clipboard history will not be deleted.'; - - @override - String get resetHardConfirmTitle => 'Hard reset?'; - - @override - String get resetHardConfirmMessage => - 'This will permanently delete all clipboard history, images, and settings, then restart the app. This cannot be undone.'; - - @override - String get resetConfirmButton => 'Reset & Restart'; - - @override - String get clearHistoryConfirmTitle => 'Clear history?'; - - @override - String get clearHistoryConfirmMessage => - 'This will permanently delete all non-pinned clipboard items. This action cannot be undone.'; - - @override - String get clearHistoryConfirmButton => 'Clear'; - - @override - String backupLastDate(String date) { - return 'Last backup: $date'; - } - - @override - String get backupNone => 'No backup created yet.'; - - @override - String get backupCreateLabel => 'Create backup'; - - @override - String get backupRestoreLabel => 'Restore backup'; - - @override - String get backupError => 'Failed to create backup. Check permissions.'; - - @override - String get restoreDialogTitle => 'Restore backup'; - - @override - String get restoreDialogWarning => - 'This will replace all current data with the backup contents. Continue?'; - - @override - String get restoreFileNotFound => 'File not found.'; - - @override - String restoreSuccess(int count) { - return 'Restored $count items.'; - } - - @override - String get restoreError => - 'Restore failed. Your previous data has been preserved.'; - - @override - String get buttonSave => 'Save'; - - @override - String get buttonClose => 'Close'; - - @override - String get buttonCancel => 'Cancel'; - - @override - String get buttonReset => 'Restore defaults'; - - @override - String get savingIndicator => 'Saving…'; - - @override - String get savedIndicator => 'Saved'; - - @override - String get menuPaste => 'Paste'; - - @override - String get menuPastePlain => 'Paste plain'; - - @override - String get menuPin => 'Pin'; - - @override - String get menuUnpin => 'Unpin'; - - @override - String get menuEdit => 'Edit card'; - - @override - String get menuDelete => 'Delete'; - - @override - String get editColorLabel => 'Color'; - - @override - String get colorRed => 'Red'; - - @override - String get colorGreen => 'Green'; - - @override - String get colorPurple => 'Purple'; - - @override - String get colorYellow => 'Yellow'; - - @override - String get colorBlue => 'Blue'; - - @override - String get colorOrange => 'Orange'; - - @override - String get typeText => 'Text'; - - @override - String get typeImage => 'Image'; - - @override - String get typeFile => 'File'; - - @override - String get typeFolder => 'Folder'; - - @override - String get typeLink => 'Link'; - - @override - String get typeAudio => 'Audio'; - - @override - String get typeVideo => 'Video'; - - @override - String get typeEmail => 'Email'; - - @override - String get typePhone => 'Phone'; - - @override - String get typeColor => 'Color'; - - @override - String get typeIp => 'IP'; - - @override - String get typeUuid => 'UUID'; - - @override - String get typeJson => 'JSON'; - - @override - String get filterAll => 'All'; - - @override - String get filterPinned => 'Pinned'; - - @override - String get trayTooltip => 'CopyPaste'; - - @override - String get trayExit => 'Exit'; - - @override - String get shortcutOpenClose => 'Open / close CopyPaste'; - - @override - String get shortcutEscape => 'Clear search or close window'; - - @override - String get shortcutTab1 => 'Switch to Recent tab'; - - @override - String get shortcutTab2 => 'Switch to Pinned tab'; - - @override - String get shortcutArrows => 'Navigate between items'; - - @override - String get shortcutEnter => 'Paste selected item'; - - @override - String get shortcutDelete => 'Delete selected item'; - - @override - String get shortcutPin => 'Pin / Unpin selected item'; - - @override - String get shortcutEdit => 'Edit card (label and color)'; - - @override - String get tabGeneral => 'General'; - - @override - String get tabBackupRestore => 'Backup & Support'; - - @override - String get tabAppearance => 'Appearance'; - - @override - String get tabShortcuts => 'Shortcuts'; - - @override - String get tabAbout => 'About'; - - @override - String get sectionLanguage => 'LANGUAGE'; - - @override - String get sectionStartup => 'STARTUP'; - - @override - String get sectionKeyboardShortcut => 'KEYBOARD SHORTCUT'; - - @override - String get sectionCategories => 'CATEGORIES'; - - @override - String get sectionPerformance => 'PERFORMANCE'; - - @override - String get sectionPaste => 'PASTE'; - - @override - String get sectionBackupRestore => 'BACKUP & RESTORE'; - - @override - String get sectionAppearance => 'APPEARANCE'; - - @override - String get settingTheme => 'Theme'; - - @override - String get themeLight => 'Light'; - - @override - String get themeDark => 'Dark'; - - @override - String get themeAuto => 'Auto'; - - @override - String get sectionBehavior => 'BEHAVIOR'; - - @override - String get sectionAbout => 'COPYPASTE'; - - @override - String get sectionLinks => 'LINKS'; - - @override - String get settingItemsPerPage => 'Items per page'; - - @override - String get settingMemoryLimit => 'Memory limit'; - - @override - String get settingScrollThreshold => 'Scroll threshold (px)'; - - @override - String get settingPasteSpeed => 'Paste speed'; - - @override - String get settingPanelWidth => 'Panel width (px)'; - - @override - String get settingPanelHeight => 'Panel height (px)'; - - @override - String get settingLinesCollapsed => 'Lines collapsed'; - - @override - String get settingLinesExpanded => 'Lines expanded'; - - @override - String get settingHideOnDeactivate => 'Hide on deactivate'; - - @override - String get settingScrollToTopOnOpen => 'Scroll to top on open'; - - @override - String get settingClearSearchOnOpen => 'Clear search on open'; - - @override - String get settingRetentionDaysLabel => 'Retention days (0 = unlimited)'; - - @override - String get settingClearHistoryLabel => 'Clear clipboard history'; - - @override - String get settingHotkeyShortcutLabel => 'Shortcut to open/close CopyPaste'; - - @override - String get subtitleStartupDesc => 'Launches in background when you sign in'; - - @override - String get subtitleHideOnDeactivate => 'Close window when clicking outside'; - - @override - String get subtitleScrollToTopOnOpen => - 'Resets scroll and selects latest item'; - - @override - String get subtitleClearSearchOnOpen => 'Clears the search text each time'; - - @override - String get subtitlePasteSpeed => 'Adjust restoration and paste timings'; - - @override - String get subtitleCategories => 'Customize the names of color categories.'; - - @override - String get linkGitHub => 'Support & Source code — GitHub'; - - @override - String get linkCoffee => 'Buy me a coffee'; - - @override - String get editDialogTitle => 'Label & Color'; - - @override - String get editDialogHint => 'Add a label...'; - - @override - String get historyCleared => 'History cleared'; - - @override - String backupSavedFile(String filename) { - return 'Backup saved: $filename'; - } - - @override - String get buttonRestore => 'Restore'; - - @override - String get restoreCompleted => 'Restore completed'; - - @override - String get restoreRestartRequired => - 'Restore completed. The app will restart to apply changes.'; - - @override - String get shortcutExpand => 'Expand / collapse card'; - - @override - String get shortcutFocusSearch => 'Focus search box'; - - @override - String get trayShowHide => 'Show/Hide'; - - @override - String get fileNotFound => 'Not found'; - - @override - String get audioFile => 'Audio file'; - - @override - String get videoFile => 'Video file'; - - @override - String get imageFile => 'Image file'; - - @override - String get timeNow => 'now'; - - @override - String get clearAllFilters => 'Clear all filters'; - - @override - String get colorSectionLabel => 'COLOR'; - - @override - String get colorNone => 'None'; - - @override - String get subtitlePastePreset => - 'Automatic paste speed. Normal/Safe recommended for most computers.'; - - @override - String get pastePresetFast => 'Fast'; - - @override - String get pastePresetNormal => 'Normal'; - - @override - String get pastePresetSafe => 'Safe'; - - @override - String get pastePresetSlow => 'Slow'; - - @override - String get pastePresetCustom => 'Custom'; - - @override - String get pastePresetWarning => - '⚠️ Fast: may cause unexpected behavior in heavy apps.\n⚠️ Slow: may feel sluggish on modern computers.'; - - @override - String get settingResetFiltersOnOpen => 'Switch to All on open'; - - @override - String get subtitleResetFiltersOnOpen => - 'Clears category and type filters and returns to the All tab'; - - @override - String get subtitleBackup => - 'Create a backup of your clipboard history, images, and settings. Restore at any time on this or another device.'; - - @override - String get aboutDescription => - 'A modern clipboard manager built to feel native on Windows, macOS, and Linux.\nLocal-first — your history, always at hand. No accounts, no telemetry, no subscriptions.'; - - @override - String get sectionPrivacy => 'PRIVACY'; - - @override - String get privacyStatement => - 'Everything local. Nothing leaves your PC — no telemetry, no sync, no accounts.'; - - @override - String get privacyPolicy => 'Privacy Policy'; - - @override - String get aboutTagLocal => 'Local-only'; - - @override - String get aboutTagOpenSource => 'Open source'; - - @override - String get aboutTagFree => 'Free'; - - @override - String get sectionOtherTools => 'OTHER TOOLS'; - - @override - String get otherToolLinkUnbound => 'LinkUnbound'; - - @override - String get otherToolLinkUnboundDesc => - 'Open-source browser selector for Windows and Mac. Same philosophy: no ads, no telemetry, everything local.'; - - @override - String get aboutLicense => 'GPL v3 License — Free and open source.'; - - @override - String get permissionsTitle => 'Accessibility Permission Required'; - - @override - String get permissionsMessage => - 'CopyPaste needs Accessibility permission to paste content into other apps.\n\nGo to System Settings → Privacy & Security → Accessibility and enable CopyPaste.'; - - @override - String get permissionsOpenSettings => 'Open Settings'; - - @override - String get permissionsDismiss => 'Later'; - - @override - String get permissionsGranted => 'Permission granted'; - - @override - String get permissionsResetTitle => 'Accessibility Permission Lost'; - - @override - String get permissionsResetMessage => - 'macOS no longer recognises CopyPaste\'s permission because the app was re-authorised through Gatekeeper.\n\nTo fix this:\n1. Open Accessibility settings below\n2. Remove CopyPaste from the list (−)\n3. Re-add it or toggle it back on'; - - @override - String get permissionsRestartMessage => - 'Make sure CopyPaste is enabled in Privacy & Security > Accessibility.\n\nThe app will continue automatically when the permission is detected.'; - - @override - String get permissionsCheckAgain => 'Check Again'; - - @override - String get permissionsRestartApp => 'Restart App'; - - @override - String get permissionsWaiting => 'Waiting for permission…'; - - @override - String updateBadge(String version) { - return 'v$version is available, please update'; - } - - @override - String updateAvailableWindows(String version) { - return 'Version $version is available.\n\nDownload the latest installer from GitHub.'; - } - - @override - String updateAvailableMac(String version) { - return 'Version $version is available.\n\nUpdate via Homebrew:\nbrew upgrade copypaste\n\nOr download the latest release from GitHub.'; - } - - @override - String updateAvailableLinux(String version) { - return 'Version $version is available.\n\nDownload the latest release from GitHub.'; - } - - @override - String updateAvailableStore(String version) { - return 'Version $version is available.\n\nMicrosoft Store delivers updates automatically. New versions may take a few days to appear after release.'; - } - - @override - String updateTooltipStore(String version) { - return 'Update $version coming via Microsoft Store'; - } - - @override - String updateTooltipGeneric(String version) { - return 'Update $version available — click for details'; - } - - @override - String get updateDialogTitle => 'Update Available'; - - @override - String get updateViewRelease => 'View release'; - - @override - String get updateDismiss => 'Later'; - - @override - String updateBadgeImportant(String version) { - return 'v$version available — important update'; - } - - @override - String get updateActionDownload => 'Download installer'; - - @override - String get updateActionOpenStore => 'Open Microsoft Store'; - - @override - String get updateActionCopyBrew => 'Copy brew command'; - - @override - String get updateActionCopied => 'Copied to clipboard'; - - @override - String get blockedTitle => 'Update required'; - - @override - String blockedDescription(String current, String required) { - return 'Version $current of CopyPaste is no longer supported. Please install version $required or newer to continue using the app.'; - } - - @override - String get blockedReasonGeneric => - 'This version was retired by the maintainers for safety or compatibility reasons.'; - - @override - String get blockedQuit => 'Quit CopyPaste'; - - @override - String get blockedFallbackHint => - 'Visit https://github.com/rgdevment/CopyPaste/releases to download the latest installer.'; - - @override - String get waylandUnsupportedTitle => 'Wayland is not supported'; - - @override - String get waylandUnsupportedBadge => 'Open source · X11 only'; - - @override - String get waylandUnsupportedBody => - 'Linux support is still a work in progress. This project is maintained by a single person and we need more testers to move forward.\n\nCopyPaste works fully on X11 — to use it, log in with an X11 session. Sorry for the inconvenience.'; - - @override - String get waylandUnsupportedGitHub => 'View on GitHub'; - - @override - String get waylandUnsupportedClose => 'Close'; - - @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.'; - } - - @override - String linuxHotkeyGrabFailedWarning(String hotkey) { - return 'The shortcut $hotkey is being used by another application. Change it in Settings → Shortcuts.'; - } - - @override - String get linuxPasteFocusTimeoutWarning => - 'The clipboard has your content. Paste manually with Ctrl+V.'; - - @override - String get linuxAppindicatorBannerTitle => 'System tray icon unavailable'; - - @override - String get linuxAppindicatorBannerBody => - 'Your desktop does not expose an AppIndicator host, so the CopyPaste tray icon will not appear. Install a tray extension for your distribution and restart CopyPaste.'; - - @override - String get linuxClipboardManagerBannerTitle => - 'No clipboard manager detected'; - - @override - String get linuxClipboardManagerBannerBody => - 'Without a clipboard manager (gpaste, klipper, clipman, copyq, …) clipboard data may be lost when CopyPaste quits. Install one and restart your session.'; - - @override - String get linuxXtestBannerTitle => 'Automatic paste-back disabled'; - - @override - String get linuxXtestBannerBody => - 'The X11 XTest extension is not available, so CopyPaste cannot inject Ctrl+V automatically. Items are still copied to the clipboard — paste manually with Ctrl+V.'; - - @override - String get linuxBannerDismiss => 'Dismiss'; - - @override - String wakeupHint(String hotkey) { - return 'CopyPaste runs in the background — press $hotkey or click the tray icon to open it anytime.'; - } - - @override - String taskbarOpenHint(String hotkey) { - return 'Tip: press $hotkey to open and paste automatically — no focus lost.'; - } - - @override - String balloonStartupBody(String hotkey) { - return 'Running in the background. Press $hotkey or click the tray icon.'; - } - - @override - String get balloonWakeupTitle => 'CopyPaste is already open'; - - @override - String balloonWakeupBody(String hotkey) { - return 'Press $hotkey or click the tray icon to bring it up.'; - } - - @override - String get onboardingTitle => 'Welcome to CopyPaste'; - - @override - String get onboardingSubtitle => 'Everything you copy, saved.'; - - @override - String get onboardingPrivacyBadge => 'No cloud · No tracking · 100% local'; - - @override - String onboardingDescription(String hotkey) { - return 'Runs silently in the background. Press $hotkey anytime to open your clipboard history.'; - } - - @override - String get onboardingTrayHint => 'Look for the CP icon next to your clock.'; - - @override - String get onboardingSettingsButton => 'Settings'; - - @override - String get onboardingDismissButton => 'Get started'; - - @override - String get tabCapture => 'Performance'; - - @override - String get tabMultimedia => 'Multimedia'; - - @override - String get tabCleanupPrivacy => 'Cleanup & Privacy'; - - @override - String get sectionMultimedia => 'MULTIMEDIA & THUMBNAILS'; - - @override - String get subtitleMultimedia => - 'Control how images, videos and audio files are previewed.'; - - @override - String get settingGenerateImageThumbnails => 'Generate image thumbnails'; - - @override - String get subtitleGenerateImageThumbnails => - 'Show preview tiles for copied or referenced images.'; - - @override - String get settingGenerateVideoThumbnails => 'Generate video thumbnails'; - - @override - String get subtitleGenerateVideoThumbnails => - 'Use the OS shell cache to show a preview frame for video files.'; - - @override - String get settingGenerateAudioThumbnails => 'Generate audio thumbnails'; - - @override - String get subtitleGenerateAudioThumbnails => - 'Show cover art when available for audio files.'; - - @override - String get settingMaxImageSize => 'Max image size for processing (MB)'; - - @override - String get subtitleMaxImageSize => - 'Larger images keep their original bitmap fallback and are not re-encoded.'; - - @override - String get sectionCleanupPrivacy => 'CLEANUP & PRIVACY'; - - @override - String get settingKeepBrokenItemsLabel => 'Keep unavailable items (days)'; - - @override - String get subtitleKeepBrokenItems => - 'Items that point to a missing file or unmounted volume are pruned after this many days. 0 prunes immediately.'; - - @override - String get settingImagesQuotaLabel => 'Storage cap for images'; - - @override - String get subtitleImagesQuota => - 'When the images folder exceeds this size, oldest unpinned items are deleted to free space.'; - - @override - String get imagesQuotaOff => 'Unlimited'; -} +// coverage:ignore-file +// ignore: unused_import +import 'package:intl/intl.dart' as intl; +import 'app_localizations.dart'; + +// ignore_for_file: type=lint + +/// The translations for English (`en`). +class AppLocalizationsEn extends AppLocalizations { + AppLocalizationsEn([String locale = 'en']) : super(locale); + + @override + String get searchPlaceholder => 'Search clipboard…'; + + @override + String get emptyState => 'No items in this section'; + + @override + String get emptyStateSubtitle => 'Copy something to get started'; + + @override + String get hintBannerText => + 'CopyPaste is active and running in the background. Look for it in the system tray or just use your shortcut. Feel free to customize your experience in'; + + @override + String get hintBannerAction => 'Settings'; + + @override + String get settingsTitle => 'Settings'; + + @override + String get sectionShortcuts => 'KEYBOARD SHORTCUTS'; + + @override + String get sectionStorage => 'STORAGE'; + + @override + String get settingRunOnStartup => 'Run on startup'; + + @override + String get settingLanguage => 'Interface language'; + + @override + String get hotkeyWillApply => 'Hotkey will apply immediately'; + + @override + String get sectionSupport => 'SUPPORT'; + + @override + String get supportExportLogs => 'Export logs'; + + @override + String get supportExportLogsSubtitle => + 'Save a zip with app logs for a bug report. Your clipboard content is never included.'; + + @override + String get supportOpenLogsFolder => 'Open logs folder'; + + @override + String get supportOpenLogsFolderSubtitle => + 'Browse the raw log files in your file manager.'; + + @override + String get supportGitHub => 'Report a bug on GitHub'; + + @override + String get supportExportSuccess => 'Logs saved to Downloads.'; + + @override + String get supportShowInFiles => 'Show'; + + @override + String get supportExportEmpty => 'No log files found.'; + + @override + String get supportExportError => 'Failed to export logs.'; + + @override + String get sectionReset => 'RESET & CLEAN INSTALL'; + + @override + String get resetSoftLabel => 'Soft Reset'; + + @override + String get resetSoftSubtitle => + 'Resets all settings to defaults and marks app as fresh install. Clipboard history is preserved.'; + + @override + String get resetHardLabel => 'Hard Reset'; + + @override + String get resetHardSubtitle => + 'Deletes all clipboard history, images, and settings. This cannot be undone.'; + + @override + String get resetSoftConfirmTitle => 'Soft reset?'; + + @override + String get resetSoftConfirmMessage => + 'All settings will return to defaults and the app will restart as if freshly installed. Your clipboard history will not be deleted.'; + + @override + String get resetHardConfirmTitle => 'Hard reset?'; + + @override + String get resetHardConfirmMessage => + 'This will permanently delete all clipboard history, images, and settings, then restart the app. This cannot be undone.'; + + @override + String get resetConfirmButton => 'Reset & Restart'; + + @override + String get clearHistoryConfirmTitle => 'Clear history?'; + + @override + String get clearHistoryConfirmMessage => + 'This will permanently delete all non-pinned clipboard items. This action cannot be undone.'; + + @override + String get clearHistoryConfirmButton => 'Clear'; + + @override + String backupLastDate(String date) { + return 'Last backup: $date'; + } + + @override + String get backupNone => 'No backup created yet.'; + + @override + String get backupCreateLabel => 'Create backup'; + + @override + String get backupRestoreLabel => 'Restore backup'; + + @override + String get backupError => 'Failed to create backup. Check permissions.'; + + @override + String get restoreDialogTitle => 'Restore backup'; + + @override + String get restoreDialogWarning => + 'This will replace all current data with the backup contents. Continue?'; + + @override + String get restoreFileNotFound => 'File not found.'; + + @override + String restoreSuccess(int count) { + return 'Restored $count items.'; + } + + @override + String get restoreError => + 'Restore failed. Your previous data has been preserved.'; + + @override + String get buttonSave => 'Save'; + + @override + String get buttonClose => 'Close'; + + @override + String get buttonCancel => 'Cancel'; + + @override + String get buttonReset => 'Restore defaults'; + + @override + String get savingIndicator => 'Saving…'; + + @override + String get savedIndicator => 'Saved'; + + @override + String get menuPaste => 'Paste'; + + @override + String get menuPastePlain => 'Paste plain'; + + @override + String get menuPin => 'Pin'; + + @override + String get menuUnpin => 'Unpin'; + + @override + String get menuEdit => 'Edit card'; + + @override + String get menuDelete => 'Delete'; + + @override + String get editColorLabel => 'Color'; + + @override + String get colorRed => 'Red'; + + @override + String get colorGreen => 'Green'; + + @override + String get colorPurple => 'Purple'; + + @override + String get colorYellow => 'Yellow'; + + @override + String get colorBlue => 'Blue'; + + @override + String get colorOrange => 'Orange'; + + @override + String get typeText => 'Text'; + + @override + String get typeImage => 'Image'; + + @override + String get typeFile => 'File'; + + @override + String get typeFolder => 'Folder'; + + @override + String get typeLink => 'Link'; + + @override + String get typeAudio => 'Audio'; + + @override + String get typeVideo => 'Video'; + + @override + String get typeEmail => 'Email'; + + @override + String get typePhone => 'Phone'; + + @override + String get typeColor => 'Color'; + + @override + String get typeIp => 'IP'; + + @override + String get typeUuid => 'UUID'; + + @override + String get typeJson => 'JSON'; + + @override + String get filterAll => 'All'; + + @override + String get filterPinned => 'Pinned'; + + @override + String get trayTooltip => 'CopyPaste'; + + @override + String get trayExit => 'Exit'; + + @override + String get shortcutOpenClose => 'Open / close CopyPaste'; + + @override + String get shortcutEscape => 'Clear search or close window'; + + @override + String get shortcutTab1 => 'Switch to Recent tab'; + + @override + String get shortcutTab2 => 'Switch to Pinned tab'; + + @override + String get shortcutArrows => 'Navigate between items'; + + @override + String get shortcutEnter => 'Paste selected item'; + + @override + String get shortcutDelete => 'Delete selected item'; + + @override + String get shortcutPin => 'Pin / Unpin selected item'; + + @override + String get shortcutEdit => 'Edit card (label and color)'; + + @override + String get tabGeneral => 'General'; + + @override + String get tabBackupRestore => 'Backup & Support'; + + @override + String get tabAppearance => 'Appearance'; + + @override + String get tabShortcuts => 'Shortcuts'; + + @override + String get tabAbout => 'About'; + + @override + String get sectionLanguage => 'LANGUAGE'; + + @override + String get sectionStartup => 'STARTUP'; + + @override + String get sectionKeyboardShortcut => 'KEYBOARD SHORTCUT'; + + @override + String get sectionCategories => 'CATEGORIES'; + + @override + String get sectionPerformance => 'PERFORMANCE'; + + @override + String get sectionPaste => 'PASTE'; + + @override + String get sectionBackupRestore => 'BACKUP & RESTORE'; + + @override + String get sectionAppearance => 'APPEARANCE'; + + @override + String get settingTheme => 'Theme'; + + @override + String get themeLight => 'Light'; + + @override + String get themeDark => 'Dark'; + + @override + String get themeAuto => 'Auto'; + + @override + String get sectionBehavior => 'BEHAVIOR'; + + @override + String get sectionAbout => 'COPYPASTE'; + + @override + String get sectionLinks => 'LINKS'; + + @override + String get settingItemsPerPage => 'Items per page'; + + @override + String get settingMemoryLimit => 'Memory limit'; + + @override + String get settingScrollThreshold => 'Scroll threshold (px)'; + + @override + String get settingPasteSpeed => 'Paste speed'; + + @override + String get settingPanelWidth => 'Panel width (px)'; + + @override + String get settingPanelHeight => 'Panel height (px)'; + + @override + String get settingLinesCollapsed => 'Lines collapsed'; + + @override + String get settingLinesExpanded => 'Lines expanded'; + + @override + String get settingHideOnDeactivate => 'Hide on deactivate'; + + @override + String get settingScrollToTopOnOpen => 'Scroll to top on open'; + + @override + String get settingClearSearchOnOpen => 'Clear search on open'; + + @override + String get settingRetentionDaysLabel => 'Retention days (0 = unlimited)'; + + @override + String get settingClearHistoryLabel => 'Clear clipboard history'; + + @override + String get settingHotkeyShortcutLabel => 'Shortcut to open/close CopyPaste'; + + @override + String get subtitleStartupDesc => 'Launches in background when you sign in'; + + @override + String get subtitleHideOnDeactivate => 'Close window when clicking outside'; + + @override + String get subtitleScrollToTopOnOpen => + 'Resets scroll and selects latest item'; + + @override + String get subtitleClearSearchOnOpen => 'Clears the search text each time'; + + @override + String get subtitlePasteSpeed => 'Adjust restoration and paste timings'; + + @override + String get subtitleCategories => 'Customize the names of color categories.'; + + @override + String get linkGitHub => 'Support & Source code — GitHub'; + + @override + String get linkCoffee => 'Buy me a coffee'; + + @override + String get editDialogTitle => 'Label & Color'; + + @override + String get editDialogHint => 'Add a label...'; + + @override + String get historyCleared => 'History cleared'; + + @override + String backupSavedFile(String filename) { + return 'Backup saved: $filename'; + } + + @override + String get buttonRestore => 'Restore'; + + @override + String get restoreCompleted => 'Restore completed'; + + @override + String get restoreRestartRequired => + 'Restore completed. The app will restart to apply changes.'; + + @override + String get shortcutExpand => 'Expand / collapse card'; + + @override + String get shortcutFocusSearch => 'Focus search box'; + + @override + String get trayShowHide => 'Show/Hide'; + + @override + String get fileNotFound => 'Not found'; + + @override + String get audioFile => 'Audio file'; + + @override + String get videoFile => 'Video file'; + + @override + String get imageFile => 'Image file'; + + @override + String get timeNow => 'now'; + + @override + String get clearAllFilters => 'Clear all filters'; + + @override + String get colorSectionLabel => 'COLOR'; + + @override + String get colorNone => 'None'; + + @override + String get subtitlePastePreset => + 'Automatic paste speed. Normal/Safe recommended for most computers.'; + + @override + String get pastePresetFast => 'Fast'; + + @override + String get pastePresetNormal => 'Normal'; + + @override + String get pastePresetSafe => 'Safe'; + + @override + String get pastePresetSlow => 'Slow'; + + @override + String get pastePresetCustom => 'Custom'; + + @override + String get pastePresetWarning => + '⚠️ Fast: may cause unexpected behavior in heavy apps.\n⚠️ Slow: may feel sluggish on modern computers.'; + + @override + String get settingResetFiltersOnOpen => 'Switch to All on open'; + + @override + String get subtitleResetFiltersOnOpen => + 'Clears category and type filters and returns to the All tab'; + + @override + String get subtitleBackup => + 'Create a backup of your clipboard history, images, and settings. Restore at any time on this or another device.'; + + @override + String get aboutDescription => + 'A modern clipboard manager built to feel native on Windows, macOS, and Linux.\nLocal-first — your history, always at hand. No accounts, no telemetry, no subscriptions.'; + + @override + String get sectionPrivacy => 'PRIVACY'; + + @override + String get privacyStatement => + 'Everything local. Nothing leaves your PC — no telemetry, no sync, no accounts.'; + + @override + String get privacyPolicy => 'Privacy Policy'; + + @override + String get aboutTagLocal => 'Local-only'; + + @override + String get aboutTagOpenSource => 'Open source'; + + @override + String get aboutTagFree => 'Free'; + + @override + String get sectionOtherTools => 'OTHER TOOLS'; + + @override + String get otherToolLinkUnbound => 'LinkUnbound'; + + @override + String get otherToolLinkUnboundDesc => + 'Open-source browser selector for Windows and Mac. Same philosophy: no ads, no telemetry, everything local.'; + + @override + String get aboutLicense => 'GPL v3 License — Free and open source.'; + + @override + String get permissionsTitle => 'Accessibility Permission Required'; + + @override + String get permissionsMessage => + 'CopyPaste needs Accessibility permission to paste content into other apps.\n\nGo to System Settings → Privacy & Security → Accessibility and enable CopyPaste.'; + + @override + String get permissionsOpenSettings => 'Open Settings'; + + @override + String get permissionsDismiss => 'Later'; + + @override + String get permissionsGranted => 'Permission granted'; + + @override + String get permissionsResetTitle => 'Accessibility Permission Lost'; + + @override + String get permissionsResetMessage => + 'macOS no longer recognises CopyPaste\'s permission because the app was re-authorised through Gatekeeper.\n\nTo fix this:\n1. Open Accessibility settings below\n2. Remove CopyPaste from the list (−)\n3. Re-add it or toggle it back on'; + + @override + String get permissionsRestartMessage => + 'Make sure CopyPaste is enabled in Privacy & Security > Accessibility.\n\nThe app will continue automatically when the permission is detected.'; + + @override + String get permissionsCheckAgain => 'Check Again'; + + @override + String get permissionsRestartApp => 'Restart App'; + + @override + String get permissionsWaiting => 'Waiting for permission…'; + + @override + String updateBadge(String version) { + return 'v$version is available, please update'; + } + + @override + String updateAvailableWindows(String version) { + return 'Version $version is available.\n\nDownload the latest installer from GitHub.'; + } + + @override + String updateAvailableMac(String version) { + return 'Version $version is available.\n\nUpdate via Homebrew:\nbrew upgrade copypaste\n\nOr download the latest release from GitHub.'; + } + + @override + String updateAvailableLinux(String version) { + return 'Version $version is available.\n\nDownload the latest release from GitHub.'; + } + + @override + String updateAvailableStore(String version) { + return 'Version $version is available.\n\nMicrosoft Store delivers updates automatically. New versions may take a few days to appear after release.'; + } + + @override + String updateTooltipStore(String version) { + return 'Update $version coming via Microsoft Store'; + } + + @override + String updateTooltipGeneric(String version) { + return 'Update $version available — click for details'; + } + + @override + String get updateDialogTitle => 'Update Available'; + + @override + String get updateViewRelease => 'View release'; + + @override + String get updateDismiss => 'Later'; + + @override + String updateBadgeImportant(String version) { + return 'v$version available — important update'; + } + + @override + String get updateActionDownload => 'Download installer'; + + @override + String get updateActionOpenStore => 'Open Microsoft Store'; + + @override + String get updateActionCopyBrew => 'Copy brew command'; + + @override + String get updateActionCopied => 'Copied to clipboard'; + + @override + String get blockedTitle => 'Update required'; + + @override + String blockedDescription(String current, String required) { + return 'Version $current of CopyPaste is no longer supported. Please install version $required or newer to continue using the app.'; + } + + @override + String get blockedReasonGeneric => + 'This version was retired by the maintainers for safety or compatibility reasons.'; + + @override + String get blockedQuit => 'Quit CopyPaste'; + + @override + String get blockedFallbackHint => + 'Visit https://github.com/rgdevment/CopyPaste/releases to download the latest installer.'; + + @override + String get waylandUnsupportedTitle => 'Wayland is not supported'; + + @override + String get waylandUnsupportedBadge => 'Open source · X11 only'; + + @override + String get waylandUnsupportedBody => + 'Linux support is still a work in progress. This project is maintained by a single person and we need more testers to move forward.\n\nCopyPaste works fully on X11 — to use it, log in with an X11 session. Sorry for the inconvenience.'; + + @override + String get waylandUnsupportedGitHub => 'View on GitHub'; + + @override + String get waylandUnsupportedClose => 'Close'; + + @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.'; + } + + @override + String linuxHotkeyGrabFailedWarning(String hotkey) { + return 'The shortcut $hotkey is being used by another application. Change it in Settings → Shortcuts.'; + } + + @override + String get linuxPasteFocusTimeoutWarning => + 'The clipboard has your content. Paste manually with Ctrl+V.'; + + @override + String get linuxAppindicatorBannerTitle => 'System tray icon unavailable'; + + @override + String get linuxAppindicatorBannerBody => + 'Your desktop does not expose an AppIndicator host, so the CopyPaste tray icon will not appear. Install a tray extension for your distribution and restart CopyPaste.'; + + @override + String get linuxClipboardManagerBannerTitle => + 'No clipboard manager detected'; + + @override + String get linuxClipboardManagerBannerBody => + 'Without a clipboard manager (gpaste, klipper, clipman, copyq, …) clipboard data may be lost when CopyPaste quits. Install one and restart your session.'; + + @override + String get linuxXtestBannerTitle => 'Automatic paste-back disabled'; + + @override + String get linuxXtestBannerBody => + 'The X11 XTest extension is not available, so CopyPaste cannot inject Ctrl+V automatically. Items are still copied to the clipboard — paste manually with Ctrl+V.'; + + @override + String get linuxBannerDismiss => 'Dismiss'; + + @override + String wakeupHint(String hotkey) { + return 'CopyPaste runs in the background — press $hotkey or click the tray icon to open it anytime.'; + } + + @override + String taskbarOpenHint(String hotkey) { + return 'Tip: press $hotkey to open and paste automatically — no focus lost.'; + } + + @override + String balloonStartupBody(String hotkey) { + return 'Running in the background. Press $hotkey or click the tray icon.'; + } + + @override + String get balloonWakeupTitle => 'CopyPaste is already open'; + + @override + String balloonWakeupBody(String hotkey) { + return 'Press $hotkey or click the tray icon to bring it up.'; + } + + @override + String get onboardingTitle => 'Welcome to CopyPaste'; + + @override + String get onboardingSubtitle => 'Everything you copy, saved.'; + + @override + String get onboardingPrivacyBadge => 'No cloud · No tracking · 100% local'; + + @override + String onboardingDescription(String hotkey) { + return 'Runs silently in the background. Press $hotkey anytime to open your clipboard history.'; + } + + @override + String get onboardingTrayHint => 'Look for the CP icon next to your clock.'; + + @override + String get onboardingSettingsButton => 'Settings'; + + @override + String get onboardingDismissButton => 'Get started'; + + @override + String get tabCapture => 'Performance'; + + @override + String get tabMultimedia => 'Multimedia'; + + @override + String get tabCleanupPrivacy => 'Cleanup & Privacy'; + + @override + String get sectionMultimedia => 'MULTIMEDIA & THUMBNAILS'; + + @override + String get subtitleMultimedia => + 'Control how images, videos and audio files are previewed.'; + + @override + String get settingGenerateImageThumbnails => 'Generate image thumbnails'; + + @override + String get subtitleGenerateImageThumbnails => + 'Show preview tiles for copied or referenced images.'; + + @override + String get settingGenerateVideoThumbnails => 'Generate video thumbnails'; + + @override + String get subtitleGenerateVideoThumbnails => + 'Use the OS shell cache to show a preview frame for video files.'; + + @override + String get settingGenerateAudioThumbnails => 'Generate audio thumbnails'; + + @override + String get subtitleGenerateAudioThumbnails => + 'Show cover art when available for audio files.'; + + @override + String get settingMaxImageSize => 'Max image size for processing (MB)'; + + @override + String get subtitleMaxImageSize => + 'Larger images keep their original bitmap fallback and are not re-encoded.'; + + @override + String get sectionCleanupPrivacy => 'CLEANUP & PRIVACY'; + + @override + String get settingKeepBrokenItemsLabel => 'Keep unavailable items (days)'; + + @override + String get subtitleKeepBrokenItems => + 'Items that point to a missing file or unmounted volume are pruned after this many days. 0 prunes immediately.'; + + @override + String get settingImagesQuotaLabel => 'Storage cap for images'; + + @override + String get subtitleImagesQuota => + 'When the images folder exceeds this size, oldest unpinned items are deleted to free space.'; + + @override + String get imagesQuotaOff => 'Unlimited'; +} diff --git a/app/lib/l10n/app_localizations_es.dart b/app/lib/l10n/app_localizations_es.dart index 629ac2ef..366b9122 100644 --- a/app/lib/l10n/app_localizations_es.dart +++ b/app/lib/l10n/app_localizations_es.dart @@ -1,838 +1,839 @@ -// ignore: unused_import -import 'package:intl/intl.dart' as intl; -import 'app_localizations.dart'; - -// ignore_for_file: type=lint - -/// The translations for Spanish Castilian (`es`). -class AppLocalizationsEs extends AppLocalizations { - AppLocalizationsEs([String locale = 'es']) : super(locale); - - @override - String get searchPlaceholder => 'Buscar en portapapeles…'; - - @override - String get emptyState => 'No hay elementos en esta sección'; - - @override - String get emptyStateSubtitle => 'Copia algo para comenzar'; - - @override - String get hintBannerText => - 'CopyPaste se ejecuta en segundo plano — encuéntralo en la bandeja del sistema o usa tu atajo de teclado. Personaliza tu experiencia en'; - - @override - String get hintBannerAction => 'Ajustes'; - - @override - String get settingsTitle => 'Configuración'; - - @override - String get sectionShortcuts => 'ATAJOS DE TECLADO'; - - @override - String get sectionStorage => 'ALMACENAMIENTO'; - - @override - String get settingRunOnStartup => 'Iniciar con el sistema'; - - @override - String get settingLanguage => 'Idioma de la interfaz'; - - @override - String get hotkeyWillApply => 'El atajo se aplicará de inmediato'; - - @override - String get sectionSupport => 'SOPORTE'; - - @override - String get supportExportLogs => 'Exportar registros'; - - @override - String get supportExportLogsSubtitle => - 'Guarda un zip con registros de la app para adjuntar a un reporte. El contenido del portapapeles nunca se incluye.'; - - @override - String get supportOpenLogsFolder => 'Abrir carpeta de registros'; - - @override - String get supportOpenLogsFolderSubtitle => - 'Explora los archivos de registro en tu gestor de archivos.'; - - @override - String get supportGitHub => 'Reportar un error en GitHub'; - - @override - String get supportExportSuccess => 'Registros guardados en Descargas.'; - - @override - String get supportShowInFiles => 'Mostrar'; - - @override - String get supportExportEmpty => 'No se encontraron archivos de registro.'; - - @override - String get supportExportError => 'Error al exportar los registros.'; - - @override - String get sectionReset => 'RESTABLECER E INSTALACIÓN LIMPIA'; - - @override - String get resetSoftLabel => 'Restablecimiento suave'; - - @override - String get resetSoftSubtitle => - 'Restablece la configuración a los valores predeterminados y marca la app como nueva instalación. El historial del portapapeles se conserva.'; - - @override - String get resetHardLabel => 'Restablecimiento completo'; - - @override - String get resetHardSubtitle => - 'Elimina todo el historial, imágenes y configuración. Esta acción no se puede deshacer.'; - - @override - String get resetSoftConfirmTitle => '¿Restablecimiento suave?'; - - @override - String get resetSoftConfirmMessage => - 'Toda la configuración volverá a los valores predeterminados y la app se reiniciará como si fuera una instalación nueva. El historial del portapapeles no se eliminará.'; - - @override - String get resetHardConfirmTitle => '¿Restablecimiento completo?'; - - @override - String get resetHardConfirmMessage => - 'Se eliminará permanentemente todo el historial, imágenes y configuración, y luego la app se reiniciará. Esta acción no se puede deshacer.'; - - @override - String get resetConfirmButton => 'Restablecer y Reiniciar'; - - @override - String get clearHistoryConfirmTitle => '¿Limpiar historial?'; - - @override - String get clearHistoryConfirmMessage => - 'Esto eliminará permanentemente todos los elementos no anclados. Esta acción no se puede deshacer.'; - - @override - String get clearHistoryConfirmButton => 'Limpiar'; - - @override - String backupLastDate(String date) { - return 'Último respaldo: $date'; - } - - @override - String get backupNone => 'Aún no se ha creado un respaldo.'; - - @override - String get backupCreateLabel => 'Crear respaldo'; - - @override - String get backupRestoreLabel => 'Restaurar respaldo'; - - @override - String get backupError => - 'Error al crear el respaldo. Verifica los permisos.'; - - @override - String get restoreDialogTitle => 'Restaurar respaldo'; - - @override - String get restoreDialogWarning => - 'Esto reemplazará todos los datos actuales con el contenido del respaldo. ¿Continuar?'; - - @override - String get restoreFileNotFound => 'Archivo no encontrado.'; - - @override - String restoreSuccess(int count) { - return 'Se restauraron $count elementos.'; - } - - @override - String get restoreError => - 'Error al restaurar. Tus datos anteriores se han preservado.'; - - @override - String get buttonSave => 'Guardar'; - - @override - String get buttonClose => 'Cerrar'; - - @override - String get buttonCancel => 'Cancelar'; - - @override - String get buttonReset => 'Restaurar predeterminados'; - - @override - String get savingIndicator => 'Guardando…'; - - @override - String get savedIndicator => 'Guardado'; - - @override - String get menuPaste => 'Pegar'; - - @override - String get menuPastePlain => 'Pegar sin formato'; - - @override - String get menuPin => 'Anclar'; - - @override - String get menuUnpin => 'Desanclar'; - - @override - String get menuEdit => 'Editar tarjeta'; - - @override - String get menuDelete => 'Eliminar'; - - @override - String get editColorLabel => 'Color'; - - @override - String get colorRed => 'Rojo'; - - @override - String get colorGreen => 'Verde'; - - @override - String get colorPurple => 'Morado'; - - @override - String get colorYellow => 'Amarillo'; - - @override - String get colorBlue => 'Azul'; - - @override - String get colorOrange => 'Naranja'; - - @override - String get typeText => 'Texto'; - - @override - String get typeImage => 'Imagen'; - - @override - String get typeFile => 'Archivo'; - - @override - String get typeFolder => 'Carpeta'; - - @override - String get typeLink => 'Enlace'; - - @override - String get typeAudio => 'Audio'; - - @override - String get typeVideo => 'Video'; - - @override - String get typeEmail => 'Email'; - - @override - String get typePhone => 'Teléfono'; - - @override - String get typeColor => 'Color'; - - @override - String get typeIp => 'IP'; - - @override - String get typeUuid => 'UUID'; - - @override - String get typeJson => 'JSON'; - - @override - String get filterAll => 'Todo'; - - @override - String get filterPinned => 'Anclados'; - - @override - String get trayTooltip => 'CopyPaste'; - - @override - String get trayExit => 'Salir'; - - @override - String get shortcutOpenClose => 'Abrir / cerrar CopyPaste'; - - @override - String get shortcutEscape => 'Limpiar búsqueda o cerrar ventana'; - - @override - String get shortcutTab1 => 'Cambiar a pestaña Recientes'; - - @override - String get shortcutTab2 => 'Cambiar a pestaña Anclados'; - - @override - String get shortcutArrows => 'Navegar entre elementos'; - - @override - String get shortcutEnter => 'Pegar elemento seleccionado'; - - @override - String get shortcutDelete => 'Eliminar elemento seleccionado'; - - @override - String get shortcutPin => 'Anclar / Desanclar elemento'; - - @override - String get shortcutEdit => 'Editar tarjeta (etiqueta y color)'; - - @override - String get tabGeneral => 'General'; - - @override - String get tabBackupRestore => 'Backup y soporte'; - - @override - String get tabAppearance => 'Apariencia'; - - @override - String get tabShortcuts => 'Atajos'; - - @override - String get tabAbout => 'Acerca de'; - - @override - String get sectionLanguage => 'IDIOMA'; - - @override - String get sectionStartup => 'INICIO'; - - @override - String get sectionKeyboardShortcut => 'ATAJO DE TECLADO'; - - @override - String get sectionCategories => 'CATEGORÍAS'; - - @override - String get sectionPerformance => 'RENDIMIENTO'; - - @override - String get sectionPaste => 'PEGADO'; - - @override - String get sectionBackupRestore => 'RESPALDO Y RESTAURACIÓN'; - - @override - String get sectionAppearance => 'APARIENCIA'; - - @override - String get settingTheme => 'Tema'; - - @override - String get themeLight => 'Claro'; - - @override - String get themeDark => 'Oscuro'; - - @override - String get themeAuto => 'Auto'; - - @override - String get sectionBehavior => 'COMPORTAMIENTO'; - - @override - String get sectionAbout => 'COPYPASTE'; - - @override - String get sectionLinks => 'ENLACES'; - - @override - String get settingItemsPerPage => 'Elementos por página'; - - @override - String get settingMemoryLimit => 'Límite de memoria'; - - @override - String get settingScrollThreshold => 'Umbral de desplazamiento (px)'; - - @override - String get settingPasteSpeed => 'Velocidad de pegado'; - - @override - String get settingPanelWidth => 'Ancho del panel (px)'; - - @override - String get settingPanelHeight => 'Alto del panel (px)'; - - @override - String get settingLinesCollapsed => 'Líneas contraídas'; - - @override - String get settingLinesExpanded => 'Líneas expandidas'; - - @override - String get settingHideOnDeactivate => 'Ocultar al hacer clic fuera'; - - @override - String get settingScrollToTopOnOpen => 'Ir al inicio al abrir'; - - @override - String get settingClearSearchOnOpen => 'Limpiar búsqueda al abrir'; - - @override - String get settingRetentionDaysLabel => 'Días de retención (0 = sin límite)'; - - @override - String get settingClearHistoryLabel => 'Limpiar historial del portapapeles'; - - @override - String get settingHotkeyShortcutLabel => 'Atajo para abrir/cerrar CopyPaste'; - - @override - String get subtitleStartupDesc => - 'Se inicia en segundo plano al iniciar sesión'; - - @override - String get subtitleHideOnDeactivate => - 'Cerrar la ventana al hacer clic fuera'; - - @override - String get subtitleScrollToTopOnOpen => - 'Restablece el desplazamiento y selecciona el último elemento'; - - @override - String get subtitleClearSearchOnOpen => 'Borra el texto de búsqueda cada vez'; - - @override - String get subtitlePasteSpeed => 'Ajustar tiempos de restauración y pegado'; - - @override - String get subtitleCategories => - 'Personaliza los nombres de las categorías de color.'; - - @override - String get linkGitHub => 'Soporte y Código fuente — GitHub'; - - @override - String get linkCoffee => 'Invítame un café'; - - @override - String get editDialogTitle => 'Etiqueta y Color'; - - @override - String get editDialogHint => 'Agregar una etiqueta...'; - - @override - String get historyCleared => 'Historial limpiado'; - - @override - String backupSavedFile(String filename) { - return 'Respaldo guardado: $filename'; - } - - @override - String get buttonRestore => 'Restaurar'; - - @override - String get restoreCompleted => 'Restauración completada'; - - @override - String get restoreRestartRequired => - 'Restauración completada. La app se reiniciará para aplicar los cambios.'; - - @override - String get shortcutExpand => 'Expandir / contraer tarjeta'; - - @override - String get shortcutFocusSearch => 'Enfocar el buscador'; - - @override - String get trayShowHide => 'Mostrar/Ocultar'; - - @override - String get fileNotFound => 'No encontrado'; - - @override - String get audioFile => 'Archivo de audio'; - - @override - String get videoFile => 'Archivo de video'; - - @override - String get imageFile => 'Archivo de imagen'; - - @override - String get timeNow => 'ahora'; - - @override - String get clearAllFilters => 'Limpiar todos los filtros'; - - @override - String get colorSectionLabel => 'COLOR'; - - @override - String get colorNone => 'Ninguno'; - - @override - String get subtitlePastePreset => - 'Velocidad de pegado automático. Normal/Seguro recomendado para la mayoría.'; - - @override - String get pastePresetFast => 'Rápido'; - - @override - String get pastePresetNormal => 'Normal'; - - @override - String get pastePresetSafe => 'Seguro'; - - @override - String get pastePresetSlow => 'Lento'; - - @override - String get pastePresetCustom => 'Personalizado'; - - @override - String get pastePresetWarning => - '⚠️ Rápido: puede causar comportamientos extraños en apps pesadas.\n⚠️ Lento: puede sentirse pesado en equipos modernos.'; - - @override - String get settingResetFiltersOnOpen => 'Volver a Todos al abrir'; - - @override - String get subtitleResetFiltersOnOpen => - 'Limpia los filtros de categoría y tipo, y vuelve a la pestaña Todos'; - - @override - String get subtitleBackup => - 'Crea un respaldo de tu historial, imágenes y configuración. Restaura en cualquier momento en este u otro dispositivo.'; - - @override - String get aboutDescription => - 'Un gestor de portapapeles moderno, nativo en Windows, macOS y Linux.\nTodo local — tu historial, siempre a mano. Sin cuentas, sin telemetría, sin suscripciones.'; - - @override - String get sectionPrivacy => 'PRIVACIDAD'; - - @override - String get privacyStatement => - 'Todo local. Nada sale de tu PC — sin telemetría, sin sincronización, sin cuentas.'; - - @override - String get privacyPolicy => 'Política de privacidad'; - - @override - String get aboutTagLocal => 'Todo local'; - - @override - String get aboutTagOpenSource => 'Código abierto'; - - @override - String get aboutTagFree => 'Gratis'; - - @override - String get sectionOtherTools => 'OTRAS HERRAMIENTAS'; - - @override - String get otherToolLinkUnbound => 'LinkUnbound'; - - @override - String get otherToolLinkUnboundDesc => - 'Selector de navegadores de código abierto para Windows y Mac. Misma filosofía: sin anuncios, sin telemetría, todo local.'; - - @override - String get aboutLicense => 'Licencia GPL v3 — Libre y de código abierto.'; - - @override - String get permissionsTitle => 'Permiso de Accesibilidad requerido'; - - @override - String get permissionsMessage => - 'CopyPaste necesita permiso de Accesibilidad para pegar contenido en otras apps.\n\nVe a Configuración del Sistema → Privacidad y Seguridad → Accesibilidad y activa CopyPaste.'; - - @override - String get permissionsOpenSettings => 'Abrir Configuración'; - - @override - String get permissionsDismiss => 'Después'; - - @override - String get permissionsGranted => 'Permiso concedido'; - - @override - String get permissionsResetTitle => 'Permiso de Accesibilidad perdido'; - - @override - String get permissionsResetMessage => - 'macOS ya no reconoce el permiso de CopyPaste porque la app fue re-autorizada a través de Gatekeeper.\n\nPara solucionarlo:\n1. Abre la configuración de Accesibilidad\n2. Elimina CopyPaste de la lista (−)\n3. Vuelve a añadirlo o actívalo de nuevo'; - - @override - String get permissionsRestartMessage => - 'Asegúrate de que CopyPaste esté activado en Privacidad y seguridad > Accesibilidad.\n\nLa app continuará automáticamente cuando detecte el permiso.'; - - @override - String get permissionsCheckAgain => 'Verificar'; - - @override - String get permissionsRestartApp => 'Reiniciar app'; - - @override - String get permissionsWaiting => 'Esperando permiso…'; - - @override - String updateBadge(String version) { - return 'v$version disponible, por favor actualiza'; - } - - @override - String updateAvailableWindows(String version) { - return 'La versión $version está disponible.\n\nDescarga el instalador más reciente desde GitHub.'; - } - - @override - String updateAvailableMac(String version) { - return 'La versión $version está disponible.\n\nActualiza con Homebrew:\nbrew upgrade copypaste\n\nO descarga la última versión desde GitHub.'; - } - - @override - String updateAvailableLinux(String version) { - return 'La versión $version está disponible.\n\nDescarga la última versión desde GitHub.'; - } - - @override - String updateAvailableStore(String version) { - return 'La versión $version está disponible.\n\nLa Microsoft Store entrega las actualizaciones automáticamente. Las nuevas versiones pueden tardar unos días en aparecer tras su publicación.'; - } - - @override - String updateTooltipStore(String version) { - return 'Actualización $version en camino por Microsoft Store'; - } - - @override - String updateTooltipGeneric(String version) { - return 'Actualización $version disponible — haz clic para detalles'; - } - - @override - String get updateDialogTitle => 'Actualización disponible'; - - @override - String get updateViewRelease => 'Ver versión'; - - @override - String get updateDismiss => 'Después'; - - @override - String updateBadgeImportant(String version) { - return 'v$version disponible — actualización importante'; - } - - @override - String get updateActionDownload => 'Descargar instalador'; - - @override - String get updateActionOpenStore => 'Abrir Microsoft Store'; - - @override - String get updateActionCopyBrew => 'Copiar comando brew'; - - @override - String get updateActionCopied => 'Copiado al portapapeles'; - - @override - String get blockedTitle => 'Actualización requerida'; - - @override - String blockedDescription(String current, String required) { - return 'La versión $current de CopyPaste ya no está soportada. Instala la versión $required o más reciente para continuar.'; - } - - @override - String get blockedReasonGeneric => - 'Esta versión fue retirada por motivos de seguridad o compatibilidad.'; - - @override - String get blockedQuit => 'Salir de CopyPaste'; - - @override - String get blockedFallbackHint => - 'Visita https://github.com/rgdevment/CopyPaste/releases para descargar el instalador más reciente.'; - - @override - String get waylandUnsupportedTitle => 'Wayland no está soportado'; - - @override - String get waylandUnsupportedBadge => 'Open source · Solo X11'; - - @override - String get waylandUnsupportedBody => - 'El soporte en Linux está en progreso. Este proyecto lo mantiene una sola persona y necesitamos más testers para avanzar.\n\nCopyPaste funciona completamente en X11 — para usarlo, inicia sesión con X11. Lamentamos las molestias.'; - - @override - String get waylandUnsupportedGitHub => 'Ver en GitHub'; - - @override - String get waylandUnsupportedClose => 'Cerrar'; - - @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.'; - } - - @override - String linuxHotkeyGrabFailedWarning(String hotkey) { - return 'El atajo $hotkey está siendo usado por otra aplicación. Cámbialo en Configuración → Atajos.'; - } - - @override - String get linuxPasteFocusTimeoutWarning => - 'El portapapeles tiene tu contenido. Pégalo manualmente con Ctrl+V.'; - - @override - String get linuxAppindicatorBannerTitle => 'Ícono de bandeja no disponible'; - - @override - String get linuxAppindicatorBannerBody => - 'Tu escritorio no expone un host de AppIndicator, por lo que el ícono de CopyPaste no aparecerá en la bandeja. Instala una extensión de bandeja para tu distribución y reinicia CopyPaste.'; - - @override - String get linuxClipboardManagerBannerTitle => - 'No se detectó un gestor de portapapeles'; - - @override - String get linuxClipboardManagerBannerBody => - 'Sin un gestor de portapapeles (gpaste, klipper, clipman, copyq, …) los datos copiados pueden perderse cuando CopyPaste se cierra. Instala uno y reinicia tu sesión.'; - - @override - String get linuxXtestBannerTitle => 'Pegado automático deshabilitado'; - - @override - String get linuxXtestBannerBody => - 'La extensión XTest de X11 no está disponible, por lo que CopyPaste no puede inyectar Ctrl+V automáticamente. Los elementos siguen copiándose al portapapeles — pégalos manualmente con Ctrl+V.'; - - @override - String get linuxBannerDismiss => 'Descartar'; - - @override - String wakeupHint(String hotkey) { - return 'CopyPaste se ejecuta en segundo plano — presiona $hotkey o haz clic en el ícono de la bandeja para abrirlo cuando quieras.'; - } - - @override - String taskbarOpenHint(String hotkey) { - return 'Tip: presiona $hotkey para abrir y pegar automáticamente, sin perder el foco.'; - } - - @override - String balloonStartupBody(String hotkey) { - return 'Ejecutándose en segundo plano. Presiona $hotkey o haz clic en el ícono de la bandeja.'; - } - - @override - String get balloonWakeupTitle => 'CopyPaste ya está abierto'; - - @override - String balloonWakeupBody(String hotkey) { - return 'Presiona $hotkey o haz clic en el ícono de la bandeja para abrirlo.'; - } - - @override - String get onboardingTitle => 'Bienvenido a CopyPaste'; - - @override - String get onboardingSubtitle => 'Todo lo que copias, guardado.'; - - @override - String get onboardingPrivacyBadge => 'Sin nube · Sin rastreo · 100% local'; - - @override - String onboardingDescription(String hotkey) { - return 'Corre en segundo plano sin que lo notes. Presiona $hotkey cuando quieras para abrir tu historial.'; - } - - @override - String get onboardingTrayHint => - 'Encuéntralo junto al reloj, abajo a la derecha.'; - - @override - String get onboardingSettingsButton => 'Configuración'; - - @override - String get onboardingDismissButton => 'Empezar'; - - @override - String get tabCapture => 'Rendimiento'; - - @override - String get tabMultimedia => 'Multimedia'; - - @override - String get tabCleanupPrivacy => 'Limpieza y privacidad'; - - @override - String get sectionMultimedia => 'MULTIMEDIA Y MINIATURAS'; - - @override - String get subtitleMultimedia => - 'Controla cómo se previsualizan imágenes, vídeos y archivos de audio.'; - - @override - String get settingGenerateImageThumbnails => 'Generar miniaturas de imágenes'; - - @override - String get subtitleGenerateImageThumbnails => - 'Muestra una vista previa de las imágenes copiadas o referenciadas.'; - - @override - String get settingGenerateVideoThumbnails => 'Generar miniaturas de vídeos'; - - @override - String get subtitleGenerateVideoThumbnails => - 'Usa la caché del sistema para mostrar un fotograma de los vídeos.'; - - @override - String get settingGenerateAudioThumbnails => 'Generar miniaturas de audio'; - - @override - String get subtitleGenerateAudioThumbnails => - 'Muestra la carátula cuando esté disponible.'; - - @override - String get settingMaxImageSize => 'Tamaño máximo a procesar (MB)'; - - @override - String get subtitleMaxImageSize => - 'Las imágenes más grandes mantienen su mapa de bits original sin reprocesarse.'; - - @override - String get sectionCleanupPrivacy => 'LIMPIEZA Y PRIVACIDAD'; - - @override - String get settingKeepBrokenItemsLabel => - 'Conservar elementos no disponibles (días)'; - - @override - String get subtitleKeepBrokenItems => - 'Los elementos que apuntan a archivos perdidos o volúmenes desconectados se eliminan tras estos días. 0 los elimina al instante.'; - - @override - String get settingImagesQuotaLabel => - 'Límite de almacenamiento para imágenes'; - - @override - String get subtitleImagesQuota => - 'Cuando la carpeta de imágenes supera este tamaño, se eliminan los elementos más antiguos no fijados para liberar espacio.'; - - @override - String get imagesQuotaOff => 'Sin límite'; -} +// coverage:ignore-file +// ignore: unused_import +import 'package:intl/intl.dart' as intl; +import 'app_localizations.dart'; + +// ignore_for_file: type=lint + +/// The translations for Spanish Castilian (`es`). +class AppLocalizationsEs extends AppLocalizations { + AppLocalizationsEs([String locale = 'es']) : super(locale); + + @override + String get searchPlaceholder => 'Buscar en portapapeles…'; + + @override + String get emptyState => 'No hay elementos en esta sección'; + + @override + String get emptyStateSubtitle => 'Copia algo para comenzar'; + + @override + String get hintBannerText => + 'CopyPaste se ejecuta en segundo plano — encuéntralo en la bandeja del sistema o usa tu atajo de teclado. Personaliza tu experiencia en'; + + @override + String get hintBannerAction => 'Ajustes'; + + @override + String get settingsTitle => 'Configuración'; + + @override + String get sectionShortcuts => 'ATAJOS DE TECLADO'; + + @override + String get sectionStorage => 'ALMACENAMIENTO'; + + @override + String get settingRunOnStartup => 'Iniciar con el sistema'; + + @override + String get settingLanguage => 'Idioma de la interfaz'; + + @override + String get hotkeyWillApply => 'El atajo se aplicará de inmediato'; + + @override + String get sectionSupport => 'SOPORTE'; + + @override + String get supportExportLogs => 'Exportar registros'; + + @override + String get supportExportLogsSubtitle => + 'Guarda un zip con registros de la app para adjuntar a un reporte. El contenido del portapapeles nunca se incluye.'; + + @override + String get supportOpenLogsFolder => 'Abrir carpeta de registros'; + + @override + String get supportOpenLogsFolderSubtitle => + 'Explora los archivos de registro en tu gestor de archivos.'; + + @override + String get supportGitHub => 'Reportar un error en GitHub'; + + @override + String get supportExportSuccess => 'Registros guardados en Descargas.'; + + @override + String get supportShowInFiles => 'Mostrar'; + + @override + String get supportExportEmpty => 'No se encontraron archivos de registro.'; + + @override + String get supportExportError => 'Error al exportar los registros.'; + + @override + String get sectionReset => 'RESTABLECER E INSTALACIÓN LIMPIA'; + + @override + String get resetSoftLabel => 'Restablecimiento suave'; + + @override + String get resetSoftSubtitle => + 'Restablece la configuración a los valores predeterminados y marca la app como nueva instalación. El historial del portapapeles se conserva.'; + + @override + String get resetHardLabel => 'Restablecimiento completo'; + + @override + String get resetHardSubtitle => + 'Elimina todo el historial, imágenes y configuración. Esta acción no se puede deshacer.'; + + @override + String get resetSoftConfirmTitle => '¿Restablecimiento suave?'; + + @override + String get resetSoftConfirmMessage => + 'Toda la configuración volverá a los valores predeterminados y la app se reiniciará como si fuera una instalación nueva. El historial del portapapeles no se eliminará.'; + + @override + String get resetHardConfirmTitle => '¿Restablecimiento completo?'; + + @override + String get resetHardConfirmMessage => + 'Se eliminará permanentemente todo el historial, imágenes y configuración, y luego la app se reiniciará. Esta acción no se puede deshacer.'; + + @override + String get resetConfirmButton => 'Restablecer y Reiniciar'; + + @override + String get clearHistoryConfirmTitle => '¿Limpiar historial?'; + + @override + String get clearHistoryConfirmMessage => + 'Esto eliminará permanentemente todos los elementos no anclados. Esta acción no se puede deshacer.'; + + @override + String get clearHistoryConfirmButton => 'Limpiar'; + + @override + String backupLastDate(String date) { + return 'Último respaldo: $date'; + } + + @override + String get backupNone => 'Aún no se ha creado un respaldo.'; + + @override + String get backupCreateLabel => 'Crear respaldo'; + + @override + String get backupRestoreLabel => 'Restaurar respaldo'; + + @override + String get backupError => + 'Error al crear el respaldo. Verifica los permisos.'; + + @override + String get restoreDialogTitle => 'Restaurar respaldo'; + + @override + String get restoreDialogWarning => + 'Esto reemplazará todos los datos actuales con el contenido del respaldo. ¿Continuar?'; + + @override + String get restoreFileNotFound => 'Archivo no encontrado.'; + + @override + String restoreSuccess(int count) { + return 'Se restauraron $count elementos.'; + } + + @override + String get restoreError => + 'Error al restaurar. Tus datos anteriores se han preservado.'; + + @override + String get buttonSave => 'Guardar'; + + @override + String get buttonClose => 'Cerrar'; + + @override + String get buttonCancel => 'Cancelar'; + + @override + String get buttonReset => 'Restaurar predeterminados'; + + @override + String get savingIndicator => 'Guardando…'; + + @override + String get savedIndicator => 'Guardado'; + + @override + String get menuPaste => 'Pegar'; + + @override + String get menuPastePlain => 'Pegar sin formato'; + + @override + String get menuPin => 'Anclar'; + + @override + String get menuUnpin => 'Desanclar'; + + @override + String get menuEdit => 'Editar tarjeta'; + + @override + String get menuDelete => 'Eliminar'; + + @override + String get editColorLabel => 'Color'; + + @override + String get colorRed => 'Rojo'; + + @override + String get colorGreen => 'Verde'; + + @override + String get colorPurple => 'Morado'; + + @override + String get colorYellow => 'Amarillo'; + + @override + String get colorBlue => 'Azul'; + + @override + String get colorOrange => 'Naranja'; + + @override + String get typeText => 'Texto'; + + @override + String get typeImage => 'Imagen'; + + @override + String get typeFile => 'Archivo'; + + @override + String get typeFolder => 'Carpeta'; + + @override + String get typeLink => 'Enlace'; + + @override + String get typeAudio => 'Audio'; + + @override + String get typeVideo => 'Video'; + + @override + String get typeEmail => 'Email'; + + @override + String get typePhone => 'Teléfono'; + + @override + String get typeColor => 'Color'; + + @override + String get typeIp => 'IP'; + + @override + String get typeUuid => 'UUID'; + + @override + String get typeJson => 'JSON'; + + @override + String get filterAll => 'Todo'; + + @override + String get filterPinned => 'Anclados'; + + @override + String get trayTooltip => 'CopyPaste'; + + @override + String get trayExit => 'Salir'; + + @override + String get shortcutOpenClose => 'Abrir / cerrar CopyPaste'; + + @override + String get shortcutEscape => 'Limpiar búsqueda o cerrar ventana'; + + @override + String get shortcutTab1 => 'Cambiar a pestaña Recientes'; + + @override + String get shortcutTab2 => 'Cambiar a pestaña Anclados'; + + @override + String get shortcutArrows => 'Navegar entre elementos'; + + @override + String get shortcutEnter => 'Pegar elemento seleccionado'; + + @override + String get shortcutDelete => 'Eliminar elemento seleccionado'; + + @override + String get shortcutPin => 'Anclar / Desanclar elemento'; + + @override + String get shortcutEdit => 'Editar tarjeta (etiqueta y color)'; + + @override + String get tabGeneral => 'General'; + + @override + String get tabBackupRestore => 'Backup y soporte'; + + @override + String get tabAppearance => 'Apariencia'; + + @override + String get tabShortcuts => 'Atajos'; + + @override + String get tabAbout => 'Acerca de'; + + @override + String get sectionLanguage => 'IDIOMA'; + + @override + String get sectionStartup => 'INICIO'; + + @override + String get sectionKeyboardShortcut => 'ATAJO DE TECLADO'; + + @override + String get sectionCategories => 'CATEGORÍAS'; + + @override + String get sectionPerformance => 'RENDIMIENTO'; + + @override + String get sectionPaste => 'PEGADO'; + + @override + String get sectionBackupRestore => 'RESPALDO Y RESTAURACIÓN'; + + @override + String get sectionAppearance => 'APARIENCIA'; + + @override + String get settingTheme => 'Tema'; + + @override + String get themeLight => 'Claro'; + + @override + String get themeDark => 'Oscuro'; + + @override + String get themeAuto => 'Auto'; + + @override + String get sectionBehavior => 'COMPORTAMIENTO'; + + @override + String get sectionAbout => 'COPYPASTE'; + + @override + String get sectionLinks => 'ENLACES'; + + @override + String get settingItemsPerPage => 'Elementos por página'; + + @override + String get settingMemoryLimit => 'Límite de memoria'; + + @override + String get settingScrollThreshold => 'Umbral de desplazamiento (px)'; + + @override + String get settingPasteSpeed => 'Velocidad de pegado'; + + @override + String get settingPanelWidth => 'Ancho del panel (px)'; + + @override + String get settingPanelHeight => 'Alto del panel (px)'; + + @override + String get settingLinesCollapsed => 'Líneas contraídas'; + + @override + String get settingLinesExpanded => 'Líneas expandidas'; + + @override + String get settingHideOnDeactivate => 'Ocultar al hacer clic fuera'; + + @override + String get settingScrollToTopOnOpen => 'Ir al inicio al abrir'; + + @override + String get settingClearSearchOnOpen => 'Limpiar búsqueda al abrir'; + + @override + String get settingRetentionDaysLabel => 'Días de retención (0 = sin límite)'; + + @override + String get settingClearHistoryLabel => 'Limpiar historial del portapapeles'; + + @override + String get settingHotkeyShortcutLabel => 'Atajo para abrir/cerrar CopyPaste'; + + @override + String get subtitleStartupDesc => + 'Se inicia en segundo plano al iniciar sesión'; + + @override + String get subtitleHideOnDeactivate => + 'Cerrar la ventana al hacer clic fuera'; + + @override + String get subtitleScrollToTopOnOpen => + 'Restablece el desplazamiento y selecciona el último elemento'; + + @override + String get subtitleClearSearchOnOpen => 'Borra el texto de búsqueda cada vez'; + + @override + String get subtitlePasteSpeed => 'Ajustar tiempos de restauración y pegado'; + + @override + String get subtitleCategories => + 'Personaliza los nombres de las categorías de color.'; + + @override + String get linkGitHub => 'Soporte y Código fuente — GitHub'; + + @override + String get linkCoffee => 'Invítame un café'; + + @override + String get editDialogTitle => 'Etiqueta y Color'; + + @override + String get editDialogHint => 'Agregar una etiqueta...'; + + @override + String get historyCleared => 'Historial limpiado'; + + @override + String backupSavedFile(String filename) { + return 'Respaldo guardado: $filename'; + } + + @override + String get buttonRestore => 'Restaurar'; + + @override + String get restoreCompleted => 'Restauración completada'; + + @override + String get restoreRestartRequired => + 'Restauración completada. La app se reiniciará para aplicar los cambios.'; + + @override + String get shortcutExpand => 'Expandir / contraer tarjeta'; + + @override + String get shortcutFocusSearch => 'Enfocar el buscador'; + + @override + String get trayShowHide => 'Mostrar/Ocultar'; + + @override + String get fileNotFound => 'No encontrado'; + + @override + String get audioFile => 'Archivo de audio'; + + @override + String get videoFile => 'Archivo de video'; + + @override + String get imageFile => 'Archivo de imagen'; + + @override + String get timeNow => 'ahora'; + + @override + String get clearAllFilters => 'Limpiar todos los filtros'; + + @override + String get colorSectionLabel => 'COLOR'; + + @override + String get colorNone => 'Ninguno'; + + @override + String get subtitlePastePreset => + 'Velocidad de pegado automático. Normal/Seguro recomendado para la mayoría.'; + + @override + String get pastePresetFast => 'Rápido'; + + @override + String get pastePresetNormal => 'Normal'; + + @override + String get pastePresetSafe => 'Seguro'; + + @override + String get pastePresetSlow => 'Lento'; + + @override + String get pastePresetCustom => 'Personalizado'; + + @override + String get pastePresetWarning => + '⚠️ Rápido: puede causar comportamientos extraños en apps pesadas.\n⚠️ Lento: puede sentirse pesado en equipos modernos.'; + + @override + String get settingResetFiltersOnOpen => 'Volver a Todos al abrir'; + + @override + String get subtitleResetFiltersOnOpen => + 'Limpia los filtros de categoría y tipo, y vuelve a la pestaña Todos'; + + @override + String get subtitleBackup => + 'Crea un respaldo de tu historial, imágenes y configuración. Restaura en cualquier momento en este u otro dispositivo.'; + + @override + String get aboutDescription => + 'Un gestor de portapapeles moderno, nativo en Windows, macOS y Linux.\nTodo local — tu historial, siempre a mano. Sin cuentas, sin telemetría, sin suscripciones.'; + + @override + String get sectionPrivacy => 'PRIVACIDAD'; + + @override + String get privacyStatement => + 'Todo local. Nada sale de tu PC — sin telemetría, sin sincronización, sin cuentas.'; + + @override + String get privacyPolicy => 'Política de privacidad'; + + @override + String get aboutTagLocal => 'Todo local'; + + @override + String get aboutTagOpenSource => 'Código abierto'; + + @override + String get aboutTagFree => 'Gratis'; + + @override + String get sectionOtherTools => 'OTRAS HERRAMIENTAS'; + + @override + String get otherToolLinkUnbound => 'LinkUnbound'; + + @override + String get otherToolLinkUnboundDesc => + 'Selector de navegadores de código abierto para Windows y Mac. Misma filosofía: sin anuncios, sin telemetría, todo local.'; + + @override + String get aboutLicense => 'Licencia GPL v3 — Libre y de código abierto.'; + + @override + String get permissionsTitle => 'Permiso de Accesibilidad requerido'; + + @override + String get permissionsMessage => + 'CopyPaste necesita permiso de Accesibilidad para pegar contenido en otras apps.\n\nVe a Configuración del Sistema → Privacidad y Seguridad → Accesibilidad y activa CopyPaste.'; + + @override + String get permissionsOpenSettings => 'Abrir Configuración'; + + @override + String get permissionsDismiss => 'Después'; + + @override + String get permissionsGranted => 'Permiso concedido'; + + @override + String get permissionsResetTitle => 'Permiso de Accesibilidad perdido'; + + @override + String get permissionsResetMessage => + 'macOS ya no reconoce el permiso de CopyPaste porque la app fue re-autorizada a través de Gatekeeper.\n\nPara solucionarlo:\n1. Abre la configuración de Accesibilidad\n2. Elimina CopyPaste de la lista (−)\n3. Vuelve a añadirlo o actívalo de nuevo'; + + @override + String get permissionsRestartMessage => + 'Asegúrate de que CopyPaste esté activado en Privacidad y seguridad > Accesibilidad.\n\nLa app continuará automáticamente cuando detecte el permiso.'; + + @override + String get permissionsCheckAgain => 'Verificar'; + + @override + String get permissionsRestartApp => 'Reiniciar app'; + + @override + String get permissionsWaiting => 'Esperando permiso…'; + + @override + String updateBadge(String version) { + return 'v$version disponible, por favor actualiza'; + } + + @override + String updateAvailableWindows(String version) { + return 'La versión $version está disponible.\n\nDescarga el instalador más reciente desde GitHub.'; + } + + @override + String updateAvailableMac(String version) { + return 'La versión $version está disponible.\n\nActualiza con Homebrew:\nbrew upgrade copypaste\n\nO descarga la última versión desde GitHub.'; + } + + @override + String updateAvailableLinux(String version) { + return 'La versión $version está disponible.\n\nDescarga la última versión desde GitHub.'; + } + + @override + String updateAvailableStore(String version) { + return 'La versión $version está disponible.\n\nLa Microsoft Store entrega las actualizaciones automáticamente. Las nuevas versiones pueden tardar unos días en aparecer tras su publicación.'; + } + + @override + String updateTooltipStore(String version) { + return 'Actualización $version en camino por Microsoft Store'; + } + + @override + String updateTooltipGeneric(String version) { + return 'Actualización $version disponible — haz clic para detalles'; + } + + @override + String get updateDialogTitle => 'Actualización disponible'; + + @override + String get updateViewRelease => 'Ver versión'; + + @override + String get updateDismiss => 'Después'; + + @override + String updateBadgeImportant(String version) { + return 'v$version disponible — actualización importante'; + } + + @override + String get updateActionDownload => 'Descargar instalador'; + + @override + String get updateActionOpenStore => 'Abrir Microsoft Store'; + + @override + String get updateActionCopyBrew => 'Copiar comando brew'; + + @override + String get updateActionCopied => 'Copiado al portapapeles'; + + @override + String get blockedTitle => 'Actualización requerida'; + + @override + String blockedDescription(String current, String required) { + return 'La versión $current de CopyPaste ya no está soportada. Instala la versión $required o más reciente para continuar.'; + } + + @override + String get blockedReasonGeneric => + 'Esta versión fue retirada por motivos de seguridad o compatibilidad.'; + + @override + String get blockedQuit => 'Salir de CopyPaste'; + + @override + String get blockedFallbackHint => + 'Visita https://github.com/rgdevment/CopyPaste/releases para descargar el instalador más reciente.'; + + @override + String get waylandUnsupportedTitle => 'Wayland no está soportado'; + + @override + String get waylandUnsupportedBadge => 'Open source · Solo X11'; + + @override + String get waylandUnsupportedBody => + 'El soporte en Linux está en progreso. Este proyecto lo mantiene una sola persona y necesitamos más testers para avanzar.\n\nCopyPaste funciona completamente en X11 — para usarlo, inicia sesión con X11. Lamentamos las molestias.'; + + @override + String get waylandUnsupportedGitHub => 'Ver en GitHub'; + + @override + String get waylandUnsupportedClose => 'Cerrar'; + + @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.'; + } + + @override + String linuxHotkeyGrabFailedWarning(String hotkey) { + return 'El atajo $hotkey está siendo usado por otra aplicación. Cámbialo en Configuración → Atajos.'; + } + + @override + String get linuxPasteFocusTimeoutWarning => + 'El portapapeles tiene tu contenido. Pégalo manualmente con Ctrl+V.'; + + @override + String get linuxAppindicatorBannerTitle => 'Ícono de bandeja no disponible'; + + @override + String get linuxAppindicatorBannerBody => + 'Tu escritorio no expone un host de AppIndicator, por lo que el ícono de CopyPaste no aparecerá en la bandeja. Instala una extensión de bandeja para tu distribución y reinicia CopyPaste.'; + + @override + String get linuxClipboardManagerBannerTitle => + 'No se detectó un gestor de portapapeles'; + + @override + String get linuxClipboardManagerBannerBody => + 'Sin un gestor de portapapeles (gpaste, klipper, clipman, copyq, …) los datos copiados pueden perderse cuando CopyPaste se cierra. Instala uno y reinicia tu sesión.'; + + @override + String get linuxXtestBannerTitle => 'Pegado automático deshabilitado'; + + @override + String get linuxXtestBannerBody => + 'La extensión XTest de X11 no está disponible, por lo que CopyPaste no puede inyectar Ctrl+V automáticamente. Los elementos siguen copiándose al portapapeles — pégalos manualmente con Ctrl+V.'; + + @override + String get linuxBannerDismiss => 'Descartar'; + + @override + String wakeupHint(String hotkey) { + return 'CopyPaste se ejecuta en segundo plano — presiona $hotkey o haz clic en el ícono de la bandeja para abrirlo cuando quieras.'; + } + + @override + String taskbarOpenHint(String hotkey) { + return 'Tip: presiona $hotkey para abrir y pegar automáticamente, sin perder el foco.'; + } + + @override + String balloonStartupBody(String hotkey) { + return 'Ejecutándose en segundo plano. Presiona $hotkey o haz clic en el ícono de la bandeja.'; + } + + @override + String get balloonWakeupTitle => 'CopyPaste ya está abierto'; + + @override + String balloonWakeupBody(String hotkey) { + return 'Presiona $hotkey o haz clic en el ícono de la bandeja para abrirlo.'; + } + + @override + String get onboardingTitle => 'Bienvenido a CopyPaste'; + + @override + String get onboardingSubtitle => 'Todo lo que copias, guardado.'; + + @override + String get onboardingPrivacyBadge => 'Sin nube · Sin rastreo · 100% local'; + + @override + String onboardingDescription(String hotkey) { + return 'Corre en segundo plano sin que lo notes. Presiona $hotkey cuando quieras para abrir tu historial.'; + } + + @override + String get onboardingTrayHint => + 'Encuéntralo junto al reloj, abajo a la derecha.'; + + @override + String get onboardingSettingsButton => 'Configuración'; + + @override + String get onboardingDismissButton => 'Empezar'; + + @override + String get tabCapture => 'Rendimiento'; + + @override + String get tabMultimedia => 'Multimedia'; + + @override + String get tabCleanupPrivacy => 'Limpieza y privacidad'; + + @override + String get sectionMultimedia => 'MULTIMEDIA Y MINIATURAS'; + + @override + String get subtitleMultimedia => + 'Controla cómo se previsualizan imágenes, vídeos y archivos de audio.'; + + @override + String get settingGenerateImageThumbnails => 'Generar miniaturas de imágenes'; + + @override + String get subtitleGenerateImageThumbnails => + 'Muestra una vista previa de las imágenes copiadas o referenciadas.'; + + @override + String get settingGenerateVideoThumbnails => 'Generar miniaturas de vídeos'; + + @override + String get subtitleGenerateVideoThumbnails => + 'Usa la caché del sistema para mostrar un fotograma de los vídeos.'; + + @override + String get settingGenerateAudioThumbnails => 'Generar miniaturas de audio'; + + @override + String get subtitleGenerateAudioThumbnails => + 'Muestra la carátula cuando esté disponible.'; + + @override + String get settingMaxImageSize => 'Tamaño máximo a procesar (MB)'; + + @override + String get subtitleMaxImageSize => + 'Las imágenes más grandes mantienen su mapa de bits original sin reprocesarse.'; + + @override + String get sectionCleanupPrivacy => 'LIMPIEZA Y PRIVACIDAD'; + + @override + String get settingKeepBrokenItemsLabel => + 'Conservar elementos no disponibles (días)'; + + @override + String get subtitleKeepBrokenItems => + 'Los elementos que apuntan a archivos perdidos o volúmenes desconectados se eliminan tras estos días. 0 los elimina al instante.'; + + @override + String get settingImagesQuotaLabel => + 'Límite de almacenamiento para imágenes'; + + @override + String get subtitleImagesQuota => + 'Cuando la carpeta de imágenes supera este tamaño, se eliminan los elementos más antiguos no fijados para liberar espacio.'; + + @override + String get imagesQuotaOff => 'Sin límite'; +} diff --git a/app/lib/services/linux_capabilities.dart b/app/lib/services/linux_capabilities.dart index 5230425e..62759a11 100644 --- a/app/lib/services/linux_capabilities.dart +++ b/app/lib/services/linux_capabilities.dart @@ -105,7 +105,7 @@ class _DefaultLinuxCapabilitiesChannel implements LinuxCapabilitiesChannel { } class LinuxCapabilitiesService { - LinuxCapabilitiesService._(); + LinuxCapabilitiesService._(); // coverage:ignore-line static LinuxCapabilities _cache = LinuxCapabilities.unsupported; static bool _initialized = false; diff --git a/app/lib/services/linux_guard.dart b/app/lib/services/linux_guard.dart index 1759cc5e..3123b282 100644 --- a/app/lib/services/linux_guard.dart +++ b/app/lib/services/linux_guard.dart @@ -3,7 +3,7 @@ import 'dart:io'; import 'linux_capabilities.dart'; class LinuxGuard { - const LinuxGuard._(); + const LinuxGuard._(); // coverage:ignore-line static LinuxCapabilities get _caps => LinuxCapabilitiesService.current; diff --git a/app/test/services/linux_capabilities_test.dart b/app/test/services/linux_capabilities_test.dart index f8a96d29..63ba188e 100644 --- a/app/test/services/linux_capabilities_test.dart +++ b/app/test/services/linux_capabilities_test.dart @@ -2,6 +2,7 @@ import 'dart:io'; import 'package:copypaste/services/linux_capabilities.dart'; import 'package:copypaste/services/linux_guard.dart'; +import 'package:copypaste/shell/linux_session.dart'; import 'package:flutter_test/flutter_test.dart'; class _FakeChannel implements LinuxCapabilitiesChannel { @@ -171,5 +172,82 @@ void main() { ); expect(LinuxGuard.canRegisterHotkey, isTrue); }); + + test('isWayland returns true when capabilities have isWayland', () { + if (!Platform.isLinux) return; + const waylandSession = LinuxSessionInfo( + sessionType: 'wayland', + hasDisplay: false, + hasWaylandDisplay: true, + hasWaylandSocket: false, + desktopEnv: '', + wmName: '', + ); + const waylandCaps = LinuxCapabilities( + session: waylandSession, + isX11: false, + hasXTest: false, + hasAppIndicator: false, + hasClipboardManager: false, + hasEwmh: false, + detectedDesktopEnv: '', + detectedWmName: '', + detectionTimedOut: false, + ); + LinuxCapabilitiesService.resetForTesting(waylandCaps); + expect(LinuxGuard.isWayland, isTrue); + LinuxCapabilitiesService.resetForTesting(LinuxCapabilities.unsupported); + expect(LinuxGuard.isWayland, isFalse); + }); + + test('isUsable requires isLinux and X11', () { + if (!Platform.isLinux) return; + LinuxCapabilitiesService.resetForTesting( + LinuxCapabilities.unsupported.copyWith(isX11: true), + ); + expect(LinuxGuard.isUsable, isTrue); + LinuxCapabilitiesService.resetForTesting(LinuxCapabilities.unsupported); + expect(LinuxGuard.isUsable, isFalse); + }); + }); + + group('LinuxCapabilities', () { + test('copyWith preserves unchanged fields', () { + if (!Platform.isLinux) return; + final original = LinuxCapabilities.unsupported.copyWith( + isX11: true, + hasXTest: true, + hasEwmh: true, + detectedDesktopEnv: 'GNOME', + detectedWmName: 'Mutter', + ); + final copy = original.copyWith(hasAppIndicator: true); + expect(copy.isX11, isTrue); + expect(copy.hasXTest, isTrue); + expect(copy.hasEwmh, isTrue); + expect(copy.hasAppIndicator, isTrue); + expect(copy.detectedDesktopEnv, 'GNOME'); + expect(copy.detectedWmName, 'Mutter'); + }); + + test('toString contains key field values', () { + if (!Platform.isLinux) return; + final caps = LinuxCapabilities.unsupported.copyWith(isX11: true); + final s = caps.toString(); + expect(s, contains('isX11=true')); + expect(s, contains('LinuxCapabilities(')); + }); + + test('isUsable is false when isX11 is false', () { + if (!Platform.isLinux) return; + const caps = LinuxCapabilities.unsupported; + expect(caps.isUsable, isFalse); + }); + + test('isUsable is true when isX11 is true and running on Linux', () { + if (!Platform.isLinux) return; + final caps = LinuxCapabilities.unsupported.copyWith(isX11: true); + expect(caps.isUsable, isTrue); + }); }); } diff --git a/core/lib/config/storage_config.dart b/core/lib/config/storage_config.dart index 15d445e6..1aebeb32 100644 --- a/core/lib/config/storage_config.dart +++ b/core/lib/config/storage_config.dart @@ -1,87 +1,95 @@ -import 'dart:io'; - -import '../services/app_logger.dart'; - -import 'package:path/path.dart' as p; -import 'package:path_provider/path_provider.dart'; - -class StorageConfig { - StorageConfig._({required this.baseDir}) - : databasePath = p.join(baseDir, 'clipboard.db'), - imagesPath = p.join(baseDir, 'images'), - configPath = p.join(baseDir, 'config'), - logsPath = p.join(baseDir, 'logs'); - - final String baseDir; - final String databasePath; - final String imagesPath; - final String configPath; - final String logsPath; - - String get _initFlagPath => p.join(baseDir, '.initialized'); - - static Future create({ - String? baseDir, - String? Function()? windowsLocalAppDataResolver, - }) async { - final String base; - if (baseDir != null) { - base = baseDir; - } else if (Platform.isWindows) { - final resolved = - windowsLocalAppDataResolver?.call() ?? - Platform.environment['LOCALAPPDATA']; - base = resolved != null - ? p.join(resolved, 'CopyPaste') - : p.join((await getApplicationSupportDirectory()).path, 'CopyPaste'); - } else { - base = p.join((await getApplicationSupportDirectory()).path, 'CopyPaste'); - } - return StorageConfig._(baseDir: base); - } - - Future ensureDirectories() async { - for (final dir in [baseDir, imagesPath, configPath, logsPath]) { - await Directory(dir).create(recursive: true); - } - } - - bool get isFirstRun => !File(_initFlagPath).existsSync(); - - void markAsInitialized() { - try { - File(_initFlagPath) - ..createSync(recursive: true) - ..writeAsStringSync(DateTime.now().toUtc().toIso8601String()); - } catch (e) { - AppLogger.error('Failed to mark as initialized: $e'); - } - } - - void clearInitialized() { - try { - final f = File(_initFlagPath); - if (f.existsSync()) f.deleteSync(); - } catch (e) { - AppLogger.error('Failed to clear initialized flag: $e'); - } - } - - void cleanOrphanImages(List validImagePaths) { - _cleanDirectory(imagesPath, validImagePaths.toSet()); - } - - void _cleanDirectory(String dirPath, Set validFiles) { - final dir = Directory(dirPath); - if (!dir.existsSync()) return; - for (final file in dir.listSync().whereType()) { - if (!validFiles.contains(file.path)) { - try { - file.deleteSync(); - } catch (e) { - AppLogger.error('Failed to delete orphan file: $e'); - } - } - } - } -} +import 'dart:io'; + +import '../services/app_logger.dart'; + +import 'package:path/path.dart' as p; +import 'package:path_provider/path_provider.dart'; + +class StorageConfig { + StorageConfig._({required this.baseDir}) + : databasePath = p.join(baseDir, 'clipboard.db'), + imagesPath = p.join(baseDir, 'images'), + configPath = p.join(baseDir, 'config'), + logsPath = p.join(baseDir, 'logs'); + + final String baseDir; + final String databasePath; + final String imagesPath; + final String configPath; + final String logsPath; + + String get _initFlagPath => p.join(baseDir, '.initialized'); + + static Future create({ + String? baseDir, + String? Function()? windowsLocalAppDataResolver, + }) async { + final String base; + if (baseDir != null) { + base = baseDir; + } else if (Platform.isWindows) { + // coverage:ignore-start + final resolved = + windowsLocalAppDataResolver?.call() ?? + Platform.environment['LOCALAPPDATA']; + base = resolved != null + ? p.join(resolved, 'CopyPaste') + : p.join((await getApplicationSupportDirectory()).path, 'CopyPaste'); + } else { + // coverage:ignore-end + base = p.join((await getApplicationSupportDirectory()).path, 'CopyPaste'); + } + return StorageConfig._(baseDir: base); + } + + Future ensureDirectories() async { + for (final dir in [baseDir, imagesPath, configPath, logsPath]) { + await Directory(dir).create(recursive: true); + } + } + + bool get isFirstRun => !File(_initFlagPath).existsSync(); + + void markAsInitialized() { + try { + File(_initFlagPath) + ..createSync(recursive: true) + ..writeAsStringSync(DateTime.now().toUtc().toIso8601String()); + } catch (e) { + AppLogger.error( + 'Failed to mark as initialized: $e', + ); // coverage:ignore-line + } + } + + void clearInitialized() { + try { + final f = File(_initFlagPath); + if (f.existsSync()) f.deleteSync(); + } catch (e) { + AppLogger.error( + 'Failed to clear initialized flag: $e', + ); // coverage:ignore-line + } + } + + void cleanOrphanImages(List validImagePaths) { + _cleanDirectory(imagesPath, validImagePaths.toSet()); + } + + void _cleanDirectory(String dirPath, Set validFiles) { + final dir = Directory(dirPath); + if (!dir.existsSync()) return; + for (final file in dir.listSync().whereType()) { + if (!validFiles.contains(file.path)) { + try { + file.deleteSync(); + } catch (e) { + AppLogger.error( + 'Failed to delete orphan file: $e', + ); // coverage:ignore-line + } + } + } + } +} diff --git a/core/lib/repository/sqlite_repository.g.dart b/core/lib/repository/sqlite_repository.g.dart index 7c286480..61f44957 100644 --- a/core/lib/repository/sqlite_repository.g.dart +++ b/core/lib/repository/sqlite_repository.g.dart @@ -1,1297 +1,1298 @@ -// GENERATED CODE - DO NOT MODIFY BY HAND - -part of 'sqlite_repository.dart'; - -// ignore_for_file: type=lint -class $ClipboardItemsTable extends ClipboardItems - with TableInfo<$ClipboardItemsTable, ClipboardRow> { - @override - final GeneratedDatabase attachedDatabase; - final String? _alias; - $ClipboardItemsTable(this.attachedDatabase, [this._alias]); - static const VerificationMeta _idMeta = const VerificationMeta('id'); - @override - late final GeneratedColumn id = GeneratedColumn( - 'id', - aliasedName, - false, - type: DriftSqlType.string, - requiredDuringInsert: true, - ); - static const VerificationMeta _contentMeta = const VerificationMeta( - 'content', - ); - @override - late final GeneratedColumn content = GeneratedColumn( - 'content', - aliasedName, - false, - type: DriftSqlType.string, - requiredDuringInsert: true, - ); - static const VerificationMeta _typeMeta = const VerificationMeta('type'); - @override - late final GeneratedColumn type = GeneratedColumn( - 'type', - aliasedName, - false, - type: DriftSqlType.int, - requiredDuringInsert: true, - ); - static const VerificationMeta _createdAtMeta = const VerificationMeta( - 'createdAt', - ); - @override - late final GeneratedColumn createdAt = GeneratedColumn( - 'created_at', - aliasedName, - false, - type: DriftSqlType.dateTime, - requiredDuringInsert: true, - ); - static const VerificationMeta _modifiedAtMeta = const VerificationMeta( - 'modifiedAt', - ); - @override - late final GeneratedColumn modifiedAt = GeneratedColumn( - 'modified_at', - aliasedName, - false, - type: DriftSqlType.dateTime, - requiredDuringInsert: true, - ); - static const VerificationMeta _appSourceMeta = const VerificationMeta( - 'appSource', - ); - @override - late final GeneratedColumn appSource = GeneratedColumn( - 'app_source', - aliasedName, - true, - type: DriftSqlType.string, - requiredDuringInsert: false, - ); - static const VerificationMeta _isPinnedMeta = const VerificationMeta( - 'isPinned', - ); - @override - late final GeneratedColumn isPinned = GeneratedColumn( - 'is_pinned', - aliasedName, - false, - type: DriftSqlType.bool, - requiredDuringInsert: false, - defaultConstraints: GeneratedColumn.constraintIsAlways( - 'CHECK ("is_pinned" IN (0, 1))', - ), - defaultValue: const Constant(false), - ); - static const VerificationMeta _labelMeta = const VerificationMeta('label'); - @override - late final GeneratedColumn label = GeneratedColumn( - 'label', - aliasedName, - true, - type: DriftSqlType.string, - requiredDuringInsert: false, - ); - static const VerificationMeta _cardColorMeta = const VerificationMeta( - 'cardColor', - ); - @override - late final GeneratedColumn cardColor = GeneratedColumn( - 'card_color', - aliasedName, - false, - type: DriftSqlType.int, - requiredDuringInsert: false, - defaultValue: const Constant(0), - ); - static const VerificationMeta _metadataMeta = const VerificationMeta( - 'metadata', - ); - @override - late final GeneratedColumn metadata = GeneratedColumn( - 'metadata', - aliasedName, - true, - type: DriftSqlType.string, - requiredDuringInsert: false, - ); - static const VerificationMeta _pasteCountMeta = const VerificationMeta( - 'pasteCount', - ); - @override - late final GeneratedColumn pasteCount = GeneratedColumn( - 'paste_count', - aliasedName, - false, - type: DriftSqlType.int, - requiredDuringInsert: false, - defaultValue: const Constant(0), - ); - static const VerificationMeta _contentHashMeta = const VerificationMeta( - 'contentHash', - ); - @override - late final GeneratedColumn contentHash = GeneratedColumn( - 'content_hash', - aliasedName, - true, - type: DriftSqlType.string, - requiredDuringInsert: false, - ); - static const VerificationMeta _thumbPathMeta = const VerificationMeta( - 'thumbPath', - ); - @override - late final GeneratedColumn thumbPath = GeneratedColumn( - 'thumb_path', - aliasedName, - true, - type: DriftSqlType.string, - requiredDuringInsert: false, - ); - static const VerificationMeta _sourceModifiedAtMeta = const VerificationMeta( - 'sourceModifiedAt', - ); - @override - late final GeneratedColumn sourceModifiedAt = - GeneratedColumn( - 'source_modified_at', - aliasedName, - true, - type: DriftSqlType.dateTime, - requiredDuringInsert: false, - ); - static const VerificationMeta _brokenSinceMeta = const VerificationMeta( - 'brokenSince', - ); - @override - late final GeneratedColumn brokenSince = GeneratedColumn( - 'broken_since', - aliasedName, - true, - type: DriftSqlType.dateTime, - requiredDuringInsert: false, - ); - @override - List get $columns => [ - id, - content, - type, - createdAt, - modifiedAt, - appSource, - isPinned, - label, - cardColor, - metadata, - pasteCount, - contentHash, - thumbPath, - sourceModifiedAt, - brokenSince, - ]; - @override - String get aliasedName => _alias ?? actualTableName; - @override - String get actualTableName => $name; - static const String $name = 'clipboard_items'; - @override - VerificationContext validateIntegrity( - Insertable instance, { - bool isInserting = false, - }) { - final context = VerificationContext(); - final data = instance.toColumns(true); - if (data.containsKey('id')) { - context.handle(_idMeta, id.isAcceptableOrUnknown(data['id']!, _idMeta)); - } else if (isInserting) { - context.missing(_idMeta); - } - if (data.containsKey('content')) { - context.handle( - _contentMeta, - content.isAcceptableOrUnknown(data['content']!, _contentMeta), - ); - } else if (isInserting) { - context.missing(_contentMeta); - } - if (data.containsKey('type')) { - context.handle( - _typeMeta, - type.isAcceptableOrUnknown(data['type']!, _typeMeta), - ); - } else if (isInserting) { - context.missing(_typeMeta); - } - if (data.containsKey('created_at')) { - context.handle( - _createdAtMeta, - createdAt.isAcceptableOrUnknown(data['created_at']!, _createdAtMeta), - ); - } else if (isInserting) { - context.missing(_createdAtMeta); - } - if (data.containsKey('modified_at')) { - context.handle( - _modifiedAtMeta, - modifiedAt.isAcceptableOrUnknown(data['modified_at']!, _modifiedAtMeta), - ); - } else if (isInserting) { - context.missing(_modifiedAtMeta); - } - if (data.containsKey('app_source')) { - context.handle( - _appSourceMeta, - appSource.isAcceptableOrUnknown(data['app_source']!, _appSourceMeta), - ); - } - if (data.containsKey('is_pinned')) { - context.handle( - _isPinnedMeta, - isPinned.isAcceptableOrUnknown(data['is_pinned']!, _isPinnedMeta), - ); - } - if (data.containsKey('label')) { - context.handle( - _labelMeta, - label.isAcceptableOrUnknown(data['label']!, _labelMeta), - ); - } - if (data.containsKey('card_color')) { - context.handle( - _cardColorMeta, - cardColor.isAcceptableOrUnknown(data['card_color']!, _cardColorMeta), - ); - } - if (data.containsKey('metadata')) { - context.handle( - _metadataMeta, - metadata.isAcceptableOrUnknown(data['metadata']!, _metadataMeta), - ); - } - if (data.containsKey('paste_count')) { - context.handle( - _pasteCountMeta, - pasteCount.isAcceptableOrUnknown(data['paste_count']!, _pasteCountMeta), - ); - } - if (data.containsKey('content_hash')) { - context.handle( - _contentHashMeta, - contentHash.isAcceptableOrUnknown( - data['content_hash']!, - _contentHashMeta, - ), - ); - } - if (data.containsKey('thumb_path')) { - context.handle( - _thumbPathMeta, - thumbPath.isAcceptableOrUnknown(data['thumb_path']!, _thumbPathMeta), - ); - } - if (data.containsKey('source_modified_at')) { - context.handle( - _sourceModifiedAtMeta, - sourceModifiedAt.isAcceptableOrUnknown( - data['source_modified_at']!, - _sourceModifiedAtMeta, - ), - ); - } - if (data.containsKey('broken_since')) { - context.handle( - _brokenSinceMeta, - brokenSince.isAcceptableOrUnknown( - data['broken_since']!, - _brokenSinceMeta, - ), - ); - } - return context; - } - - @override - Set get $primaryKey => {id}; - @override - ClipboardRow map(Map data, {String? tablePrefix}) { - final effectivePrefix = tablePrefix != null ? '$tablePrefix.' : ''; - return ClipboardRow( - id: attachedDatabase.typeMapping.read( - DriftSqlType.string, - data['${effectivePrefix}id'], - )!, - content: attachedDatabase.typeMapping.read( - DriftSqlType.string, - data['${effectivePrefix}content'], - )!, - type: attachedDatabase.typeMapping.read( - DriftSqlType.int, - data['${effectivePrefix}type'], - )!, - createdAt: attachedDatabase.typeMapping.read( - DriftSqlType.dateTime, - data['${effectivePrefix}created_at'], - )!, - modifiedAt: attachedDatabase.typeMapping.read( - DriftSqlType.dateTime, - data['${effectivePrefix}modified_at'], - )!, - appSource: attachedDatabase.typeMapping.read( - DriftSqlType.string, - data['${effectivePrefix}app_source'], - ), - isPinned: attachedDatabase.typeMapping.read( - DriftSqlType.bool, - data['${effectivePrefix}is_pinned'], - )!, - label: attachedDatabase.typeMapping.read( - DriftSqlType.string, - data['${effectivePrefix}label'], - ), - cardColor: attachedDatabase.typeMapping.read( - DriftSqlType.int, - data['${effectivePrefix}card_color'], - )!, - metadata: attachedDatabase.typeMapping.read( - DriftSqlType.string, - data['${effectivePrefix}metadata'], - ), - pasteCount: attachedDatabase.typeMapping.read( - DriftSqlType.int, - data['${effectivePrefix}paste_count'], - )!, - contentHash: attachedDatabase.typeMapping.read( - DriftSqlType.string, - data['${effectivePrefix}content_hash'], - ), - thumbPath: attachedDatabase.typeMapping.read( - DriftSqlType.string, - data['${effectivePrefix}thumb_path'], - ), - sourceModifiedAt: attachedDatabase.typeMapping.read( - DriftSqlType.dateTime, - data['${effectivePrefix}source_modified_at'], - ), - brokenSince: attachedDatabase.typeMapping.read( - DriftSqlType.dateTime, - data['${effectivePrefix}broken_since'], - ), - ); - } - - @override - $ClipboardItemsTable createAlias(String alias) { - return $ClipboardItemsTable(attachedDatabase, alias); - } -} - -class ClipboardRow extends DataClass implements Insertable { - final String id; - final String content; - final int type; - final DateTime createdAt; - final DateTime modifiedAt; - final String? appSource; - final bool isPinned; - final String? label; - final int cardColor; - final String? metadata; - final int pasteCount; - final String? contentHash; - final String? thumbPath; - final DateTime? sourceModifiedAt; - final DateTime? brokenSince; - const ClipboardRow({ - required this.id, - required this.content, - required this.type, - required this.createdAt, - required this.modifiedAt, - this.appSource, - required this.isPinned, - this.label, - required this.cardColor, - this.metadata, - required this.pasteCount, - this.contentHash, - this.thumbPath, - this.sourceModifiedAt, - this.brokenSince, - }); - @override - Map toColumns(bool nullToAbsent) { - final map = {}; - map['id'] = Variable(id); - map['content'] = Variable(content); - map['type'] = Variable(type); - map['created_at'] = Variable(createdAt); - map['modified_at'] = Variable(modifiedAt); - if (!nullToAbsent || appSource != null) { - map['app_source'] = Variable(appSource); - } - map['is_pinned'] = Variable(isPinned); - if (!nullToAbsent || label != null) { - map['label'] = Variable(label); - } - map['card_color'] = Variable(cardColor); - if (!nullToAbsent || metadata != null) { - map['metadata'] = Variable(metadata); - } - map['paste_count'] = Variable(pasteCount); - if (!nullToAbsent || contentHash != null) { - map['content_hash'] = Variable(contentHash); - } - if (!nullToAbsent || thumbPath != null) { - map['thumb_path'] = Variable(thumbPath); - } - if (!nullToAbsent || sourceModifiedAt != null) { - map['source_modified_at'] = Variable(sourceModifiedAt); - } - if (!nullToAbsent || brokenSince != null) { - map['broken_since'] = Variable(brokenSince); - } - return map; - } - - ClipboardItemsCompanion toCompanion(bool nullToAbsent) { - return ClipboardItemsCompanion( - id: Value(id), - content: Value(content), - type: Value(type), - createdAt: Value(createdAt), - modifiedAt: Value(modifiedAt), - appSource: appSource == null && nullToAbsent - ? const Value.absent() - : Value(appSource), - isPinned: Value(isPinned), - label: label == null && nullToAbsent - ? const Value.absent() - : Value(label), - cardColor: Value(cardColor), - metadata: metadata == null && nullToAbsent - ? const Value.absent() - : Value(metadata), - pasteCount: Value(pasteCount), - contentHash: contentHash == null && nullToAbsent - ? const Value.absent() - : Value(contentHash), - thumbPath: thumbPath == null && nullToAbsent - ? const Value.absent() - : Value(thumbPath), - sourceModifiedAt: sourceModifiedAt == null && nullToAbsent - ? const Value.absent() - : Value(sourceModifiedAt), - brokenSince: brokenSince == null && nullToAbsent - ? const Value.absent() - : Value(brokenSince), - ); - } - - factory ClipboardRow.fromJson( - Map json, { - ValueSerializer? serializer, - }) { - serializer ??= driftRuntimeOptions.defaultSerializer; - return ClipboardRow( - id: serializer.fromJson(json['id']), - content: serializer.fromJson(json['content']), - type: serializer.fromJson(json['type']), - createdAt: serializer.fromJson(json['createdAt']), - modifiedAt: serializer.fromJson(json['modifiedAt']), - appSource: serializer.fromJson(json['appSource']), - isPinned: serializer.fromJson(json['isPinned']), - label: serializer.fromJson(json['label']), - cardColor: serializer.fromJson(json['cardColor']), - metadata: serializer.fromJson(json['metadata']), - pasteCount: serializer.fromJson(json['pasteCount']), - contentHash: serializer.fromJson(json['contentHash']), - thumbPath: serializer.fromJson(json['thumbPath']), - sourceModifiedAt: serializer.fromJson( - json['sourceModifiedAt'], - ), - brokenSince: serializer.fromJson(json['brokenSince']), - ); - } - @override - Map toJson({ValueSerializer? serializer}) { - serializer ??= driftRuntimeOptions.defaultSerializer; - return { - 'id': serializer.toJson(id), - 'content': serializer.toJson(content), - 'type': serializer.toJson(type), - 'createdAt': serializer.toJson(createdAt), - 'modifiedAt': serializer.toJson(modifiedAt), - 'appSource': serializer.toJson(appSource), - 'isPinned': serializer.toJson(isPinned), - 'label': serializer.toJson(label), - 'cardColor': serializer.toJson(cardColor), - 'metadata': serializer.toJson(metadata), - 'pasteCount': serializer.toJson(pasteCount), - 'contentHash': serializer.toJson(contentHash), - 'thumbPath': serializer.toJson(thumbPath), - 'sourceModifiedAt': serializer.toJson(sourceModifiedAt), - 'brokenSince': serializer.toJson(brokenSince), - }; - } - - ClipboardRow copyWith({ - String? id, - String? content, - int? type, - DateTime? createdAt, - DateTime? modifiedAt, - Value appSource = const Value.absent(), - bool? isPinned, - Value label = const Value.absent(), - int? cardColor, - Value metadata = const Value.absent(), - int? pasteCount, - Value contentHash = const Value.absent(), - Value thumbPath = const Value.absent(), - Value sourceModifiedAt = const Value.absent(), - Value brokenSince = const Value.absent(), - }) => ClipboardRow( - id: id ?? this.id, - content: content ?? this.content, - type: type ?? this.type, - createdAt: createdAt ?? this.createdAt, - modifiedAt: modifiedAt ?? this.modifiedAt, - appSource: appSource.present ? appSource.value : this.appSource, - isPinned: isPinned ?? this.isPinned, - label: label.present ? label.value : this.label, - cardColor: cardColor ?? this.cardColor, - metadata: metadata.present ? metadata.value : this.metadata, - pasteCount: pasteCount ?? this.pasteCount, - contentHash: contentHash.present ? contentHash.value : this.contentHash, - thumbPath: thumbPath.present ? thumbPath.value : this.thumbPath, - sourceModifiedAt: sourceModifiedAt.present - ? sourceModifiedAt.value - : this.sourceModifiedAt, - brokenSince: brokenSince.present ? brokenSince.value : this.brokenSince, - ); - ClipboardRow copyWithCompanion(ClipboardItemsCompanion data) { - return ClipboardRow( - id: data.id.present ? data.id.value : this.id, - content: data.content.present ? data.content.value : this.content, - type: data.type.present ? data.type.value : this.type, - createdAt: data.createdAt.present ? data.createdAt.value : this.createdAt, - modifiedAt: data.modifiedAt.present - ? data.modifiedAt.value - : this.modifiedAt, - appSource: data.appSource.present ? data.appSource.value : this.appSource, - isPinned: data.isPinned.present ? data.isPinned.value : this.isPinned, - label: data.label.present ? data.label.value : this.label, - cardColor: data.cardColor.present ? data.cardColor.value : this.cardColor, - metadata: data.metadata.present ? data.metadata.value : this.metadata, - pasteCount: data.pasteCount.present - ? data.pasteCount.value - : this.pasteCount, - contentHash: data.contentHash.present - ? data.contentHash.value - : this.contentHash, - thumbPath: data.thumbPath.present ? data.thumbPath.value : this.thumbPath, - sourceModifiedAt: data.sourceModifiedAt.present - ? data.sourceModifiedAt.value - : this.sourceModifiedAt, - brokenSince: data.brokenSince.present - ? data.brokenSince.value - : this.brokenSince, - ); - } - - @override - String toString() { - return (StringBuffer('ClipboardRow(') - ..write('id: $id, ') - ..write('content: $content, ') - ..write('type: $type, ') - ..write('createdAt: $createdAt, ') - ..write('modifiedAt: $modifiedAt, ') - ..write('appSource: $appSource, ') - ..write('isPinned: $isPinned, ') - ..write('label: $label, ') - ..write('cardColor: $cardColor, ') - ..write('metadata: $metadata, ') - ..write('pasteCount: $pasteCount, ') - ..write('contentHash: $contentHash, ') - ..write('thumbPath: $thumbPath, ') - ..write('sourceModifiedAt: $sourceModifiedAt, ') - ..write('brokenSince: $brokenSince') - ..write(')')) - .toString(); - } - - @override - int get hashCode => Object.hash( - id, - content, - type, - createdAt, - modifiedAt, - appSource, - isPinned, - label, - cardColor, - metadata, - pasteCount, - contentHash, - thumbPath, - sourceModifiedAt, - brokenSince, - ); - @override - bool operator ==(Object other) => - identical(this, other) || - (other is ClipboardRow && - other.id == this.id && - other.content == this.content && - other.type == this.type && - other.createdAt == this.createdAt && - other.modifiedAt == this.modifiedAt && - other.appSource == this.appSource && - other.isPinned == this.isPinned && - other.label == this.label && - other.cardColor == this.cardColor && - other.metadata == this.metadata && - other.pasteCount == this.pasteCount && - other.contentHash == this.contentHash && - other.thumbPath == this.thumbPath && - other.sourceModifiedAt == this.sourceModifiedAt && - other.brokenSince == this.brokenSince); -} - -class ClipboardItemsCompanion extends UpdateCompanion { - final Value id; - final Value content; - final Value type; - final Value createdAt; - final Value modifiedAt; - final Value appSource; - final Value isPinned; - final Value label; - final Value cardColor; - final Value metadata; - final Value pasteCount; - final Value contentHash; - final Value thumbPath; - final Value sourceModifiedAt; - final Value brokenSince; - final Value rowid; - const ClipboardItemsCompanion({ - this.id = const Value.absent(), - this.content = const Value.absent(), - this.type = const Value.absent(), - this.createdAt = const Value.absent(), - this.modifiedAt = const Value.absent(), - this.appSource = const Value.absent(), - this.isPinned = const Value.absent(), - this.label = const Value.absent(), - this.cardColor = const Value.absent(), - this.metadata = const Value.absent(), - this.pasteCount = const Value.absent(), - this.contentHash = const Value.absent(), - this.thumbPath = const Value.absent(), - this.sourceModifiedAt = const Value.absent(), - this.brokenSince = const Value.absent(), - this.rowid = const Value.absent(), - }); - ClipboardItemsCompanion.insert({ - required String id, - required String content, - required int type, - required DateTime createdAt, - required DateTime modifiedAt, - this.appSource = const Value.absent(), - this.isPinned = const Value.absent(), - this.label = const Value.absent(), - this.cardColor = const Value.absent(), - this.metadata = const Value.absent(), - this.pasteCount = const Value.absent(), - this.contentHash = const Value.absent(), - this.thumbPath = const Value.absent(), - this.sourceModifiedAt = const Value.absent(), - this.brokenSince = const Value.absent(), - this.rowid = const Value.absent(), - }) : id = Value(id), - content = Value(content), - type = Value(type), - createdAt = Value(createdAt), - modifiedAt = Value(modifiedAt); - static Insertable custom({ - Expression? id, - Expression? content, - Expression? type, - Expression? createdAt, - Expression? modifiedAt, - Expression? appSource, - Expression? isPinned, - Expression? label, - Expression? cardColor, - Expression? metadata, - Expression? pasteCount, - Expression? contentHash, - Expression? thumbPath, - Expression? sourceModifiedAt, - Expression? brokenSince, - Expression? rowid, - }) { - return RawValuesInsertable({ - if (id != null) 'id': id, - if (content != null) 'content': content, - if (type != null) 'type': type, - if (createdAt != null) 'created_at': createdAt, - if (modifiedAt != null) 'modified_at': modifiedAt, - if (appSource != null) 'app_source': appSource, - if (isPinned != null) 'is_pinned': isPinned, - if (label != null) 'label': label, - if (cardColor != null) 'card_color': cardColor, - if (metadata != null) 'metadata': metadata, - if (pasteCount != null) 'paste_count': pasteCount, - if (contentHash != null) 'content_hash': contentHash, - if (thumbPath != null) 'thumb_path': thumbPath, - if (sourceModifiedAt != null) 'source_modified_at': sourceModifiedAt, - if (brokenSince != null) 'broken_since': brokenSince, - if (rowid != null) 'rowid': rowid, - }); - } - - ClipboardItemsCompanion copyWith({ - Value? id, - Value? content, - Value? type, - Value? createdAt, - Value? modifiedAt, - Value? appSource, - Value? isPinned, - Value? label, - Value? cardColor, - Value? metadata, - Value? pasteCount, - Value? contentHash, - Value? thumbPath, - Value? sourceModifiedAt, - Value? brokenSince, - Value? rowid, - }) { - return ClipboardItemsCompanion( - id: id ?? this.id, - content: content ?? this.content, - type: type ?? this.type, - createdAt: createdAt ?? this.createdAt, - modifiedAt: modifiedAt ?? this.modifiedAt, - appSource: appSource ?? this.appSource, - isPinned: isPinned ?? this.isPinned, - label: label ?? this.label, - cardColor: cardColor ?? this.cardColor, - metadata: metadata ?? this.metadata, - pasteCount: pasteCount ?? this.pasteCount, - contentHash: contentHash ?? this.contentHash, - thumbPath: thumbPath ?? this.thumbPath, - sourceModifiedAt: sourceModifiedAt ?? this.sourceModifiedAt, - brokenSince: brokenSince ?? this.brokenSince, - rowid: rowid ?? this.rowid, - ); - } - - @override - Map toColumns(bool nullToAbsent) { - final map = {}; - if (id.present) { - map['id'] = Variable(id.value); - } - if (content.present) { - map['content'] = Variable(content.value); - } - if (type.present) { - map['type'] = Variable(type.value); - } - if (createdAt.present) { - map['created_at'] = Variable(createdAt.value); - } - if (modifiedAt.present) { - map['modified_at'] = Variable(modifiedAt.value); - } - if (appSource.present) { - map['app_source'] = Variable(appSource.value); - } - if (isPinned.present) { - map['is_pinned'] = Variable(isPinned.value); - } - if (label.present) { - map['label'] = Variable(label.value); - } - if (cardColor.present) { - map['card_color'] = Variable(cardColor.value); - } - if (metadata.present) { - map['metadata'] = Variable(metadata.value); - } - if (pasteCount.present) { - map['paste_count'] = Variable(pasteCount.value); - } - if (contentHash.present) { - map['content_hash'] = Variable(contentHash.value); - } - if (thumbPath.present) { - map['thumb_path'] = Variable(thumbPath.value); - } - if (sourceModifiedAt.present) { - map['source_modified_at'] = Variable(sourceModifiedAt.value); - } - if (brokenSince.present) { - map['broken_since'] = Variable(brokenSince.value); - } - if (rowid.present) { - map['rowid'] = Variable(rowid.value); - } - return map; - } - - @override - String toString() { - return (StringBuffer('ClipboardItemsCompanion(') - ..write('id: $id, ') - ..write('content: $content, ') - ..write('type: $type, ') - ..write('createdAt: $createdAt, ') - ..write('modifiedAt: $modifiedAt, ') - ..write('appSource: $appSource, ') - ..write('isPinned: $isPinned, ') - ..write('label: $label, ') - ..write('cardColor: $cardColor, ') - ..write('metadata: $metadata, ') - ..write('pasteCount: $pasteCount, ') - ..write('contentHash: $contentHash, ') - ..write('thumbPath: $thumbPath, ') - ..write('sourceModifiedAt: $sourceModifiedAt, ') - ..write('brokenSince: $brokenSince, ') - ..write('rowid: $rowid') - ..write(')')) - .toString(); - } -} - -abstract class _$_AppDatabase extends GeneratedDatabase { - _$_AppDatabase(QueryExecutor e) : super(e); - $_AppDatabaseManager get managers => $_AppDatabaseManager(this); - late final $ClipboardItemsTable clipboardItems = $ClipboardItemsTable(this); - @override - Iterable> get allTables => - allSchemaEntities.whereType>(); - @override - List get allSchemaEntities => [clipboardItems]; -} - -typedef $$ClipboardItemsTableCreateCompanionBuilder = - ClipboardItemsCompanion Function({ - required String id, - required String content, - required int type, - required DateTime createdAt, - required DateTime modifiedAt, - Value appSource, - Value isPinned, - Value label, - Value cardColor, - Value metadata, - Value pasteCount, - Value contentHash, - Value thumbPath, - Value sourceModifiedAt, - Value brokenSince, - Value rowid, - }); -typedef $$ClipboardItemsTableUpdateCompanionBuilder = - ClipboardItemsCompanion Function({ - Value id, - Value content, - Value type, - Value createdAt, - Value modifiedAt, - Value appSource, - Value isPinned, - Value label, - Value cardColor, - Value metadata, - Value pasteCount, - Value contentHash, - Value thumbPath, - Value sourceModifiedAt, - Value brokenSince, - Value rowid, - }); - -class $$ClipboardItemsTableFilterComposer - extends Composer<_$_AppDatabase, $ClipboardItemsTable> { - $$ClipboardItemsTableFilterComposer({ - required super.$db, - required super.$table, - super.joinBuilder, - super.$addJoinBuilderToRootComposer, - super.$removeJoinBuilderFromRootComposer, - }); - ColumnFilters get id => $composableBuilder( - column: $table.id, - builder: (column) => ColumnFilters(column), - ); - - ColumnFilters get content => $composableBuilder( - column: $table.content, - builder: (column) => ColumnFilters(column), - ); - - ColumnFilters get type => $composableBuilder( - column: $table.type, - builder: (column) => ColumnFilters(column), - ); - - ColumnFilters get createdAt => $composableBuilder( - column: $table.createdAt, - builder: (column) => ColumnFilters(column), - ); - - ColumnFilters get modifiedAt => $composableBuilder( - column: $table.modifiedAt, - builder: (column) => ColumnFilters(column), - ); - - ColumnFilters get appSource => $composableBuilder( - column: $table.appSource, - builder: (column) => ColumnFilters(column), - ); - - ColumnFilters get isPinned => $composableBuilder( - column: $table.isPinned, - builder: (column) => ColumnFilters(column), - ); - - ColumnFilters get label => $composableBuilder( - column: $table.label, - builder: (column) => ColumnFilters(column), - ); - - ColumnFilters get cardColor => $composableBuilder( - column: $table.cardColor, - builder: (column) => ColumnFilters(column), - ); - - ColumnFilters get metadata => $composableBuilder( - column: $table.metadata, - builder: (column) => ColumnFilters(column), - ); - - ColumnFilters get pasteCount => $composableBuilder( - column: $table.pasteCount, - builder: (column) => ColumnFilters(column), - ); - - ColumnFilters get contentHash => $composableBuilder( - column: $table.contentHash, - builder: (column) => ColumnFilters(column), - ); - - ColumnFilters get thumbPath => $composableBuilder( - column: $table.thumbPath, - builder: (column) => ColumnFilters(column), - ); - - ColumnFilters get sourceModifiedAt => $composableBuilder( - column: $table.sourceModifiedAt, - builder: (column) => ColumnFilters(column), - ); - - ColumnFilters get brokenSince => $composableBuilder( - column: $table.brokenSince, - builder: (column) => ColumnFilters(column), - ); -} - -class $$ClipboardItemsTableOrderingComposer - extends Composer<_$_AppDatabase, $ClipboardItemsTable> { - $$ClipboardItemsTableOrderingComposer({ - required super.$db, - required super.$table, - super.joinBuilder, - super.$addJoinBuilderToRootComposer, - super.$removeJoinBuilderFromRootComposer, - }); - ColumnOrderings get id => $composableBuilder( - column: $table.id, - builder: (column) => ColumnOrderings(column), - ); - - ColumnOrderings get content => $composableBuilder( - column: $table.content, - builder: (column) => ColumnOrderings(column), - ); - - ColumnOrderings get type => $composableBuilder( - column: $table.type, - builder: (column) => ColumnOrderings(column), - ); - - ColumnOrderings get createdAt => $composableBuilder( - column: $table.createdAt, - builder: (column) => ColumnOrderings(column), - ); - - ColumnOrderings get modifiedAt => $composableBuilder( - column: $table.modifiedAt, - builder: (column) => ColumnOrderings(column), - ); - - ColumnOrderings get appSource => $composableBuilder( - column: $table.appSource, - builder: (column) => ColumnOrderings(column), - ); - - ColumnOrderings get isPinned => $composableBuilder( - column: $table.isPinned, - builder: (column) => ColumnOrderings(column), - ); - - ColumnOrderings get label => $composableBuilder( - column: $table.label, - builder: (column) => ColumnOrderings(column), - ); - - ColumnOrderings get cardColor => $composableBuilder( - column: $table.cardColor, - builder: (column) => ColumnOrderings(column), - ); - - ColumnOrderings get metadata => $composableBuilder( - column: $table.metadata, - builder: (column) => ColumnOrderings(column), - ); - - ColumnOrderings get pasteCount => $composableBuilder( - column: $table.pasteCount, - builder: (column) => ColumnOrderings(column), - ); - - ColumnOrderings get contentHash => $composableBuilder( - column: $table.contentHash, - builder: (column) => ColumnOrderings(column), - ); - - ColumnOrderings get thumbPath => $composableBuilder( - column: $table.thumbPath, - builder: (column) => ColumnOrderings(column), - ); - - ColumnOrderings get sourceModifiedAt => $composableBuilder( - column: $table.sourceModifiedAt, - builder: (column) => ColumnOrderings(column), - ); - - ColumnOrderings get brokenSince => $composableBuilder( - column: $table.brokenSince, - builder: (column) => ColumnOrderings(column), - ); -} - -class $$ClipboardItemsTableAnnotationComposer - extends Composer<_$_AppDatabase, $ClipboardItemsTable> { - $$ClipboardItemsTableAnnotationComposer({ - required super.$db, - required super.$table, - super.joinBuilder, - super.$addJoinBuilderToRootComposer, - super.$removeJoinBuilderFromRootComposer, - }); - GeneratedColumn get id => - $composableBuilder(column: $table.id, builder: (column) => column); - - GeneratedColumn get content => - $composableBuilder(column: $table.content, builder: (column) => column); - - GeneratedColumn get type => - $composableBuilder(column: $table.type, builder: (column) => column); - - GeneratedColumn get createdAt => - $composableBuilder(column: $table.createdAt, builder: (column) => column); - - GeneratedColumn get modifiedAt => $composableBuilder( - column: $table.modifiedAt, - builder: (column) => column, - ); - - GeneratedColumn get appSource => - $composableBuilder(column: $table.appSource, builder: (column) => column); - - GeneratedColumn get isPinned => - $composableBuilder(column: $table.isPinned, builder: (column) => column); - - GeneratedColumn get label => - $composableBuilder(column: $table.label, builder: (column) => column); - - GeneratedColumn get cardColor => - $composableBuilder(column: $table.cardColor, builder: (column) => column); - - GeneratedColumn get metadata => - $composableBuilder(column: $table.metadata, builder: (column) => column); - - GeneratedColumn get pasteCount => $composableBuilder( - column: $table.pasteCount, - builder: (column) => column, - ); - - GeneratedColumn get contentHash => $composableBuilder( - column: $table.contentHash, - builder: (column) => column, - ); - - GeneratedColumn get thumbPath => - $composableBuilder(column: $table.thumbPath, builder: (column) => column); - - GeneratedColumn get sourceModifiedAt => $composableBuilder( - column: $table.sourceModifiedAt, - builder: (column) => column, - ); - - GeneratedColumn get brokenSince => $composableBuilder( - column: $table.brokenSince, - builder: (column) => column, - ); -} - -class $$ClipboardItemsTableTableManager - extends - RootTableManager< - _$_AppDatabase, - $ClipboardItemsTable, - ClipboardRow, - $$ClipboardItemsTableFilterComposer, - $$ClipboardItemsTableOrderingComposer, - $$ClipboardItemsTableAnnotationComposer, - $$ClipboardItemsTableCreateCompanionBuilder, - $$ClipboardItemsTableUpdateCompanionBuilder, - ( - ClipboardRow, - BaseReferences<_$_AppDatabase, $ClipboardItemsTable, ClipboardRow>, - ), - ClipboardRow, - PrefetchHooks Function() - > { - $$ClipboardItemsTableTableManager( - _$_AppDatabase db, - $ClipboardItemsTable table, - ) : super( - TableManagerState( - db: db, - table: table, - createFilteringComposer: () => - $$ClipboardItemsTableFilterComposer($db: db, $table: table), - createOrderingComposer: () => - $$ClipboardItemsTableOrderingComposer($db: db, $table: table), - createComputedFieldComposer: () => - $$ClipboardItemsTableAnnotationComposer($db: db, $table: table), - updateCompanionCallback: - ({ - Value id = const Value.absent(), - Value content = const Value.absent(), - Value type = const Value.absent(), - Value createdAt = const Value.absent(), - Value modifiedAt = const Value.absent(), - Value appSource = const Value.absent(), - Value isPinned = const Value.absent(), - Value label = const Value.absent(), - Value cardColor = const Value.absent(), - Value metadata = const Value.absent(), - Value pasteCount = const Value.absent(), - Value contentHash = const Value.absent(), - Value thumbPath = const Value.absent(), - Value sourceModifiedAt = const Value.absent(), - Value brokenSince = const Value.absent(), - Value rowid = const Value.absent(), - }) => ClipboardItemsCompanion( - id: id, - content: content, - type: type, - createdAt: createdAt, - modifiedAt: modifiedAt, - appSource: appSource, - isPinned: isPinned, - label: label, - cardColor: cardColor, - metadata: metadata, - pasteCount: pasteCount, - contentHash: contentHash, - thumbPath: thumbPath, - sourceModifiedAt: sourceModifiedAt, - brokenSince: brokenSince, - rowid: rowid, - ), - createCompanionCallback: - ({ - required String id, - required String content, - required int type, - required DateTime createdAt, - required DateTime modifiedAt, - Value appSource = const Value.absent(), - Value isPinned = const Value.absent(), - Value label = const Value.absent(), - Value cardColor = const Value.absent(), - Value metadata = const Value.absent(), - Value pasteCount = const Value.absent(), - Value contentHash = const Value.absent(), - Value thumbPath = const Value.absent(), - Value sourceModifiedAt = const Value.absent(), - Value brokenSince = const Value.absent(), - Value rowid = const Value.absent(), - }) => ClipboardItemsCompanion.insert( - id: id, - content: content, - type: type, - createdAt: createdAt, - modifiedAt: modifiedAt, - appSource: appSource, - isPinned: isPinned, - label: label, - cardColor: cardColor, - metadata: metadata, - pasteCount: pasteCount, - contentHash: contentHash, - thumbPath: thumbPath, - sourceModifiedAt: sourceModifiedAt, - brokenSince: brokenSince, - rowid: rowid, - ), - withReferenceMapper: (p0) => p0 - .map((e) => (e.readTable(table), BaseReferences(db, table, e))) - .toList(), - prefetchHooksCallback: null, - ), - ); -} - -typedef $$ClipboardItemsTableProcessedTableManager = - ProcessedTableManager< - _$_AppDatabase, - $ClipboardItemsTable, - ClipboardRow, - $$ClipboardItemsTableFilterComposer, - $$ClipboardItemsTableOrderingComposer, - $$ClipboardItemsTableAnnotationComposer, - $$ClipboardItemsTableCreateCompanionBuilder, - $$ClipboardItemsTableUpdateCompanionBuilder, - ( - ClipboardRow, - BaseReferences<_$_AppDatabase, $ClipboardItemsTable, ClipboardRow>, - ), - ClipboardRow, - PrefetchHooks Function() - >; - -class $_AppDatabaseManager { - final _$_AppDatabase _db; - $_AppDatabaseManager(this._db); - $$ClipboardItemsTableTableManager get clipboardItems => - $$ClipboardItemsTableTableManager(_db, _db.clipboardItems); -} +// coverage:ignore-file +// GENERATED CODE - DO NOT MODIFY BY HAND + +part of 'sqlite_repository.dart'; + +// ignore_for_file: type=lint +class $ClipboardItemsTable extends ClipboardItems + with TableInfo<$ClipboardItemsTable, ClipboardRow> { + @override + final GeneratedDatabase attachedDatabase; + final String? _alias; + $ClipboardItemsTable(this.attachedDatabase, [this._alias]); + static const VerificationMeta _idMeta = const VerificationMeta('id'); + @override + late final GeneratedColumn id = GeneratedColumn( + 'id', + aliasedName, + false, + type: DriftSqlType.string, + requiredDuringInsert: true, + ); + static const VerificationMeta _contentMeta = const VerificationMeta( + 'content', + ); + @override + late final GeneratedColumn content = GeneratedColumn( + 'content', + aliasedName, + false, + type: DriftSqlType.string, + requiredDuringInsert: true, + ); + static const VerificationMeta _typeMeta = const VerificationMeta('type'); + @override + late final GeneratedColumn type = GeneratedColumn( + 'type', + aliasedName, + false, + type: DriftSqlType.int, + requiredDuringInsert: true, + ); + static const VerificationMeta _createdAtMeta = const VerificationMeta( + 'createdAt', + ); + @override + late final GeneratedColumn createdAt = GeneratedColumn( + 'created_at', + aliasedName, + false, + type: DriftSqlType.dateTime, + requiredDuringInsert: true, + ); + static const VerificationMeta _modifiedAtMeta = const VerificationMeta( + 'modifiedAt', + ); + @override + late final GeneratedColumn modifiedAt = GeneratedColumn( + 'modified_at', + aliasedName, + false, + type: DriftSqlType.dateTime, + requiredDuringInsert: true, + ); + static const VerificationMeta _appSourceMeta = const VerificationMeta( + 'appSource', + ); + @override + late final GeneratedColumn appSource = GeneratedColumn( + 'app_source', + aliasedName, + true, + type: DriftSqlType.string, + requiredDuringInsert: false, + ); + static const VerificationMeta _isPinnedMeta = const VerificationMeta( + 'isPinned', + ); + @override + late final GeneratedColumn isPinned = GeneratedColumn( + 'is_pinned', + aliasedName, + false, + type: DriftSqlType.bool, + requiredDuringInsert: false, + defaultConstraints: GeneratedColumn.constraintIsAlways( + 'CHECK ("is_pinned" IN (0, 1))', + ), + defaultValue: const Constant(false), + ); + static const VerificationMeta _labelMeta = const VerificationMeta('label'); + @override + late final GeneratedColumn label = GeneratedColumn( + 'label', + aliasedName, + true, + type: DriftSqlType.string, + requiredDuringInsert: false, + ); + static const VerificationMeta _cardColorMeta = const VerificationMeta( + 'cardColor', + ); + @override + late final GeneratedColumn cardColor = GeneratedColumn( + 'card_color', + aliasedName, + false, + type: DriftSqlType.int, + requiredDuringInsert: false, + defaultValue: const Constant(0), + ); + static const VerificationMeta _metadataMeta = const VerificationMeta( + 'metadata', + ); + @override + late final GeneratedColumn metadata = GeneratedColumn( + 'metadata', + aliasedName, + true, + type: DriftSqlType.string, + requiredDuringInsert: false, + ); + static const VerificationMeta _pasteCountMeta = const VerificationMeta( + 'pasteCount', + ); + @override + late final GeneratedColumn pasteCount = GeneratedColumn( + 'paste_count', + aliasedName, + false, + type: DriftSqlType.int, + requiredDuringInsert: false, + defaultValue: const Constant(0), + ); + static const VerificationMeta _contentHashMeta = const VerificationMeta( + 'contentHash', + ); + @override + late final GeneratedColumn contentHash = GeneratedColumn( + 'content_hash', + aliasedName, + true, + type: DriftSqlType.string, + requiredDuringInsert: false, + ); + static const VerificationMeta _thumbPathMeta = const VerificationMeta( + 'thumbPath', + ); + @override + late final GeneratedColumn thumbPath = GeneratedColumn( + 'thumb_path', + aliasedName, + true, + type: DriftSqlType.string, + requiredDuringInsert: false, + ); + static const VerificationMeta _sourceModifiedAtMeta = const VerificationMeta( + 'sourceModifiedAt', + ); + @override + late final GeneratedColumn sourceModifiedAt = + GeneratedColumn( + 'source_modified_at', + aliasedName, + true, + type: DriftSqlType.dateTime, + requiredDuringInsert: false, + ); + static const VerificationMeta _brokenSinceMeta = const VerificationMeta( + 'brokenSince', + ); + @override + late final GeneratedColumn brokenSince = GeneratedColumn( + 'broken_since', + aliasedName, + true, + type: DriftSqlType.dateTime, + requiredDuringInsert: false, + ); + @override + List get $columns => [ + id, + content, + type, + createdAt, + modifiedAt, + appSource, + isPinned, + label, + cardColor, + metadata, + pasteCount, + contentHash, + thumbPath, + sourceModifiedAt, + brokenSince, + ]; + @override + String get aliasedName => _alias ?? actualTableName; + @override + String get actualTableName => $name; + static const String $name = 'clipboard_items'; + @override + VerificationContext validateIntegrity( + Insertable instance, { + bool isInserting = false, + }) { + final context = VerificationContext(); + final data = instance.toColumns(true); + if (data.containsKey('id')) { + context.handle(_idMeta, id.isAcceptableOrUnknown(data['id']!, _idMeta)); + } else if (isInserting) { + context.missing(_idMeta); + } + if (data.containsKey('content')) { + context.handle( + _contentMeta, + content.isAcceptableOrUnknown(data['content']!, _contentMeta), + ); + } else if (isInserting) { + context.missing(_contentMeta); + } + if (data.containsKey('type')) { + context.handle( + _typeMeta, + type.isAcceptableOrUnknown(data['type']!, _typeMeta), + ); + } else if (isInserting) { + context.missing(_typeMeta); + } + if (data.containsKey('created_at')) { + context.handle( + _createdAtMeta, + createdAt.isAcceptableOrUnknown(data['created_at']!, _createdAtMeta), + ); + } else if (isInserting) { + context.missing(_createdAtMeta); + } + if (data.containsKey('modified_at')) { + context.handle( + _modifiedAtMeta, + modifiedAt.isAcceptableOrUnknown(data['modified_at']!, _modifiedAtMeta), + ); + } else if (isInserting) { + context.missing(_modifiedAtMeta); + } + if (data.containsKey('app_source')) { + context.handle( + _appSourceMeta, + appSource.isAcceptableOrUnknown(data['app_source']!, _appSourceMeta), + ); + } + if (data.containsKey('is_pinned')) { + context.handle( + _isPinnedMeta, + isPinned.isAcceptableOrUnknown(data['is_pinned']!, _isPinnedMeta), + ); + } + if (data.containsKey('label')) { + context.handle( + _labelMeta, + label.isAcceptableOrUnknown(data['label']!, _labelMeta), + ); + } + if (data.containsKey('card_color')) { + context.handle( + _cardColorMeta, + cardColor.isAcceptableOrUnknown(data['card_color']!, _cardColorMeta), + ); + } + if (data.containsKey('metadata')) { + context.handle( + _metadataMeta, + metadata.isAcceptableOrUnknown(data['metadata']!, _metadataMeta), + ); + } + if (data.containsKey('paste_count')) { + context.handle( + _pasteCountMeta, + pasteCount.isAcceptableOrUnknown(data['paste_count']!, _pasteCountMeta), + ); + } + if (data.containsKey('content_hash')) { + context.handle( + _contentHashMeta, + contentHash.isAcceptableOrUnknown( + data['content_hash']!, + _contentHashMeta, + ), + ); + } + if (data.containsKey('thumb_path')) { + context.handle( + _thumbPathMeta, + thumbPath.isAcceptableOrUnknown(data['thumb_path']!, _thumbPathMeta), + ); + } + if (data.containsKey('source_modified_at')) { + context.handle( + _sourceModifiedAtMeta, + sourceModifiedAt.isAcceptableOrUnknown( + data['source_modified_at']!, + _sourceModifiedAtMeta, + ), + ); + } + if (data.containsKey('broken_since')) { + context.handle( + _brokenSinceMeta, + brokenSince.isAcceptableOrUnknown( + data['broken_since']!, + _brokenSinceMeta, + ), + ); + } + return context; + } + + @override + Set get $primaryKey => {id}; + @override + ClipboardRow map(Map data, {String? tablePrefix}) { + final effectivePrefix = tablePrefix != null ? '$tablePrefix.' : ''; + return ClipboardRow( + id: attachedDatabase.typeMapping.read( + DriftSqlType.string, + data['${effectivePrefix}id'], + )!, + content: attachedDatabase.typeMapping.read( + DriftSqlType.string, + data['${effectivePrefix}content'], + )!, + type: attachedDatabase.typeMapping.read( + DriftSqlType.int, + data['${effectivePrefix}type'], + )!, + createdAt: attachedDatabase.typeMapping.read( + DriftSqlType.dateTime, + data['${effectivePrefix}created_at'], + )!, + modifiedAt: attachedDatabase.typeMapping.read( + DriftSqlType.dateTime, + data['${effectivePrefix}modified_at'], + )!, + appSource: attachedDatabase.typeMapping.read( + DriftSqlType.string, + data['${effectivePrefix}app_source'], + ), + isPinned: attachedDatabase.typeMapping.read( + DriftSqlType.bool, + data['${effectivePrefix}is_pinned'], + )!, + label: attachedDatabase.typeMapping.read( + DriftSqlType.string, + data['${effectivePrefix}label'], + ), + cardColor: attachedDatabase.typeMapping.read( + DriftSqlType.int, + data['${effectivePrefix}card_color'], + )!, + metadata: attachedDatabase.typeMapping.read( + DriftSqlType.string, + data['${effectivePrefix}metadata'], + ), + pasteCount: attachedDatabase.typeMapping.read( + DriftSqlType.int, + data['${effectivePrefix}paste_count'], + )!, + contentHash: attachedDatabase.typeMapping.read( + DriftSqlType.string, + data['${effectivePrefix}content_hash'], + ), + thumbPath: attachedDatabase.typeMapping.read( + DriftSqlType.string, + data['${effectivePrefix}thumb_path'], + ), + sourceModifiedAt: attachedDatabase.typeMapping.read( + DriftSqlType.dateTime, + data['${effectivePrefix}source_modified_at'], + ), + brokenSince: attachedDatabase.typeMapping.read( + DriftSqlType.dateTime, + data['${effectivePrefix}broken_since'], + ), + ); + } + + @override + $ClipboardItemsTable createAlias(String alias) { + return $ClipboardItemsTable(attachedDatabase, alias); + } +} + +class ClipboardRow extends DataClass implements Insertable { + final String id; + final String content; + final int type; + final DateTime createdAt; + final DateTime modifiedAt; + final String? appSource; + final bool isPinned; + final String? label; + final int cardColor; + final String? metadata; + final int pasteCount; + final String? contentHash; + final String? thumbPath; + final DateTime? sourceModifiedAt; + final DateTime? brokenSince; + const ClipboardRow({ + required this.id, + required this.content, + required this.type, + required this.createdAt, + required this.modifiedAt, + this.appSource, + required this.isPinned, + this.label, + required this.cardColor, + this.metadata, + required this.pasteCount, + this.contentHash, + this.thumbPath, + this.sourceModifiedAt, + this.brokenSince, + }); + @override + Map toColumns(bool nullToAbsent) { + final map = {}; + map['id'] = Variable(id); + map['content'] = Variable(content); + map['type'] = Variable(type); + map['created_at'] = Variable(createdAt); + map['modified_at'] = Variable(modifiedAt); + if (!nullToAbsent || appSource != null) { + map['app_source'] = Variable(appSource); + } + map['is_pinned'] = Variable(isPinned); + if (!nullToAbsent || label != null) { + map['label'] = Variable(label); + } + map['card_color'] = Variable(cardColor); + if (!nullToAbsent || metadata != null) { + map['metadata'] = Variable(metadata); + } + map['paste_count'] = Variable(pasteCount); + if (!nullToAbsent || contentHash != null) { + map['content_hash'] = Variable(contentHash); + } + if (!nullToAbsent || thumbPath != null) { + map['thumb_path'] = Variable(thumbPath); + } + if (!nullToAbsent || sourceModifiedAt != null) { + map['source_modified_at'] = Variable(sourceModifiedAt); + } + if (!nullToAbsent || brokenSince != null) { + map['broken_since'] = Variable(brokenSince); + } + return map; + } + + ClipboardItemsCompanion toCompanion(bool nullToAbsent) { + return ClipboardItemsCompanion( + id: Value(id), + content: Value(content), + type: Value(type), + createdAt: Value(createdAt), + modifiedAt: Value(modifiedAt), + appSource: appSource == null && nullToAbsent + ? const Value.absent() + : Value(appSource), + isPinned: Value(isPinned), + label: label == null && nullToAbsent + ? const Value.absent() + : Value(label), + cardColor: Value(cardColor), + metadata: metadata == null && nullToAbsent + ? const Value.absent() + : Value(metadata), + pasteCount: Value(pasteCount), + contentHash: contentHash == null && nullToAbsent + ? const Value.absent() + : Value(contentHash), + thumbPath: thumbPath == null && nullToAbsent + ? const Value.absent() + : Value(thumbPath), + sourceModifiedAt: sourceModifiedAt == null && nullToAbsent + ? const Value.absent() + : Value(sourceModifiedAt), + brokenSince: brokenSince == null && nullToAbsent + ? const Value.absent() + : Value(brokenSince), + ); + } + + factory ClipboardRow.fromJson( + Map json, { + ValueSerializer? serializer, + }) { + serializer ??= driftRuntimeOptions.defaultSerializer; + return ClipboardRow( + id: serializer.fromJson(json['id']), + content: serializer.fromJson(json['content']), + type: serializer.fromJson(json['type']), + createdAt: serializer.fromJson(json['createdAt']), + modifiedAt: serializer.fromJson(json['modifiedAt']), + appSource: serializer.fromJson(json['appSource']), + isPinned: serializer.fromJson(json['isPinned']), + label: serializer.fromJson(json['label']), + cardColor: serializer.fromJson(json['cardColor']), + metadata: serializer.fromJson(json['metadata']), + pasteCount: serializer.fromJson(json['pasteCount']), + contentHash: serializer.fromJson(json['contentHash']), + thumbPath: serializer.fromJson(json['thumbPath']), + sourceModifiedAt: serializer.fromJson( + json['sourceModifiedAt'], + ), + brokenSince: serializer.fromJson(json['brokenSince']), + ); + } + @override + Map toJson({ValueSerializer? serializer}) { + serializer ??= driftRuntimeOptions.defaultSerializer; + return { + 'id': serializer.toJson(id), + 'content': serializer.toJson(content), + 'type': serializer.toJson(type), + 'createdAt': serializer.toJson(createdAt), + 'modifiedAt': serializer.toJson(modifiedAt), + 'appSource': serializer.toJson(appSource), + 'isPinned': serializer.toJson(isPinned), + 'label': serializer.toJson(label), + 'cardColor': serializer.toJson(cardColor), + 'metadata': serializer.toJson(metadata), + 'pasteCount': serializer.toJson(pasteCount), + 'contentHash': serializer.toJson(contentHash), + 'thumbPath': serializer.toJson(thumbPath), + 'sourceModifiedAt': serializer.toJson(sourceModifiedAt), + 'brokenSince': serializer.toJson(brokenSince), + }; + } + + ClipboardRow copyWith({ + String? id, + String? content, + int? type, + DateTime? createdAt, + DateTime? modifiedAt, + Value appSource = const Value.absent(), + bool? isPinned, + Value label = const Value.absent(), + int? cardColor, + Value metadata = const Value.absent(), + int? pasteCount, + Value contentHash = const Value.absent(), + Value thumbPath = const Value.absent(), + Value sourceModifiedAt = const Value.absent(), + Value brokenSince = const Value.absent(), + }) => ClipboardRow( + id: id ?? this.id, + content: content ?? this.content, + type: type ?? this.type, + createdAt: createdAt ?? this.createdAt, + modifiedAt: modifiedAt ?? this.modifiedAt, + appSource: appSource.present ? appSource.value : this.appSource, + isPinned: isPinned ?? this.isPinned, + label: label.present ? label.value : this.label, + cardColor: cardColor ?? this.cardColor, + metadata: metadata.present ? metadata.value : this.metadata, + pasteCount: pasteCount ?? this.pasteCount, + contentHash: contentHash.present ? contentHash.value : this.contentHash, + thumbPath: thumbPath.present ? thumbPath.value : this.thumbPath, + sourceModifiedAt: sourceModifiedAt.present + ? sourceModifiedAt.value + : this.sourceModifiedAt, + brokenSince: brokenSince.present ? brokenSince.value : this.brokenSince, + ); + ClipboardRow copyWithCompanion(ClipboardItemsCompanion data) { + return ClipboardRow( + id: data.id.present ? data.id.value : this.id, + content: data.content.present ? data.content.value : this.content, + type: data.type.present ? data.type.value : this.type, + createdAt: data.createdAt.present ? data.createdAt.value : this.createdAt, + modifiedAt: data.modifiedAt.present + ? data.modifiedAt.value + : this.modifiedAt, + appSource: data.appSource.present ? data.appSource.value : this.appSource, + isPinned: data.isPinned.present ? data.isPinned.value : this.isPinned, + label: data.label.present ? data.label.value : this.label, + cardColor: data.cardColor.present ? data.cardColor.value : this.cardColor, + metadata: data.metadata.present ? data.metadata.value : this.metadata, + pasteCount: data.pasteCount.present + ? data.pasteCount.value + : this.pasteCount, + contentHash: data.contentHash.present + ? data.contentHash.value + : this.contentHash, + thumbPath: data.thumbPath.present ? data.thumbPath.value : this.thumbPath, + sourceModifiedAt: data.sourceModifiedAt.present + ? data.sourceModifiedAt.value + : this.sourceModifiedAt, + brokenSince: data.brokenSince.present + ? data.brokenSince.value + : this.brokenSince, + ); + } + + @override + String toString() { + return (StringBuffer('ClipboardRow(') + ..write('id: $id, ') + ..write('content: $content, ') + ..write('type: $type, ') + ..write('createdAt: $createdAt, ') + ..write('modifiedAt: $modifiedAt, ') + ..write('appSource: $appSource, ') + ..write('isPinned: $isPinned, ') + ..write('label: $label, ') + ..write('cardColor: $cardColor, ') + ..write('metadata: $metadata, ') + ..write('pasteCount: $pasteCount, ') + ..write('contentHash: $contentHash, ') + ..write('thumbPath: $thumbPath, ') + ..write('sourceModifiedAt: $sourceModifiedAt, ') + ..write('brokenSince: $brokenSince') + ..write(')')) + .toString(); + } + + @override + int get hashCode => Object.hash( + id, + content, + type, + createdAt, + modifiedAt, + appSource, + isPinned, + label, + cardColor, + metadata, + pasteCount, + contentHash, + thumbPath, + sourceModifiedAt, + brokenSince, + ); + @override + bool operator ==(Object other) => + identical(this, other) || + (other is ClipboardRow && + other.id == this.id && + other.content == this.content && + other.type == this.type && + other.createdAt == this.createdAt && + other.modifiedAt == this.modifiedAt && + other.appSource == this.appSource && + other.isPinned == this.isPinned && + other.label == this.label && + other.cardColor == this.cardColor && + other.metadata == this.metadata && + other.pasteCount == this.pasteCount && + other.contentHash == this.contentHash && + other.thumbPath == this.thumbPath && + other.sourceModifiedAt == this.sourceModifiedAt && + other.brokenSince == this.brokenSince); +} + +class ClipboardItemsCompanion extends UpdateCompanion { + final Value id; + final Value content; + final Value type; + final Value createdAt; + final Value modifiedAt; + final Value appSource; + final Value isPinned; + final Value label; + final Value cardColor; + final Value metadata; + final Value pasteCount; + final Value contentHash; + final Value thumbPath; + final Value sourceModifiedAt; + final Value brokenSince; + final Value rowid; + const ClipboardItemsCompanion({ + this.id = const Value.absent(), + this.content = const Value.absent(), + this.type = const Value.absent(), + this.createdAt = const Value.absent(), + this.modifiedAt = const Value.absent(), + this.appSource = const Value.absent(), + this.isPinned = const Value.absent(), + this.label = const Value.absent(), + this.cardColor = const Value.absent(), + this.metadata = const Value.absent(), + this.pasteCount = const Value.absent(), + this.contentHash = const Value.absent(), + this.thumbPath = const Value.absent(), + this.sourceModifiedAt = const Value.absent(), + this.brokenSince = const Value.absent(), + this.rowid = const Value.absent(), + }); + ClipboardItemsCompanion.insert({ + required String id, + required String content, + required int type, + required DateTime createdAt, + required DateTime modifiedAt, + this.appSource = const Value.absent(), + this.isPinned = const Value.absent(), + this.label = const Value.absent(), + this.cardColor = const Value.absent(), + this.metadata = const Value.absent(), + this.pasteCount = const Value.absent(), + this.contentHash = const Value.absent(), + this.thumbPath = const Value.absent(), + this.sourceModifiedAt = const Value.absent(), + this.brokenSince = const Value.absent(), + this.rowid = const Value.absent(), + }) : id = Value(id), + content = Value(content), + type = Value(type), + createdAt = Value(createdAt), + modifiedAt = Value(modifiedAt); + static Insertable custom({ + Expression? id, + Expression? content, + Expression? type, + Expression? createdAt, + Expression? modifiedAt, + Expression? appSource, + Expression? isPinned, + Expression? label, + Expression? cardColor, + Expression? metadata, + Expression? pasteCount, + Expression? contentHash, + Expression? thumbPath, + Expression? sourceModifiedAt, + Expression? brokenSince, + Expression? rowid, + }) { + return RawValuesInsertable({ + if (id != null) 'id': id, + if (content != null) 'content': content, + if (type != null) 'type': type, + if (createdAt != null) 'created_at': createdAt, + if (modifiedAt != null) 'modified_at': modifiedAt, + if (appSource != null) 'app_source': appSource, + if (isPinned != null) 'is_pinned': isPinned, + if (label != null) 'label': label, + if (cardColor != null) 'card_color': cardColor, + if (metadata != null) 'metadata': metadata, + if (pasteCount != null) 'paste_count': pasteCount, + if (contentHash != null) 'content_hash': contentHash, + if (thumbPath != null) 'thumb_path': thumbPath, + if (sourceModifiedAt != null) 'source_modified_at': sourceModifiedAt, + if (brokenSince != null) 'broken_since': brokenSince, + if (rowid != null) 'rowid': rowid, + }); + } + + ClipboardItemsCompanion copyWith({ + Value? id, + Value? content, + Value? type, + Value? createdAt, + Value? modifiedAt, + Value? appSource, + Value? isPinned, + Value? label, + Value? cardColor, + Value? metadata, + Value? pasteCount, + Value? contentHash, + Value? thumbPath, + Value? sourceModifiedAt, + Value? brokenSince, + Value? rowid, + }) { + return ClipboardItemsCompanion( + id: id ?? this.id, + content: content ?? this.content, + type: type ?? this.type, + createdAt: createdAt ?? this.createdAt, + modifiedAt: modifiedAt ?? this.modifiedAt, + appSource: appSource ?? this.appSource, + isPinned: isPinned ?? this.isPinned, + label: label ?? this.label, + cardColor: cardColor ?? this.cardColor, + metadata: metadata ?? this.metadata, + pasteCount: pasteCount ?? this.pasteCount, + contentHash: contentHash ?? this.contentHash, + thumbPath: thumbPath ?? this.thumbPath, + sourceModifiedAt: sourceModifiedAt ?? this.sourceModifiedAt, + brokenSince: brokenSince ?? this.brokenSince, + rowid: rowid ?? this.rowid, + ); + } + + @override + Map toColumns(bool nullToAbsent) { + final map = {}; + if (id.present) { + map['id'] = Variable(id.value); + } + if (content.present) { + map['content'] = Variable(content.value); + } + if (type.present) { + map['type'] = Variable(type.value); + } + if (createdAt.present) { + map['created_at'] = Variable(createdAt.value); + } + if (modifiedAt.present) { + map['modified_at'] = Variable(modifiedAt.value); + } + if (appSource.present) { + map['app_source'] = Variable(appSource.value); + } + if (isPinned.present) { + map['is_pinned'] = Variable(isPinned.value); + } + if (label.present) { + map['label'] = Variable(label.value); + } + if (cardColor.present) { + map['card_color'] = Variable(cardColor.value); + } + if (metadata.present) { + map['metadata'] = Variable(metadata.value); + } + if (pasteCount.present) { + map['paste_count'] = Variable(pasteCount.value); + } + if (contentHash.present) { + map['content_hash'] = Variable(contentHash.value); + } + if (thumbPath.present) { + map['thumb_path'] = Variable(thumbPath.value); + } + if (sourceModifiedAt.present) { + map['source_modified_at'] = Variable(sourceModifiedAt.value); + } + if (brokenSince.present) { + map['broken_since'] = Variable(brokenSince.value); + } + if (rowid.present) { + map['rowid'] = Variable(rowid.value); + } + return map; + } + + @override + String toString() { + return (StringBuffer('ClipboardItemsCompanion(') + ..write('id: $id, ') + ..write('content: $content, ') + ..write('type: $type, ') + ..write('createdAt: $createdAt, ') + ..write('modifiedAt: $modifiedAt, ') + ..write('appSource: $appSource, ') + ..write('isPinned: $isPinned, ') + ..write('label: $label, ') + ..write('cardColor: $cardColor, ') + ..write('metadata: $metadata, ') + ..write('pasteCount: $pasteCount, ') + ..write('contentHash: $contentHash, ') + ..write('thumbPath: $thumbPath, ') + ..write('sourceModifiedAt: $sourceModifiedAt, ') + ..write('brokenSince: $brokenSince, ') + ..write('rowid: $rowid') + ..write(')')) + .toString(); + } +} + +abstract class _$_AppDatabase extends GeneratedDatabase { + _$_AppDatabase(QueryExecutor e) : super(e); + $_AppDatabaseManager get managers => $_AppDatabaseManager(this); + late final $ClipboardItemsTable clipboardItems = $ClipboardItemsTable(this); + @override + Iterable> get allTables => + allSchemaEntities.whereType>(); + @override + List get allSchemaEntities => [clipboardItems]; +} + +typedef $$ClipboardItemsTableCreateCompanionBuilder = + ClipboardItemsCompanion Function({ + required String id, + required String content, + required int type, + required DateTime createdAt, + required DateTime modifiedAt, + Value appSource, + Value isPinned, + Value label, + Value cardColor, + Value metadata, + Value pasteCount, + Value contentHash, + Value thumbPath, + Value sourceModifiedAt, + Value brokenSince, + Value rowid, + }); +typedef $$ClipboardItemsTableUpdateCompanionBuilder = + ClipboardItemsCompanion Function({ + Value id, + Value content, + Value type, + Value createdAt, + Value modifiedAt, + Value appSource, + Value isPinned, + Value label, + Value cardColor, + Value metadata, + Value pasteCount, + Value contentHash, + Value thumbPath, + Value sourceModifiedAt, + Value brokenSince, + Value rowid, + }); + +class $$ClipboardItemsTableFilterComposer + extends Composer<_$_AppDatabase, $ClipboardItemsTable> { + $$ClipboardItemsTableFilterComposer({ + required super.$db, + required super.$table, + super.joinBuilder, + super.$addJoinBuilderToRootComposer, + super.$removeJoinBuilderFromRootComposer, + }); + ColumnFilters get id => $composableBuilder( + column: $table.id, + builder: (column) => ColumnFilters(column), + ); + + ColumnFilters get content => $composableBuilder( + column: $table.content, + builder: (column) => ColumnFilters(column), + ); + + ColumnFilters get type => $composableBuilder( + column: $table.type, + builder: (column) => ColumnFilters(column), + ); + + ColumnFilters get createdAt => $composableBuilder( + column: $table.createdAt, + builder: (column) => ColumnFilters(column), + ); + + ColumnFilters get modifiedAt => $composableBuilder( + column: $table.modifiedAt, + builder: (column) => ColumnFilters(column), + ); + + ColumnFilters get appSource => $composableBuilder( + column: $table.appSource, + builder: (column) => ColumnFilters(column), + ); + + ColumnFilters get isPinned => $composableBuilder( + column: $table.isPinned, + builder: (column) => ColumnFilters(column), + ); + + ColumnFilters get label => $composableBuilder( + column: $table.label, + builder: (column) => ColumnFilters(column), + ); + + ColumnFilters get cardColor => $composableBuilder( + column: $table.cardColor, + builder: (column) => ColumnFilters(column), + ); + + ColumnFilters get metadata => $composableBuilder( + column: $table.metadata, + builder: (column) => ColumnFilters(column), + ); + + ColumnFilters get pasteCount => $composableBuilder( + column: $table.pasteCount, + builder: (column) => ColumnFilters(column), + ); + + ColumnFilters get contentHash => $composableBuilder( + column: $table.contentHash, + builder: (column) => ColumnFilters(column), + ); + + ColumnFilters get thumbPath => $composableBuilder( + column: $table.thumbPath, + builder: (column) => ColumnFilters(column), + ); + + ColumnFilters get sourceModifiedAt => $composableBuilder( + column: $table.sourceModifiedAt, + builder: (column) => ColumnFilters(column), + ); + + ColumnFilters get brokenSince => $composableBuilder( + column: $table.brokenSince, + builder: (column) => ColumnFilters(column), + ); +} + +class $$ClipboardItemsTableOrderingComposer + extends Composer<_$_AppDatabase, $ClipboardItemsTable> { + $$ClipboardItemsTableOrderingComposer({ + required super.$db, + required super.$table, + super.joinBuilder, + super.$addJoinBuilderToRootComposer, + super.$removeJoinBuilderFromRootComposer, + }); + ColumnOrderings get id => $composableBuilder( + column: $table.id, + builder: (column) => ColumnOrderings(column), + ); + + ColumnOrderings get content => $composableBuilder( + column: $table.content, + builder: (column) => ColumnOrderings(column), + ); + + ColumnOrderings get type => $composableBuilder( + column: $table.type, + builder: (column) => ColumnOrderings(column), + ); + + ColumnOrderings get createdAt => $composableBuilder( + column: $table.createdAt, + builder: (column) => ColumnOrderings(column), + ); + + ColumnOrderings get modifiedAt => $composableBuilder( + column: $table.modifiedAt, + builder: (column) => ColumnOrderings(column), + ); + + ColumnOrderings get appSource => $composableBuilder( + column: $table.appSource, + builder: (column) => ColumnOrderings(column), + ); + + ColumnOrderings get isPinned => $composableBuilder( + column: $table.isPinned, + builder: (column) => ColumnOrderings(column), + ); + + ColumnOrderings get label => $composableBuilder( + column: $table.label, + builder: (column) => ColumnOrderings(column), + ); + + ColumnOrderings get cardColor => $composableBuilder( + column: $table.cardColor, + builder: (column) => ColumnOrderings(column), + ); + + ColumnOrderings get metadata => $composableBuilder( + column: $table.metadata, + builder: (column) => ColumnOrderings(column), + ); + + ColumnOrderings get pasteCount => $composableBuilder( + column: $table.pasteCount, + builder: (column) => ColumnOrderings(column), + ); + + ColumnOrderings get contentHash => $composableBuilder( + column: $table.contentHash, + builder: (column) => ColumnOrderings(column), + ); + + ColumnOrderings get thumbPath => $composableBuilder( + column: $table.thumbPath, + builder: (column) => ColumnOrderings(column), + ); + + ColumnOrderings get sourceModifiedAt => $composableBuilder( + column: $table.sourceModifiedAt, + builder: (column) => ColumnOrderings(column), + ); + + ColumnOrderings get brokenSince => $composableBuilder( + column: $table.brokenSince, + builder: (column) => ColumnOrderings(column), + ); +} + +class $$ClipboardItemsTableAnnotationComposer + extends Composer<_$_AppDatabase, $ClipboardItemsTable> { + $$ClipboardItemsTableAnnotationComposer({ + required super.$db, + required super.$table, + super.joinBuilder, + super.$addJoinBuilderToRootComposer, + super.$removeJoinBuilderFromRootComposer, + }); + GeneratedColumn get id => + $composableBuilder(column: $table.id, builder: (column) => column); + + GeneratedColumn get content => + $composableBuilder(column: $table.content, builder: (column) => column); + + GeneratedColumn get type => + $composableBuilder(column: $table.type, builder: (column) => column); + + GeneratedColumn get createdAt => + $composableBuilder(column: $table.createdAt, builder: (column) => column); + + GeneratedColumn get modifiedAt => $composableBuilder( + column: $table.modifiedAt, + builder: (column) => column, + ); + + GeneratedColumn get appSource => + $composableBuilder(column: $table.appSource, builder: (column) => column); + + GeneratedColumn get isPinned => + $composableBuilder(column: $table.isPinned, builder: (column) => column); + + GeneratedColumn get label => + $composableBuilder(column: $table.label, builder: (column) => column); + + GeneratedColumn get cardColor => + $composableBuilder(column: $table.cardColor, builder: (column) => column); + + GeneratedColumn get metadata => + $composableBuilder(column: $table.metadata, builder: (column) => column); + + GeneratedColumn get pasteCount => $composableBuilder( + column: $table.pasteCount, + builder: (column) => column, + ); + + GeneratedColumn get contentHash => $composableBuilder( + column: $table.contentHash, + builder: (column) => column, + ); + + GeneratedColumn get thumbPath => + $composableBuilder(column: $table.thumbPath, builder: (column) => column); + + GeneratedColumn get sourceModifiedAt => $composableBuilder( + column: $table.sourceModifiedAt, + builder: (column) => column, + ); + + GeneratedColumn get brokenSince => $composableBuilder( + column: $table.brokenSince, + builder: (column) => column, + ); +} + +class $$ClipboardItemsTableTableManager + extends + RootTableManager< + _$_AppDatabase, + $ClipboardItemsTable, + ClipboardRow, + $$ClipboardItemsTableFilterComposer, + $$ClipboardItemsTableOrderingComposer, + $$ClipboardItemsTableAnnotationComposer, + $$ClipboardItemsTableCreateCompanionBuilder, + $$ClipboardItemsTableUpdateCompanionBuilder, + ( + ClipboardRow, + BaseReferences<_$_AppDatabase, $ClipboardItemsTable, ClipboardRow>, + ), + ClipboardRow, + PrefetchHooks Function() + > { + $$ClipboardItemsTableTableManager( + _$_AppDatabase db, + $ClipboardItemsTable table, + ) : super( + TableManagerState( + db: db, + table: table, + createFilteringComposer: () => + $$ClipboardItemsTableFilterComposer($db: db, $table: table), + createOrderingComposer: () => + $$ClipboardItemsTableOrderingComposer($db: db, $table: table), + createComputedFieldComposer: () => + $$ClipboardItemsTableAnnotationComposer($db: db, $table: table), + updateCompanionCallback: + ({ + Value id = const Value.absent(), + Value content = const Value.absent(), + Value type = const Value.absent(), + Value createdAt = const Value.absent(), + Value modifiedAt = const Value.absent(), + Value appSource = const Value.absent(), + Value isPinned = const Value.absent(), + Value label = const Value.absent(), + Value cardColor = const Value.absent(), + Value metadata = const Value.absent(), + Value pasteCount = const Value.absent(), + Value contentHash = const Value.absent(), + Value thumbPath = const Value.absent(), + Value sourceModifiedAt = const Value.absent(), + Value brokenSince = const Value.absent(), + Value rowid = const Value.absent(), + }) => ClipboardItemsCompanion( + id: id, + content: content, + type: type, + createdAt: createdAt, + modifiedAt: modifiedAt, + appSource: appSource, + isPinned: isPinned, + label: label, + cardColor: cardColor, + metadata: metadata, + pasteCount: pasteCount, + contentHash: contentHash, + thumbPath: thumbPath, + sourceModifiedAt: sourceModifiedAt, + brokenSince: brokenSince, + rowid: rowid, + ), + createCompanionCallback: + ({ + required String id, + required String content, + required int type, + required DateTime createdAt, + required DateTime modifiedAt, + Value appSource = const Value.absent(), + Value isPinned = const Value.absent(), + Value label = const Value.absent(), + Value cardColor = const Value.absent(), + Value metadata = const Value.absent(), + Value pasteCount = const Value.absent(), + Value contentHash = const Value.absent(), + Value thumbPath = const Value.absent(), + Value sourceModifiedAt = const Value.absent(), + Value brokenSince = const Value.absent(), + Value rowid = const Value.absent(), + }) => ClipboardItemsCompanion.insert( + id: id, + content: content, + type: type, + createdAt: createdAt, + modifiedAt: modifiedAt, + appSource: appSource, + isPinned: isPinned, + label: label, + cardColor: cardColor, + metadata: metadata, + pasteCount: pasteCount, + contentHash: contentHash, + thumbPath: thumbPath, + sourceModifiedAt: sourceModifiedAt, + brokenSince: brokenSince, + rowid: rowid, + ), + withReferenceMapper: (p0) => p0 + .map((e) => (e.readTable(table), BaseReferences(db, table, e))) + .toList(), + prefetchHooksCallback: null, + ), + ); +} + +typedef $$ClipboardItemsTableProcessedTableManager = + ProcessedTableManager< + _$_AppDatabase, + $ClipboardItemsTable, + ClipboardRow, + $$ClipboardItemsTableFilterComposer, + $$ClipboardItemsTableOrderingComposer, + $$ClipboardItemsTableAnnotationComposer, + $$ClipboardItemsTableCreateCompanionBuilder, + $$ClipboardItemsTableUpdateCompanionBuilder, + ( + ClipboardRow, + BaseReferences<_$_AppDatabase, $ClipboardItemsTable, ClipboardRow>, + ), + ClipboardRow, + PrefetchHooks Function() + >; + +class $_AppDatabaseManager { + final _$_AppDatabase _db; + $_AppDatabaseManager(this._db); + $$ClipboardItemsTableTableManager get clipboardItems => + $$ClipboardItemsTableTableManager(_db, _db.clipboardItems); +} diff --git a/core/lib/services/crash_logger.dart b/core/lib/services/crash_logger.dart index 97c13637..bbc1d1ba 100644 --- a/core/lib/services/crash_logger.dart +++ b/core/lib/services/crash_logger.dart @@ -1,120 +1,130 @@ -import 'dart:io'; - -import 'package:path/path.dart' as p; - -class CrashLogger { - CrashLogger._(); - - static const String fileName = 'crash.log'; - static const int _maxSizeBytes = 512 * 1024; - - static String? _filePath; - - static String? get filePath => _filePath; - - static void initialize(String baseDir) { - try { - Directory(baseDir).createSync(recursive: true); - _filePath = p.join(baseDir, fileName); - } catch (_) { - _filePath = null; - } - } - - static String? resolveBootstrapPath() { - try { - final base = _bootstrapBaseDir(); - if (base == null) return null; - Directory(base).createSync(recursive: true); - return p.join(base, fileName); - } catch (_) { - return null; - } - } - - static void report( - Object error, - StackTrace? stack, { - String context = '', - String? overridePath, - }) { - final target = overridePath ?? _filePath ?? resolveBootstrapPath(); - if (target == null) return; - try { - final file = File(target); - if (file.existsSync() && file.lengthSync() > _maxSizeBytes) { - file.writeAsStringSync('', flush: true); - } - final ts = DateTime.now().toUtc().toIso8601String(); - final sb = StringBuffer() - ..writeln('==== $ts ====') - ..writeln( - 'Platform: ${Platform.operatingSystem} ' - '${Platform.operatingSystemVersion}', - ) - ..writeln('Dart: ${Platform.version}'); - if (context.isNotEmpty) sb.writeln('Context: $context'); - sb.writeln('Error: ${redact(error.toString())}'); - if (stack != null) { - sb.writeln('Stack:'); - sb.writeln(redact(stack.toString())); - } - sb.writeln(); - file.writeAsStringSync(sb.toString(), mode: FileMode.append, flush: true); - } catch (_) {} - } - - static String redact(String input) { - var out = input; - final userProfile = Platform.environment['USERPROFILE']; - final home = Platform.environment['HOME']; - final username = - Platform.environment['USERNAME'] ?? Platform.environment['USER']; - for (final raw in [userProfile, home]) { - if (raw != null && raw.isNotEmpty) { - out = out.replaceAll(raw, ''); - } - } - if (username != null && username.isNotEmpty && username.length > 1) { - out = out.replaceAll( - RegExp(r'\\Users\\' + RegExp.escape(username), caseSensitive: false), - r'\Users\', - ); - out = out.replaceAll( - RegExp(r'/Users/' + RegExp.escape(username)), - '/Users/', - ); - out = out.replaceAll( - RegExp(r'/home/' + RegExp.escape(username)), - '/home/', - ); - } - out = out.replaceAll( - RegExp(r'[A-Za-z0-9._%+-]+@[A-Za-z0-9.-]+\.[A-Za-z]{2,}'), - '', - ); - return out; - } - - static String? _bootstrapBaseDir() { - if (Platform.isWindows) { - final local = Platform.environment['LOCALAPPDATA']; - if (local != null && local.isNotEmpty) { - return p.join(local, 'CopyPaste'); - } - final profile = Platform.environment['USERPROFILE']; - if (profile != null && profile.isNotEmpty) { - return p.join(profile, 'AppData', 'Local', 'CopyPaste'); - } - return p.join(Directory.systemTemp.path, 'CopyPaste'); - } - final home = Platform.environment['HOME']; - if (home != null && home.isNotEmpty) { - if (Platform.isMacOS) { - return p.join(home, 'Library', 'Application Support', 'CopyPaste'); - } - return p.join(home, '.local', 'share', 'CopyPaste'); - } - return p.join(Directory.systemTemp.path, 'CopyPaste'); - } -} +import 'dart:io'; + +import 'package:path/path.dart' as p; + +class CrashLogger { + CrashLogger._(); // coverage:ignore-line + + static const String fileName = 'crash.log'; + static const int _maxSizeBytes = 512 * 1024; + + static String? _filePath; + + static String? get filePath => _filePath; + + static void initialize(String baseDir) { + try { + Directory(baseDir).createSync(recursive: true); + _filePath = p.join(baseDir, fileName); + } catch (_) { + _filePath = null; + } + } + + static String? resolveBootstrapPath() { + try { + final base = _bootstrapBaseDir(); + if (base == null) return null; + Directory(base).createSync(recursive: true); + return p.join(base, fileName); + } catch (_) { + return null; + } + } + + static void report( + Object error, + StackTrace? stack, { + String context = '', + String? overridePath, + }) { + final target = overridePath ?? _filePath ?? resolveBootstrapPath(); + if (target == null) return; + try { + final file = File(target); + if (file.existsSync() && file.lengthSync() > _maxSizeBytes) { + file.writeAsStringSync('', flush: true); + } + final ts = DateTime.now().toUtc().toIso8601String(); + final sb = StringBuffer() + ..writeln('==== $ts ====') + ..writeln( + 'Platform: ${Platform.operatingSystem} ' + '${Platform.operatingSystemVersion}', + ) + ..writeln('Dart: ${Platform.version}'); + if (context.isNotEmpty) sb.writeln('Context: $context'); + sb.writeln('Error: ${redact(error.toString())}'); + if (stack != null) { + sb.writeln('Stack:'); + sb.writeln(redact(stack.toString())); + } + sb.writeln(); + file.writeAsStringSync(sb.toString(), mode: FileMode.append, flush: true); + } catch (_) {} + } + + static String redact(String input) { + var out = input; + final userProfile = Platform.environment['USERPROFILE']; + final home = Platform.environment['HOME']; + final username = + Platform.environment['USERNAME'] ?? Platform.environment['USER']; + for (final raw in [userProfile, home]) { + if (raw != null && raw.isNotEmpty) { + out = out.replaceAll(raw, ''); + } + } + if (username != null && username.isNotEmpty && username.length > 1) { + out = out.replaceAll( + RegExp(r'\\Users\\' + RegExp.escape(username), caseSensitive: false), + r'\Users\', + ); + out = out.replaceAll( + RegExp(r'/Users/' + RegExp.escape(username)), + '/Users/', + ); + out = out.replaceAll( + RegExp(r'/home/' + RegExp.escape(username)), + '/home/', + ); + } + out = out.replaceAll( + RegExp(r'[A-Za-z0-9._%+-]+@[A-Za-z0-9.-]+\.[A-Za-z]{2,}'), + '', + ); + return out; + } + + static String? _bootstrapBaseDir() { + // coverage:ignore-start + if (Platform.isWindows) { + final local = Platform.environment['LOCALAPPDATA']; + if (local != null && local.isNotEmpty) { + return p.join(local, 'CopyPaste'); + } + final profile = Platform.environment['USERPROFILE']; + if (profile != null && profile.isNotEmpty) { + return p.join(profile, 'AppData', 'Local', 'CopyPaste'); + } + return p.join(Directory.systemTemp.path, 'CopyPaste'); + } + // coverage:ignore-end + final home = Platform.environment['HOME']; + if (home != null && home.isNotEmpty) { + if (Platform.isMacOS) { + return p.join( + home, + 'Library', + 'Application Support', + 'CopyPaste', + ); // coverage:ignore-line + } + return p.join(home, '.local', 'share', 'CopyPaste'); + } + return p.join( + Directory.systemTemp.path, + 'CopyPaste', + ); // coverage:ignore-line + } +} diff --git a/core/lib/services/support_service.dart b/core/lib/services/support_service.dart index 06ab8264..f18f00f4 100644 --- a/core/lib/services/support_service.dart +++ b/core/lib/services/support_service.dart @@ -1,156 +1,160 @@ -import 'dart:io'; - -import 'package:archive/archive.dart'; -import 'package:flutter/foundation.dart' show visibleForTesting; -import 'package:path/path.dart' as p; - -import '../config/storage_config.dart'; -import 'app_logger.dart'; -import 'crash_logger.dart'; - -class SupportService { - SupportService._(); - - /// Exports all log files into a zip archive saved at [savePath]. - /// - /// The zip includes: - /// - All `.log` files from [StorageConfig.logsPath]. - /// - A `device_info.txt` with basic platform and version details. - /// - /// Returns the number of log files included, or throws on failure. - static Future exportLogs( - StorageConfig storage, - String appVersion, - String savePath, - ) async { - AppLogger.info('exportLogs: starting — savePath=$savePath'); - final logsDir = Directory(storage.logsPath); - final archive = Archive(); - - final logFiles = logsDir.existsSync() - ? logsDir - .listSync() - .whereType() - .where((f) => f.path.endsWith('.log')) - .toList() - : []; - - if (logFiles.isEmpty) { - AppLogger.warn('exportLogs: no .log files found in ${storage.logsPath}'); - } - - for (final file in logFiles) { - try { - final raw = await file.readAsString(); - final redacted = CrashLogger.redact(raw); - final bytes = redacted.codeUnits; - archive.addFile( - ArchiveFile(p.basename(file.path), bytes.length, bytes), - ); - } catch (e) { - AppLogger.error('exportLogs: failed to read ${file.path}: $e'); - } - } - - final crashFile = File(p.join(storage.baseDir, CrashLogger.fileName)); - if (crashFile.existsSync()) { - try { - final raw = await crashFile.readAsString(); - final redacted = CrashLogger.redact(raw); - final bytes = redacted.codeUnits; - archive.addFile(ArchiveFile(CrashLogger.fileName, bytes.length, bytes)); - } catch (e) { - AppLogger.error('exportLogs: failed to read crash.log: $e'); - } - } - - // Add device info so the report is self-contained - final info = _buildDeviceInfo(appVersion); - final infoBytes = info.codeUnits; - archive.addFile( - ArchiveFile('device_info.txt', infoBytes.length, infoBytes), - ); - - final zipData = ZipEncoder().encode(archive); - if (zipData.isEmpty) { - AppLogger.error('exportLogs: ZipEncoder returned empty data'); - throw StateError('Zip encoding produced no output'); - } - - await File(savePath).writeAsBytes(zipData); - AppLogger.info( - 'exportLogs: done — ${logFiles.length} log file(s) → $savePath', - ); - return logFiles.length; - } - - /// Reveals [filePath] in the system file browser (Finder, Explorer, etc.). - static Future revealFile(String filePath) async { - AppLogger.info('revealFile: $filePath'); - try { - if (Platform.isWindows) { - await Process.run('explorer', ['/select,', filePath]); - } else if (Platform.isMacOS) { - await Process.run('open', ['-R', filePath]); - } else if (Platform.isLinux) { - await Process.run('xdg-open', [File(filePath).parent.path]); - } - } catch (e, s) { - AppLogger.exception(e, s, 'revealFile'); - } - } - - /// Opens the logs directory in the system file browser. - static Future openLogsFolder(StorageConfig storage) async { - final logsDir = Directory(storage.logsPath); - if (!logsDir.existsSync()) { - AppLogger.info('openLogsFolder: logs dir missing, creating it'); - await logsDir.create(recursive: true); - } - - AppLogger.info('openLogsFolder: opening ${logsDir.path}'); - try { - if (Platform.isWindows) { - // Process.run('explorer', path) silently fails in MSIX packages because - // Windows routes the open request via DDE to the existing shell process, - // and the AppContainer blocks cross-process DDE. Using cmd's start - // command calls ShellExecuteEx instead, which works correctly in MSIX. - await Process.run('cmd', ['/c', 'start', '', logsDir.path]); - } else if (Platform.isMacOS) { - await Process.run('open', [logsDir.path]); - } else if (Platform.isLinux) { - await Process.run('xdg-open', [logsDir.path]); - } - } catch (e, s) { - AppLogger.exception(e, s, 'openLogsFolder'); - rethrow; - } - } - - static String _buildDeviceInfo(String appVersion) { - final osVersion = Platform.isWindows - ? correctWindowsVersion(Platform.operatingSystemVersion) - : Platform.operatingSystemVersion; - final lines = [ - 'CopyPaste v$appVersion', - 'Generated: ${DateTime.now().toUtc().toIso8601String()}', - '', - 'Platform : ${Platform.operatingSystem}', - 'OS : $osVersion', - 'Locale : ${Platform.localeName}', - 'Dart : ${Platform.version}', - ]; - return lines.join('\n'); - } - - // Dart/Flutter always reports "Windows 10" even on Windows 11 due to Win32 - // backwards-compat shim. Windows 11 starts at build 22000. - @visibleForTesting - static String correctWindowsVersion(String raw) { - if (!raw.contains('Windows 10')) return raw; - final match = RegExp(r'Build (\d+)').firstMatch(raw); - if (match == null) return raw; - final build = int.tryParse(match.group(1) ?? '') ?? 0; - return build >= 22000 ? raw.replaceFirst('Windows 10', 'Windows 11') : raw; - } -} +import 'dart:io'; + +import 'package:archive/archive.dart'; +import 'package:flutter/foundation.dart' show visibleForTesting; +import 'package:path/path.dart' as p; + +import '../config/storage_config.dart'; +import 'app_logger.dart'; +import 'crash_logger.dart'; + +class SupportService { + SupportService._(); // coverage:ignore-line + + /// Exports all log files into a zip archive saved at [savePath]. + /// + /// The zip includes: + /// - All `.log` files from [StorageConfig.logsPath]. + /// - A `device_info.txt` with basic platform and version details. + /// + /// Returns the number of log files included, or throws on failure. + static Future exportLogs( + StorageConfig storage, + String appVersion, + String savePath, + ) async { + AppLogger.info('exportLogs: starting — savePath=$savePath'); + final logsDir = Directory(storage.logsPath); + final archive = Archive(); + + final logFiles = logsDir.existsSync() + ? logsDir + .listSync() + .whereType() + .where((f) => f.path.endsWith('.log')) + .toList() + : []; + + if (logFiles.isEmpty) { + AppLogger.warn('exportLogs: no .log files found in ${storage.logsPath}'); + } + + for (final file in logFiles) { + try { + final raw = await file.readAsString(); + final redacted = CrashLogger.redact(raw); + final bytes = redacted.codeUnits; + archive.addFile( + ArchiveFile(p.basename(file.path), bytes.length, bytes), + ); + } catch (e) { + AppLogger.error('exportLogs: failed to read ${file.path}: $e'); + } + } + + final crashFile = File(p.join(storage.baseDir, CrashLogger.fileName)); + if (crashFile.existsSync()) { + try { + final raw = await crashFile.readAsString(); + final redacted = CrashLogger.redact(raw); + final bytes = redacted.codeUnits; + archive.addFile(ArchiveFile(CrashLogger.fileName, bytes.length, bytes)); + } catch (e) { + AppLogger.error('exportLogs: failed to read crash.log: $e'); + } + } + + // Add device info so the report is self-contained + final info = _buildDeviceInfo(appVersion); + final infoBytes = info.codeUnits; + archive.addFile( + ArchiveFile('device_info.txt', infoBytes.length, infoBytes), + ); + + final zipData = ZipEncoder().encode(archive); + if (zipData.isEmpty) { + AppLogger.error('exportLogs: ZipEncoder returned empty data'); + throw StateError('Zip encoding produced no output'); + } + + await File(savePath).writeAsBytes(zipData); + AppLogger.info( + 'exportLogs: done — ${logFiles.length} log file(s) → $savePath', + ); + return logFiles.length; + } + + /// Reveals [filePath] in the system file browser (Finder, Explorer, etc.). + static Future revealFile(String filePath) async { + AppLogger.info('revealFile: $filePath'); + try { + // coverage:ignore-start + if (Platform.isWindows) { + await Process.run('explorer', ['/select,', filePath]); + } else if (Platform.isMacOS) { + await Process.run('open', ['-R', filePath]); + } else // coverage:ignore-end + if (Platform.isLinux) { + await Process.run('xdg-open', [File(filePath).parent.path]); + } + } catch (e, s) { + AppLogger.exception(e, s, 'revealFile'); + } + } + + /// Opens the logs directory in the system file browser. + static Future openLogsFolder(StorageConfig storage) async { + final logsDir = Directory(storage.logsPath); + if (!logsDir.existsSync()) { + AppLogger.info('openLogsFolder: logs dir missing, creating it'); + await logsDir.create(recursive: true); + } + + AppLogger.info('openLogsFolder: opening ${logsDir.path}'); + try { + // coverage:ignore-start + if (Platform.isWindows) { + // Process.run('explorer', path) silently fails in MSIX packages because + // Windows routes the open request via DDE to the existing shell process, + // and the AppContainer blocks cross-process DDE. Using cmd's start + // command calls ShellExecuteEx instead, which works correctly in MSIX. + await Process.run('cmd', ['/c', 'start', '', logsDir.path]); + } else if (Platform.isMacOS) { + await Process.run('open', [logsDir.path]); + } else // coverage:ignore-end + if (Platform.isLinux) { + await Process.run('xdg-open', [logsDir.path]); + } + } catch (e, s) { + AppLogger.exception(e, s, 'openLogsFolder'); + rethrow; + } + } + + static String _buildDeviceInfo(String appVersion) { + final osVersion = Platform.isWindows + ? correctWindowsVersion(Platform.operatingSystemVersion) + : Platform.operatingSystemVersion; + final lines = [ + 'CopyPaste v$appVersion', + 'Generated: ${DateTime.now().toUtc().toIso8601String()}', + '', + 'Platform : ${Platform.operatingSystem}', + 'OS : $osVersion', + 'Locale : ${Platform.localeName}', + 'Dart : ${Platform.version}', + ]; + return lines.join('\n'); + } + + // Dart/Flutter always reports "Windows 10" even on Windows 11 due to Win32 + // backwards-compat shim. Windows 11 starts at build 22000. + @visibleForTesting + static String correctWindowsVersion(String raw) { + if (!raw.contains('Windows 10')) return raw; + final match = RegExp(r'Build (\d+)').firstMatch(raw); + if (match == null) return raw; + final build = int.tryParse(match.group(1) ?? '') ?? 0; + return build >= 22000 ? raw.replaceFirst('Windows 10', 'Windows 11') : raw; + } +} diff --git a/core/test/support_service_test.dart b/core/test/support_service_test.dart index ae28cd22..9dea286e 100644 --- a/core/test/support_service_test.dart +++ b/core/test/support_service_test.dart @@ -1,230 +1,267 @@ -import 'dart:io'; - -import 'package:archive/archive.dart'; -import 'package:flutter_test/flutter_test.dart'; -import 'package:path/path.dart' as p; - -import 'package:core/core.dart'; - -void main() { - late Directory tempDir; - late StorageConfig storage; - - setUp(() async { - tempDir = Directory.systemTemp.createTempSync('support_test_'); - storage = await StorageConfig.create(baseDir: tempDir.path); - await Directory(storage.logsPath).create(recursive: true); - }); - - tearDown(() => tempDir.deleteSync(recursive: true)); - - // --------------------------------------------------------------------------- - // correctWindowsVersion - // --------------------------------------------------------------------------- - group('SupportService.correctWindowsVersion', () { - test('replaces Windows 10 with Windows 11 for build >= 22000', () { - const raw = 'Windows 10.0.22621 Build 22621'; - expect( - SupportService.correctWindowsVersion(raw), - equals('Windows 11.0.22621 Build 22621'), - ); - }); - - test('does not replace for build < 22000', () { - const raw = 'Windows 10.0.19045 Build 19045'; - expect(SupportService.correctWindowsVersion(raw), equals(raw)); - }); - - test('returns raw string unchanged when no Windows 10 text', () { - const raw = 'Windows 11.0.22621'; - expect(SupportService.correctWindowsVersion(raw), equals(raw)); - }); - - test('returns raw string unchanged when no Build number present', () { - const raw = 'Windows 10 Pro'; - expect(SupportService.correctWindowsVersion(raw), equals(raw)); - }); - - test('handles build exactly at boundary (22000 → Windows 11)', () { - const raw = 'Windows 10.0.22000 Build 22000'; - expect(SupportService.correctWindowsVersion(raw), contains('Windows 11')); - }); - - test('handles build one below boundary (21999 → unchanged)', () { - const raw = 'Windows 10.0.21999 Build 21999'; - expect(SupportService.correctWindowsVersion(raw), equals(raw)); - }); - }); - - // --------------------------------------------------------------------------- - // exportLogs - // --------------------------------------------------------------------------- - group('SupportService.exportLogs', () { - test('returns 0 when logs directory is empty', () async { - final savePath = p.join(tempDir.path, 'out.zip'); - final count = await SupportService.exportLogs(storage, '2.0.0', savePath); - expect(count, equals(0)); - expect(File(savePath).existsSync(), isTrue); - }); - - test('returns 0 when logs directory does not exist', () async { - await Directory(storage.logsPath).delete(recursive: true); - final savePath = p.join(tempDir.path, 'out.zip'); - final count = await SupportService.exportLogs(storage, '2.0.0', savePath); - expect(count, equals(0)); - }); - - test('returns correct count of .log files', () async { - File(p.join(storage.logsPath, 'app.log')).writeAsStringSync('log1'); - File(p.join(storage.logsPath, 'app2.log')).writeAsStringSync('log2'); - final savePath = p.join(tempDir.path, 'out.zip'); - final count = await SupportService.exportLogs(storage, '2.0.0', savePath); - expect(count, equals(2)); - }); - - test('excludes non-.log files from count and archive', () async { - File(p.join(storage.logsPath, 'app.log')).writeAsStringSync('log'); - File(p.join(storage.logsPath, 'readme.txt')).writeAsStringSync('text'); - final savePath = p.join(tempDir.path, 'out.zip'); - final count = await SupportService.exportLogs(storage, '2.0.0', savePath); - expect(count, equals(1)); - - final archive = ZipDecoder().decodeBytes( - File(savePath).readAsBytesSync(), - ); - final names = archive.map((f) => f.name).toList(); - expect(names, contains('app.log')); - expect(names, isNot(contains('readme.txt'))); - }); - - test('ZIP always contains device_info.txt', () async { - final savePath = p.join(tempDir.path, 'out.zip'); - await SupportService.exportLogs(storage, '2.5.1', savePath); - - final archive = ZipDecoder().decodeBytes( - File(savePath).readAsBytesSync(), - ); - final infoFile = archive.firstWhere((f) => f.name == 'device_info.txt'); - final content = String.fromCharCodes(infoFile.content as List); - expect(content, contains('CopyPaste v2.5.1')); - }); - - test('device_info.txt contains platform and Dart version', () async { - final savePath = p.join(tempDir.path, 'out.zip'); - await SupportService.exportLogs(storage, '2.0.0', savePath); - - final archive = ZipDecoder().decodeBytes( - File(savePath).readAsBytesSync(), - ); - final infoFile = archive.firstWhere((f) => f.name == 'device_info.txt'); - final content = String.fromCharCodes(infoFile.content as List); - expect(content, contains('Platform')); - expect(content, contains('Dart')); - expect(content, contains('Generated:')); - }); - - test('ZIP contains log file content verbatim', () async { - File(p.join(storage.logsPath, 'app.log')).writeAsStringSync('hello log'); - final savePath = p.join(tempDir.path, 'out.zip'); - await SupportService.exportLogs(storage, '2.0.0', savePath); - - final archive = ZipDecoder().decodeBytes( - File(savePath).readAsBytesSync(), - ); - final logFile = archive.firstWhere((f) => f.name == 'app.log'); - final content = String.fromCharCodes(logFile.content as List); - expect(content, equals('hello log')); - }); - - test('saves zip file at specified path', () async { - final savePath = p.join(tempDir.path, 'subdir', 'export.zip'); - await Directory(p.join(tempDir.path, 'subdir')).create(); - await SupportService.exportLogs(storage, '2.0.0', savePath); - expect(File(savePath).existsSync(), isTrue); - expect(File(savePath).lengthSync(), greaterThan(0)); - }); - - test('includes crash.log in archive when it exists', () async { - File( - p.join(storage.baseDir, 'crash.log'), - ).writeAsStringSync('==== crash entry ===='); - final savePath = p.join(tempDir.path, 'out.zip'); - await SupportService.exportLogs(storage, '2.0.0', savePath); - - final archive = ZipDecoder().decodeBytes( - File(savePath).readAsBytesSync(), - ); - final names = archive.map((f) => f.name).toList(); - expect(names, contains('crash.log')); - }); - - test('does not include crash.log entry when file does not exist', () async { - File(p.join(storage.logsPath, 'app.log')).writeAsStringSync('log'); - final savePath = p.join(tempDir.path, 'out.zip'); - await SupportService.exportLogs(storage, '2.0.0', savePath); - - final archive = ZipDecoder().decodeBytes( - File(savePath).readAsBytesSync(), - ); - final names = archive.map((f) => f.name).toList(); - expect(names, isNot(contains('crash.log'))); - }); - - test('crash.log count is not added to returned log file count', () async { - File(p.join(storage.logsPath, 'app.log')).writeAsStringSync('log'); - File( - p.join(storage.baseDir, 'crash.log'), - ).writeAsStringSync('crash entry'); - final savePath = p.join(tempDir.path, 'out.zip'); - final count = await SupportService.exportLogs(storage, '2.0.0', savePath); - expect(count, equals(1)); - }); - - test('log files are redacted in archive — email is replaced', () async { - File( - p.join(storage.logsPath, 'app.log'), - ).writeAsStringSync('error for admin@corp.example.com'); - final savePath = p.join(tempDir.path, 'out.zip'); - await SupportService.exportLogs(storage, '2.0.0', savePath); - - final archive = ZipDecoder().decodeBytes( - File(savePath).readAsBytesSync(), - ); - final logFile = archive.firstWhere((f) => f.name == 'app.log'); - final content = String.fromCharCodes(logFile.content as List); - expect(content, isNot(contains('admin@corp.example.com'))); - expect(content, contains('')); - }); - - test('crash.log is redacted in archive — email is replaced', () async { - File( - p.join(storage.baseDir, 'crash.log'), - ).writeAsStringSync('crash for user@example.com'); - final savePath = p.join(tempDir.path, 'out.zip'); - await SupportService.exportLogs(storage, '2.0.0', savePath); - - final archive = ZipDecoder().decodeBytes( - File(savePath).readAsBytesSync(), - ); - final crashFile = archive.firstWhere((f) => f.name == 'crash.log'); - final content = String.fromCharCodes(crashFile.content as List); - expect(content, isNot(contains('user@example.com'))); - expect(content, contains('')); - }); - - test('non-sensitive log content is preserved after redaction', () async { - File( - p.join(storage.logsPath, 'app.log'), - ).writeAsStringSync('[INFO] Bootstrap: CopyPaste 2.0 starting'); - final savePath = p.join(tempDir.path, 'out.zip'); - await SupportService.exportLogs(storage, '2.0.0', savePath); - - final archive = ZipDecoder().decodeBytes( - File(savePath).readAsBytesSync(), - ); - final logFile = archive.firstWhere((f) => f.name == 'app.log'); - final content = String.fromCharCodes(logFile.content as List); - expect(content, equals('[INFO] Bootstrap: CopyPaste 2.0 starting')); - }); - }); -} +import 'dart:io'; + +import 'package:archive/archive.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:path/path.dart' as p; + +import 'package:core/core.dart'; + +void main() { + late Directory tempDir; + late StorageConfig storage; + + setUp(() async { + tempDir = Directory.systemTemp.createTempSync('support_test_'); + storage = await StorageConfig.create(baseDir: tempDir.path); + await Directory(storage.logsPath).create(recursive: true); + }); + + tearDown(() => tempDir.deleteSync(recursive: true)); + + // --------------------------------------------------------------------------- + // correctWindowsVersion + // --------------------------------------------------------------------------- + group('SupportService.correctWindowsVersion', () { + test('replaces Windows 10 with Windows 11 for build >= 22000', () { + const raw = 'Windows 10.0.22621 Build 22621'; + expect( + SupportService.correctWindowsVersion(raw), + equals('Windows 11.0.22621 Build 22621'), + ); + }); + + test('does not replace for build < 22000', () { + const raw = 'Windows 10.0.19045 Build 19045'; + expect(SupportService.correctWindowsVersion(raw), equals(raw)); + }); + + test('returns raw string unchanged when no Windows 10 text', () { + const raw = 'Windows 11.0.22621'; + expect(SupportService.correctWindowsVersion(raw), equals(raw)); + }); + + test('returns raw string unchanged when no Build number present', () { + const raw = 'Windows 10 Pro'; + expect(SupportService.correctWindowsVersion(raw), equals(raw)); + }); + + test('handles build exactly at boundary (22000 → Windows 11)', () { + const raw = 'Windows 10.0.22000 Build 22000'; + expect(SupportService.correctWindowsVersion(raw), contains('Windows 11')); + }); + + test('handles build one below boundary (21999 → unchanged)', () { + const raw = 'Windows 10.0.21999 Build 21999'; + expect(SupportService.correctWindowsVersion(raw), equals(raw)); + }); + }); + + // --------------------------------------------------------------------------- + // exportLogs + // --------------------------------------------------------------------------- + group('SupportService.exportLogs', () { + test('returns 0 when logs directory is empty', () async { + final savePath = p.join(tempDir.path, 'out.zip'); + final count = await SupportService.exportLogs(storage, '2.0.0', savePath); + expect(count, equals(0)); + expect(File(savePath).existsSync(), isTrue); + }); + + test('returns 0 when logs directory does not exist', () async { + await Directory(storage.logsPath).delete(recursive: true); + final savePath = p.join(tempDir.path, 'out.zip'); + final count = await SupportService.exportLogs(storage, '2.0.0', savePath); + expect(count, equals(0)); + }); + + test('returns correct count of .log files', () async { + File(p.join(storage.logsPath, 'app.log')).writeAsStringSync('log1'); + File(p.join(storage.logsPath, 'app2.log')).writeAsStringSync('log2'); + final savePath = p.join(tempDir.path, 'out.zip'); + final count = await SupportService.exportLogs(storage, '2.0.0', savePath); + expect(count, equals(2)); + }); + + test('excludes non-.log files from count and archive', () async { + File(p.join(storage.logsPath, 'app.log')).writeAsStringSync('log'); + File(p.join(storage.logsPath, 'readme.txt')).writeAsStringSync('text'); + final savePath = p.join(tempDir.path, 'out.zip'); + final count = await SupportService.exportLogs(storage, '2.0.0', savePath); + expect(count, equals(1)); + + final archive = ZipDecoder().decodeBytes( + File(savePath).readAsBytesSync(), + ); + final names = archive.map((f) => f.name).toList(); + expect(names, contains('app.log')); + expect(names, isNot(contains('readme.txt'))); + }); + + test('ZIP always contains device_info.txt', () async { + final savePath = p.join(tempDir.path, 'out.zip'); + await SupportService.exportLogs(storage, '2.5.1', savePath); + + final archive = ZipDecoder().decodeBytes( + File(savePath).readAsBytesSync(), + ); + final infoFile = archive.firstWhere((f) => f.name == 'device_info.txt'); + final content = String.fromCharCodes(infoFile.content as List); + expect(content, contains('CopyPaste v2.5.1')); + }); + + test('device_info.txt contains platform and Dart version', () async { + final savePath = p.join(tempDir.path, 'out.zip'); + await SupportService.exportLogs(storage, '2.0.0', savePath); + + final archive = ZipDecoder().decodeBytes( + File(savePath).readAsBytesSync(), + ); + final infoFile = archive.firstWhere((f) => f.name == 'device_info.txt'); + final content = String.fromCharCodes(infoFile.content as List); + expect(content, contains('Platform')); + expect(content, contains('Dart')); + expect(content, contains('Generated:')); + }); + + test('ZIP contains log file content verbatim', () async { + File(p.join(storage.logsPath, 'app.log')).writeAsStringSync('hello log'); + final savePath = p.join(tempDir.path, 'out.zip'); + await SupportService.exportLogs(storage, '2.0.0', savePath); + + final archive = ZipDecoder().decodeBytes( + File(savePath).readAsBytesSync(), + ); + final logFile = archive.firstWhere((f) => f.name == 'app.log'); + final content = String.fromCharCodes(logFile.content as List); + expect(content, equals('hello log')); + }); + + test('saves zip file at specified path', () async { + final savePath = p.join(tempDir.path, 'subdir', 'export.zip'); + await Directory(p.join(tempDir.path, 'subdir')).create(); + await SupportService.exportLogs(storage, '2.0.0', savePath); + expect(File(savePath).existsSync(), isTrue); + expect(File(savePath).lengthSync(), greaterThan(0)); + }); + + test('includes crash.log in archive when it exists', () async { + File( + p.join(storage.baseDir, 'crash.log'), + ).writeAsStringSync('==== crash entry ===='); + final savePath = p.join(tempDir.path, 'out.zip'); + await SupportService.exportLogs(storage, '2.0.0', savePath); + + final archive = ZipDecoder().decodeBytes( + File(savePath).readAsBytesSync(), + ); + final names = archive.map((f) => f.name).toList(); + expect(names, contains('crash.log')); + }); + + test('does not include crash.log entry when file does not exist', () async { + File(p.join(storage.logsPath, 'app.log')).writeAsStringSync('log'); + final savePath = p.join(tempDir.path, 'out.zip'); + await SupportService.exportLogs(storage, '2.0.0', savePath); + + final archive = ZipDecoder().decodeBytes( + File(savePath).readAsBytesSync(), + ); + final names = archive.map((f) => f.name).toList(); + expect(names, isNot(contains('crash.log'))); + }); + + test('crash.log count is not added to returned log file count', () async { + File(p.join(storage.logsPath, 'app.log')).writeAsStringSync('log'); + File( + p.join(storage.baseDir, 'crash.log'), + ).writeAsStringSync('crash entry'); + final savePath = p.join(tempDir.path, 'out.zip'); + final count = await SupportService.exportLogs(storage, '2.0.0', savePath); + expect(count, equals(1)); + }); + + test('log files are redacted in archive — email is replaced', () async { + File( + p.join(storage.logsPath, 'app.log'), + ).writeAsStringSync('error for admin@corp.example.com'); + final savePath = p.join(tempDir.path, 'out.zip'); + await SupportService.exportLogs(storage, '2.0.0', savePath); + + final archive = ZipDecoder().decodeBytes( + File(savePath).readAsBytesSync(), + ); + final logFile = archive.firstWhere((f) => f.name == 'app.log'); + final content = String.fromCharCodes(logFile.content as List); + expect(content, isNot(contains('admin@corp.example.com'))); + expect(content, contains('')); + }); + + test('crash.log is redacted in archive — email is replaced', () async { + File( + p.join(storage.baseDir, 'crash.log'), + ).writeAsStringSync('crash for user@example.com'); + final savePath = p.join(tempDir.path, 'out.zip'); + await SupportService.exportLogs(storage, '2.0.0', savePath); + + final archive = ZipDecoder().decodeBytes( + File(savePath).readAsBytesSync(), + ); + final crashFile = archive.firstWhere((f) => f.name == 'crash.log'); + final content = String.fromCharCodes(crashFile.content as List); + expect(content, isNot(contains('user@example.com'))); + expect(content, contains('')); + }); + + test('non-sensitive log content is preserved after redaction', () async { + File( + p.join(storage.logsPath, 'app.log'), + ).writeAsStringSync('[INFO] Bootstrap: CopyPaste 2.0 starting'); + final savePath = p.join(tempDir.path, 'out.zip'); + await SupportService.exportLogs(storage, '2.0.0', savePath); + + final archive = ZipDecoder().decodeBytes( + File(savePath).readAsBytesSync(), + ); + final logFile = archive.firstWhere((f) => f.name == 'app.log'); + final content = String.fromCharCodes(logFile.content as List); + expect(content, equals('[INFO] Bootstrap: CopyPaste 2.0 starting')); + }); + }); + + group('SupportService.revealFile', () { + test('completes without throwing on Linux', () async { + if (!Platform.isLinux) return; + final file = File(p.join(tempDir.path, 'reveal_test.log')) + ..writeAsStringSync('data'); + // xdg-open is called internally; exceptions are caught, so always completes + await expectLater(SupportService.revealFile(file.path), completes); + }); + + test('completes without throwing when path is empty string', () async { + // Platform checks guard the Process.run call; no spawn attempted for empty + await expectLater(SupportService.revealFile(''), completes); + }); + }); + group('SupportService.openLogsFolder', () { + test('creates logs directory when it does not exist', () async { + await Directory(storage.logsPath).delete(recursive: true); + expect(Directory(storage.logsPath).existsSync(), isFalse); + try { + await SupportService.openLogsFolder(storage); + } catch (_) { + // xdg-open may not be available in headless CI; that's acceptable + } + expect(Directory(storage.logsPath).existsSync(), isTrue); + }); + + test('opens existing logs folder on Linux', () async { + if (!Platform.isLinux) return; + // xdg-open may fail in headless CI, but the function body is covered + try { + await SupportService.openLogsFolder(storage); + } catch (_) { + // ProcessException acceptable when no display server available + } + }); + }); +} diff --git a/listener/lib/macos_native_thumbnail_provider.dart b/listener/lib/macos_native_thumbnail_provider.dart index 189a24a5..8b2f2e57 100644 --- a/listener/lib/macos_native_thumbnail_provider.dart +++ b/listener/lib/macos_native_thumbnail_provider.dart @@ -1,3 +1,4 @@ +// coverage:ignore-file import 'dart:async'; import 'dart:io' show Platform; import 'dart:ui' as ui show PlatformDispatcher; diff --git a/listener/lib/windows_native_thumbnail_provider.dart b/listener/lib/windows_native_thumbnail_provider.dart index 790f980a..f53e025e 100644 --- a/listener/lib/windows_native_thumbnail_provider.dart +++ b/listener/lib/windows_native_thumbnail_provider.dart @@ -1,73 +1,74 @@ -import 'dart:async'; -import 'dart:io' show Platform; -import 'dart:ui' as ui show PlatformDispatcher; - -import 'package:core/core.dart'; -import 'package:flutter/services.dart'; - -/// Windows-backed [NativeThumbnailProvider]. Bridges to the native handler -/// `getNativeThumbnail` exposed by the listener plugin, which uses -/// `IShellItemImageFactory::GetImage(SIIGBF_THUMBNAILONLY | SIIGBF_INCACHEONLY)` -/// and re-encodes the resulting bitmap as PNG before returning the bytes. -/// -/// This provider is a no-op on non-Windows platforms — the call returns -/// `null` immediately so the queue can fall back to the Dart pipeline. -/// -/// HiDPI: the requested [sizePx] is multiplied by the platform device -/// pixel ratio so the OS produces a bitmap large enough for the largest -/// connected display. The C++ side enforces a 64-px minimum heuristic to -/// reject generic file-type icons. -class WindowsNativeThumbnailProvider implements NativeThumbnailProvider { - WindowsNativeThumbnailProvider({MethodChannel? channel}) - : _channel = channel ?? const MethodChannel('copypaste/clipboard_writer'); - - final MethodChannel _channel; - - @override - Future request(String path, {int sizePx = 256}) async { - if (!Platform.isWindows) return null; - if (path.isEmpty || sizePx <= 0) return null; - - final scaled = (sizePx * _devicePixelRatio()).round().clamp(64, 1024); - - try { - final result = await _channel.invokeMethod( - 'getNativeThumbnail', - {'path': path, 'sizePx': scaled}, - ); - if (result is Uint8List && result.isNotEmpty) { - AppLogger.info( - '[NativeThumb] OK ${result.length}B for $path (size=$scaled)', - ); - return result; - } - if (result is List && result.isNotEmpty) { - AppLogger.info( - '[NativeThumb] OK ${result.length}B for $path (size=$scaled)', - ); - return Uint8List.fromList(result); - } - AppLogger.info('[NativeThumb] empty for $path (size=$scaled)'); - return null; - } on PlatformException catch (e, s) { - AppLogger.warn( - 'WindowsNativeThumbnailProvider: platform error: ${e.code} ${e.message}\n$s', - ); - return null; - } on MissingPluginException { - // Plugin not registered (e.g. running in a unit test host without the - // listener plugin loaded). Quiet fallback. - return null; - } - } - - double _devicePixelRatio() { - final views = ui.PlatformDispatcher.instance.views; - if (views.isEmpty) return 1.0; - var maxRatio = 1.0; - for (final view in views) { - if (view.devicePixelRatio > maxRatio) maxRatio = view.devicePixelRatio; - } - return maxRatio; - } -} +// coverage:ignore-file +import 'dart:async'; +import 'dart:io' show Platform; +import 'dart:ui' as ui show PlatformDispatcher; + +import 'package:core/core.dart'; +import 'package:flutter/services.dart'; + +/// Windows-backed [NativeThumbnailProvider]. Bridges to the native handler +/// `getNativeThumbnail` exposed by the listener plugin, which uses +/// `IShellItemImageFactory::GetImage(SIIGBF_THUMBNAILONLY | SIIGBF_INCACHEONLY)` +/// and re-encodes the resulting bitmap as PNG before returning the bytes. +/// +/// This provider is a no-op on non-Windows platforms — the call returns +/// `null` immediately so the queue can fall back to the Dart pipeline. +/// +/// HiDPI: the requested [sizePx] is multiplied by the platform device +/// pixel ratio so the OS produces a bitmap large enough for the largest +/// connected display. The C++ side enforces a 64-px minimum heuristic to +/// reject generic file-type icons. +class WindowsNativeThumbnailProvider implements NativeThumbnailProvider { + WindowsNativeThumbnailProvider({MethodChannel? channel}) + : _channel = channel ?? const MethodChannel('copypaste/clipboard_writer'); + + final MethodChannel _channel; + + @override + Future request(String path, {int sizePx = 256}) async { + if (!Platform.isWindows) return null; + if (path.isEmpty || sizePx <= 0) return null; + + final scaled = (sizePx * _devicePixelRatio()).round().clamp(64, 1024); + + try { + final result = await _channel.invokeMethod( + 'getNativeThumbnail', + {'path': path, 'sizePx': scaled}, + ); + if (result is Uint8List && result.isNotEmpty) { + AppLogger.info( + '[NativeThumb] OK ${result.length}B for $path (size=$scaled)', + ); + return result; + } + if (result is List && result.isNotEmpty) { + AppLogger.info( + '[NativeThumb] OK ${result.length}B for $path (size=$scaled)', + ); + return Uint8List.fromList(result); + } + AppLogger.info('[NativeThumb] empty for $path (size=$scaled)'); + return null; + } on PlatformException catch (e, s) { + AppLogger.warn( + 'WindowsNativeThumbnailProvider: platform error: ${e.code} ${e.message}\n$s', + ); + return null; + } on MissingPluginException { + // Plugin not registered (e.g. running in a unit test host without the + // listener plugin loaded). Quiet fallback. + return null; + } + } + + double _devicePixelRatio() { + final views = ui.PlatformDispatcher.instance.views; + if (views.isEmpty) return 1.0; + var maxRatio = 1.0; + for (final view in views) { + if (view.devicePixelRatio > maxRatio) maxRatio = view.devicePixelRatio; + } + return maxRatio; + } +} From f34344b1e40c6169fa6b7726cba74f5565af79fc Mon Sep 17 00:00:00 2001 From: rgdevment Date: Fri, 24 Apr 2026 15:21:51 -0400 Subject: [PATCH 15/31] fix: autogenerated files --- app/lib/l10n/app_localizations_en.dart | 1663 +++++++++-------- app/lib/l10n/app_localizations_es.dart | 1677 +++++++++--------- codecov.yml | 4 +- core/lib/repository/sqlite_repository.g.dart | 1 - 4 files changed, 1670 insertions(+), 1675 deletions(-) diff --git a/app/lib/l10n/app_localizations_en.dart b/app/lib/l10n/app_localizations_en.dart index a1fcb1c1..8cb04ac2 100644 --- a/app/lib/l10n/app_localizations_en.dart +++ b/app/lib/l10n/app_localizations_en.dart @@ -1,832 +1,831 @@ -// coverage:ignore-file -// ignore: unused_import -import 'package:intl/intl.dart' as intl; -import 'app_localizations.dart'; - -// ignore_for_file: type=lint - -/// The translations for English (`en`). -class AppLocalizationsEn extends AppLocalizations { - AppLocalizationsEn([String locale = 'en']) : super(locale); - - @override - String get searchPlaceholder => 'Search clipboard…'; - - @override - String get emptyState => 'No items in this section'; - - @override - String get emptyStateSubtitle => 'Copy something to get started'; - - @override - String get hintBannerText => - 'CopyPaste is active and running in the background. Look for it in the system tray or just use your shortcut. Feel free to customize your experience in'; - - @override - String get hintBannerAction => 'Settings'; - - @override - String get settingsTitle => 'Settings'; - - @override - String get sectionShortcuts => 'KEYBOARD SHORTCUTS'; - - @override - String get sectionStorage => 'STORAGE'; - - @override - String get settingRunOnStartup => 'Run on startup'; - - @override - String get settingLanguage => 'Interface language'; - - @override - String get hotkeyWillApply => 'Hotkey will apply immediately'; - - @override - String get sectionSupport => 'SUPPORT'; - - @override - String get supportExportLogs => 'Export logs'; - - @override - String get supportExportLogsSubtitle => - 'Save a zip with app logs for a bug report. Your clipboard content is never included.'; - - @override - String get supportOpenLogsFolder => 'Open logs folder'; - - @override - String get supportOpenLogsFolderSubtitle => - 'Browse the raw log files in your file manager.'; - - @override - String get supportGitHub => 'Report a bug on GitHub'; - - @override - String get supportExportSuccess => 'Logs saved to Downloads.'; - - @override - String get supportShowInFiles => 'Show'; - - @override - String get supportExportEmpty => 'No log files found.'; - - @override - String get supportExportError => 'Failed to export logs.'; - - @override - String get sectionReset => 'RESET & CLEAN INSTALL'; - - @override - String get resetSoftLabel => 'Soft Reset'; - - @override - String get resetSoftSubtitle => - 'Resets all settings to defaults and marks app as fresh install. Clipboard history is preserved.'; - - @override - String get resetHardLabel => 'Hard Reset'; - - @override - String get resetHardSubtitle => - 'Deletes all clipboard history, images, and settings. This cannot be undone.'; - - @override - String get resetSoftConfirmTitle => 'Soft reset?'; - - @override - String get resetSoftConfirmMessage => - 'All settings will return to defaults and the app will restart as if freshly installed. Your clipboard history will not be deleted.'; - - @override - String get resetHardConfirmTitle => 'Hard reset?'; - - @override - String get resetHardConfirmMessage => - 'This will permanently delete all clipboard history, images, and settings, then restart the app. This cannot be undone.'; - - @override - String get resetConfirmButton => 'Reset & Restart'; - - @override - String get clearHistoryConfirmTitle => 'Clear history?'; - - @override - String get clearHistoryConfirmMessage => - 'This will permanently delete all non-pinned clipboard items. This action cannot be undone.'; - - @override - String get clearHistoryConfirmButton => 'Clear'; - - @override - String backupLastDate(String date) { - return 'Last backup: $date'; - } - - @override - String get backupNone => 'No backup created yet.'; - - @override - String get backupCreateLabel => 'Create backup'; - - @override - String get backupRestoreLabel => 'Restore backup'; - - @override - String get backupError => 'Failed to create backup. Check permissions.'; - - @override - String get restoreDialogTitle => 'Restore backup'; - - @override - String get restoreDialogWarning => - 'This will replace all current data with the backup contents. Continue?'; - - @override - String get restoreFileNotFound => 'File not found.'; - - @override - String restoreSuccess(int count) { - return 'Restored $count items.'; - } - - @override - String get restoreError => - 'Restore failed. Your previous data has been preserved.'; - - @override - String get buttonSave => 'Save'; - - @override - String get buttonClose => 'Close'; - - @override - String get buttonCancel => 'Cancel'; - - @override - String get buttonReset => 'Restore defaults'; - - @override - String get savingIndicator => 'Saving…'; - - @override - String get savedIndicator => 'Saved'; - - @override - String get menuPaste => 'Paste'; - - @override - String get menuPastePlain => 'Paste plain'; - - @override - String get menuPin => 'Pin'; - - @override - String get menuUnpin => 'Unpin'; - - @override - String get menuEdit => 'Edit card'; - - @override - String get menuDelete => 'Delete'; - - @override - String get editColorLabel => 'Color'; - - @override - String get colorRed => 'Red'; - - @override - String get colorGreen => 'Green'; - - @override - String get colorPurple => 'Purple'; - - @override - String get colorYellow => 'Yellow'; - - @override - String get colorBlue => 'Blue'; - - @override - String get colorOrange => 'Orange'; - - @override - String get typeText => 'Text'; - - @override - String get typeImage => 'Image'; - - @override - String get typeFile => 'File'; - - @override - String get typeFolder => 'Folder'; - - @override - String get typeLink => 'Link'; - - @override - String get typeAudio => 'Audio'; - - @override - String get typeVideo => 'Video'; - - @override - String get typeEmail => 'Email'; - - @override - String get typePhone => 'Phone'; - - @override - String get typeColor => 'Color'; - - @override - String get typeIp => 'IP'; - - @override - String get typeUuid => 'UUID'; - - @override - String get typeJson => 'JSON'; - - @override - String get filterAll => 'All'; - - @override - String get filterPinned => 'Pinned'; - - @override - String get trayTooltip => 'CopyPaste'; - - @override - String get trayExit => 'Exit'; - - @override - String get shortcutOpenClose => 'Open / close CopyPaste'; - - @override - String get shortcutEscape => 'Clear search or close window'; - - @override - String get shortcutTab1 => 'Switch to Recent tab'; - - @override - String get shortcutTab2 => 'Switch to Pinned tab'; - - @override - String get shortcutArrows => 'Navigate between items'; - - @override - String get shortcutEnter => 'Paste selected item'; - - @override - String get shortcutDelete => 'Delete selected item'; - - @override - String get shortcutPin => 'Pin / Unpin selected item'; - - @override - String get shortcutEdit => 'Edit card (label and color)'; - - @override - String get tabGeneral => 'General'; - - @override - String get tabBackupRestore => 'Backup & Support'; - - @override - String get tabAppearance => 'Appearance'; - - @override - String get tabShortcuts => 'Shortcuts'; - - @override - String get tabAbout => 'About'; - - @override - String get sectionLanguage => 'LANGUAGE'; - - @override - String get sectionStartup => 'STARTUP'; - - @override - String get sectionKeyboardShortcut => 'KEYBOARD SHORTCUT'; - - @override - String get sectionCategories => 'CATEGORIES'; - - @override - String get sectionPerformance => 'PERFORMANCE'; - - @override - String get sectionPaste => 'PASTE'; - - @override - String get sectionBackupRestore => 'BACKUP & RESTORE'; - - @override - String get sectionAppearance => 'APPEARANCE'; - - @override - String get settingTheme => 'Theme'; - - @override - String get themeLight => 'Light'; - - @override - String get themeDark => 'Dark'; - - @override - String get themeAuto => 'Auto'; - - @override - String get sectionBehavior => 'BEHAVIOR'; - - @override - String get sectionAbout => 'COPYPASTE'; - - @override - String get sectionLinks => 'LINKS'; - - @override - String get settingItemsPerPage => 'Items per page'; - - @override - String get settingMemoryLimit => 'Memory limit'; - - @override - String get settingScrollThreshold => 'Scroll threshold (px)'; - - @override - String get settingPasteSpeed => 'Paste speed'; - - @override - String get settingPanelWidth => 'Panel width (px)'; - - @override - String get settingPanelHeight => 'Panel height (px)'; - - @override - String get settingLinesCollapsed => 'Lines collapsed'; - - @override - String get settingLinesExpanded => 'Lines expanded'; - - @override - String get settingHideOnDeactivate => 'Hide on deactivate'; - - @override - String get settingScrollToTopOnOpen => 'Scroll to top on open'; - - @override - String get settingClearSearchOnOpen => 'Clear search on open'; - - @override - String get settingRetentionDaysLabel => 'Retention days (0 = unlimited)'; - - @override - String get settingClearHistoryLabel => 'Clear clipboard history'; - - @override - String get settingHotkeyShortcutLabel => 'Shortcut to open/close CopyPaste'; - - @override - String get subtitleStartupDesc => 'Launches in background when you sign in'; - - @override - String get subtitleHideOnDeactivate => 'Close window when clicking outside'; - - @override - String get subtitleScrollToTopOnOpen => - 'Resets scroll and selects latest item'; - - @override - String get subtitleClearSearchOnOpen => 'Clears the search text each time'; - - @override - String get subtitlePasteSpeed => 'Adjust restoration and paste timings'; - - @override - String get subtitleCategories => 'Customize the names of color categories.'; - - @override - String get linkGitHub => 'Support & Source code — GitHub'; - - @override - String get linkCoffee => 'Buy me a coffee'; - - @override - String get editDialogTitle => 'Label & Color'; - - @override - String get editDialogHint => 'Add a label...'; - - @override - String get historyCleared => 'History cleared'; - - @override - String backupSavedFile(String filename) { - return 'Backup saved: $filename'; - } - - @override - String get buttonRestore => 'Restore'; - - @override - String get restoreCompleted => 'Restore completed'; - - @override - String get restoreRestartRequired => - 'Restore completed. The app will restart to apply changes.'; - - @override - String get shortcutExpand => 'Expand / collapse card'; - - @override - String get shortcutFocusSearch => 'Focus search box'; - - @override - String get trayShowHide => 'Show/Hide'; - - @override - String get fileNotFound => 'Not found'; - - @override - String get audioFile => 'Audio file'; - - @override - String get videoFile => 'Video file'; - - @override - String get imageFile => 'Image file'; - - @override - String get timeNow => 'now'; - - @override - String get clearAllFilters => 'Clear all filters'; - - @override - String get colorSectionLabel => 'COLOR'; - - @override - String get colorNone => 'None'; - - @override - String get subtitlePastePreset => - 'Automatic paste speed. Normal/Safe recommended for most computers.'; - - @override - String get pastePresetFast => 'Fast'; - - @override - String get pastePresetNormal => 'Normal'; - - @override - String get pastePresetSafe => 'Safe'; - - @override - String get pastePresetSlow => 'Slow'; - - @override - String get pastePresetCustom => 'Custom'; - - @override - String get pastePresetWarning => - '⚠️ Fast: may cause unexpected behavior in heavy apps.\n⚠️ Slow: may feel sluggish on modern computers.'; - - @override - String get settingResetFiltersOnOpen => 'Switch to All on open'; - - @override - String get subtitleResetFiltersOnOpen => - 'Clears category and type filters and returns to the All tab'; - - @override - String get subtitleBackup => - 'Create a backup of your clipboard history, images, and settings. Restore at any time on this or another device.'; - - @override - String get aboutDescription => - 'A modern clipboard manager built to feel native on Windows, macOS, and Linux.\nLocal-first — your history, always at hand. No accounts, no telemetry, no subscriptions.'; - - @override - String get sectionPrivacy => 'PRIVACY'; - - @override - String get privacyStatement => - 'Everything local. Nothing leaves your PC — no telemetry, no sync, no accounts.'; - - @override - String get privacyPolicy => 'Privacy Policy'; - - @override - String get aboutTagLocal => 'Local-only'; - - @override - String get aboutTagOpenSource => 'Open source'; - - @override - String get aboutTagFree => 'Free'; - - @override - String get sectionOtherTools => 'OTHER TOOLS'; - - @override - String get otherToolLinkUnbound => 'LinkUnbound'; - - @override - String get otherToolLinkUnboundDesc => - 'Open-source browser selector for Windows and Mac. Same philosophy: no ads, no telemetry, everything local.'; - - @override - String get aboutLicense => 'GPL v3 License — Free and open source.'; - - @override - String get permissionsTitle => 'Accessibility Permission Required'; - - @override - String get permissionsMessage => - 'CopyPaste needs Accessibility permission to paste content into other apps.\n\nGo to System Settings → Privacy & Security → Accessibility and enable CopyPaste.'; - - @override - String get permissionsOpenSettings => 'Open Settings'; - - @override - String get permissionsDismiss => 'Later'; - - @override - String get permissionsGranted => 'Permission granted'; - - @override - String get permissionsResetTitle => 'Accessibility Permission Lost'; - - @override - String get permissionsResetMessage => - 'macOS no longer recognises CopyPaste\'s permission because the app was re-authorised through Gatekeeper.\n\nTo fix this:\n1. Open Accessibility settings below\n2. Remove CopyPaste from the list (−)\n3. Re-add it or toggle it back on'; - - @override - String get permissionsRestartMessage => - 'Make sure CopyPaste is enabled in Privacy & Security > Accessibility.\n\nThe app will continue automatically when the permission is detected.'; - - @override - String get permissionsCheckAgain => 'Check Again'; - - @override - String get permissionsRestartApp => 'Restart App'; - - @override - String get permissionsWaiting => 'Waiting for permission…'; - - @override - String updateBadge(String version) { - return 'v$version is available, please update'; - } - - @override - String updateAvailableWindows(String version) { - return 'Version $version is available.\n\nDownload the latest installer from GitHub.'; - } - - @override - String updateAvailableMac(String version) { - return 'Version $version is available.\n\nUpdate via Homebrew:\nbrew upgrade copypaste\n\nOr download the latest release from GitHub.'; - } - - @override - String updateAvailableLinux(String version) { - return 'Version $version is available.\n\nDownload the latest release from GitHub.'; - } - - @override - String updateAvailableStore(String version) { - return 'Version $version is available.\n\nMicrosoft Store delivers updates automatically. New versions may take a few days to appear after release.'; - } - - @override - String updateTooltipStore(String version) { - return 'Update $version coming via Microsoft Store'; - } - - @override - String updateTooltipGeneric(String version) { - return 'Update $version available — click for details'; - } - - @override - String get updateDialogTitle => 'Update Available'; - - @override - String get updateViewRelease => 'View release'; - - @override - String get updateDismiss => 'Later'; - - @override - String updateBadgeImportant(String version) { - return 'v$version available — important update'; - } - - @override - String get updateActionDownload => 'Download installer'; - - @override - String get updateActionOpenStore => 'Open Microsoft Store'; - - @override - String get updateActionCopyBrew => 'Copy brew command'; - - @override - String get updateActionCopied => 'Copied to clipboard'; - - @override - String get blockedTitle => 'Update required'; - - @override - String blockedDescription(String current, String required) { - return 'Version $current of CopyPaste is no longer supported. Please install version $required or newer to continue using the app.'; - } - - @override - String get blockedReasonGeneric => - 'This version was retired by the maintainers for safety or compatibility reasons.'; - - @override - String get blockedQuit => 'Quit CopyPaste'; - - @override - String get blockedFallbackHint => - 'Visit https://github.com/rgdevment/CopyPaste/releases to download the latest installer.'; - - @override - String get waylandUnsupportedTitle => 'Wayland is not supported'; - - @override - String get waylandUnsupportedBadge => 'Open source · X11 only'; - - @override - String get waylandUnsupportedBody => - 'Linux support is still a work in progress. This project is maintained by a single person and we need more testers to move forward.\n\nCopyPaste works fully on X11 — to use it, log in with an X11 session. Sorry for the inconvenience.'; - - @override - String get waylandUnsupportedGitHub => 'View on GitHub'; - - @override - String get waylandUnsupportedClose => 'Close'; - - @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.'; - } - - @override - String linuxHotkeyGrabFailedWarning(String hotkey) { - return 'The shortcut $hotkey is being used by another application. Change it in Settings → Shortcuts.'; - } - - @override - String get linuxPasteFocusTimeoutWarning => - 'The clipboard has your content. Paste manually with Ctrl+V.'; - - @override - String get linuxAppindicatorBannerTitle => 'System tray icon unavailable'; - - @override - String get linuxAppindicatorBannerBody => - 'Your desktop does not expose an AppIndicator host, so the CopyPaste tray icon will not appear. Install a tray extension for your distribution and restart CopyPaste.'; - - @override - String get linuxClipboardManagerBannerTitle => - 'No clipboard manager detected'; - - @override - String get linuxClipboardManagerBannerBody => - 'Without a clipboard manager (gpaste, klipper, clipman, copyq, …) clipboard data may be lost when CopyPaste quits. Install one and restart your session.'; - - @override - String get linuxXtestBannerTitle => 'Automatic paste-back disabled'; - - @override - String get linuxXtestBannerBody => - 'The X11 XTest extension is not available, so CopyPaste cannot inject Ctrl+V automatically. Items are still copied to the clipboard — paste manually with Ctrl+V.'; - - @override - String get linuxBannerDismiss => 'Dismiss'; - - @override - String wakeupHint(String hotkey) { - return 'CopyPaste runs in the background — press $hotkey or click the tray icon to open it anytime.'; - } - - @override - String taskbarOpenHint(String hotkey) { - return 'Tip: press $hotkey to open and paste automatically — no focus lost.'; - } - - @override - String balloonStartupBody(String hotkey) { - return 'Running in the background. Press $hotkey or click the tray icon.'; - } - - @override - String get balloonWakeupTitle => 'CopyPaste is already open'; - - @override - String balloonWakeupBody(String hotkey) { - return 'Press $hotkey or click the tray icon to bring it up.'; - } - - @override - String get onboardingTitle => 'Welcome to CopyPaste'; - - @override - String get onboardingSubtitle => 'Everything you copy, saved.'; - - @override - String get onboardingPrivacyBadge => 'No cloud · No tracking · 100% local'; - - @override - String onboardingDescription(String hotkey) { - return 'Runs silently in the background. Press $hotkey anytime to open your clipboard history.'; - } - - @override - String get onboardingTrayHint => 'Look for the CP icon next to your clock.'; - - @override - String get onboardingSettingsButton => 'Settings'; - - @override - String get onboardingDismissButton => 'Get started'; - - @override - String get tabCapture => 'Performance'; - - @override - String get tabMultimedia => 'Multimedia'; - - @override - String get tabCleanupPrivacy => 'Cleanup & Privacy'; - - @override - String get sectionMultimedia => 'MULTIMEDIA & THUMBNAILS'; - - @override - String get subtitleMultimedia => - 'Control how images, videos and audio files are previewed.'; - - @override - String get settingGenerateImageThumbnails => 'Generate image thumbnails'; - - @override - String get subtitleGenerateImageThumbnails => - 'Show preview tiles for copied or referenced images.'; - - @override - String get settingGenerateVideoThumbnails => 'Generate video thumbnails'; - - @override - String get subtitleGenerateVideoThumbnails => - 'Use the OS shell cache to show a preview frame for video files.'; - - @override - String get settingGenerateAudioThumbnails => 'Generate audio thumbnails'; - - @override - String get subtitleGenerateAudioThumbnails => - 'Show cover art when available for audio files.'; - - @override - String get settingMaxImageSize => 'Max image size for processing (MB)'; - - @override - String get subtitleMaxImageSize => - 'Larger images keep their original bitmap fallback and are not re-encoded.'; - - @override - String get sectionCleanupPrivacy => 'CLEANUP & PRIVACY'; - - @override - String get settingKeepBrokenItemsLabel => 'Keep unavailable items (days)'; - - @override - String get subtitleKeepBrokenItems => - 'Items that point to a missing file or unmounted volume are pruned after this many days. 0 prunes immediately.'; - - @override - String get settingImagesQuotaLabel => 'Storage cap for images'; - - @override - String get subtitleImagesQuota => - 'When the images folder exceeds this size, oldest unpinned items are deleted to free space.'; - - @override - String get imagesQuotaOff => 'Unlimited'; -} +// ignore: unused_import +import 'package:intl/intl.dart' as intl; +import 'app_localizations.dart'; + +// ignore_for_file: type=lint + +/// The translations for English (`en`). +class AppLocalizationsEn extends AppLocalizations { + AppLocalizationsEn([String locale = 'en']) : super(locale); + + @override + String get searchPlaceholder => 'Search clipboard…'; + + @override + String get emptyState => 'No items in this section'; + + @override + String get emptyStateSubtitle => 'Copy something to get started'; + + @override + String get hintBannerText => + 'CopyPaste is active and running in the background. Look for it in the system tray or just use your shortcut. Feel free to customize your experience in'; + + @override + String get hintBannerAction => 'Settings'; + + @override + String get settingsTitle => 'Settings'; + + @override + String get sectionShortcuts => 'KEYBOARD SHORTCUTS'; + + @override + String get sectionStorage => 'STORAGE'; + + @override + String get settingRunOnStartup => 'Run on startup'; + + @override + String get settingLanguage => 'Interface language'; + + @override + String get hotkeyWillApply => 'Hotkey will apply immediately'; + + @override + String get sectionSupport => 'SUPPORT'; + + @override + String get supportExportLogs => 'Export logs'; + + @override + String get supportExportLogsSubtitle => + 'Save a zip with app logs for a bug report. Your clipboard content is never included.'; + + @override + String get supportOpenLogsFolder => 'Open logs folder'; + + @override + String get supportOpenLogsFolderSubtitle => + 'Browse the raw log files in your file manager.'; + + @override + String get supportGitHub => 'Report a bug on GitHub'; + + @override + String get supportExportSuccess => 'Logs saved to Downloads.'; + + @override + String get supportShowInFiles => 'Show'; + + @override + String get supportExportEmpty => 'No log files found.'; + + @override + String get supportExportError => 'Failed to export logs.'; + + @override + String get sectionReset => 'RESET & CLEAN INSTALL'; + + @override + String get resetSoftLabel => 'Soft Reset'; + + @override + String get resetSoftSubtitle => + 'Resets all settings to defaults and marks app as fresh install. Clipboard history is preserved.'; + + @override + String get resetHardLabel => 'Hard Reset'; + + @override + String get resetHardSubtitle => + 'Deletes all clipboard history, images, and settings. This cannot be undone.'; + + @override + String get resetSoftConfirmTitle => 'Soft reset?'; + + @override + String get resetSoftConfirmMessage => + 'All settings will return to defaults and the app will restart as if freshly installed. Your clipboard history will not be deleted.'; + + @override + String get resetHardConfirmTitle => 'Hard reset?'; + + @override + String get resetHardConfirmMessage => + 'This will permanently delete all clipboard history, images, and settings, then restart the app. This cannot be undone.'; + + @override + String get resetConfirmButton => 'Reset & Restart'; + + @override + String get clearHistoryConfirmTitle => 'Clear history?'; + + @override + String get clearHistoryConfirmMessage => + 'This will permanently delete all non-pinned clipboard items. This action cannot be undone.'; + + @override + String get clearHistoryConfirmButton => 'Clear'; + + @override + String backupLastDate(String date) { + return 'Last backup: $date'; + } + + @override + String get backupNone => 'No backup created yet.'; + + @override + String get backupCreateLabel => 'Create backup'; + + @override + String get backupRestoreLabel => 'Restore backup'; + + @override + String get backupError => 'Failed to create backup. Check permissions.'; + + @override + String get restoreDialogTitle => 'Restore backup'; + + @override + String get restoreDialogWarning => + 'This will replace all current data with the backup contents. Continue?'; + + @override + String get restoreFileNotFound => 'File not found.'; + + @override + String restoreSuccess(int count) { + return 'Restored $count items.'; + } + + @override + String get restoreError => + 'Restore failed. Your previous data has been preserved.'; + + @override + String get buttonSave => 'Save'; + + @override + String get buttonClose => 'Close'; + + @override + String get buttonCancel => 'Cancel'; + + @override + String get buttonReset => 'Restore defaults'; + + @override + String get savingIndicator => 'Saving…'; + + @override + String get savedIndicator => 'Saved'; + + @override + String get menuPaste => 'Paste'; + + @override + String get menuPastePlain => 'Paste plain'; + + @override + String get menuPin => 'Pin'; + + @override + String get menuUnpin => 'Unpin'; + + @override + String get menuEdit => 'Edit card'; + + @override + String get menuDelete => 'Delete'; + + @override + String get editColorLabel => 'Color'; + + @override + String get colorRed => 'Red'; + + @override + String get colorGreen => 'Green'; + + @override + String get colorPurple => 'Purple'; + + @override + String get colorYellow => 'Yellow'; + + @override + String get colorBlue => 'Blue'; + + @override + String get colorOrange => 'Orange'; + + @override + String get typeText => 'Text'; + + @override + String get typeImage => 'Image'; + + @override + String get typeFile => 'File'; + + @override + String get typeFolder => 'Folder'; + + @override + String get typeLink => 'Link'; + + @override + String get typeAudio => 'Audio'; + + @override + String get typeVideo => 'Video'; + + @override + String get typeEmail => 'Email'; + + @override + String get typePhone => 'Phone'; + + @override + String get typeColor => 'Color'; + + @override + String get typeIp => 'IP'; + + @override + String get typeUuid => 'UUID'; + + @override + String get typeJson => 'JSON'; + + @override + String get filterAll => 'All'; + + @override + String get filterPinned => 'Pinned'; + + @override + String get trayTooltip => 'CopyPaste'; + + @override + String get trayExit => 'Exit'; + + @override + String get shortcutOpenClose => 'Open / close CopyPaste'; + + @override + String get shortcutEscape => 'Clear search or close window'; + + @override + String get shortcutTab1 => 'Switch to Recent tab'; + + @override + String get shortcutTab2 => 'Switch to Pinned tab'; + + @override + String get shortcutArrows => 'Navigate between items'; + + @override + String get shortcutEnter => 'Paste selected item'; + + @override + String get shortcutDelete => 'Delete selected item'; + + @override + String get shortcutPin => 'Pin / Unpin selected item'; + + @override + String get shortcutEdit => 'Edit card (label and color)'; + + @override + String get tabGeneral => 'General'; + + @override + String get tabBackupRestore => 'Backup & Support'; + + @override + String get tabAppearance => 'Appearance'; + + @override + String get tabShortcuts => 'Shortcuts'; + + @override + String get tabAbout => 'About'; + + @override + String get sectionLanguage => 'LANGUAGE'; + + @override + String get sectionStartup => 'STARTUP'; + + @override + String get sectionKeyboardShortcut => 'KEYBOARD SHORTCUT'; + + @override + String get sectionCategories => 'CATEGORIES'; + + @override + String get sectionPerformance => 'PERFORMANCE'; + + @override + String get sectionPaste => 'PASTE'; + + @override + String get sectionBackupRestore => 'BACKUP & RESTORE'; + + @override + String get sectionAppearance => 'APPEARANCE'; + + @override + String get settingTheme => 'Theme'; + + @override + String get themeLight => 'Light'; + + @override + String get themeDark => 'Dark'; + + @override + String get themeAuto => 'Auto'; + + @override + String get sectionBehavior => 'BEHAVIOR'; + + @override + String get sectionAbout => 'COPYPASTE'; + + @override + String get sectionLinks => 'LINKS'; + + @override + String get settingItemsPerPage => 'Items per page'; + + @override + String get settingMemoryLimit => 'Memory limit'; + + @override + String get settingScrollThreshold => 'Scroll threshold (px)'; + + @override + String get settingPasteSpeed => 'Paste speed'; + + @override + String get settingPanelWidth => 'Panel width (px)'; + + @override + String get settingPanelHeight => 'Panel height (px)'; + + @override + String get settingLinesCollapsed => 'Lines collapsed'; + + @override + String get settingLinesExpanded => 'Lines expanded'; + + @override + String get settingHideOnDeactivate => 'Hide on deactivate'; + + @override + String get settingScrollToTopOnOpen => 'Scroll to top on open'; + + @override + String get settingClearSearchOnOpen => 'Clear search on open'; + + @override + String get settingRetentionDaysLabel => 'Retention days (0 = unlimited)'; + + @override + String get settingClearHistoryLabel => 'Clear clipboard history'; + + @override + String get settingHotkeyShortcutLabel => 'Shortcut to open/close CopyPaste'; + + @override + String get subtitleStartupDesc => 'Launches in background when you sign in'; + + @override + String get subtitleHideOnDeactivate => 'Close window when clicking outside'; + + @override + String get subtitleScrollToTopOnOpen => + 'Resets scroll and selects latest item'; + + @override + String get subtitleClearSearchOnOpen => 'Clears the search text each time'; + + @override + String get subtitlePasteSpeed => 'Adjust restoration and paste timings'; + + @override + String get subtitleCategories => 'Customize the names of color categories.'; + + @override + String get linkGitHub => 'Support & Source code — GitHub'; + + @override + String get linkCoffee => 'Buy me a coffee'; + + @override + String get editDialogTitle => 'Label & Color'; + + @override + String get editDialogHint => 'Add a label...'; + + @override + String get historyCleared => 'History cleared'; + + @override + String backupSavedFile(String filename) { + return 'Backup saved: $filename'; + } + + @override + String get buttonRestore => 'Restore'; + + @override + String get restoreCompleted => 'Restore completed'; + + @override + String get restoreRestartRequired => + 'Restore completed. The app will restart to apply changes.'; + + @override + String get shortcutExpand => 'Expand / collapse card'; + + @override + String get shortcutFocusSearch => 'Focus search box'; + + @override + String get trayShowHide => 'Show/Hide'; + + @override + String get fileNotFound => 'Not found'; + + @override + String get audioFile => 'Audio file'; + + @override + String get videoFile => 'Video file'; + + @override + String get imageFile => 'Image file'; + + @override + String get timeNow => 'now'; + + @override + String get clearAllFilters => 'Clear all filters'; + + @override + String get colorSectionLabel => 'COLOR'; + + @override + String get colorNone => 'None'; + + @override + String get subtitlePastePreset => + 'Automatic paste speed. Normal/Safe recommended for most computers.'; + + @override + String get pastePresetFast => 'Fast'; + + @override + String get pastePresetNormal => 'Normal'; + + @override + String get pastePresetSafe => 'Safe'; + + @override + String get pastePresetSlow => 'Slow'; + + @override + String get pastePresetCustom => 'Custom'; + + @override + String get pastePresetWarning => + '⚠️ Fast: may cause unexpected behavior in heavy apps.\n⚠️ Slow: may feel sluggish on modern computers.'; + + @override + String get settingResetFiltersOnOpen => 'Switch to All on open'; + + @override + String get subtitleResetFiltersOnOpen => + 'Clears category and type filters and returns to the All tab'; + + @override + String get subtitleBackup => + 'Create a backup of your clipboard history, images, and settings. Restore at any time on this or another device.'; + + @override + String get aboutDescription => + 'A modern clipboard manager built to feel native on Windows, macOS, and Linux.\nLocal-first — your history, always at hand. No accounts, no telemetry, no subscriptions.'; + + @override + String get sectionPrivacy => 'PRIVACY'; + + @override + String get privacyStatement => + 'Everything local. Nothing leaves your PC — no telemetry, no sync, no accounts.'; + + @override + String get privacyPolicy => 'Privacy Policy'; + + @override + String get aboutTagLocal => 'Local-only'; + + @override + String get aboutTagOpenSource => 'Open source'; + + @override + String get aboutTagFree => 'Free'; + + @override + String get sectionOtherTools => 'OTHER TOOLS'; + + @override + String get otherToolLinkUnbound => 'LinkUnbound'; + + @override + String get otherToolLinkUnboundDesc => + 'Open-source browser selector for Windows and Mac. Same philosophy: no ads, no telemetry, everything local.'; + + @override + String get aboutLicense => 'GPL v3 License — Free and open source.'; + + @override + String get permissionsTitle => 'Accessibility Permission Required'; + + @override + String get permissionsMessage => + 'CopyPaste needs Accessibility permission to paste content into other apps.\n\nGo to System Settings → Privacy & Security → Accessibility and enable CopyPaste.'; + + @override + String get permissionsOpenSettings => 'Open Settings'; + + @override + String get permissionsDismiss => 'Later'; + + @override + String get permissionsGranted => 'Permission granted'; + + @override + String get permissionsResetTitle => 'Accessibility Permission Lost'; + + @override + String get permissionsResetMessage => + 'macOS no longer recognises CopyPaste\'s permission because the app was re-authorised through Gatekeeper.\n\nTo fix this:\n1. Open Accessibility settings below\n2. Remove CopyPaste from the list (−)\n3. Re-add it or toggle it back on'; + + @override + String get permissionsRestartMessage => + 'Make sure CopyPaste is enabled in Privacy & Security > Accessibility.\n\nThe app will continue automatically when the permission is detected.'; + + @override + String get permissionsCheckAgain => 'Check Again'; + + @override + String get permissionsRestartApp => 'Restart App'; + + @override + String get permissionsWaiting => 'Waiting for permission…'; + + @override + String updateBadge(String version) { + return 'v$version is available, please update'; + } + + @override + String updateAvailableWindows(String version) { + return 'Version $version is available.\n\nDownload the latest installer from GitHub.'; + } + + @override + String updateAvailableMac(String version) { + return 'Version $version is available.\n\nUpdate via Homebrew:\nbrew upgrade copypaste\n\nOr download the latest release from GitHub.'; + } + + @override + String updateAvailableLinux(String version) { + return 'Version $version is available.\n\nDownload the latest release from GitHub.'; + } + + @override + String updateAvailableStore(String version) { + return 'Version $version is available.\n\nMicrosoft Store delivers updates automatically. New versions may take a few days to appear after release.'; + } + + @override + String updateTooltipStore(String version) { + return 'Update $version coming via Microsoft Store'; + } + + @override + String updateTooltipGeneric(String version) { + return 'Update $version available — click for details'; + } + + @override + String get updateDialogTitle => 'Update Available'; + + @override + String get updateViewRelease => 'View release'; + + @override + String get updateDismiss => 'Later'; + + @override + String updateBadgeImportant(String version) { + return 'v$version available — important update'; + } + + @override + String get updateActionDownload => 'Download installer'; + + @override + String get updateActionOpenStore => 'Open Microsoft Store'; + + @override + String get updateActionCopyBrew => 'Copy brew command'; + + @override + String get updateActionCopied => 'Copied to clipboard'; + + @override + String get blockedTitle => 'Update required'; + + @override + String blockedDescription(String current, String required) { + return 'Version $current of CopyPaste is no longer supported. Please install version $required or newer to continue using the app.'; + } + + @override + String get blockedReasonGeneric => + 'This version was retired by the maintainers for safety or compatibility reasons.'; + + @override + String get blockedQuit => 'Quit CopyPaste'; + + @override + String get blockedFallbackHint => + 'Visit https://github.com/rgdevment/CopyPaste/releases to download the latest installer.'; + + @override + String get waylandUnsupportedTitle => 'Wayland is not supported'; + + @override + String get waylandUnsupportedBadge => 'Open source · X11 only'; + + @override + String get waylandUnsupportedBody => + 'Linux support is still a work in progress. This project is maintained by a single person and we need more testers to move forward.\n\nCopyPaste works fully on X11 — to use it, log in with an X11 session. Sorry for the inconvenience.'; + + @override + String get waylandUnsupportedGitHub => 'View on GitHub'; + + @override + String get waylandUnsupportedClose => 'Close'; + + @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.'; + } + + @override + String linuxHotkeyGrabFailedWarning(String hotkey) { + return 'The shortcut $hotkey is being used by another application. Change it in Settings → Shortcuts.'; + } + + @override + String get linuxPasteFocusTimeoutWarning => + 'The clipboard has your content. Paste manually with Ctrl+V.'; + + @override + String get linuxAppindicatorBannerTitle => 'System tray icon unavailable'; + + @override + String get linuxAppindicatorBannerBody => + 'Your desktop does not expose an AppIndicator host, so the CopyPaste tray icon will not appear. Install a tray extension for your distribution and restart CopyPaste.'; + + @override + String get linuxClipboardManagerBannerTitle => + 'No clipboard manager detected'; + + @override + String get linuxClipboardManagerBannerBody => + 'Without a clipboard manager (gpaste, klipper, clipman, copyq, …) clipboard data may be lost when CopyPaste quits. Install one and restart your session.'; + + @override + String get linuxXtestBannerTitle => 'Automatic paste-back disabled'; + + @override + String get linuxXtestBannerBody => + 'The X11 XTest extension is not available, so CopyPaste cannot inject Ctrl+V automatically. Items are still copied to the clipboard — paste manually with Ctrl+V.'; + + @override + String get linuxBannerDismiss => 'Dismiss'; + + @override + String wakeupHint(String hotkey) { + return 'CopyPaste runs in the background — press $hotkey or click the tray icon to open it anytime.'; + } + + @override + String taskbarOpenHint(String hotkey) { + return 'Tip: press $hotkey to open and paste automatically — no focus lost.'; + } + + @override + String balloonStartupBody(String hotkey) { + return 'Running in the background. Press $hotkey or click the tray icon.'; + } + + @override + String get balloonWakeupTitle => 'CopyPaste is already open'; + + @override + String balloonWakeupBody(String hotkey) { + return 'Press $hotkey or click the tray icon to bring it up.'; + } + + @override + String get onboardingTitle => 'Welcome to CopyPaste'; + + @override + String get onboardingSubtitle => 'Everything you copy, saved.'; + + @override + String get onboardingPrivacyBadge => 'No cloud · No tracking · 100% local'; + + @override + String onboardingDescription(String hotkey) { + return 'Runs silently in the background. Press $hotkey anytime to open your clipboard history.'; + } + + @override + String get onboardingTrayHint => 'Look for the CP icon next to your clock.'; + + @override + String get onboardingSettingsButton => 'Settings'; + + @override + String get onboardingDismissButton => 'Get started'; + + @override + String get tabCapture => 'Performance'; + + @override + String get tabMultimedia => 'Multimedia'; + + @override + String get tabCleanupPrivacy => 'Cleanup & Privacy'; + + @override + String get sectionMultimedia => 'MULTIMEDIA & THUMBNAILS'; + + @override + String get subtitleMultimedia => + 'Control how images, videos and audio files are previewed.'; + + @override + String get settingGenerateImageThumbnails => 'Generate image thumbnails'; + + @override + String get subtitleGenerateImageThumbnails => + 'Show preview tiles for copied or referenced images.'; + + @override + String get settingGenerateVideoThumbnails => 'Generate video thumbnails'; + + @override + String get subtitleGenerateVideoThumbnails => + 'Use the OS shell cache to show a preview frame for video files.'; + + @override + String get settingGenerateAudioThumbnails => 'Generate audio thumbnails'; + + @override + String get subtitleGenerateAudioThumbnails => + 'Show cover art when available for audio files.'; + + @override + String get settingMaxImageSize => 'Max image size for processing (MB)'; + + @override + String get subtitleMaxImageSize => + 'Larger images keep their original bitmap fallback and are not re-encoded.'; + + @override + String get sectionCleanupPrivacy => 'CLEANUP & PRIVACY'; + + @override + String get settingKeepBrokenItemsLabel => 'Keep unavailable items (days)'; + + @override + String get subtitleKeepBrokenItems => + 'Items that point to a missing file or unmounted volume are pruned after this many days. 0 prunes immediately.'; + + @override + String get settingImagesQuotaLabel => 'Storage cap for images'; + + @override + String get subtitleImagesQuota => + 'When the images folder exceeds this size, oldest unpinned items are deleted to free space.'; + + @override + String get imagesQuotaOff => 'Unlimited'; +} diff --git a/app/lib/l10n/app_localizations_es.dart b/app/lib/l10n/app_localizations_es.dart index 366b9122..629ac2ef 100644 --- a/app/lib/l10n/app_localizations_es.dart +++ b/app/lib/l10n/app_localizations_es.dart @@ -1,839 +1,838 @@ -// coverage:ignore-file -// ignore: unused_import -import 'package:intl/intl.dart' as intl; -import 'app_localizations.dart'; - -// ignore_for_file: type=lint - -/// The translations for Spanish Castilian (`es`). -class AppLocalizationsEs extends AppLocalizations { - AppLocalizationsEs([String locale = 'es']) : super(locale); - - @override - String get searchPlaceholder => 'Buscar en portapapeles…'; - - @override - String get emptyState => 'No hay elementos en esta sección'; - - @override - String get emptyStateSubtitle => 'Copia algo para comenzar'; - - @override - String get hintBannerText => - 'CopyPaste se ejecuta en segundo plano — encuéntralo en la bandeja del sistema o usa tu atajo de teclado. Personaliza tu experiencia en'; - - @override - String get hintBannerAction => 'Ajustes'; - - @override - String get settingsTitle => 'Configuración'; - - @override - String get sectionShortcuts => 'ATAJOS DE TECLADO'; - - @override - String get sectionStorage => 'ALMACENAMIENTO'; - - @override - String get settingRunOnStartup => 'Iniciar con el sistema'; - - @override - String get settingLanguage => 'Idioma de la interfaz'; - - @override - String get hotkeyWillApply => 'El atajo se aplicará de inmediato'; - - @override - String get sectionSupport => 'SOPORTE'; - - @override - String get supportExportLogs => 'Exportar registros'; - - @override - String get supportExportLogsSubtitle => - 'Guarda un zip con registros de la app para adjuntar a un reporte. El contenido del portapapeles nunca se incluye.'; - - @override - String get supportOpenLogsFolder => 'Abrir carpeta de registros'; - - @override - String get supportOpenLogsFolderSubtitle => - 'Explora los archivos de registro en tu gestor de archivos.'; - - @override - String get supportGitHub => 'Reportar un error en GitHub'; - - @override - String get supportExportSuccess => 'Registros guardados en Descargas.'; - - @override - String get supportShowInFiles => 'Mostrar'; - - @override - String get supportExportEmpty => 'No se encontraron archivos de registro.'; - - @override - String get supportExportError => 'Error al exportar los registros.'; - - @override - String get sectionReset => 'RESTABLECER E INSTALACIÓN LIMPIA'; - - @override - String get resetSoftLabel => 'Restablecimiento suave'; - - @override - String get resetSoftSubtitle => - 'Restablece la configuración a los valores predeterminados y marca la app como nueva instalación. El historial del portapapeles se conserva.'; - - @override - String get resetHardLabel => 'Restablecimiento completo'; - - @override - String get resetHardSubtitle => - 'Elimina todo el historial, imágenes y configuración. Esta acción no se puede deshacer.'; - - @override - String get resetSoftConfirmTitle => '¿Restablecimiento suave?'; - - @override - String get resetSoftConfirmMessage => - 'Toda la configuración volverá a los valores predeterminados y la app se reiniciará como si fuera una instalación nueva. El historial del portapapeles no se eliminará.'; - - @override - String get resetHardConfirmTitle => '¿Restablecimiento completo?'; - - @override - String get resetHardConfirmMessage => - 'Se eliminará permanentemente todo el historial, imágenes y configuración, y luego la app se reiniciará. Esta acción no se puede deshacer.'; - - @override - String get resetConfirmButton => 'Restablecer y Reiniciar'; - - @override - String get clearHistoryConfirmTitle => '¿Limpiar historial?'; - - @override - String get clearHistoryConfirmMessage => - 'Esto eliminará permanentemente todos los elementos no anclados. Esta acción no se puede deshacer.'; - - @override - String get clearHistoryConfirmButton => 'Limpiar'; - - @override - String backupLastDate(String date) { - return 'Último respaldo: $date'; - } - - @override - String get backupNone => 'Aún no se ha creado un respaldo.'; - - @override - String get backupCreateLabel => 'Crear respaldo'; - - @override - String get backupRestoreLabel => 'Restaurar respaldo'; - - @override - String get backupError => - 'Error al crear el respaldo. Verifica los permisos.'; - - @override - String get restoreDialogTitle => 'Restaurar respaldo'; - - @override - String get restoreDialogWarning => - 'Esto reemplazará todos los datos actuales con el contenido del respaldo. ¿Continuar?'; - - @override - String get restoreFileNotFound => 'Archivo no encontrado.'; - - @override - String restoreSuccess(int count) { - return 'Se restauraron $count elementos.'; - } - - @override - String get restoreError => - 'Error al restaurar. Tus datos anteriores se han preservado.'; - - @override - String get buttonSave => 'Guardar'; - - @override - String get buttonClose => 'Cerrar'; - - @override - String get buttonCancel => 'Cancelar'; - - @override - String get buttonReset => 'Restaurar predeterminados'; - - @override - String get savingIndicator => 'Guardando…'; - - @override - String get savedIndicator => 'Guardado'; - - @override - String get menuPaste => 'Pegar'; - - @override - String get menuPastePlain => 'Pegar sin formato'; - - @override - String get menuPin => 'Anclar'; - - @override - String get menuUnpin => 'Desanclar'; - - @override - String get menuEdit => 'Editar tarjeta'; - - @override - String get menuDelete => 'Eliminar'; - - @override - String get editColorLabel => 'Color'; - - @override - String get colorRed => 'Rojo'; - - @override - String get colorGreen => 'Verde'; - - @override - String get colorPurple => 'Morado'; - - @override - String get colorYellow => 'Amarillo'; - - @override - String get colorBlue => 'Azul'; - - @override - String get colorOrange => 'Naranja'; - - @override - String get typeText => 'Texto'; - - @override - String get typeImage => 'Imagen'; - - @override - String get typeFile => 'Archivo'; - - @override - String get typeFolder => 'Carpeta'; - - @override - String get typeLink => 'Enlace'; - - @override - String get typeAudio => 'Audio'; - - @override - String get typeVideo => 'Video'; - - @override - String get typeEmail => 'Email'; - - @override - String get typePhone => 'Teléfono'; - - @override - String get typeColor => 'Color'; - - @override - String get typeIp => 'IP'; - - @override - String get typeUuid => 'UUID'; - - @override - String get typeJson => 'JSON'; - - @override - String get filterAll => 'Todo'; - - @override - String get filterPinned => 'Anclados'; - - @override - String get trayTooltip => 'CopyPaste'; - - @override - String get trayExit => 'Salir'; - - @override - String get shortcutOpenClose => 'Abrir / cerrar CopyPaste'; - - @override - String get shortcutEscape => 'Limpiar búsqueda o cerrar ventana'; - - @override - String get shortcutTab1 => 'Cambiar a pestaña Recientes'; - - @override - String get shortcutTab2 => 'Cambiar a pestaña Anclados'; - - @override - String get shortcutArrows => 'Navegar entre elementos'; - - @override - String get shortcutEnter => 'Pegar elemento seleccionado'; - - @override - String get shortcutDelete => 'Eliminar elemento seleccionado'; - - @override - String get shortcutPin => 'Anclar / Desanclar elemento'; - - @override - String get shortcutEdit => 'Editar tarjeta (etiqueta y color)'; - - @override - String get tabGeneral => 'General'; - - @override - String get tabBackupRestore => 'Backup y soporte'; - - @override - String get tabAppearance => 'Apariencia'; - - @override - String get tabShortcuts => 'Atajos'; - - @override - String get tabAbout => 'Acerca de'; - - @override - String get sectionLanguage => 'IDIOMA'; - - @override - String get sectionStartup => 'INICIO'; - - @override - String get sectionKeyboardShortcut => 'ATAJO DE TECLADO'; - - @override - String get sectionCategories => 'CATEGORÍAS'; - - @override - String get sectionPerformance => 'RENDIMIENTO'; - - @override - String get sectionPaste => 'PEGADO'; - - @override - String get sectionBackupRestore => 'RESPALDO Y RESTAURACIÓN'; - - @override - String get sectionAppearance => 'APARIENCIA'; - - @override - String get settingTheme => 'Tema'; - - @override - String get themeLight => 'Claro'; - - @override - String get themeDark => 'Oscuro'; - - @override - String get themeAuto => 'Auto'; - - @override - String get sectionBehavior => 'COMPORTAMIENTO'; - - @override - String get sectionAbout => 'COPYPASTE'; - - @override - String get sectionLinks => 'ENLACES'; - - @override - String get settingItemsPerPage => 'Elementos por página'; - - @override - String get settingMemoryLimit => 'Límite de memoria'; - - @override - String get settingScrollThreshold => 'Umbral de desplazamiento (px)'; - - @override - String get settingPasteSpeed => 'Velocidad de pegado'; - - @override - String get settingPanelWidth => 'Ancho del panel (px)'; - - @override - String get settingPanelHeight => 'Alto del panel (px)'; - - @override - String get settingLinesCollapsed => 'Líneas contraídas'; - - @override - String get settingLinesExpanded => 'Líneas expandidas'; - - @override - String get settingHideOnDeactivate => 'Ocultar al hacer clic fuera'; - - @override - String get settingScrollToTopOnOpen => 'Ir al inicio al abrir'; - - @override - String get settingClearSearchOnOpen => 'Limpiar búsqueda al abrir'; - - @override - String get settingRetentionDaysLabel => 'Días de retención (0 = sin límite)'; - - @override - String get settingClearHistoryLabel => 'Limpiar historial del portapapeles'; - - @override - String get settingHotkeyShortcutLabel => 'Atajo para abrir/cerrar CopyPaste'; - - @override - String get subtitleStartupDesc => - 'Se inicia en segundo plano al iniciar sesión'; - - @override - String get subtitleHideOnDeactivate => - 'Cerrar la ventana al hacer clic fuera'; - - @override - String get subtitleScrollToTopOnOpen => - 'Restablece el desplazamiento y selecciona el último elemento'; - - @override - String get subtitleClearSearchOnOpen => 'Borra el texto de búsqueda cada vez'; - - @override - String get subtitlePasteSpeed => 'Ajustar tiempos de restauración y pegado'; - - @override - String get subtitleCategories => - 'Personaliza los nombres de las categorías de color.'; - - @override - String get linkGitHub => 'Soporte y Código fuente — GitHub'; - - @override - String get linkCoffee => 'Invítame un café'; - - @override - String get editDialogTitle => 'Etiqueta y Color'; - - @override - String get editDialogHint => 'Agregar una etiqueta...'; - - @override - String get historyCleared => 'Historial limpiado'; - - @override - String backupSavedFile(String filename) { - return 'Respaldo guardado: $filename'; - } - - @override - String get buttonRestore => 'Restaurar'; - - @override - String get restoreCompleted => 'Restauración completada'; - - @override - String get restoreRestartRequired => - 'Restauración completada. La app se reiniciará para aplicar los cambios.'; - - @override - String get shortcutExpand => 'Expandir / contraer tarjeta'; - - @override - String get shortcutFocusSearch => 'Enfocar el buscador'; - - @override - String get trayShowHide => 'Mostrar/Ocultar'; - - @override - String get fileNotFound => 'No encontrado'; - - @override - String get audioFile => 'Archivo de audio'; - - @override - String get videoFile => 'Archivo de video'; - - @override - String get imageFile => 'Archivo de imagen'; - - @override - String get timeNow => 'ahora'; - - @override - String get clearAllFilters => 'Limpiar todos los filtros'; - - @override - String get colorSectionLabel => 'COLOR'; - - @override - String get colorNone => 'Ninguno'; - - @override - String get subtitlePastePreset => - 'Velocidad de pegado automático. Normal/Seguro recomendado para la mayoría.'; - - @override - String get pastePresetFast => 'Rápido'; - - @override - String get pastePresetNormal => 'Normal'; - - @override - String get pastePresetSafe => 'Seguro'; - - @override - String get pastePresetSlow => 'Lento'; - - @override - String get pastePresetCustom => 'Personalizado'; - - @override - String get pastePresetWarning => - '⚠️ Rápido: puede causar comportamientos extraños en apps pesadas.\n⚠️ Lento: puede sentirse pesado en equipos modernos.'; - - @override - String get settingResetFiltersOnOpen => 'Volver a Todos al abrir'; - - @override - String get subtitleResetFiltersOnOpen => - 'Limpia los filtros de categoría y tipo, y vuelve a la pestaña Todos'; - - @override - String get subtitleBackup => - 'Crea un respaldo de tu historial, imágenes y configuración. Restaura en cualquier momento en este u otro dispositivo.'; - - @override - String get aboutDescription => - 'Un gestor de portapapeles moderno, nativo en Windows, macOS y Linux.\nTodo local — tu historial, siempre a mano. Sin cuentas, sin telemetría, sin suscripciones.'; - - @override - String get sectionPrivacy => 'PRIVACIDAD'; - - @override - String get privacyStatement => - 'Todo local. Nada sale de tu PC — sin telemetría, sin sincronización, sin cuentas.'; - - @override - String get privacyPolicy => 'Política de privacidad'; - - @override - String get aboutTagLocal => 'Todo local'; - - @override - String get aboutTagOpenSource => 'Código abierto'; - - @override - String get aboutTagFree => 'Gratis'; - - @override - String get sectionOtherTools => 'OTRAS HERRAMIENTAS'; - - @override - String get otherToolLinkUnbound => 'LinkUnbound'; - - @override - String get otherToolLinkUnboundDesc => - 'Selector de navegadores de código abierto para Windows y Mac. Misma filosofía: sin anuncios, sin telemetría, todo local.'; - - @override - String get aboutLicense => 'Licencia GPL v3 — Libre y de código abierto.'; - - @override - String get permissionsTitle => 'Permiso de Accesibilidad requerido'; - - @override - String get permissionsMessage => - 'CopyPaste necesita permiso de Accesibilidad para pegar contenido en otras apps.\n\nVe a Configuración del Sistema → Privacidad y Seguridad → Accesibilidad y activa CopyPaste.'; - - @override - String get permissionsOpenSettings => 'Abrir Configuración'; - - @override - String get permissionsDismiss => 'Después'; - - @override - String get permissionsGranted => 'Permiso concedido'; - - @override - String get permissionsResetTitle => 'Permiso de Accesibilidad perdido'; - - @override - String get permissionsResetMessage => - 'macOS ya no reconoce el permiso de CopyPaste porque la app fue re-autorizada a través de Gatekeeper.\n\nPara solucionarlo:\n1. Abre la configuración de Accesibilidad\n2. Elimina CopyPaste de la lista (−)\n3. Vuelve a añadirlo o actívalo de nuevo'; - - @override - String get permissionsRestartMessage => - 'Asegúrate de que CopyPaste esté activado en Privacidad y seguridad > Accesibilidad.\n\nLa app continuará automáticamente cuando detecte el permiso.'; - - @override - String get permissionsCheckAgain => 'Verificar'; - - @override - String get permissionsRestartApp => 'Reiniciar app'; - - @override - String get permissionsWaiting => 'Esperando permiso…'; - - @override - String updateBadge(String version) { - return 'v$version disponible, por favor actualiza'; - } - - @override - String updateAvailableWindows(String version) { - return 'La versión $version está disponible.\n\nDescarga el instalador más reciente desde GitHub.'; - } - - @override - String updateAvailableMac(String version) { - return 'La versión $version está disponible.\n\nActualiza con Homebrew:\nbrew upgrade copypaste\n\nO descarga la última versión desde GitHub.'; - } - - @override - String updateAvailableLinux(String version) { - return 'La versión $version está disponible.\n\nDescarga la última versión desde GitHub.'; - } - - @override - String updateAvailableStore(String version) { - return 'La versión $version está disponible.\n\nLa Microsoft Store entrega las actualizaciones automáticamente. Las nuevas versiones pueden tardar unos días en aparecer tras su publicación.'; - } - - @override - String updateTooltipStore(String version) { - return 'Actualización $version en camino por Microsoft Store'; - } - - @override - String updateTooltipGeneric(String version) { - return 'Actualización $version disponible — haz clic para detalles'; - } - - @override - String get updateDialogTitle => 'Actualización disponible'; - - @override - String get updateViewRelease => 'Ver versión'; - - @override - String get updateDismiss => 'Después'; - - @override - String updateBadgeImportant(String version) { - return 'v$version disponible — actualización importante'; - } - - @override - String get updateActionDownload => 'Descargar instalador'; - - @override - String get updateActionOpenStore => 'Abrir Microsoft Store'; - - @override - String get updateActionCopyBrew => 'Copiar comando brew'; - - @override - String get updateActionCopied => 'Copiado al portapapeles'; - - @override - String get blockedTitle => 'Actualización requerida'; - - @override - String blockedDescription(String current, String required) { - return 'La versión $current de CopyPaste ya no está soportada. Instala la versión $required o más reciente para continuar.'; - } - - @override - String get blockedReasonGeneric => - 'Esta versión fue retirada por motivos de seguridad o compatibilidad.'; - - @override - String get blockedQuit => 'Salir de CopyPaste'; - - @override - String get blockedFallbackHint => - 'Visita https://github.com/rgdevment/CopyPaste/releases para descargar el instalador más reciente.'; - - @override - String get waylandUnsupportedTitle => 'Wayland no está soportado'; - - @override - String get waylandUnsupportedBadge => 'Open source · Solo X11'; - - @override - String get waylandUnsupportedBody => - 'El soporte en Linux está en progreso. Este proyecto lo mantiene una sola persona y necesitamos más testers para avanzar.\n\nCopyPaste funciona completamente en X11 — para usarlo, inicia sesión con X11. Lamentamos las molestias.'; - - @override - String get waylandUnsupportedGitHub => 'Ver en GitHub'; - - @override - String get waylandUnsupportedClose => 'Cerrar'; - - @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.'; - } - - @override - String linuxHotkeyGrabFailedWarning(String hotkey) { - return 'El atajo $hotkey está siendo usado por otra aplicación. Cámbialo en Configuración → Atajos.'; - } - - @override - String get linuxPasteFocusTimeoutWarning => - 'El portapapeles tiene tu contenido. Pégalo manualmente con Ctrl+V.'; - - @override - String get linuxAppindicatorBannerTitle => 'Ícono de bandeja no disponible'; - - @override - String get linuxAppindicatorBannerBody => - 'Tu escritorio no expone un host de AppIndicator, por lo que el ícono de CopyPaste no aparecerá en la bandeja. Instala una extensión de bandeja para tu distribución y reinicia CopyPaste.'; - - @override - String get linuxClipboardManagerBannerTitle => - 'No se detectó un gestor de portapapeles'; - - @override - String get linuxClipboardManagerBannerBody => - 'Sin un gestor de portapapeles (gpaste, klipper, clipman, copyq, …) los datos copiados pueden perderse cuando CopyPaste se cierra. Instala uno y reinicia tu sesión.'; - - @override - String get linuxXtestBannerTitle => 'Pegado automático deshabilitado'; - - @override - String get linuxXtestBannerBody => - 'La extensión XTest de X11 no está disponible, por lo que CopyPaste no puede inyectar Ctrl+V automáticamente. Los elementos siguen copiándose al portapapeles — pégalos manualmente con Ctrl+V.'; - - @override - String get linuxBannerDismiss => 'Descartar'; - - @override - String wakeupHint(String hotkey) { - return 'CopyPaste se ejecuta en segundo plano — presiona $hotkey o haz clic en el ícono de la bandeja para abrirlo cuando quieras.'; - } - - @override - String taskbarOpenHint(String hotkey) { - return 'Tip: presiona $hotkey para abrir y pegar automáticamente, sin perder el foco.'; - } - - @override - String balloonStartupBody(String hotkey) { - return 'Ejecutándose en segundo plano. Presiona $hotkey o haz clic en el ícono de la bandeja.'; - } - - @override - String get balloonWakeupTitle => 'CopyPaste ya está abierto'; - - @override - String balloonWakeupBody(String hotkey) { - return 'Presiona $hotkey o haz clic en el ícono de la bandeja para abrirlo.'; - } - - @override - String get onboardingTitle => 'Bienvenido a CopyPaste'; - - @override - String get onboardingSubtitle => 'Todo lo que copias, guardado.'; - - @override - String get onboardingPrivacyBadge => 'Sin nube · Sin rastreo · 100% local'; - - @override - String onboardingDescription(String hotkey) { - return 'Corre en segundo plano sin que lo notes. Presiona $hotkey cuando quieras para abrir tu historial.'; - } - - @override - String get onboardingTrayHint => - 'Encuéntralo junto al reloj, abajo a la derecha.'; - - @override - String get onboardingSettingsButton => 'Configuración'; - - @override - String get onboardingDismissButton => 'Empezar'; - - @override - String get tabCapture => 'Rendimiento'; - - @override - String get tabMultimedia => 'Multimedia'; - - @override - String get tabCleanupPrivacy => 'Limpieza y privacidad'; - - @override - String get sectionMultimedia => 'MULTIMEDIA Y MINIATURAS'; - - @override - String get subtitleMultimedia => - 'Controla cómo se previsualizan imágenes, vídeos y archivos de audio.'; - - @override - String get settingGenerateImageThumbnails => 'Generar miniaturas de imágenes'; - - @override - String get subtitleGenerateImageThumbnails => - 'Muestra una vista previa de las imágenes copiadas o referenciadas.'; - - @override - String get settingGenerateVideoThumbnails => 'Generar miniaturas de vídeos'; - - @override - String get subtitleGenerateVideoThumbnails => - 'Usa la caché del sistema para mostrar un fotograma de los vídeos.'; - - @override - String get settingGenerateAudioThumbnails => 'Generar miniaturas de audio'; - - @override - String get subtitleGenerateAudioThumbnails => - 'Muestra la carátula cuando esté disponible.'; - - @override - String get settingMaxImageSize => 'Tamaño máximo a procesar (MB)'; - - @override - String get subtitleMaxImageSize => - 'Las imágenes más grandes mantienen su mapa de bits original sin reprocesarse.'; - - @override - String get sectionCleanupPrivacy => 'LIMPIEZA Y PRIVACIDAD'; - - @override - String get settingKeepBrokenItemsLabel => - 'Conservar elementos no disponibles (días)'; - - @override - String get subtitleKeepBrokenItems => - 'Los elementos que apuntan a archivos perdidos o volúmenes desconectados se eliminan tras estos días. 0 los elimina al instante.'; - - @override - String get settingImagesQuotaLabel => - 'Límite de almacenamiento para imágenes'; - - @override - String get subtitleImagesQuota => - 'Cuando la carpeta de imágenes supera este tamaño, se eliminan los elementos más antiguos no fijados para liberar espacio.'; - - @override - String get imagesQuotaOff => 'Sin límite'; -} +// ignore: unused_import +import 'package:intl/intl.dart' as intl; +import 'app_localizations.dart'; + +// ignore_for_file: type=lint + +/// The translations for Spanish Castilian (`es`). +class AppLocalizationsEs extends AppLocalizations { + AppLocalizationsEs([String locale = 'es']) : super(locale); + + @override + String get searchPlaceholder => 'Buscar en portapapeles…'; + + @override + String get emptyState => 'No hay elementos en esta sección'; + + @override + String get emptyStateSubtitle => 'Copia algo para comenzar'; + + @override + String get hintBannerText => + 'CopyPaste se ejecuta en segundo plano — encuéntralo en la bandeja del sistema o usa tu atajo de teclado. Personaliza tu experiencia en'; + + @override + String get hintBannerAction => 'Ajustes'; + + @override + String get settingsTitle => 'Configuración'; + + @override + String get sectionShortcuts => 'ATAJOS DE TECLADO'; + + @override + String get sectionStorage => 'ALMACENAMIENTO'; + + @override + String get settingRunOnStartup => 'Iniciar con el sistema'; + + @override + String get settingLanguage => 'Idioma de la interfaz'; + + @override + String get hotkeyWillApply => 'El atajo se aplicará de inmediato'; + + @override + String get sectionSupport => 'SOPORTE'; + + @override + String get supportExportLogs => 'Exportar registros'; + + @override + String get supportExportLogsSubtitle => + 'Guarda un zip con registros de la app para adjuntar a un reporte. El contenido del portapapeles nunca se incluye.'; + + @override + String get supportOpenLogsFolder => 'Abrir carpeta de registros'; + + @override + String get supportOpenLogsFolderSubtitle => + 'Explora los archivos de registro en tu gestor de archivos.'; + + @override + String get supportGitHub => 'Reportar un error en GitHub'; + + @override + String get supportExportSuccess => 'Registros guardados en Descargas.'; + + @override + String get supportShowInFiles => 'Mostrar'; + + @override + String get supportExportEmpty => 'No se encontraron archivos de registro.'; + + @override + String get supportExportError => 'Error al exportar los registros.'; + + @override + String get sectionReset => 'RESTABLECER E INSTALACIÓN LIMPIA'; + + @override + String get resetSoftLabel => 'Restablecimiento suave'; + + @override + String get resetSoftSubtitle => + 'Restablece la configuración a los valores predeterminados y marca la app como nueva instalación. El historial del portapapeles se conserva.'; + + @override + String get resetHardLabel => 'Restablecimiento completo'; + + @override + String get resetHardSubtitle => + 'Elimina todo el historial, imágenes y configuración. Esta acción no se puede deshacer.'; + + @override + String get resetSoftConfirmTitle => '¿Restablecimiento suave?'; + + @override + String get resetSoftConfirmMessage => + 'Toda la configuración volverá a los valores predeterminados y la app se reiniciará como si fuera una instalación nueva. El historial del portapapeles no se eliminará.'; + + @override + String get resetHardConfirmTitle => '¿Restablecimiento completo?'; + + @override + String get resetHardConfirmMessage => + 'Se eliminará permanentemente todo el historial, imágenes y configuración, y luego la app se reiniciará. Esta acción no se puede deshacer.'; + + @override + String get resetConfirmButton => 'Restablecer y Reiniciar'; + + @override + String get clearHistoryConfirmTitle => '¿Limpiar historial?'; + + @override + String get clearHistoryConfirmMessage => + 'Esto eliminará permanentemente todos los elementos no anclados. Esta acción no se puede deshacer.'; + + @override + String get clearHistoryConfirmButton => 'Limpiar'; + + @override + String backupLastDate(String date) { + return 'Último respaldo: $date'; + } + + @override + String get backupNone => 'Aún no se ha creado un respaldo.'; + + @override + String get backupCreateLabel => 'Crear respaldo'; + + @override + String get backupRestoreLabel => 'Restaurar respaldo'; + + @override + String get backupError => + 'Error al crear el respaldo. Verifica los permisos.'; + + @override + String get restoreDialogTitle => 'Restaurar respaldo'; + + @override + String get restoreDialogWarning => + 'Esto reemplazará todos los datos actuales con el contenido del respaldo. ¿Continuar?'; + + @override + String get restoreFileNotFound => 'Archivo no encontrado.'; + + @override + String restoreSuccess(int count) { + return 'Se restauraron $count elementos.'; + } + + @override + String get restoreError => + 'Error al restaurar. Tus datos anteriores se han preservado.'; + + @override + String get buttonSave => 'Guardar'; + + @override + String get buttonClose => 'Cerrar'; + + @override + String get buttonCancel => 'Cancelar'; + + @override + String get buttonReset => 'Restaurar predeterminados'; + + @override + String get savingIndicator => 'Guardando…'; + + @override + String get savedIndicator => 'Guardado'; + + @override + String get menuPaste => 'Pegar'; + + @override + String get menuPastePlain => 'Pegar sin formato'; + + @override + String get menuPin => 'Anclar'; + + @override + String get menuUnpin => 'Desanclar'; + + @override + String get menuEdit => 'Editar tarjeta'; + + @override + String get menuDelete => 'Eliminar'; + + @override + String get editColorLabel => 'Color'; + + @override + String get colorRed => 'Rojo'; + + @override + String get colorGreen => 'Verde'; + + @override + String get colorPurple => 'Morado'; + + @override + String get colorYellow => 'Amarillo'; + + @override + String get colorBlue => 'Azul'; + + @override + String get colorOrange => 'Naranja'; + + @override + String get typeText => 'Texto'; + + @override + String get typeImage => 'Imagen'; + + @override + String get typeFile => 'Archivo'; + + @override + String get typeFolder => 'Carpeta'; + + @override + String get typeLink => 'Enlace'; + + @override + String get typeAudio => 'Audio'; + + @override + String get typeVideo => 'Video'; + + @override + String get typeEmail => 'Email'; + + @override + String get typePhone => 'Teléfono'; + + @override + String get typeColor => 'Color'; + + @override + String get typeIp => 'IP'; + + @override + String get typeUuid => 'UUID'; + + @override + String get typeJson => 'JSON'; + + @override + String get filterAll => 'Todo'; + + @override + String get filterPinned => 'Anclados'; + + @override + String get trayTooltip => 'CopyPaste'; + + @override + String get trayExit => 'Salir'; + + @override + String get shortcutOpenClose => 'Abrir / cerrar CopyPaste'; + + @override + String get shortcutEscape => 'Limpiar búsqueda o cerrar ventana'; + + @override + String get shortcutTab1 => 'Cambiar a pestaña Recientes'; + + @override + String get shortcutTab2 => 'Cambiar a pestaña Anclados'; + + @override + String get shortcutArrows => 'Navegar entre elementos'; + + @override + String get shortcutEnter => 'Pegar elemento seleccionado'; + + @override + String get shortcutDelete => 'Eliminar elemento seleccionado'; + + @override + String get shortcutPin => 'Anclar / Desanclar elemento'; + + @override + String get shortcutEdit => 'Editar tarjeta (etiqueta y color)'; + + @override + String get tabGeneral => 'General'; + + @override + String get tabBackupRestore => 'Backup y soporte'; + + @override + String get tabAppearance => 'Apariencia'; + + @override + String get tabShortcuts => 'Atajos'; + + @override + String get tabAbout => 'Acerca de'; + + @override + String get sectionLanguage => 'IDIOMA'; + + @override + String get sectionStartup => 'INICIO'; + + @override + String get sectionKeyboardShortcut => 'ATAJO DE TECLADO'; + + @override + String get sectionCategories => 'CATEGORÍAS'; + + @override + String get sectionPerformance => 'RENDIMIENTO'; + + @override + String get sectionPaste => 'PEGADO'; + + @override + String get sectionBackupRestore => 'RESPALDO Y RESTAURACIÓN'; + + @override + String get sectionAppearance => 'APARIENCIA'; + + @override + String get settingTheme => 'Tema'; + + @override + String get themeLight => 'Claro'; + + @override + String get themeDark => 'Oscuro'; + + @override + String get themeAuto => 'Auto'; + + @override + String get sectionBehavior => 'COMPORTAMIENTO'; + + @override + String get sectionAbout => 'COPYPASTE'; + + @override + String get sectionLinks => 'ENLACES'; + + @override + String get settingItemsPerPage => 'Elementos por página'; + + @override + String get settingMemoryLimit => 'Límite de memoria'; + + @override + String get settingScrollThreshold => 'Umbral de desplazamiento (px)'; + + @override + String get settingPasteSpeed => 'Velocidad de pegado'; + + @override + String get settingPanelWidth => 'Ancho del panel (px)'; + + @override + String get settingPanelHeight => 'Alto del panel (px)'; + + @override + String get settingLinesCollapsed => 'Líneas contraídas'; + + @override + String get settingLinesExpanded => 'Líneas expandidas'; + + @override + String get settingHideOnDeactivate => 'Ocultar al hacer clic fuera'; + + @override + String get settingScrollToTopOnOpen => 'Ir al inicio al abrir'; + + @override + String get settingClearSearchOnOpen => 'Limpiar búsqueda al abrir'; + + @override + String get settingRetentionDaysLabel => 'Días de retención (0 = sin límite)'; + + @override + String get settingClearHistoryLabel => 'Limpiar historial del portapapeles'; + + @override + String get settingHotkeyShortcutLabel => 'Atajo para abrir/cerrar CopyPaste'; + + @override + String get subtitleStartupDesc => + 'Se inicia en segundo plano al iniciar sesión'; + + @override + String get subtitleHideOnDeactivate => + 'Cerrar la ventana al hacer clic fuera'; + + @override + String get subtitleScrollToTopOnOpen => + 'Restablece el desplazamiento y selecciona el último elemento'; + + @override + String get subtitleClearSearchOnOpen => 'Borra el texto de búsqueda cada vez'; + + @override + String get subtitlePasteSpeed => 'Ajustar tiempos de restauración y pegado'; + + @override + String get subtitleCategories => + 'Personaliza los nombres de las categorías de color.'; + + @override + String get linkGitHub => 'Soporte y Código fuente — GitHub'; + + @override + String get linkCoffee => 'Invítame un café'; + + @override + String get editDialogTitle => 'Etiqueta y Color'; + + @override + String get editDialogHint => 'Agregar una etiqueta...'; + + @override + String get historyCleared => 'Historial limpiado'; + + @override + String backupSavedFile(String filename) { + return 'Respaldo guardado: $filename'; + } + + @override + String get buttonRestore => 'Restaurar'; + + @override + String get restoreCompleted => 'Restauración completada'; + + @override + String get restoreRestartRequired => + 'Restauración completada. La app se reiniciará para aplicar los cambios.'; + + @override + String get shortcutExpand => 'Expandir / contraer tarjeta'; + + @override + String get shortcutFocusSearch => 'Enfocar el buscador'; + + @override + String get trayShowHide => 'Mostrar/Ocultar'; + + @override + String get fileNotFound => 'No encontrado'; + + @override + String get audioFile => 'Archivo de audio'; + + @override + String get videoFile => 'Archivo de video'; + + @override + String get imageFile => 'Archivo de imagen'; + + @override + String get timeNow => 'ahora'; + + @override + String get clearAllFilters => 'Limpiar todos los filtros'; + + @override + String get colorSectionLabel => 'COLOR'; + + @override + String get colorNone => 'Ninguno'; + + @override + String get subtitlePastePreset => + 'Velocidad de pegado automático. Normal/Seguro recomendado para la mayoría.'; + + @override + String get pastePresetFast => 'Rápido'; + + @override + String get pastePresetNormal => 'Normal'; + + @override + String get pastePresetSafe => 'Seguro'; + + @override + String get pastePresetSlow => 'Lento'; + + @override + String get pastePresetCustom => 'Personalizado'; + + @override + String get pastePresetWarning => + '⚠️ Rápido: puede causar comportamientos extraños en apps pesadas.\n⚠️ Lento: puede sentirse pesado en equipos modernos.'; + + @override + String get settingResetFiltersOnOpen => 'Volver a Todos al abrir'; + + @override + String get subtitleResetFiltersOnOpen => + 'Limpia los filtros de categoría y tipo, y vuelve a la pestaña Todos'; + + @override + String get subtitleBackup => + 'Crea un respaldo de tu historial, imágenes y configuración. Restaura en cualquier momento en este u otro dispositivo.'; + + @override + String get aboutDescription => + 'Un gestor de portapapeles moderno, nativo en Windows, macOS y Linux.\nTodo local — tu historial, siempre a mano. Sin cuentas, sin telemetría, sin suscripciones.'; + + @override + String get sectionPrivacy => 'PRIVACIDAD'; + + @override + String get privacyStatement => + 'Todo local. Nada sale de tu PC — sin telemetría, sin sincronización, sin cuentas.'; + + @override + String get privacyPolicy => 'Política de privacidad'; + + @override + String get aboutTagLocal => 'Todo local'; + + @override + String get aboutTagOpenSource => 'Código abierto'; + + @override + String get aboutTagFree => 'Gratis'; + + @override + String get sectionOtherTools => 'OTRAS HERRAMIENTAS'; + + @override + String get otherToolLinkUnbound => 'LinkUnbound'; + + @override + String get otherToolLinkUnboundDesc => + 'Selector de navegadores de código abierto para Windows y Mac. Misma filosofía: sin anuncios, sin telemetría, todo local.'; + + @override + String get aboutLicense => 'Licencia GPL v3 — Libre y de código abierto.'; + + @override + String get permissionsTitle => 'Permiso de Accesibilidad requerido'; + + @override + String get permissionsMessage => + 'CopyPaste necesita permiso de Accesibilidad para pegar contenido en otras apps.\n\nVe a Configuración del Sistema → Privacidad y Seguridad → Accesibilidad y activa CopyPaste.'; + + @override + String get permissionsOpenSettings => 'Abrir Configuración'; + + @override + String get permissionsDismiss => 'Después'; + + @override + String get permissionsGranted => 'Permiso concedido'; + + @override + String get permissionsResetTitle => 'Permiso de Accesibilidad perdido'; + + @override + String get permissionsResetMessage => + 'macOS ya no reconoce el permiso de CopyPaste porque la app fue re-autorizada a través de Gatekeeper.\n\nPara solucionarlo:\n1. Abre la configuración de Accesibilidad\n2. Elimina CopyPaste de la lista (−)\n3. Vuelve a añadirlo o actívalo de nuevo'; + + @override + String get permissionsRestartMessage => + 'Asegúrate de que CopyPaste esté activado en Privacidad y seguridad > Accesibilidad.\n\nLa app continuará automáticamente cuando detecte el permiso.'; + + @override + String get permissionsCheckAgain => 'Verificar'; + + @override + String get permissionsRestartApp => 'Reiniciar app'; + + @override + String get permissionsWaiting => 'Esperando permiso…'; + + @override + String updateBadge(String version) { + return 'v$version disponible, por favor actualiza'; + } + + @override + String updateAvailableWindows(String version) { + return 'La versión $version está disponible.\n\nDescarga el instalador más reciente desde GitHub.'; + } + + @override + String updateAvailableMac(String version) { + return 'La versión $version está disponible.\n\nActualiza con Homebrew:\nbrew upgrade copypaste\n\nO descarga la última versión desde GitHub.'; + } + + @override + String updateAvailableLinux(String version) { + return 'La versión $version está disponible.\n\nDescarga la última versión desde GitHub.'; + } + + @override + String updateAvailableStore(String version) { + return 'La versión $version está disponible.\n\nLa Microsoft Store entrega las actualizaciones automáticamente. Las nuevas versiones pueden tardar unos días en aparecer tras su publicación.'; + } + + @override + String updateTooltipStore(String version) { + return 'Actualización $version en camino por Microsoft Store'; + } + + @override + String updateTooltipGeneric(String version) { + return 'Actualización $version disponible — haz clic para detalles'; + } + + @override + String get updateDialogTitle => 'Actualización disponible'; + + @override + String get updateViewRelease => 'Ver versión'; + + @override + String get updateDismiss => 'Después'; + + @override + String updateBadgeImportant(String version) { + return 'v$version disponible — actualización importante'; + } + + @override + String get updateActionDownload => 'Descargar instalador'; + + @override + String get updateActionOpenStore => 'Abrir Microsoft Store'; + + @override + String get updateActionCopyBrew => 'Copiar comando brew'; + + @override + String get updateActionCopied => 'Copiado al portapapeles'; + + @override + String get blockedTitle => 'Actualización requerida'; + + @override + String blockedDescription(String current, String required) { + return 'La versión $current de CopyPaste ya no está soportada. Instala la versión $required o más reciente para continuar.'; + } + + @override + String get blockedReasonGeneric => + 'Esta versión fue retirada por motivos de seguridad o compatibilidad.'; + + @override + String get blockedQuit => 'Salir de CopyPaste'; + + @override + String get blockedFallbackHint => + 'Visita https://github.com/rgdevment/CopyPaste/releases para descargar el instalador más reciente.'; + + @override + String get waylandUnsupportedTitle => 'Wayland no está soportado'; + + @override + String get waylandUnsupportedBadge => 'Open source · Solo X11'; + + @override + String get waylandUnsupportedBody => + 'El soporte en Linux está en progreso. Este proyecto lo mantiene una sola persona y necesitamos más testers para avanzar.\n\nCopyPaste funciona completamente en X11 — para usarlo, inicia sesión con X11. Lamentamos las molestias.'; + + @override + String get waylandUnsupportedGitHub => 'Ver en GitHub'; + + @override + String get waylandUnsupportedClose => 'Cerrar'; + + @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.'; + } + + @override + String linuxHotkeyGrabFailedWarning(String hotkey) { + return 'El atajo $hotkey está siendo usado por otra aplicación. Cámbialo en Configuración → Atajos.'; + } + + @override + String get linuxPasteFocusTimeoutWarning => + 'El portapapeles tiene tu contenido. Pégalo manualmente con Ctrl+V.'; + + @override + String get linuxAppindicatorBannerTitle => 'Ícono de bandeja no disponible'; + + @override + String get linuxAppindicatorBannerBody => + 'Tu escritorio no expone un host de AppIndicator, por lo que el ícono de CopyPaste no aparecerá en la bandeja. Instala una extensión de bandeja para tu distribución y reinicia CopyPaste.'; + + @override + String get linuxClipboardManagerBannerTitle => + 'No se detectó un gestor de portapapeles'; + + @override + String get linuxClipboardManagerBannerBody => + 'Sin un gestor de portapapeles (gpaste, klipper, clipman, copyq, …) los datos copiados pueden perderse cuando CopyPaste se cierra. Instala uno y reinicia tu sesión.'; + + @override + String get linuxXtestBannerTitle => 'Pegado automático deshabilitado'; + + @override + String get linuxXtestBannerBody => + 'La extensión XTest de X11 no está disponible, por lo que CopyPaste no puede inyectar Ctrl+V automáticamente. Los elementos siguen copiándose al portapapeles — pégalos manualmente con Ctrl+V.'; + + @override + String get linuxBannerDismiss => 'Descartar'; + + @override + String wakeupHint(String hotkey) { + return 'CopyPaste se ejecuta en segundo plano — presiona $hotkey o haz clic en el ícono de la bandeja para abrirlo cuando quieras.'; + } + + @override + String taskbarOpenHint(String hotkey) { + return 'Tip: presiona $hotkey para abrir y pegar automáticamente, sin perder el foco.'; + } + + @override + String balloonStartupBody(String hotkey) { + return 'Ejecutándose en segundo plano. Presiona $hotkey o haz clic en el ícono de la bandeja.'; + } + + @override + String get balloonWakeupTitle => 'CopyPaste ya está abierto'; + + @override + String balloonWakeupBody(String hotkey) { + return 'Presiona $hotkey o haz clic en el ícono de la bandeja para abrirlo.'; + } + + @override + String get onboardingTitle => 'Bienvenido a CopyPaste'; + + @override + String get onboardingSubtitle => 'Todo lo que copias, guardado.'; + + @override + String get onboardingPrivacyBadge => 'Sin nube · Sin rastreo · 100% local'; + + @override + String onboardingDescription(String hotkey) { + return 'Corre en segundo plano sin que lo notes. Presiona $hotkey cuando quieras para abrir tu historial.'; + } + + @override + String get onboardingTrayHint => + 'Encuéntralo junto al reloj, abajo a la derecha.'; + + @override + String get onboardingSettingsButton => 'Configuración'; + + @override + String get onboardingDismissButton => 'Empezar'; + + @override + String get tabCapture => 'Rendimiento'; + + @override + String get tabMultimedia => 'Multimedia'; + + @override + String get tabCleanupPrivacy => 'Limpieza y privacidad'; + + @override + String get sectionMultimedia => 'MULTIMEDIA Y MINIATURAS'; + + @override + String get subtitleMultimedia => + 'Controla cómo se previsualizan imágenes, vídeos y archivos de audio.'; + + @override + String get settingGenerateImageThumbnails => 'Generar miniaturas de imágenes'; + + @override + String get subtitleGenerateImageThumbnails => + 'Muestra una vista previa de las imágenes copiadas o referenciadas.'; + + @override + String get settingGenerateVideoThumbnails => 'Generar miniaturas de vídeos'; + + @override + String get subtitleGenerateVideoThumbnails => + 'Usa la caché del sistema para mostrar un fotograma de los vídeos.'; + + @override + String get settingGenerateAudioThumbnails => 'Generar miniaturas de audio'; + + @override + String get subtitleGenerateAudioThumbnails => + 'Muestra la carátula cuando esté disponible.'; + + @override + String get settingMaxImageSize => 'Tamaño máximo a procesar (MB)'; + + @override + String get subtitleMaxImageSize => + 'Las imágenes más grandes mantienen su mapa de bits original sin reprocesarse.'; + + @override + String get sectionCleanupPrivacy => 'LIMPIEZA Y PRIVACIDAD'; + + @override + String get settingKeepBrokenItemsLabel => + 'Conservar elementos no disponibles (días)'; + + @override + String get subtitleKeepBrokenItems => + 'Los elementos que apuntan a archivos perdidos o volúmenes desconectados se eliminan tras estos días. 0 los elimina al instante.'; + + @override + String get settingImagesQuotaLabel => + 'Límite de almacenamiento para imágenes'; + + @override + String get subtitleImagesQuota => + 'Cuando la carpeta de imágenes supera este tamaño, se eliminan los elementos más antiguos no fijados para liberar espacio.'; + + @override + String get imagesQuotaOff => 'Sin límite'; +} diff --git a/codecov.yml b/codecov.yml index 72f6c725..83fcf0d5 100644 --- a/codecov.yml +++ b/codecov.yml @@ -2,15 +2,13 @@ coverage: precision: 2 round: down ignore: - # Generated localization files — coverage annotations not processed by CI runner + - "core/lib/repository/sqlite_repository.g.dart" - "app/lib/l10n/app_localizations_en.dart" - "app/lib/l10n/app_localizations_es.dart" - # Platform-specific shell integrations (Win32 FFI, hotkeys, tray — untestable in headless CI) - "app/lib/shell" - "app/lib/services" - "app/lib/screens/settings_screen.dart" - "app/lib/main.dart" - # Platform-conditional helper — only one OS branch can be hit per CI run - "app/lib/helpers/url_helper.dart" comment: diff --git a/core/lib/repository/sqlite_repository.g.dart b/core/lib/repository/sqlite_repository.g.dart index 61f44957..cb3672e2 100644 --- a/core/lib/repository/sqlite_repository.g.dart +++ b/core/lib/repository/sqlite_repository.g.dart @@ -1,4 +1,3 @@ -// coverage:ignore-file // GENERATED CODE - DO NOT MODIFY BY HAND part of 'sqlite_repository.dart'; From 081ff83b49820a471d9d74cf64f996fb4061c5df Mon Sep 17 00:00:00 2001 From: rgdevment Date: Fri, 24 Apr 2026 15:34:13 -0400 Subject: [PATCH 16/31] feat: enhance main screen tests with Linux capabilities and update handling --- app/test/screens/main_screen_test.dart | 303 +++++++++++++++++++++++++ 1 file changed, 303 insertions(+) diff --git a/app/test/screens/main_screen_test.dart b/app/test/screens/main_screen_test.dart index 9693e1e1..7e487387 100644 --- a/app/test/screens/main_screen_test.dart +++ b/app/test/screens/main_screen_test.dart @@ -1,10 +1,14 @@ import 'package:core/core.dart'; +import 'package:flutter/gestures.dart'; import 'package:flutter/material.dart'; import 'package:flutter/services.dart'; import 'package:flutter_test/flutter_test.dart'; +import 'package:copypaste/helpers/url_helper.dart'; import 'package:copypaste/l10n/app_localizations.dart'; import 'package:copypaste/screens/main_screen.dart'; +import 'package:copypaste/services/linux_capabilities.dart'; +import 'package:copypaste/services/release_manifest_service.dart'; import 'package:copypaste/theme/compact_theme.dart'; import 'package:copypaste/theme/theme_provider.dart'; import 'package:copypaste/widgets/clipboard_card.dart'; @@ -22,6 +26,10 @@ Widget _buildApp({ bool showHint = false, VoidCallback? onDismissHint, String? updateVersion, + ManifestSeverity? updateSeverity, + AppConfig? appConfig, + LinuxCapabilities? linuxCapabilities, + Future Function(AppConfig Function(AppConfig))? onLinuxConfigUpdate, Key? key, }) { return MaterialApp( @@ -45,6 +53,10 @@ Widget _buildApp({ showHint: showHint, onDismissHint: onDismissHint, updateVersion: updateVersion, + updateSeverity: updateSeverity, + appConfig: appConfig, + linuxCapabilities: linuxCapabilities, + onLinuxConfigUpdate: onLinuxConfigUpdate, ), ), ), @@ -1260,5 +1272,296 @@ void main() { // Screen still renders correctly. expect(find.byType(MainScreen), findsOneWidget); }); + + testWidgets( + 'LinuxCapabilitiesBanner renders when all linux params provided', + (tester) async { + const capabilities = LinuxCapabilities.unsupported; + const config = AppConfig(); + bool callbackCalled = false; + + await tester.pumpWidget( + _buildApp( + service: service, + onPaste: (_) {}, + appConfig: config, + linuxCapabilities: capabilities, + onLinuxConfigUpdate: (fn) async { + callbackCalled = true; + }, + ), + ); + await tester.pumpAndSettle(); + + expect(find.byType(MainScreen), findsOneWidget); + expect(callbackCalled, isFalse); + }, + ); + + testWidgets('Alt+T shortcut opens filter bar', (tester) async { + final key = GlobalKey(); + await tester.pumpWidget( + _buildApp(service: service, onPaste: (_) {}, key: key), + ); + await tester.pumpAndSettle(); + + key.currentState!.onWindowShow(); + await tester.pump(); + + await tester.sendKeyDownEvent(LogicalKeyboardKey.altLeft); + await tester.sendKeyEvent(LogicalKeyboardKey.keyT); + await tester.sendKeyUpEvent(LogicalKeyboardKey.altLeft); + await tester.pumpAndSettle(); + + expect(find.byType(MainScreen), findsOneWidget); + }); + + testWidgets('update dialog View Release button triggers dismiss', ( + tester, + ) async { + UrlHelper.platformOverride = 'other'; + addTearDown(() => UrlHelper.platformOverride = null); + + await tester.pumpWidget( + _buildApp(service: service, onPaste: (_) {}, updateVersion: '3.0.0'), + ); + await tester.pumpAndSettle(); + + await tester.tap(find.text('v3.0.0 is available, please update')); + await tester.pumpAndSettle(); + + expect(find.byType(AlertDialog), findsOneWidget); + + final viewRelease = find.text('View Release'); + if (viewRelease.evaluate().isNotEmpty) { + await tester.tap(viewRelease.first); + await tester.pumpAndSettle(); + expect(find.byType(AlertDialog), findsNothing); + } + }); + + testWidgets( + 'update badge with critical severity uses important badge text', + (tester) async { + await tester.pumpWidget( + _buildApp( + service: service, + onPaste: (_) {}, + updateVersion: '4.0.0', + updateSeverity: ManifestSeverity.critical, + ), + ); + await tester.pumpAndSettle(); + + expect(find.byType(MainScreen), findsOneWidget); + final badge = find.textContaining('4.0.0'); + expect(badge, findsAtLeastNWidgets(1)); + }, + ); + + testWidgets('_onItemOpen link item calls UrlHelper', (tester) async { + UrlHelper.platformOverride = 'other'; + addTearDown(() => UrlHelper.platformOverride = null); + + await repo.save( + ClipboardItem( + content: 'https://example.com', + type: ClipboardContentType.link, + ), + ); + + await tester.pumpWidget(_buildApp(service: service, onPaste: (_) {})); + await tester.pumpAndSettle(); + + final openButtons = find.byIcon(Icons.open_in_new_outlined); + if (openButtons.evaluate().isNotEmpty) { + await tester.tap(openButtons.first); + await tester.pumpAndSettle(); + } + expect(find.byType(MainScreen), findsOneWidget); + }); + + testWidgets('_onItemOpen email item calls mailto', (tester) async { + UrlHelper.platformOverride = 'other'; + addTearDown(() => UrlHelper.platformOverride = null); + + await repo.save( + ClipboardItem( + content: 'test@example.com', + type: ClipboardContentType.email, + ), + ); + + await tester.pumpWidget(_buildApp(service: service, onPaste: (_) {})); + await tester.pumpAndSettle(); + + final openButtons = find.byIcon(Icons.open_in_new_outlined); + if (openButtons.evaluate().isNotEmpty) { + await tester.tap(openButtons.first); + await tester.pumpAndSettle(); + } + expect(find.byType(MainScreen), findsOneWidget); + }); + + testWidgets('_onItemOpen phone item calls tel scheme', (tester) async { + UrlHelper.platformOverride = 'other'; + addTearDown(() => UrlHelper.platformOverride = null); + + await repo.save( + ClipboardItem(content: '+1234567890', type: ClipboardContentType.phone), + ); + + await tester.pumpWidget(_buildApp(service: service, onPaste: (_) {})); + await tester.pumpAndSettle(); + + final openButtons = find.byIcon(Icons.open_in_new_outlined); + if (openButtons.evaluate().isNotEmpty) { + await tester.tap(openButtons.first); + await tester.pumpAndSettle(); + } + expect(find.byType(MainScreen), findsOneWidget); + }); + + testWidgets( + '_onItemOpen image with missing file returns false gracefully', + (tester) async { + UrlHelper.platformOverride = 'other'; + addTearDown(() => UrlHelper.platformOverride = null); + + await repo.save( + ClipboardItem( + content: '/nonexistent/path/image.png', + type: ClipboardContentType.image, + ), + ); + + await tester.pumpWidget(_buildApp(service: service, onPaste: (_) {})); + await tester.pumpAndSettle(); + + final openButtons = find.byIcon(Icons.open_in_new_outlined); + if (openButtons.evaluate().isNotEmpty) { + await tester.tap(openButtons.first); + await tester.pumpAndSettle(); + } + expect(find.byType(MainScreen), findsOneWidget); + }, + ); + + testWidgets('_onItemOpen file item opens path', (tester) async { + UrlHelper.platformOverride = 'other'; + addTearDown(() => UrlHelper.platformOverride = null); + + await repo.save( + ClipboardItem( + content: '/tmp/some_file.txt', + type: ClipboardContentType.file, + ), + ); + + await tester.pumpWidget(_buildApp(service: service, onPaste: (_) {})); + await tester.pumpAndSettle(); + + final openButtons = find.byIcon(Icons.open_in_new_outlined); + if (openButtons.evaluate().isNotEmpty) { + await tester.tap(openButtons.first); + await tester.pumpAndSettle(); + } + expect(find.byType(MainScreen), findsOneWidget); + }); + + testWidgets('onWindowHide trims _items list when > pageSize', ( + tester, + ) async { + for (var i = 0; i < 35; i++) { + await repo.save( + ClipboardItem(content: 'Item $i', type: ClipboardContentType.text), + ); + } + + final key = GlobalKey(); + await tester.pumpWidget( + _buildApp(service: service, onPaste: (_) {}, key: key), + ); + await tester.pumpAndSettle(); + + key.currentState!.onWindowShow(); + await tester.pump(); + + await tester.drag(find.byType(ListView).last, const Offset(0, -5000)); + await tester.pumpAndSettle(); + + key.currentState!.onWindowHide(); + await tester.pumpAndSettle(); + + expect(find.byType(MainScreen), findsOneWidget); + }); + + testWidgets( + 'second page loaded on scroll accumulates items in _items.addAll path', + (tester) async { + for (var i = 0; i < 35; i++) { + await repo.save( + ClipboardItem( + content: 'Page item $i', + type: ClipboardContentType.text, + ), + ); + } + + await tester.pumpWidget(_buildApp(service: service, onPaste: (_) {})); + await tester.pumpAndSettle(); + + await tester.drag(find.byType(ListView).last, const Offset(0, -5000)); + await tester.pumpAndSettle(); + + expect(find.byType(MainScreen), findsOneWidget); + }, + ); + + testWidgets('_BottomBarAction hover state changes icon opacity', ( + tester, + ) async { + await tester.pumpWidget(_buildApp(service: service, onPaste: (_) {})); + await tester.pumpAndSettle(); + + final bugIcon = find.byIcon(Icons.bug_report_outlined); + if (bugIcon.evaluate().isNotEmpty) { + final gesture = await tester.createGesture( + kind: PointerDeviceKind.mouse, + ); + await gesture.addPointer(location: Offset.zero); + addTearDown(gesture.removePointer); + await tester.pump(); + await gesture.moveTo(tester.getCenter(bugIcon)); + await tester.pump(); + expect(find.byType(MainScreen), findsOneWidget); + await gesture.moveTo(Offset.zero); + await tester.pump(); + } + }); + + testWidgets('_loadItems with non-empty searchQuery uses query param', ( + tester, + ) async { + await repo.save( + ClipboardItem(content: 'SearchMe', type: ClipboardContentType.text), + ); + + final key = GlobalKey(); + await tester.pumpWidget( + _buildApp(service: service, onPaste: (_) {}, key: key), + ); + await tester.pumpAndSettle(); + + key.currentState!.onWindowShow(); + await tester.pump(); + + final searchField = find.byType(TextField).first; + await tester.enterText(searchField, 'SearchMe'); + await tester.pump(const Duration(milliseconds: 400)); + await tester.pumpAndSettle(); + + expect(find.byType(MainScreen), findsOneWidget); + }); }); } From b25b334d888a7836250e266fdb7d6c7538034ebe Mon Sep 17 00:00:00 2001 From: rgdevment Date: Fri, 24 Apr 2026 19:25:29 -0400 Subject: [PATCH 17/31] feat: update melos bootstrap command to enforce lockfile --- pubspec.yaml | 116 +++++++++++++++++++++++++-------------------------- 1 file changed, 58 insertions(+), 58 deletions(-) diff --git a/pubspec.yaml b/pubspec.yaml index 1ac3d135..dd68b5c0 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -1,58 +1,58 @@ -name: copypaste_workspace -publish_to: none - -environment: - sdk: ^3.11.1 - -workspace: - - core - - listener - - app - -dev_dependencies: - melos: ^7.5.1 - -melos: - name: copypaste_workspace - - command: - bootstrap: - enforceLockfile: false - - scripts: - analyze: - exec: flutter analyze - description: Run flutter analyze in all packages - packageFilters: - orderDependents: true - - test: - exec: flutter test - description: Run tests in all packages - - test:coverage: - exec: flutter test --coverage - description: Run tests with coverage in all packages - - format: - run: dart format . - description: Format all code from workspace root - - format:check: - run: dart format --output=none --set-exit-if-changed . - description: Check formatting without modifying files - - fix: - run: dart fix --apply . - description: Apply all auto-fixable lint fixes - - fix:check: - run: | - OUTPUT=$(dart fix --dry-run . 2>&1) - echo "$OUTPUT" - echo "$OUTPUT" | grep -q "Nothing to fix!" || { echo "Auto-fixable issues found. Run 'melos run fix' and commit."; exit 1; } - description: Check for auto-fixable issues without applying - - outdated: - exec: flutter pub outdated --no-dev-dependencies - description: Check for outdated dependencies +name: copypaste_workspace +publish_to: none + +environment: + sdk: ^3.11.1 + +workspace: + - core + - listener + - app + +dev_dependencies: + melos: ^7.5.1 + +melos: + name: copypaste_workspace + + command: + bootstrap: + enforceLockfile: true + + scripts: + analyze: + exec: flutter analyze + description: Run flutter analyze in all packages + packageFilters: + orderDependents: true + + test: + exec: flutter test + description: Run tests in all packages + + test:coverage: + exec: flutter test --coverage + description: Run tests with coverage in all packages + + format: + run: dart format . + description: Format all code from workspace root + + format:check: + run: dart format --output=none --set-exit-if-changed . + description: Check formatting without modifying files + + fix: + run: dart fix --apply . + description: Apply all auto-fixable lint fixes + + fix:check: + run: | + OUTPUT=$(dart fix --dry-run . 2>&1) + echo "$OUTPUT" + echo "$OUTPUT" | grep -q "Nothing to fix!" || { echo "Auto-fixable issues found. Run 'melos run fix' and commit."; exit 1; } + description: Check for auto-fixable issues without applying + + outdated: + exec: flutter pub outdated --no-dev-dependencies + description: Check for outdated dependencies From d788d5ee6c4caeef7e8164d17c0776562ce53940 Mon Sep 17 00:00:00 2001 From: rgdevment Date: Fri, 24 Apr 2026 19:40:33 -0400 Subject: [PATCH 18/31] feat: remove clipboard manager warnings and related logic for Linux --- app/lib/l10n/app_en.arb | 6 - 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 -- .../screens/linux_capabilities_banner.dart | 12 +- app/lib/services/linux_capabilities.dart | 9 +- app/lib/services/linux_guard.dart | 1 - app/linux/runner/copypaste_linux_shell.c | 109 +----------------- .../linux_capabilities_banner_test.dart | 14 +-- .../services/linux_capabilities_test.dart | 5 - core/lib/config/app_config.dart | 11 -- core/test/app_config_test.dart | 9 -- 13 files changed, 5 insertions(+), 201 deletions(-) diff --git a/app/lib/l10n/app_en.arb b/app/lib/l10n/app_en.arb index e587fa0b..aa03f2a7 100644 --- a/app/lib/l10n/app_en.arb +++ b/app/lib/l10n/app_en.arb @@ -560,12 +560,6 @@ "linuxAppindicatorBannerBody": "Your desktop does not expose an AppIndicator host, so the CopyPaste tray icon will not appear. Install a tray extension for your distribution and restart CopyPaste.", "@linuxAppindicatorBannerBody": { "description": "Body of the AppIndicator missing banner" }, - "linuxClipboardManagerBannerTitle": "No clipboard manager detected", - "@linuxClipboardManagerBannerTitle": { "description": "Title of the missing clipboard manager banner" }, - - "linuxClipboardManagerBannerBody": "Without a clipboard manager (gpaste, klipper, clipman, copyq, …) clipboard data may be lost when CopyPaste quits. Install one and restart your session.", - "@linuxClipboardManagerBannerBody": { "description": "Body of the missing clipboard manager banner" }, - "linuxXtestBannerTitle": "Automatic paste-back disabled", "@linuxXtestBannerTitle": { "description": "Title of the missing XTest banner" }, diff --git a/app/lib/l10n/app_es.arb b/app/lib/l10n/app_es.arb index 3cb2a3df..bb063c15 100644 --- a/app/lib/l10n/app_es.arb +++ b/app/lib/l10n/app_es.arb @@ -251,8 +251,6 @@ "linuxPasteFocusTimeoutWarning": "El portapapeles tiene tu contenido. P\u00e9galo manualmente con Ctrl+V.", "linuxAppindicatorBannerTitle": "\u00cdcono de bandeja no disponible", "linuxAppindicatorBannerBody": "Tu escritorio no expone un host de AppIndicator, por lo que el \u00edcono de CopyPaste no aparecer\u00e1 en la bandeja. Instala una extensi\u00f3n de bandeja para tu distribuci\u00f3n y reinicia CopyPaste.", - "linuxClipboardManagerBannerTitle": "No se detect\u00f3 un gestor de portapapeles", - "linuxClipboardManagerBannerBody": "Sin un gestor de portapapeles (gpaste, klipper, clipman, copyq, \u2026) los datos copiados pueden perderse cuando CopyPaste se cierra. Instala uno y reinicia tu sesi\u00f3n.", "linuxXtestBannerTitle": "Pegado autom\u00e1tico deshabilitado", "linuxXtestBannerBody": "La extensi\u00f3n XTest de X11 no est\u00e1 disponible, por lo que CopyPaste no puede inyectar Ctrl+V autom\u00e1ticamente. Los elementos siguen copi\u00e1ndose al portapapeles \u2014 p\u00e9galos manualmente con Ctrl+V.", "linuxBannerDismiss": "Descartar", diff --git a/app/lib/l10n/app_localizations.dart b/app/lib/l10n/app_localizations.dart index cb3b8cbf..2b08b4d3 100644 --- a/app/lib/l10n/app_localizations.dart +++ b/app/lib/l10n/app_localizations.dart @@ -1370,18 +1370,6 @@ abstract class AppLocalizations { /// **'Your desktop does not expose an AppIndicator host, so the CopyPaste tray icon will not appear. Install a tray extension for your distribution and restart CopyPaste.'** String get linuxAppindicatorBannerBody; - /// Title of the missing clipboard manager banner - /// - /// In en, this message translates to: - /// **'No clipboard manager detected'** - String get linuxClipboardManagerBannerTitle; - - /// Body of the missing clipboard manager banner - /// - /// In en, this message translates to: - /// **'Without a clipboard manager (gpaste, klipper, clipman, copyq, …) clipboard data may be lost when CopyPaste quits. Install one and restart your session.'** - String get linuxClipboardManagerBannerBody; - /// Title of the missing XTest banner /// /// 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 8cb04ac2..c470986c 100644 --- a/app/lib/l10n/app_localizations_en.dart +++ b/app/lib/l10n/app_localizations_en.dart @@ -701,14 +701,6 @@ class AppLocalizationsEn extends AppLocalizations { String get linuxAppindicatorBannerBody => 'Your desktop does not expose an AppIndicator host, so the CopyPaste tray icon will not appear. Install a tray extension for your distribution and restart CopyPaste.'; - @override - String get linuxClipboardManagerBannerTitle => - 'No clipboard manager detected'; - - @override - String get linuxClipboardManagerBannerBody => - 'Without a clipboard manager (gpaste, klipper, clipman, copyq, …) clipboard data may be lost when CopyPaste quits. Install one and restart your session.'; - @override String get linuxXtestBannerTitle => 'Automatic paste-back disabled'; diff --git a/app/lib/l10n/app_localizations_es.dart b/app/lib/l10n/app_localizations_es.dart index 629ac2ef..a6ba6d8d 100644 --- a/app/lib/l10n/app_localizations_es.dart +++ b/app/lib/l10n/app_localizations_es.dart @@ -705,14 +705,6 @@ class AppLocalizationsEs extends AppLocalizations { String get linuxAppindicatorBannerBody => 'Tu escritorio no expone un host de AppIndicator, por lo que el ícono de CopyPaste no aparecerá en la bandeja. Instala una extensión de bandeja para tu distribución y reinicia CopyPaste.'; - @override - String get linuxClipboardManagerBannerTitle => - 'No se detectó un gestor de portapapeles'; - - @override - String get linuxClipboardManagerBannerBody => - 'Sin un gestor de portapapeles (gpaste, klipper, clipman, copyq, …) los datos copiados pueden perderse cuando CopyPaste se cierra. Instala uno y reinicia tu sesión.'; - @override String get linuxXtestBannerTitle => 'Pegado automático deshabilitado'; diff --git a/app/lib/screens/linux_capabilities_banner.dart b/app/lib/screens/linux_capabilities_banner.dart index 6e64de41..d1a577b0 100644 --- a/app/lib/screens/linux_capabilities_banner.dart +++ b/app/lib/screens/linux_capabilities_banner.dart @@ -29,10 +29,6 @@ class LinuxCapabilitiesBanner extends StatelessWidget { if (!capabilities.hasXTest && !config.linuxXtestWarningDismissed) { return _BannerKind.xtest; } - if (!capabilities.hasClipboardManager && - !config.linuxClipboardManagerWarningDismissed) { - return _BannerKind.clipboardManager; - } return null; } @@ -49,10 +45,6 @@ class LinuxCapabilitiesBanner extends StatelessWidget { l.linuxAppindicatorBannerBody, ), _BannerKind.xtest => (l.linuxXtestBannerTitle, l.linuxXtestBannerBody), - _BannerKind.clipboardManager => ( - l.linuxClipboardManagerBannerTitle, - l.linuxClipboardManagerBannerBody, - ), }; return Container( @@ -110,11 +102,9 @@ class LinuxCapabilitiesBanner extends StatelessWidget { return c.copyWith(linuxAppindicatorWarningDismissed: true); case _BannerKind.xtest: return c.copyWith(linuxXtestWarningDismissed: true); - case _BannerKind.clipboardManager: - return c.copyWith(linuxClipboardManagerWarningDismissed: true); } }); } } -enum _BannerKind { appIndicator, xtest, clipboardManager } +enum _BannerKind { appIndicator, xtest } diff --git a/app/lib/services/linux_capabilities.dart b/app/lib/services/linux_capabilities.dart index 62759a11..71c1b5a3 100644 --- a/app/lib/services/linux_capabilities.dart +++ b/app/lib/services/linux_capabilities.dart @@ -14,7 +14,6 @@ class LinuxCapabilities { required this.isX11, required this.hasXTest, required this.hasAppIndicator, - required this.hasClipboardManager, required this.hasEwmh, required this.detectedDesktopEnv, required this.detectedWmName, @@ -25,7 +24,6 @@ class LinuxCapabilities { final bool isX11; final bool hasXTest; final bool hasAppIndicator; - final bool hasClipboardManager; final bool hasEwmh; final String detectedDesktopEnv; final String detectedWmName; @@ -40,7 +38,6 @@ class LinuxCapabilities { isX11: false, hasXTest: false, hasAppIndicator: false, - hasClipboardManager: false, hasEwmh: false, detectedDesktopEnv: '', detectedWmName: '', @@ -51,7 +48,6 @@ class LinuxCapabilities { bool? isX11, bool? hasXTest, bool? hasAppIndicator, - bool? hasClipboardManager, bool? hasEwmh, String? detectedDesktopEnv, String? detectedWmName, @@ -62,7 +58,6 @@ class LinuxCapabilities { isX11: isX11 ?? this.isX11, hasXTest: hasXTest ?? this.hasXTest, hasAppIndicator: hasAppIndicator ?? this.hasAppIndicator, - hasClipboardManager: hasClipboardManager ?? this.hasClipboardManager, hasEwmh: hasEwmh ?? this.hasEwmh, detectedDesktopEnv: detectedDesktopEnv ?? this.detectedDesktopEnv, detectedWmName: detectedWmName ?? this.detectedWmName, @@ -73,7 +68,7 @@ class LinuxCapabilities { @override String toString() => 'LinuxCapabilities(isX11=$isX11, hasXTest=$hasXTest, ' - 'hasAppIndicator=$hasAppIndicator, hasClipboardManager=$hasClipboardManager, ' + 'hasAppIndicator=$hasAppIndicator, ' 'hasEwmh=$hasEwmh, desktopEnv=$detectedDesktopEnv, wm=$detectedWmName, ' 'timedOut=$detectionTimedOut, session=$session)'; } @@ -167,7 +162,6 @@ class LinuxCapabilitiesService { isX11: _readBool(shellCaps, 'isX11', fallback: true), hasXTest: _readBool(listenerCaps, 'hasXTest'), hasAppIndicator: _readBool(shellCaps, 'hasAppIndicator'), - hasClipboardManager: _readBool(shellCaps, 'hasClipboardManager'), hasEwmh: _readBool(shellCaps, 'hasEwmh'), detectedDesktopEnv: _readString(shellCaps, 'desktopEnv'), detectedWmName: _readString(shellCaps, 'wmName'), @@ -203,7 +197,6 @@ extension _LinuxCapabilitiesSession on LinuxCapabilities { isX11: isX11, hasXTest: hasXTest, hasAppIndicator: hasAppIndicator, - hasClipboardManager: hasClipboardManager, hasEwmh: hasEwmh, detectedDesktopEnv: detectedDesktopEnv, detectedWmName: detectedWmName, diff --git a/app/lib/services/linux_guard.dart b/app/lib/services/linux_guard.dart index 3123b282..939e3c8f 100644 --- a/app/lib/services/linux_guard.dart +++ b/app/lib/services/linux_guard.dart @@ -14,7 +14,6 @@ class LinuxGuard { static bool get canRegisterHotkey => isUsable && _caps.hasEwmh; static bool get canPasteBack => isUsable && _caps.hasXTest; static bool get canShowTray => isUsable && _caps.hasAppIndicator; - static bool get canPersistClipboard => isUsable && _caps.hasClipboardManager; static bool get canAutostart => isUsable; static bool get usesNativeWindowEffects => false; } diff --git a/app/linux/runner/copypaste_linux_shell.c b/app/linux/runner/copypaste_linux_shell.c index 526dd17d..73ae5fd8 100644 --- a/app/linux/runner/copypaste_linux_shell.c +++ b/app/linux/runner/copypaste_linux_shell.c @@ -596,114 +596,7 @@ static gchar* read_wm_name(void) { #endif } -static gboolean is_clipboard_manager_name(const gchar* comm) { - if (comm == NULL) return FALSE; - static const gchar* kKnown[] = { - "klipper", "gpaste-applet", "gpaste-client", "clipman", "copyq", - "xfce4-clipman", "parcellite", "diodon", "clipit", NULL, - }; - for (int i = 0; kKnown[i] != NULL; ++i) { - if (g_strcmp0(comm, kKnown[i]) == 0) return TRUE; - } - return FALSE; -} - -static gboolean clipboard_manager_running(void) { - GDir* dir = g_dir_open("/proc", 0, NULL); - if (dir == NULL) return FALSE; - gboolean found = FALSE; - const gchar* name = NULL; - while ((name = g_dir_read_name(dir)) != NULL) { - gboolean only_digits = TRUE; - for (const gchar* p = name; *p != '\0'; ++p) { - if (!g_ascii_isdigit(*p)) { only_digits = FALSE; break; } - } - if (!only_digits) continue; - g_autofree gchar* path = g_build_filename("/proc", name, "comm", NULL); - g_autofree gchar* contents = NULL; - gsize length = 0; - if (!g_file_get_contents(path, &contents, &length, NULL)) continue; - if (contents == NULL) continue; - g_strstrip(contents); - if (is_clipboard_manager_name(contents)) { found = TRUE; break; } - } - g_dir_close(dir); - return found; -} - -static FlValue* build_capabilities(void) { - FlValue* caps = fl_value_new_map(); - fl_value_set_string_take(caps, "isX11", fl_value_new_bool(shell_is_x11())); - fl_value_set_string_take(caps, "hasAppIndicator", - fl_value_new_bool(has_app_indicator_runtime())); - fl_value_set_string_take(caps, "hasEwmh", - fl_value_new_bool(ewmh_supports_active_window())); - fl_value_set_string_take(caps, "hasClipboardManager", - fl_value_new_bool(clipboard_manager_running())); - const gchar* desktop_env = g_getenv("XDG_CURRENT_DESKTOP"); - if (desktop_env == NULL) desktop_env = g_getenv("DESKTOP_SESSION"); - fl_value_set_string_take(caps, "desktopEnv", - fl_value_new_string(desktop_env != NULL ? desktop_env : "")); - g_autofree gchar* wm = read_wm_name(); - fl_value_set_string_take(caps, "wmName", - fl_value_new_string(wm != NULL ? wm : "")); - return caps; -} - -static FlValue* build_cursor_monitor(void) { - GdkDisplay* display = gdk_display_get_default(); - if (display == NULL) { - return fl_value_new_null(); - } - GdkSeat* seat = gdk_display_get_default_seat(display); - if (seat == NULL) { - return fl_value_new_null(); - } - GdkDevice* pointer = gdk_seat_get_pointer(seat); - if (pointer == NULL) { - return fl_value_new_null(); - } - gint cursor_x = 0; - gint cursor_y = 0; - GdkScreen* screen = NULL; - gdk_device_get_position(pointer, &screen, &cursor_x, &cursor_y); - - GdkMonitor* monitor = - gdk_display_get_monitor_at_point(display, cursor_x, cursor_y); - if (monitor == NULL) { - monitor = gdk_display_get_primary_monitor(display); - } - if (monitor == NULL) { - return fl_value_new_null(); - } - - GdkRectangle workarea = {0, 0, 0, 0}; - gdk_monitor_get_workarea(monitor, &workarea); - gint scale = gdk_monitor_get_scale_factor(monitor); - if (scale <= 0) { - scale = 1; - } - - FlValue* result = fl_value_new_map(); - fl_value_set_string_take(result, "cursorX", - fl_value_new_float((double)cursor_x)); - fl_value_set_string_take(result, "cursorY", - fl_value_new_float((double)cursor_y)); - fl_value_set_string_take(result, "x", - fl_value_new_float((double)workarea.x)); - fl_value_set_string_take(result, "y", - fl_value_new_float((double)workarea.y)); - fl_value_set_string_take(result, "width", - fl_value_new_float((double)workarea.width)); - fl_value_set_string_take(result, "height", - fl_value_new_float((double)workarea.height)); - fl_value_set_string_take(result, "scaleFactor", - fl_value_new_float((double)scale)); - return result; -} - -static FlValue* build_input_focus(CopyPasteLinuxShell* shell) { - FlValue* result = fl_value_new_map(); +static ue* result = fl_value_new_map(); fl_value_set_string_take(result, "ownsFocus", fl_value_new_bool(FALSE)); fl_value_set_string_take(result, "focusWindow", fl_value_new_int(0)); fl_value_set_string_take(result, "ownWindow", fl_value_new_int(0)); diff --git a/app/test/screens/linux_capabilities_banner_test.dart b/app/test/screens/linux_capabilities_banner_test.dart index a24c222f..fc9edca9 100644 --- a/app/test/screens/linux_capabilities_banner_test.dart +++ b/app/test/screens/linux_capabilities_banner_test.dart @@ -11,11 +11,7 @@ import 'package:copypaste/theme/compact_theme.dart'; import 'package:copypaste/theme/theme_provider.dart'; import 'package:core/core.dart'; -LinuxCapabilities _caps({ - bool hasAppIndicator = true, - bool hasXTest = true, - bool hasClipboardManager = true, -}) { +LinuxCapabilities _caps({bool hasAppIndicator = true, bool hasXTest = true}) { return LinuxCapabilities( session: const LinuxSessionInfo( sessionType: 'x11', @@ -28,7 +24,6 @@ LinuxCapabilities _caps({ isX11: true, hasXTest: hasXTest, hasAppIndicator: hasAppIndicator, - hasClipboardManager: hasClipboardManager, hasEwmh: true, detectedDesktopEnv: 'gnome', detectedWmName: 'mutter', @@ -91,13 +86,8 @@ void main() { config: const AppConfig( linuxAppindicatorWarningDismissed: true, linuxXtestWarningDismissed: true, - linuxClipboardManagerWarningDismissed: true, - ), - capabilities: _caps( - hasAppIndicator: false, - hasXTest: false, - hasClipboardManager: false, ), + capabilities: _caps(hasAppIndicator: false, hasXTest: false), onDismiss: (_) async {}, ), ), diff --git a/app/test/services/linux_capabilities_test.dart b/app/test/services/linux_capabilities_test.dart index 63ba188e..cde42018 100644 --- a/app/test/services/linux_capabilities_test.dart +++ b/app/test/services/linux_capabilities_test.dart @@ -59,7 +59,6 @@ void main() { shellResponse: const { 'isX11': true, 'hasAppIndicator': true, - 'hasClipboardManager': true, 'hasEwmh': true, 'desktopEnv': 'GNOME', 'wmName': 'Mutter', @@ -69,7 +68,6 @@ void main() { final caps = await LinuxCapabilitiesService.detect(channel: channel); expect(caps.hasXTest, isTrue); expect(caps.hasAppIndicator, isTrue); - expect(caps.hasClipboardManager, isTrue); expect(caps.hasEwmh, isTrue); expect(caps.detectedDesktopEnv, equals('GNOME')); expect(caps.detectedWmName, equals('Mutter')); @@ -85,7 +83,6 @@ void main() { final caps = await LinuxCapabilitiesService.detect(channel: channel); expect(caps.hasXTest, isFalse); expect(caps.hasAppIndicator, isFalse); - expect(caps.hasClipboardManager, isFalse); expect(caps.hasEwmh, isFalse); expect(caps.detectionTimedOut, isFalse); }); @@ -133,7 +130,6 @@ void main() { expect(LinuxGuard.canRegisterHotkey, isFalse); expect(LinuxGuard.canPasteBack, isFalse); expect(LinuxGuard.canShowTray, isFalse); - expect(LinuxGuard.canPersistClipboard, isFalse); expect(LinuxGuard.canAutostart, isFalse); expect(LinuxGuard.usesNativeWindowEffects, isFalse); }); @@ -188,7 +184,6 @@ void main() { isX11: false, hasXTest: false, hasAppIndicator: false, - hasClipboardManager: false, hasEwmh: false, detectedDesktopEnv: '', detectedWmName: '', diff --git a/core/lib/config/app_config.dart b/core/lib/config/app_config.dart index 077dea9f..d4a9ccb3 100644 --- a/core/lib/config/app_config.dart +++ b/core/lib/config/app_config.dart @@ -46,7 +46,6 @@ class AppConfig { this.maxImageProcessingSizeMB = 25, this.imagesQuotaMB = 0, this.linuxAppindicatorWarningDismissed = false, - this.linuxClipboardManagerWarningDismissed = false, this.linuxXtestWarningDismissed = false, }); @@ -135,9 +134,6 @@ class AppConfig { linuxAppindicatorWarningDismissed: json['linuxAppindicatorWarningDismissed'] as bool? ?? defaults.linuxAppindicatorWarningDismissed, - linuxClipboardManagerWarningDismissed: - json['linuxClipboardManagerWarningDismissed'] as bool? ?? - defaults.linuxClipboardManagerWarningDismissed, linuxXtestWarningDismissed: json['linuxXtestWarningDismissed'] as bool? ?? defaults.linuxXtestWarningDismissed, @@ -215,7 +211,6 @@ class AppConfig { // Linux capability warning banners (dismissible). final bool linuxAppindicatorWarningDismissed; - final bool linuxClipboardManagerWarningDismissed; final bool linuxXtestWarningDismissed; AppConfig copyWith({ @@ -258,7 +253,6 @@ class AppConfig { int? maxImageProcessingSizeMB, int? imagesQuotaMB, bool? linuxAppindicatorWarningDismissed, - bool? linuxClipboardManagerWarningDismissed, bool? linuxXtestWarningDismissed, }) => AppConfig( preferredLanguage: preferredLanguage ?? this.preferredLanguage, @@ -312,9 +306,6 @@ class AppConfig { linuxAppindicatorWarningDismissed: linuxAppindicatorWarningDismissed ?? this.linuxAppindicatorWarningDismissed, - linuxClipboardManagerWarningDismissed: - linuxClipboardManagerWarningDismissed ?? - this.linuxClipboardManagerWarningDismissed, linuxXtestWarningDismissed: linuxXtestWarningDismissed ?? this.linuxXtestWarningDismissed, ); @@ -360,8 +351,6 @@ class AppConfig { 'maxImageProcessingSizeMB': maxImageProcessingSizeMB, 'imagesQuotaMB': imagesQuotaMB, 'linuxAppindicatorWarningDismissed': linuxAppindicatorWarningDismissed, - 'linuxClipboardManagerWarningDismissed': - linuxClipboardManagerWarningDismissed, 'linuxXtestWarningDismissed': linuxXtestWarningDismissed, }; diff --git a/core/test/app_config_test.dart b/core/test/app_config_test.dart index e46297a3..9b257302 100644 --- a/core/test/app_config_test.dart +++ b/core/test/app_config_test.dart @@ -221,19 +221,16 @@ void main() { test('linux capability dismiss flags default to false', () { const config = AppConfig(); expect(config.linuxAppindicatorWarningDismissed, isFalse); - expect(config.linuxClipboardManagerWarningDismissed, isFalse); expect(config.linuxXtestWarningDismissed, isFalse); }); test('linux capability dismiss flags round-trip via JSON', () { const config = AppConfig( linuxAppindicatorWarningDismissed: true, - linuxClipboardManagerWarningDismissed: true, linuxXtestWarningDismissed: true, ); final restored = AppConfig.fromJson(config.toJson()); expect(restored.linuxAppindicatorWarningDismissed, isTrue); - expect(restored.linuxClipboardManagerWarningDismissed, isTrue); expect(restored.linuxXtestWarningDismissed, isTrue); }); @@ -245,12 +242,6 @@ void main() { .linuxAppindicatorWarningDismissed, isTrue, ); - expect( - config - .copyWith(linuxClipboardManagerWarningDismissed: true) - .linuxClipboardManagerWarningDismissed, - isTrue, - ); expect( config .copyWith(linuxXtestWarningDismissed: true) From 320a788366cc5bc54a5f3f8100eabe41e4e1c383 Mon Sep 17 00:00:00 2001 From: rgdevment Date: Fri, 24 Apr 2026 19:54:22 -0400 Subject: [PATCH 19/31] feat: enhance file handling and feedback in clipboard service for Linux --- app/lib/screens/main_screen.dart | 27 +- app/linux/runner/my_application.cc | 4 +- core/lib/services/clipboard_service.dart | 932 ++++++++++++----------- 3 files changed, 515 insertions(+), 448 deletions(-) diff --git a/app/lib/screens/main_screen.dart b/app/lib/screens/main_screen.dart index 56cd80b4..feb8dbb8 100644 --- a/app/lib/screens/main_screen.dart +++ b/app/lib/screens/main_screen.dart @@ -272,7 +272,13 @@ class MainScreenState extends State { case ClipboardContentType.folder: case ClipboardContentType.audio: case ClipboardContentType.video: - await UrlHelper.open(item.content.split('\n').first.trim()); + final path = item.content.split('\n').first.trim(); + if (path.isEmpty || + (!File(path).existsSync() && !Directory(path).existsSync())) { + _showFileNotFoundFeedback(); + return; + } + await UrlHelper.open(path); opened = true; case ClipboardContentType.link: await UrlHelper.open(item.content.trim()); @@ -295,7 +301,10 @@ class MainScreenState extends State { Future _openImageInTemp(ClipboardItem item) async { final src = File(item.content); - if (!src.existsSync()) return false; + if (!src.existsSync()) { + _showFileNotFoundFeedback(); + return false; + } final name = item.content.split(Platform.pathSeparator).last; final tmp = await Directory.systemTemp.createTemp('copypaste_'); final dest = File('${tmp.path}${Platform.pathSeparator}$name'); @@ -304,6 +313,20 @@ class MainScreenState extends State { return true; } + void _showFileNotFoundFeedback() { + final ctx = context; + if (!ctx.mounted) return; + final messenger = ScaffoldMessenger.maybeOf(ctx); + if (messenger == null) return; + messenger.hideCurrentSnackBar(); + messenger.showSnackBar( + SnackBar( + content: Text(AppLocalizations.of(ctx).fileNotFound), + duration: const Duration(seconds: 2), + ), + ); + } + Future _onItemLabelColor( ClipboardItem item, String? label, diff --git a/app/linux/runner/my_application.cc b/app/linux/runner/my_application.cc index 34c27c56..b1e16f54 100644 --- a/app/linux/runner/my_application.cc +++ b/app/linux/runner/my_application.cc @@ -47,8 +47,6 @@ static void my_application_activate(GApplication* application) { fl_dart_project_set_dart_entrypoint_arguments( project, self->dart_entrypoint_arguments); - gdk_notify_startup_complete(); - FlView* view = fl_view_new(project); GdkRGBA background_color; gdk_rgba_parse(&background_color, "#1a1a2e"); @@ -65,6 +63,8 @@ static void my_application_activate(GApplication* application) { self->shell = copypaste_linux_shell_new(messenger, window); gtk_widget_grab_focus(GTK_WIDGET(view)); + + gdk_notify_startup_complete(); } static gboolean my_application_local_command_line(GApplication* application, diff --git a/core/lib/services/clipboard_service.dart b/core/lib/services/clipboard_service.dart index 7393960d..1a919679 100644 --- a/core/lib/services/clipboard_service.dart +++ b/core/lib/services/clipboard_service.dart @@ -1,444 +1,488 @@ -import 'dart:async'; -import 'dart:convert'; -import 'dart:io'; - -import 'package:path/path.dart' as p; - -import '../models/card_color.dart'; -import '../models/clipboard_content_type.dart'; -import '../models/clipboard_item.dart'; -import '../repository/i_clipboard_repository.dart'; -import 'app_logger.dart'; -import 'image_processing_queue.dart'; -import 'native_thumbnail_provider.dart'; -import 'text_classifier.dart'; -import 'thumbnail_queue.dart'; -import 'thumbnail_service.dart'; - -class ClipboardService { - ClipboardService( - this._repository, { - String? imagesPath, - NativeThumbnailProvider? nativeThumbnailProvider, - bool Function(ClipboardContentType type)? isThumbnailTypeEnabled, - int Function()? getMaxImageBytes, - }) : _imagesPath = imagesPath, - _thumbnailService = (imagesPath != null && imagesPath.isNotEmpty) - ? ThumbnailService( - imagesPath: imagesPath, - nativeProvider: nativeThumbnailProvider, - isTypeEnabled: isThumbnailTypeEnabled, - ) - : null { - _imageQueue = ImageProcessingQueue( - repository: _repository, - onItemUpdated: _onImageItemUpdated, - getMaxImageBytes: getMaxImageBytes, - ); - final service = _thumbnailService; - _thumbQueue = service == null - ? null - : ThumbnailQueue( - repository: _repository, - service: service, - onItemUpdated: _onThumbItemUpdated, - ); - } - - final IClipboardRepository _repository; - final String? _imagesPath; - late final ImageProcessingQueue _imageQueue; - final ThumbnailService? _thumbnailService; - late final ThumbnailQueue? _thumbQueue; - final _itemAdded = StreamController.broadcast(); - final _itemReactivated = StreamController.broadcast(); - bool _disposed = false; - - void _onImageItemUpdated(ClipboardItem item) { - if (!_disposed) { - try { - _itemReactivated.add(item); - } on StateError catch (_) {} - } - } - - void _onThumbItemUpdated(ClipboardItem item) { - if (_disposed) return; - try { - _itemReactivated.add(item); - } on StateError catch (_) {} - } - - /// Requests background regeneration of [item]'s thumbnail if the source - /// file's `mtime` no longer matches the recorded `sourceModifiedAt`. - /// No-op when no `imagesPath` was configured. Safe to call from `build()` - /// — work is enqueued asynchronously. - void requestThumbnailIfStale(ClipboardItem item) { - _thumbQueue?.enqueueIfStale(item); - } - - /// Forces an enqueue regardless of staleness (e.g. the user explicitly - /// asked to refresh the thumb). - void requestThumbnailRefresh(ClipboardItem item) { - _thumbQueue?.enqueue(item, reason: ThumbnailJobReason.manualRefresh); - } - - void updateThumbnailTypeGate(bool Function(ClipboardContentType type)? gate) { - _thumbnailService?.isTypeEnabled = gate; - } - - void updateMaxImageBytesGate(int Function()? gate) { - _imageQueue.getMaxImageBytes = gate; - } - - Stream get onItemAdded => _itemAdded.stream; - Stream get onItemReactivated => _itemReactivated.stream; - - int pasteIgnoreWindowMs = 450; - - Stopwatch? _pasteStopwatch; - String? _lastPastedContent; - - Future notifyPasteInitiated(String itemId) async { - _pasteStopwatch = Stopwatch()..start(); - final item = await _repository.getById(itemId); - _lastPastedContent = item?.content; - } - - bool _shouldIgnore(String? content) { - final sw = _pasteStopwatch; - if (sw == null) return false; - final elapsed = sw.elapsedMilliseconds; - if (elapsed < pasteIgnoreWindowMs) return true; - if (content != null && - content == _lastPastedContent && - elapsed < pasteIgnoreWindowMs * 2) { - return true; - } - return false; - } - - Future processText( - String content, - ClipboardContentType type, { - String? source, - List? rtfBytes, - List? htmlBytes, - }) async { - if (_shouldIgnore(content)) return null; - - final resolvedType = type == ClipboardContentType.text - ? TextClassifier.classify(content) - : type; - - final existing = await _repository.findByContentAndType( - content, - resolvedType, - ); - if (existing != null) { - final updated = existing.copyWith(modifiedAt: DateTime.now().toUtc()); - await _repository.update(updated); - _itemReactivated.add(updated); - return updated; - } - - if (resolvedType != ClipboardContentType.text) { - final legacy = await _repository.findByContentAndType( - content, - ClipboardContentType.text, - ); - if (legacy != null) { - final updated = legacy.copyWith( - type: resolvedType, - modifiedAt: DateTime.now().toUtc(), - ); - await _repository.update(updated); - _itemReactivated.add(updated); - return updated; - } - } - - final meta = {}; - if (rtfBytes != null) meta['rtf'] = base64Encode(rtfBytes); - if (htmlBytes != null) meta['html'] = base64Encode(htmlBytes); - - final item = ClipboardItem( - content: content, - type: resolvedType, - appSource: source, - metadata: meta.isNotEmpty ? jsonEncode(meta) : null, - ); - await _repository.save(item); - _itemAdded.add(item); - return item; - } - - Future processImage( - String contentHash, { - String? source, - String? imagePath, - List? imageBytes, - }) async { - if (_shouldIgnore(null)) return null; - - final existing = await _repository.findByContentHash(contentHash); - if (existing != null) { - final updated = existing.copyWith(modifiedAt: DateTime.now().toUtc()); - await _repository.update(updated); - _itemReactivated.add(updated); - // Items captured before the native thumb provider was wired may - // have no thumbPath yet. enqueueIfStale is a no-op when thumb is - // already up-to-date. - _thumbQueue?.enqueueIfStale(updated); - return updated; - } - - final item = ClipboardItem( - content: imagePath ?? '', - type: ClipboardContentType.image, - appSource: source, - contentHash: contentHash, - ); - - var savedItem = item; - if (imageBytes != null && imageBytes.isNotEmpty && _imagesPath != null) { - try { - final tempPath = p.join(_imagesPath, '${item.id}.bmp'); - await File(tempPath).writeAsBytes(imageBytes); - savedItem = item.copyWith(content: tempPath); - } catch (e) { - AppLogger.warn( - 'processImage: could not write temp BMP for ${item.id}: $e', - ); - } - } - - await _repository.save(savedItem); - _itemAdded.add(savedItem); - - if (imageBytes != null && imageBytes.isNotEmpty && _imagesPath != null) { - _imageQueue.enqueue( - item: savedItem, - imageBytes: imageBytes, - imagesPath: _imagesPath, - ); - } else { - // External image referenced by path: schedule thumb generation. - // (When imageBytes is non-empty the result will land inside - // imagesPath and ThumbnailService skips it by design.) - _thumbQueue?.enqueue(savedItem); - } - - return savedItem; - } - - Future processFiles( - List files, - ClipboardContentType type, { - String? source, - }) async { - if (files.isEmpty) return null; - if (_shouldIgnore(null)) return null; - - final content = files.join('\n'); - final existing = await _repository.findByContentAndType(content, type); - if (existing != null) { - final updated = existing.copyWith(modifiedAt: DateTime.now().toUtc()); - await _repository.update(updated); - _itemReactivated.add(updated); - // Cover items captured before the native thumb provider was wired. - if (files.length == 1) { - _thumbQueue?.enqueueIfStale(updated); - } - return updated; - } - - final firstFile = files.first; - final meta = { - 'file_count': files.length, - 'file_name': p.basename(firstFile), - 'first_ext': p.extension(firstFile), - 'is_directory': type == ClipboardContentType.folder, - }; - - if (files.length == 1) { - try { - final fileSize = File(firstFile).lengthSync(); - meta['file_size'] = fileSize; - } catch (e) { - AppLogger.warn('processFiles: could not read size of $firstFile: $e'); - } - } - - final item = ClipboardItem( - content: content, - type: type, - appSource: source, - metadata: jsonEncode(meta), - ); - await _repository.save(item); - _itemAdded.add(item); - - // Native-backed thumbs cover video/audio (and image when the path is - // external). The queue ignores types it cannot handle, so this is a - // safe fire-and-forget call. - if (files.length == 1) { - _thumbQueue?.enqueue(item); - } - - return item; - } - - Future recordPaste(String itemId) async { - final now = DateTime.now().toUtc(); - final item = await _repository.getById(itemId); - if (item == null) return null; - final updated = item.copyWith( - pasteCount: item.pasteCount + 1, - modifiedAt: now, - ); - await _repository.update(updated); - return updated; - } - - Future removeItem(String id) async { - final item = await _repository.getById(id); - await _repository.delete(id); - if (item != null) { - _cleanupItemFiles(item); - } - } - - /// Deletes a file only if [path] is canonically contained inside the app's - /// own images directory. Any path outside is refused and logged. - /// - /// This is the single entry point for file deletion in this service. Never - /// call `File.delete*` directly on a path that comes from user input, item - /// content, or any source outside the app's own path builder. - bool _deleteAppFile(String path) { - final imagesPath = _imagesPath; - if (imagesPath == null || imagesPath.isEmpty) return false; - final String canonicalBase; - final String canonicalTarget; - try { - canonicalBase = p.canonicalize(imagesPath); - canonicalTarget = p.canonicalize(path); - } catch (e) { - AppLogger.warn('_deleteAppFile: canonicalize failed for "$path": $e'); - return false; - } - final baseWithSep = canonicalBase.endsWith(p.separator) - ? canonicalBase - : '$canonicalBase${p.separator}'; - if (!canonicalTarget.startsWith(baseWithSep)) { - AppLogger.error( - '_deleteAppFile: refused to delete out-of-scope path ' - '"$canonicalTarget" (base="$canonicalBase")', - ); - return false; - } - try { - final file = File(canonicalTarget); - if (file.existsSync()) file.deleteSync(); - return true; - } catch (e) { - AppLogger.warn('_deleteAppFile: delete failed for "$path": $e'); - return false; - } - } - - void _cleanupItemFiles(ClipboardItem item) { - if (item.type == ClipboardContentType.image && item.content.isNotEmpty) { - _deleteAppFile(item.content); - } - final thumb = item.thumbPath; - if (thumb != null && thumb.isNotEmpty) { - _deleteAppFile(thumb); - } - } - - Future> getHistoryAdvanced({ - String? query, - List? types, - List? colors, - bool? isPinned, - int limit = 50, - int skip = 0, - }) => _repository.searchAdvanced( - query: query, - types: types, - colors: colors, - isPinned: isPinned, - limit: limit, - skip: skip, - ); - - Future updatePin(String id, bool isPinned) async { - final item = await _repository.getById(id); - if (item == null) return; - await _repository.update( - item.copyWith(isPinned: isPinned, modifiedAt: DateTime.now().toUtc()), - ); - } - - Future updateLabelAndColor( - String id, - String? label, - CardColor color, - ) async { - final item = await _repository.getById(id); - if (item == null) return; - await _repository.update( - item.copyWith( - label: label, - cardColor: color, - modifiedAt: DateTime.now().toUtc(), - ), - ); - } - - Future clearUnpinnedHistory() => _repository.deleteAllUnpinned(); - - Future getItemCount() => _repository.count(); - - Future reclassifyLegacyTextItems() async { - const batchSize = 50; - var skip = 0; - while (true) { - if (_disposed) return; - final batch = await _repository.searchAdvanced( - types: [ClipboardContentType.text], - limit: batchSize, - skip: skip, - ); - if (batch.isEmpty) return; - for (final item in batch) { - if (_disposed) return; - final resolved = TextClassifier.classify(item.content); - if (resolved != ClipboardContentType.text) { - await _repository.update(item.copyWith(type: resolved)); - } - } - if (batch.length < batchSize) return; - skip += batchSize; - } - } - - Future walCheckpoint() => _repository.walCheckpoint(); - - Future updateMetadata(String id, String metadata) async { - final item = await _repository.getById(id); - if (item == null) return; - final updated = item.copyWith(metadata: metadata); - await _repository.update(updated); - if (!_disposed) _itemReactivated.add(updated); - } - - Future dispose() async { - _disposed = true; - await _imageQueue.dispose(); - await _thumbQueue?.dispose(); - await _itemAdded.close(); - await _itemReactivated.close(); - } -} +import 'dart:async'; +import 'dart:convert'; +import 'dart:io'; + +import 'package:path/path.dart' as p; + +import '../models/card_color.dart'; +import '../models/clipboard_content_type.dart'; +import '../models/clipboard_item.dart'; +import '../repository/i_clipboard_repository.dart'; +import 'app_logger.dart'; +import 'image_processing_queue.dart'; +import 'native_thumbnail_provider.dart'; +import 'text_classifier.dart'; +import 'thumbnail_queue.dart'; +import 'thumbnail_service.dart'; + +class ClipboardService { + ClipboardService( + this._repository, { + String? imagesPath, + NativeThumbnailProvider? nativeThumbnailProvider, + bool Function(ClipboardContentType type)? isThumbnailTypeEnabled, + int Function()? getMaxImageBytes, + }) : _imagesPath = imagesPath, + _thumbnailService = (imagesPath != null && imagesPath.isNotEmpty) + ? ThumbnailService( + imagesPath: imagesPath, + nativeProvider: nativeThumbnailProvider, + isTypeEnabled: isThumbnailTypeEnabled, + ) + : null { + _imageQueue = ImageProcessingQueue( + repository: _repository, + onItemUpdated: _onImageItemUpdated, + getMaxImageBytes: getMaxImageBytes, + ); + final service = _thumbnailService; + _thumbQueue = service == null + ? null + : ThumbnailQueue( + repository: _repository, + service: service, + onItemUpdated: _onThumbItemUpdated, + ); + } + + final IClipboardRepository _repository; + final String? _imagesPath; + late final ImageProcessingQueue _imageQueue; + final ThumbnailService? _thumbnailService; + late final ThumbnailQueue? _thumbQueue; + final _itemAdded = StreamController.broadcast(); + final _itemReactivated = StreamController.broadcast(); + bool _disposed = false; + + void _onImageItemUpdated(ClipboardItem item) { + if (!_disposed) { + try { + _itemReactivated.add(item); + } on StateError catch (_) {} + } + } + + void _onThumbItemUpdated(ClipboardItem item) { + if (_disposed) return; + try { + _itemReactivated.add(item); + } on StateError catch (_) {} + } + + /// Requests background regeneration of [item]'s thumbnail if the source + /// file's `mtime` no longer matches the recorded `sourceModifiedAt`. + /// No-op when no `imagesPath` was configured. Safe to call from `build()` + /// — work is enqueued asynchronously. + void requestThumbnailIfStale(ClipboardItem item) { + _thumbQueue?.enqueueIfStale(item); + } + + /// Forces an enqueue regardless of staleness (e.g. the user explicitly + /// asked to refresh the thumb). + void requestThumbnailRefresh(ClipboardItem item) { + _thumbQueue?.enqueue(item, reason: ThumbnailJobReason.manualRefresh); + } + + void updateThumbnailTypeGate(bool Function(ClipboardContentType type)? gate) { + _thumbnailService?.isTypeEnabled = gate; + } + + void updateMaxImageBytesGate(int Function()? gate) { + _imageQueue.getMaxImageBytes = gate; + } + + Stream get onItemAdded => _itemAdded.stream; + Stream get onItemReactivated => _itemReactivated.stream; + + int pasteIgnoreWindowMs = 450; + + Stopwatch? _pasteStopwatch; + String? _lastPastedContent; + + static const Duration _suppressionTtl = Duration(seconds: 5); + final Map _suppressedKeys = {}; + + String? _suppressionKeyForItem(ClipboardItem item) { + if (item.type == ClipboardContentType.image) { + final hash = item.contentHash; + if (hash == null || hash.isEmpty) return null; + return 'i:$hash'; + } + if (item.content.isEmpty) return null; + return 'c:${item.content}'; + } + + void _markSuppressed(ClipboardItem item) { + final key = _suppressionKeyForItem(item); + if (key == null) return; + _suppressedKeys[key] = DateTime.now().toUtc(); + } + + bool _consumeSuppression(String? key) { + if (key == null || key.isEmpty) return false; + const expiry = _suppressionTtl; + final now = DateTime.now().toUtc(); + _suppressedKeys.removeWhere((_, ts) => now.difference(ts) > expiry); + return _suppressedKeys.remove(key) != null; + } + + Future notifyPasteInitiated(String itemId) async { + _pasteStopwatch = Stopwatch()..start(); + final item = await _repository.getById(itemId); + _lastPastedContent = item?.content; + } + + bool _shouldIgnore(String? content) { + final sw = _pasteStopwatch; + if (sw == null) return false; + final elapsed = sw.elapsedMilliseconds; + if (elapsed < pasteIgnoreWindowMs) return true; + if (content != null && + content == _lastPastedContent && + elapsed < pasteIgnoreWindowMs * 2) { + return true; + } + return false; + } + + Future processText( + String content, + ClipboardContentType type, { + String? source, + List? rtfBytes, + List? htmlBytes, + }) async { + if (_shouldIgnore(content)) return null; + if (_consumeSuppression('c:$content')) return null; + + final resolvedType = type == ClipboardContentType.text + ? TextClassifier.classify(content) + : type; + + final existing = await _repository.findByContentAndType( + content, + resolvedType, + ); + if (existing != null) { + final updated = existing.copyWith(modifiedAt: DateTime.now().toUtc()); + await _repository.update(updated); + _itemReactivated.add(updated); + return updated; + } + + if (resolvedType != ClipboardContentType.text) { + final legacy = await _repository.findByContentAndType( + content, + ClipboardContentType.text, + ); + if (legacy != null) { + final updated = legacy.copyWith( + type: resolvedType, + modifiedAt: DateTime.now().toUtc(), + ); + await _repository.update(updated); + _itemReactivated.add(updated); + return updated; + } + } + + final meta = {}; + if (rtfBytes != null) meta['rtf'] = base64Encode(rtfBytes); + if (htmlBytes != null) meta['html'] = base64Encode(htmlBytes); + + final item = ClipboardItem( + content: content, + type: resolvedType, + appSource: source, + metadata: meta.isNotEmpty ? jsonEncode(meta) : null, + ); + await _repository.save(item); + _itemAdded.add(item); + return item; + } + + Future processImage( + String contentHash, { + String? source, + String? imagePath, + List? imageBytes, + }) async { + if (_shouldIgnore(null)) return null; + if (_consumeSuppression('i:$contentHash')) return null; + + final existing = await _repository.findByContentHash(contentHash); + if (existing != null) { + final updated = existing.copyWith(modifiedAt: DateTime.now().toUtc()); + await _repository.update(updated); + _itemReactivated.add(updated); + // Items captured before the native thumb provider was wired may + // have no thumbPath yet. enqueueIfStale is a no-op when thumb is + // already up-to-date. + _thumbQueue?.enqueueIfStale(updated); + return updated; + } + + final item = ClipboardItem( + content: imagePath ?? '', + type: ClipboardContentType.image, + appSource: source, + contentHash: contentHash, + ); + + var savedItem = item; + if (imageBytes != null && imageBytes.isNotEmpty && _imagesPath != null) { + try { + final tempPath = p.join(_imagesPath, '${item.id}.bmp'); + await File(tempPath).writeAsBytes(imageBytes); + savedItem = item.copyWith(content: tempPath); + } catch (e) { + AppLogger.warn( + 'processImage: could not write temp BMP for ${item.id}: $e', + ); + } + } + + await _repository.save(savedItem); + _itemAdded.add(savedItem); + + if (imageBytes != null && imageBytes.isNotEmpty && _imagesPath != null) { + _imageQueue.enqueue( + item: savedItem, + imageBytes: imageBytes, + imagesPath: _imagesPath, + ); + } else { + // External image referenced by path: schedule thumb generation. + // (When imageBytes is non-empty the result will land inside + // imagesPath and ThumbnailService skips it by design.) + _thumbQueue?.enqueue(savedItem); + } + + return savedItem; + } + + Future processFiles( + List files, + ClipboardContentType type, { + String? source, + }) async { + if (files.isEmpty) return null; + if (_shouldIgnore(null)) return null; + + final content = files.join('\n'); + if (_consumeSuppression('c:$content')) return null; + final existing = await _repository.findByContentAndType(content, type); + if (existing != null) { + final updated = existing.copyWith(modifiedAt: DateTime.now().toUtc()); + await _repository.update(updated); + _itemReactivated.add(updated); + // Cover items captured before the native thumb provider was wired. + if (files.length == 1) { + _thumbQueue?.enqueueIfStale(updated); + } + return updated; + } + + final firstFile = files.first; + final meta = { + 'file_count': files.length, + 'file_name': p.basename(firstFile), + 'first_ext': p.extension(firstFile), + 'is_directory': type == ClipboardContentType.folder, + }; + + if (files.length == 1) { + try { + final fileSize = File(firstFile).lengthSync(); + meta['file_size'] = fileSize; + } catch (e) { + AppLogger.warn('processFiles: could not read size of $firstFile: $e'); + } + } + + final item = ClipboardItem( + content: content, + type: type, + appSource: source, + metadata: jsonEncode(meta), + ); + await _repository.save(item); + _itemAdded.add(item); + + // Native-backed thumbs cover video/audio (and image when the path is + // external). The queue ignores types it cannot handle, so this is a + // safe fire-and-forget call. + if (files.length == 1) { + _thumbQueue?.enqueue(item); + } + + return item; + } + + Future recordPaste(String itemId) async { + final now = DateTime.now().toUtc(); + final item = await _repository.getById(itemId); + if (item == null) return null; + final updated = item.copyWith( + pasteCount: item.pasteCount + 1, + modifiedAt: now, + ); + await _repository.update(updated); + return updated; + } + + Future removeItem(String id) async { + final item = await _repository.getById(id); + if (item != null) { + _markSuppressed(item); + } + await _repository.delete(id); + if (item != null) { + _cleanupItemFiles(item); + } + } + + /// Deletes a file only if [path] is canonically contained inside the app's + /// own images directory. Any path outside is refused and logged. + /// + /// This is the single entry point for file deletion in this service. Never + /// call `File.delete*` directly on a path that comes from user input, item + /// content, or any source outside the app's own path builder. + bool _deleteAppFile(String path) { + final imagesPath = _imagesPath; + if (imagesPath == null || imagesPath.isEmpty) return false; + final String canonicalBase; + final String canonicalTarget; + try { + canonicalBase = p.canonicalize(imagesPath); + canonicalTarget = p.canonicalize(path); + } catch (e) { + AppLogger.warn('_deleteAppFile: canonicalize failed for "$path": $e'); + return false; + } + final baseWithSep = canonicalBase.endsWith(p.separator) + ? canonicalBase + : '$canonicalBase${p.separator}'; + if (!canonicalTarget.startsWith(baseWithSep)) { + AppLogger.error( + '_deleteAppFile: refused to delete out-of-scope path ' + '"$canonicalTarget" (base="$canonicalBase")', + ); + return false; + } + try { + final file = File(canonicalTarget); + if (file.existsSync()) file.deleteSync(); + return true; + } catch (e) { + AppLogger.warn('_deleteAppFile: delete failed for "$path": $e'); + return false; + } + } + + void _cleanupItemFiles(ClipboardItem item) { + if (item.type == ClipboardContentType.image && item.content.isNotEmpty) { + _deleteAppFile(item.content); + } + final thumb = item.thumbPath; + if (thumb != null && thumb.isNotEmpty) { + _deleteAppFile(thumb); + } + } + + Future> getHistoryAdvanced({ + String? query, + List? types, + List? colors, + bool? isPinned, + int limit = 50, + int skip = 0, + }) => _repository.searchAdvanced( + query: query, + types: types, + colors: colors, + isPinned: isPinned, + limit: limit, + skip: skip, + ); + + Future updatePin(String id, bool isPinned) async { + final item = await _repository.getById(id); + if (item == null) return; + await _repository.update( + item.copyWith(isPinned: isPinned, modifiedAt: DateTime.now().toUtc()), + ); + } + + Future updateLabelAndColor( + String id, + String? label, + CardColor color, + ) async { + final item = await _repository.getById(id); + if (item == null) return; + await _repository.update( + item.copyWith( + label: label, + cardColor: color, + modifiedAt: DateTime.now().toUtc(), + ), + ); + } + + Future clearUnpinnedHistory() async { + final unpinned = await _repository.searchAdvanced( + isPinned: false, + limit: 100000, + skip: 0, + ); + for (final item in unpinned) { + _markSuppressed(item); + } + return _repository.deleteAllUnpinned(); + } + + Future getItemCount() => _repository.count(); + + Future reclassifyLegacyTextItems() async { + const batchSize = 50; + var skip = 0; + while (true) { + if (_disposed) return; + final batch = await _repository.searchAdvanced( + types: [ClipboardContentType.text], + limit: batchSize, + skip: skip, + ); + if (batch.isEmpty) return; + for (final item in batch) { + if (_disposed) return; + final resolved = TextClassifier.classify(item.content); + if (resolved != ClipboardContentType.text) { + await _repository.update(item.copyWith(type: resolved)); + } + } + if (batch.length < batchSize) return; + skip += batchSize; + } + } + + Future walCheckpoint() => _repository.walCheckpoint(); + + Future updateMetadata(String id, String metadata) async { + final item = await _repository.getById(id); + if (item == null) return; + final updated = item.copyWith(metadata: metadata); + await _repository.update(updated); + if (!_disposed) _itemReactivated.add(updated); + } + + Future dispose() async { + _disposed = true; + _suppressedKeys.clear(); + await _imageQueue.dispose(); + await _thumbQueue?.dispose(); + await _itemAdded.close(); + await _itemReactivated.close(); + } +} From dad664b846975a5a18487db2913b5c803d5f5175 Mon Sep 17 00:00:00 2001 From: rgdevment Date: Fri, 24 Apr 2026 19:57:40 -0400 Subject: [PATCH 20/31] feat: add capabilities and cursor monitor functions for Linux --- app/linux/runner/copypaste_linux_shell.c | 72 +++++++++++++++++++++++- 1 file changed, 71 insertions(+), 1 deletion(-) diff --git a/app/linux/runner/copypaste_linux_shell.c b/app/linux/runner/copypaste_linux_shell.c index 73ae5fd8..4b91a817 100644 --- a/app/linux/runner/copypaste_linux_shell.c +++ b/app/linux/runner/copypaste_linux_shell.c @@ -596,7 +596,77 @@ static gchar* read_wm_name(void) { #endif } -static ue* result = fl_value_new_map(); +static FlValue* build_capabilities(void) { + FlValue* caps = fl_value_new_map(); + fl_value_set_string_take(caps, "isX11", fl_value_new_bool(shell_is_x11())); + fl_value_set_string_take(caps, "hasAppIndicator", + fl_value_new_bool(has_app_indicator_runtime())); + fl_value_set_string_take(caps, "hasEwmh", + fl_value_new_bool(ewmh_supports_active_window())); + const gchar* desktop_env = g_getenv("XDG_CURRENT_DESKTOP"); + if (desktop_env == NULL) desktop_env = g_getenv("DESKTOP_SESSION"); + fl_value_set_string_take(caps, "desktopEnv", + fl_value_new_string(desktop_env != NULL ? desktop_env : "")); + g_autofree gchar* wm = read_wm_name(); + fl_value_set_string_take(caps, "wmName", + fl_value_new_string(wm != NULL ? wm : "")); + return caps; +} + +static FlValue* build_cursor_monitor(void) { + GdkDisplay* display = gdk_display_get_default(); + if (display == NULL) { + return fl_value_new_null(); + } + GdkSeat* seat = gdk_display_get_default_seat(display); + if (seat == NULL) { + return fl_value_new_null(); + } + GdkDevice* pointer = gdk_seat_get_pointer(seat); + if (pointer == NULL) { + return fl_value_new_null(); + } + gint cursor_x = 0; + gint cursor_y = 0; + GdkScreen* screen = NULL; + gdk_device_get_position(pointer, &screen, &cursor_x, &cursor_y); + + GdkMonitor* monitor = + gdk_display_get_monitor_at_point(display, cursor_x, cursor_y); + if (monitor == NULL) { + monitor = gdk_display_get_primary_monitor(display); + } + if (monitor == NULL) { + return fl_value_new_null(); + } + + GdkRectangle workarea = {0, 0, 0, 0}; + gdk_monitor_get_workarea(monitor, &workarea); + gint scale = gdk_monitor_get_scale_factor(monitor); + if (scale <= 0) { + scale = 1; + } + + FlValue* result = fl_value_new_map(); + fl_value_set_string_take(result, "cursorX", + fl_value_new_float((double)cursor_x)); + fl_value_set_string_take(result, "cursorY", + fl_value_new_float((double)cursor_y)); + fl_value_set_string_take(result, "x", + fl_value_new_float((double)workarea.x)); + fl_value_set_string_take(result, "y", + fl_value_new_float((double)workarea.y)); + fl_value_set_string_take(result, "width", + fl_value_new_float((double)workarea.width)); + fl_value_set_string_take(result, "height", + fl_value_new_float((double)workarea.height)); + fl_value_set_string_take(result, "scaleFactor", + fl_value_new_float((double)scale)); + return result; +} + +static FlValue* build_input_focus(CopyPasteLinuxShell* shell) { + FlValue* result = fl_value_new_map(); fl_value_set_string_take(result, "ownsFocus", fl_value_new_bool(FALSE)); fl_value_set_string_take(result, "focusWindow", fl_value_new_int(0)); fl_value_set_string_take(result, "ownWindow", fl_value_new_int(0)); From 39dad2b23e26879b2ad0282bc95cc1ca98b1ce5d Mon Sep 17 00:00:00 2001 From: rgdevment Date: Fri, 24 Apr 2026 20:09:22 -0400 Subject: [PATCH 21/31] fix: clipboard change handling to remove debounce logic - Removed the kClipboardDebounceMs constant and associated debounce checks in is_duplicate_change function. - Simplified the logic for handling duplicate clipboard changes by only checking for hash equality. - Ensured last_content_hash is freed and set to NULL when clipboard content is empty or on owner change. --- app/lib/widgets/clipboard_card.dart | 3292 +++++++++++++-------------- listener/linux/listener_plugin.c | 15 +- 2 files changed, 1646 insertions(+), 1661 deletions(-) diff --git a/app/lib/widgets/clipboard_card.dart b/app/lib/widgets/clipboard_card.dart index 313b02c4..49b0a063 100644 --- a/app/lib/widgets/clipboard_card.dart +++ b/app/lib/widgets/clipboard_card.dart @@ -1,1656 +1,1636 @@ -import 'dart:convert'; -import 'dart:io'; - -import 'package:core/core.dart'; -import 'package:flutter/gestures.dart'; -import 'package:flutter/material.dart'; -import '../l10n/app_localizations.dart'; -import '../theme/app_theme_data.dart'; -import '../theme/theme_provider.dart'; -import 'label_color_dialog.dart'; - -class ClipboardCard extends StatefulWidget { - const ClipboardCard({ - required this.item, - required this.onTap, - required this.onPin, - required this.onDelete, - required this.onLabelColor, - this.onPastePlain, - this.onExpandToggle, - this.onOpen, - this.onSelect, - this.onRequestThumbnailRefresh, - this.isSelected = false, - this.isExpanded = false, - this.cardMinLines, - this.cardMaxLines, - super.key, - }); - - final ClipboardItem item; - final VoidCallback onTap; - final VoidCallback onPin; - final VoidCallback onDelete; - final void Function(String? label, CardColor color) onLabelColor; - final VoidCallback? onPastePlain; - final VoidCallback? onExpandToggle; - final VoidCallback? onOpen; - final VoidCallback? onSelect; - - /// Invoked once per resolved image item to let the host trigger - /// background regeneration of `_thumb.png` when the source file's - /// `mtime` no longer matches `item.sourceModifiedAt`. - final void Function(ClipboardItem item)? onRequestThumbnailRefresh; - final bool isSelected; - final bool isExpanded; - final int? cardMinLines; - final int? cardMaxLines; - - @override - State createState() => _ClipboardCardState(); -} - -class _ClipboardCardState extends State { - bool _hovering = false; - String? _resolvedImagePath; - bool _resolvedIsThumb = false; - bool _imagePathResolved = false; - bool? _fileAvailable; - DateTime? _lastPrimaryDown; - bool _isTextOverflowing = false; - - static const _doubleTapTimeout = Duration(milliseconds: 300); - - void _handlePointerDown(PointerDownEvent event) { - if (event.buttons != kPrimaryButton) return; - widget.onSelect?.call(); - final now = DateTime.now(); - if (_lastPrimaryDown != null && - now.difference(_lastPrimaryDown!) < _doubleTapTimeout) { - _lastPrimaryDown = null; - widget.onTap(); - } else { - _lastPrimaryDown = now; - } - } - - @override - void initState() { - super.initState(); - _resolveImagePath(); - _resolveFileAvailability(); - } - - @override - void didUpdateWidget(ClipboardCard oldWidget) { - super.didUpdateWidget(oldWidget); - if (oldWidget.item.id != widget.item.id || - oldWidget.item.content != widget.item.content || - oldWidget.item.thumbPath != widget.item.thumbPath || - oldWidget.item.metadata != widget.item.metadata) { - _imagePathResolved = false; - _resolvedIsThumb = false; - _fileAvailable = null; - _resolveImagePath(); - _resolveFileAvailability(); - } - } - - bool _needsExpandToggle(ClipboardItem item) { - if (widget.isExpanded) return true; - final type = item.type; - if (type == ClipboardContentType.text || - type == ClipboardContentType.unknown || - type == ClipboardContentType.json) { - return _isTextOverflowing; - } - return false; - } - - bool _needsOpenAction(ClipboardItem item) { - return switch (item.type) { - ClipboardContentType.image => - _imagePathResolved && - _resolvedImagePath != null && - (_fileAvailable ?? true), - ClipboardContentType.file || - ClipboardContentType.folder || - ClipboardContentType.audio || - ClipboardContentType.video => _fileAvailable ?? false, - ClipboardContentType.link || - ClipboardContentType.email || - ClipboardContentType.phone => true, - _ => false, - }; - } - - void _resolveImagePath() { - final item = widget.item; - final isImage = item.type == ClipboardContentType.image; - final isMedia = - item.type == ClipboardContentType.video || - item.type == ClipboardContentType.audio; - if (!isImage && !isMedia) { - return; - } - if (isImage) { - // Always ask the host to refresh the thumb if the source mtime is - // stale. The host is responsible for deciding (and rate-limiting). - widget.onRequestThumbnailRefresh?.call(item); - } - _checkImagePathsAsync(item, allowContentFallback: isImage); - } - - /// Resolves the best path to display for an image item: prefers - /// `item.thumbPath` (when present and the file exists), falls back to - /// `item.content`, finally null. - /// - /// When [allowContentFallback] is false (video / audio items) the - /// content path is never used as a fallback because it points to the - /// external media file, not a renderable image. - Future _checkImagePathsAsync( - ClipboardItem item, { - bool allowContentFallback = true, - }) async { - final thumb = item.thumbPath; - if (thumb != null && thumb.isNotEmpty) { - if (await File(thumb).exists()) { - if (!mounted) return; - setState(() { - _resolvedImagePath = thumb; - _resolvedIsThumb = true; - _imagePathResolved = true; - }); - return; - } - } - - if (!allowContentFallback) { - if (!mounted) return; - setState(() { - _resolvedImagePath = null; - _resolvedIsThumb = false; - _imagePathResolved = true; - }); - return; - } - - final content = item.content; - if (content.isEmpty) { - if (!mounted) return; - setState(() { - _resolvedImagePath = null; - _resolvedIsThumb = false; - _imagePathResolved = true; - }); - return; - } - final exists = await File(content).exists(); - if (!mounted) return; - setState(() { - _resolvedImagePath = exists ? content : null; - _resolvedIsThumb = false; - _imagePathResolved = true; - }); - } - - void _resolveFileAvailability() { - final item = widget.item; - if (item.type != ClipboardContentType.file && - item.type != ClipboardContentType.folder && - item.type != ClipboardContentType.audio && - item.type != ClipboardContentType.video && - item.type != ClipboardContentType.image) { - return; - } - final path = item.content.split('\n').first.trim(); - if (path.isEmpty) { - if (mounted) setState(() => _fileAvailable = false); - return; - } - _checkFileAvailableAsync(path); - } - - Future _checkFileAvailableAsync(String path) async { - final exists = await File(path).exists() || await Directory(path).exists(); - if (mounted) setState(() => _fileAvailable = exists); - } - - Future _editLabelColor(BuildContext context) async { - final result = await LabelColorDialog.show( - context, - currentLabel: widget.item.label, - currentColor: widget.item.cardColor, - ); - if (result != null && mounted) { - widget.onLabelColor(result.label, result.color); - } - } - - bool get _isPlainPasteable => - widget.item.type == ClipboardContentType.text || - widget.item.type == ClipboardContentType.link; - - Future _showContextMenu(BuildContext ctx, Offset position) async { - final size = MediaQuery.of(ctx).size; - final item = widget.item; - final colors = CopyPasteTheme.colorsOf(ctx); - final isDark = Theme.of(ctx).brightness == Brightness.dark; - final l = AppLocalizations.of(ctx); - final action = await showMenu<_ContextAction>( - context: ctx, - position: RelativeRect.fromLTRB( - position.dx, - position.dy, - size.width - position.dx, - size.height - position.dy, - ), - elevation: 8, - color: isDark ? colors.surfaceVariant : colors.cardBackground, - shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(8)), - items: [ - PopupMenuItem( - value: _ContextAction.paste, - height: 32, - child: _ContextMenuItem( - icon: Icons.content_paste_rounded, - label: l.menuPaste, - colors: colors, - ), - ), - if (_isPlainPasteable && widget.onPastePlain != null) - PopupMenuItem( - value: _ContextAction.pastePlain, - height: 32, - child: _ContextMenuItem( - icon: Icons.format_clear_rounded, - label: l.menuPastePlain, - colors: colors, - ), - ), - const PopupMenuDivider(height: 1), - PopupMenuItem( - value: _ContextAction.pin, - height: 32, - child: _ContextMenuItem( - icon: item.isPinned - ? Icons.push_pin_rounded - : Icons.push_pin_outlined, - label: item.isPinned ? l.menuUnpin : l.menuPin, - colors: colors, - ), - ), - PopupMenuItem( - value: _ContextAction.edit, - height: 32, - child: _ContextMenuItem( - icon: Icons.edit_rounded, - label: l.menuEdit, - colors: colors, - ), - ), - const PopupMenuDivider(height: 1), - PopupMenuItem( - value: _ContextAction.delete, - height: 32, - child: _ContextMenuItem( - icon: Icons.delete_rounded, - label: l.menuDelete, - colors: colors, - danger: true, - ), - ), - ], - ); - if (!mounted) return; - switch (action) { - case _ContextAction.paste: - widget.onTap(); - case _ContextAction.pastePlain: - widget.onPastePlain?.call(); - case _ContextAction.pin: - widget.onPin(); - case _ContextAction.edit: - await _editLabelColor(context); - case _ContextAction.delete: - widget.onDelete(); - case null: - break; - } - } - - @override - Widget build(BuildContext context) { - final theme = CopyPasteTheme.of(context); - final colors = CopyPasteTheme.colorsOf(context); - final isDark = Theme.of(context).brightness == Brightness.dark; - final item = widget.item; - final accentColor = colors.accentForIndex(item.cardColor.value); - final hasColor = item.cardColor != CardColor.none; - - return MouseRegion( - onEnter: (_) => setState(() => _hovering = true), - onExit: (_) => setState(() => _hovering = false), - child: Listener( - onPointerDown: _handlePointerDown, - child: GestureDetector( - onSecondaryTapUp: (d) => _showContextMenu(context, d.globalPosition), - child: AnimatedContainer( - duration: const Duration(milliseconds: 150), - curve: Curves.easeOut, - constraints: BoxConstraints(minHeight: theme.sizing.cardMinHeight), - transform: _hovering ? Matrix4.translationValues(0, -1, 0) : null, - decoration: BoxDecoration( - color: _hovering && isDark - ? colors.surfaceVariant - : colors.cardBackground, - borderRadius: BorderRadius.circular(theme.radii.card), - border: Border.all( - color: widget.isSelected - ? colors.primary.withValues(alpha: 0.5) - : _hovering - ? colors.onSurface.withValues(alpha: isDark ? 0.1 : 0.18) - : colors.cardBorder, - width: theme.cardStyle.borderWidth, - ), - boxShadow: [ - if (widget.isSelected) - BoxShadow( - color: colors.primary.withValues(alpha: 0.2), - blurRadius: 8, - spreadRadius: 1, - ), - if (isDark) - BoxShadow( - color: Colors.black.withValues( - alpha: _hovering ? 0.3 : 0.2, - ), - blurRadius: _hovering ? 12 : 6, - offset: Offset(0, _hovering ? 3 : 1), - ) - else - BoxShadow( - color: Colors.black.withValues( - alpha: _hovering ? 0.1 : 0.07, - ), - blurRadius: _hovering ? 10 : 4, - offset: Offset(0, _hovering ? 3 : 1), - ), - ], - ), - child: Stack( - children: [ - if (hasColor) - Positioned( - left: 0, - top: 0, - bottom: 0, - child: Container( - width: theme.sizing.colorIndicatorWidth, - decoration: BoxDecoration( - color: accentColor, - borderRadius: - theme.cardStyle.colorIndicatorBorderRadius, - ), - ), - ), - Padding( - padding: theme.spacing.cardPadding.copyWith( - left: hasColor - ? theme.spacing.cardPadding.left + - theme.sizing.colorIndicatorWidth + - 2 - : theme.spacing.cardPadding.left, - ), - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - mainAxisSize: MainAxisSize.min, - children: [ - _buildHeader(theme, colors, item), - const SizedBox(height: 4), - _buildContent(theme, colors, item), - if (_hasFooter(item)) ...[ - const SizedBox(height: 6), - _buildFooter(theme, colors, item), - ], - ], - ), - ), - ], - ), - ), - ), - ), - ); - } - - Widget _buildHeader( - AppThemeData theme, - AppThemeColorScheme colors, - ClipboardItem item, - ) { - final l = AppLocalizations.of(context); - final typeColor = _typeColor(item.type, colors); - final iconSize = theme.sizing.cardTypeIconContainerSize; - - final isDark = Theme.of(context).brightness == Brightness.dark; - final iconBgAlpha = isDark ? 0.2 : 0.13; - - return Stack( - children: [ - Row( - crossAxisAlignment: CrossAxisAlignment.center, - children: [ - Container( - width: iconSize, - height: iconSize, - decoration: BoxDecoration( - color: typeColor.withValues(alpha: iconBgAlpha), - borderRadius: BorderRadius.circular(8), - ), - child: Center( - child: Icon( - theme.icons.forContentType(item.type.value), - size: 16, - color: typeColor, - ), - ), - ), - const SizedBox(width: 10), - Expanded( - child: Padding( - padding: const EdgeInsets.only(right: 40), - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - mainAxisSize: MainAxisSize.min, - children: [ - Text( - item.label ?? _contentTypeName(item.type, l), - style: theme.typography.cardLabel.copyWith( - color: item.label != null - ? typeColor.withValues(alpha: 0.85) - : colors.onSurface.withValues( - alpha: theme.cardStyle.headerOpacity, - ), - letterSpacing: 0.06, - ), - maxLines: 1, - overflow: TextOverflow.ellipsis, - ), - if (item.appSource != null && - item.type != ClipboardContentType.color) ...[ - const SizedBox(height: 1), - Text( - '· ${item.appSource!}', - style: theme.typography.cardFooter.copyWith( - color: colors.onSurface.withValues( - alpha: theme.cardStyle.appSourceOpacity, - ), - fontSize: 10, - letterSpacing: 0.2, - ), - maxLines: 1, - overflow: TextOverflow.ellipsis, - ), - ], - ], - ), - ), - ), - ], - ), - Positioned( - right: 0, - top: 0, - bottom: 0, - child: Align( - alignment: Alignment.centerRight, - child: Stack( - alignment: Alignment.centerRight, - children: [ - AnimatedOpacity( - opacity: _hovering ? 0.0 : 1.0, - duration: const Duration(milliseconds: 120), - child: Row( - mainAxisSize: MainAxisSize.min, - children: [ - if (item.isPinned) - Padding( - padding: const EdgeInsets.only(right: 4), - child: Icon( - theme.icons.pinFilled, - size: theme.sizing.iconSizeXs, - color: colors.primary.withValues(alpha: 0.5), - ), - ), - Text( - _formatTimestamp(item.modifiedAt, l), - style: theme.typography.cardTimestamp.copyWith( - color: colors.onSurface.withValues( - alpha: theme.cardStyle.timestampOpacity, - ), - ), - ), - ], - ), - ), - IgnorePointer( - ignoring: !_hovering, - child: AnimatedOpacity( - opacity: _hovering ? 1.0 : 0.0, - duration: const Duration(milliseconds: 120), - child: Row( - mainAxisSize: MainAxisSize.min, - children: [ - _CardActionButton( - icon: theme.icons.paste, - tooltip: l.menuPaste, - onTap: widget.onTap, - ), - const SizedBox(width: 3), - if (_isPlainPasteable && - widget.onPastePlain != null) ...[ - _CardActionButton( - icon: Icons.notes_rounded, - tooltip: l.menuPastePlain, - onTap: widget.onPastePlain!, - ), - const SizedBox(width: 3), - ], - _CardActionButton( - icon: theme.icons.edit, - tooltip: l.menuEdit, - onTap: () => _editLabelColor(context), - ), - const SizedBox(width: 3), - _CardActionButton( - icon: item.isPinned - ? theme.icons.pinFilled - : theme.icons.pin, - tooltip: item.isPinned ? l.menuUnpin : l.menuPin, - onTap: widget.onPin, - ), - const SizedBox(width: 3), - _CardActionButton( - icon: theme.icons.delete, - tooltip: l.menuDelete, - onTap: widget.onDelete, - isDanger: true, - ), - ], - ), - ), - ), - ], - ), - ), - ), - ], - ); - } - - Widget _buildContent( - AppThemeData theme, - AppThemeColorScheme colors, - ClipboardItem item, - ) { - switch (item.type) { - case ClipboardContentType.image: - return _buildImageContent(theme, colors, item); - case ClipboardContentType.audio: - return _buildMediaContent(theme, colors, item); - case ClipboardContentType.video: - return _buildMediaContent(theme, colors, item); - case ClipboardContentType.file: - case ClipboardContentType.folder: - return _buildFileContent(theme, colors, item); - case ClipboardContentType.link: - return _buildLinkContent(theme, colors, item); - case ClipboardContentType.text: - case ClipboardContentType.unknown: - case ClipboardContentType.email: - case ClipboardContentType.phone: - case ClipboardContentType.ip: - case ClipboardContentType.uuid: - case ClipboardContentType.json: - return _buildTextContent(theme, colors, item); - case ClipboardContentType.color: - return _buildColorContent(theme, colors, item); - } - } - - Widget _buildTextContent( - AppThemeData theme, - AppThemeColorScheme colors, - ClipboardItem item, - ) { - final minLines = widget.cardMinLines ?? theme.sizing.cardMinLines; - final displayMaxLines = widget.isExpanded - ? (widget.cardMaxLines ?? theme.sizing.cardMaxLines) - : minLines; - final textStyle = theme.typography.cardContent.copyWith( - color: colors.onSurface.withValues(alpha: theme.cardStyle.contentOpacity), - ); - - return LayoutBuilder( - builder: (context, constraints) { - final tp = TextPainter( - text: TextSpan(text: item.content, style: textStyle), - maxLines: minLines, - textDirection: Directionality.of(context), - )..layout(maxWidth: constraints.maxWidth); - final overflows = tp.didExceedMaxLines; - tp.dispose(); - if (overflows != _isTextOverflowing) { - _isTextOverflowing = overflows; - WidgetsBinding.instance.addPostFrameCallback((_) { - if (mounted) setState(() {}); - }); - } - - return Text( - item.content, - style: textStyle, - maxLines: displayMaxLines, - overflow: TextOverflow.ellipsis, - ); - }, - ); - } - - Widget _buildColorContent( - AppThemeData theme, - AppThemeColorScheme colors, - ClipboardItem item, - ) { - return Text( - item.content, - style: theme.typography.cardContent.copyWith( - color: colors.onSurface.withValues( - alpha: theme.cardStyle.contentOpacity, - ), - ), - ); - } - - Widget _buildImageContent( - AppThemeData theme, - AppThemeColorScheme colors, - ClipboardItem item, - ) { - if (!_imagePathResolved) { - return Container( - height: theme.sizing.cardImageHeight, - decoration: BoxDecoration( - color: colors.surfaceVariant, - borderRadius: BorderRadius.circular(theme.radii.thumbnail), - ), - ); - } - - final l10n = AppLocalizations.of(context); - final contentPath = item.content.trim(); - final filename = contentPath.isEmpty - ? '' - : contentPath.split(Platform.pathSeparator).last; - - // File is known to be missing: show explicit warning instead of - // letting Image.file fail silently via errorBuilder. - if (_resolvedImagePath == null) { - return Semantics( - label: filename.isEmpty - ? l10n.imageFile - : '${l10n.imageFile}: $filename, ${l10n.fileNotFound}', - child: Container( - height: theme.sizing.cardImageHeight, - decoration: BoxDecoration( - color: colors.surfaceVariant, - borderRadius: BorderRadius.circular(theme.radii.thumbnail), - ), - child: contentPath.isEmpty - ? Center( - child: Icon( - theme.icons.image, - size: theme.sizing.iconSizeLg, - color: colors.onSurfaceMuted, - ), - ) - : Center( - child: Column( - mainAxisSize: MainAxisSize.min, - children: [ - Icon( - theme.icons.warning, - size: theme.sizing.iconSizeLg, - color: colors.warning, - ), - const SizedBox(height: 4), - _ExtBadge( - label: l10n.fileNotFound, - color: colors.warning, - ), - ], - ), - ), - ), - ); - } - - return Semantics( - label: filename.isEmpty - ? l10n.imageFile - : (_fileAvailable == false - ? '${l10n.imageFile}: $filename, ${l10n.fileNotFound}' - : '${l10n.imageFile}: $filename'), - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - ClipRRect( - borderRadius: BorderRadius.circular(theme.radii.thumbnail), - child: Container( - height: theme.sizing.cardImageHeight, - width: double.infinity, - color: colors.surfaceVariant, - child: Image.file( - File(_resolvedImagePath!), - fit: BoxFit.cover, - cacheWidth: _resolvedIsThumb ? 256 : 700, - errorBuilder: (_, e, s) => Center( - child: Icon( - theme.icons.warning, - color: colors.warning, - size: theme.sizing.iconSizeLg, - ), - ), - ), - ), - ), - if (_fileAvailable == false) ...[ - const SizedBox(height: 4), - Row( - mainAxisSize: MainAxisSize.min, - children: [ - _ExtBadge(label: l10n.fileNotFound, color: colors.warning), - ], - ), - ], - ], - ), - ); - } - - Widget _buildFileContent( - AppThemeData theme, - AppThemeColorScheme colors, - ClipboardItem item, - ) { - final files = item.content.split('\n').where((s) => s.isNotEmpty).toList(); - final available = item.isFileAvailable(); - final firstName = files.isEmpty - ? '' - : files.first.split(Platform.pathSeparator).last; - - final semanticsLabel = [ - if (firstName.isNotEmpty) firstName else item.content, - if (!available) AppLocalizations.of(context).fileNotFound, - ].join(', '); - - return Semantics( - label: semanticsLabel, - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Text( - firstName.isEmpty ? item.content : firstName, - style: theme.typography.cardContent.copyWith( - color: colors.onSurface.withValues( - alpha: theme.cardStyle.contentOpacity, - ), - ), - maxLines: 1, - overflow: TextOverflow.ellipsis, - ), - if (files.length > 1 || !available) ...[ - const SizedBox(height: 4), - Row( - mainAxisSize: MainAxisSize.min, - children: [ - if (files.length > 1) ...[ - _ExtBadge( - label: '+${files.length - 1}', - color: colors.onSurfaceMuted, - ), - ], - if (!available) ...[ - if (files.length > 1) const SizedBox(width: 4), - _ExtBadge( - label: AppLocalizations.of(context).fileNotFound, - color: colors.warning, - ), - ], - ], - ), - ], - ], - ), - ); - } - - Widget _buildMediaContent( - AppThemeData theme, - AppThemeColorScheme colors, - ClipboardItem item, - ) { - final path = item.content.trim(); - final filename = path.isEmpty - ? '' - : path.split(Platform.pathSeparator).last; - final isAudio = item.type == ClipboardContentType.audio; - final typeColor = _typeColor(item.type, colors); - final l10n = AppLocalizations.of(context); - final typeName = isAudio ? l10n.audioFile : l10n.videoFile; - final missing = _fileAvailable == false; - - final semanticsLabel = [ - filename.isEmpty ? typeName : filename, - if (missing) l10n.fileNotFound, - ].join(', '); - - final hasThumb = _imagePathResolved && _resolvedImagePath != null; - - if (!isAudio && hasThumb) { - return Semantics( - label: semanticsLabel, - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - ClipRRect( - borderRadius: BorderRadius.circular(theme.radii.thumbnail), - child: Container( - height: theme.sizing.cardImageHeight, - width: double.infinity, - color: colors.surfaceVariant, - child: Stack( - fit: StackFit.expand, - children: [ - Image.file( - File(_resolvedImagePath!), - fit: BoxFit.contain, - cacheWidth: _resolvedIsThumb ? 256 : 700, - errorBuilder: (_, e, st) => _MediaIcon( - isAudio: false, - typeColor: typeColor, - radius: theme.radii.thumbnail, - ), - ), - Center( - child: Container( - width: 28, - height: 28, - decoration: BoxDecoration( - color: Colors.black.withValues(alpha: 0.45), - shape: BoxShape.circle, - ), - child: const Center( - child: Icon( - Icons.play_arrow_rounded, - size: 16, - color: Colors.white, - ), - ), - ), - ), - ], - ), - ), - ), - const SizedBox(height: 4), - Text( - filename.isEmpty ? l10n.videoFile : filename, - style: theme.typography.cardContent.copyWith( - color: colors.onSurface.withValues( - alpha: theme.cardStyle.contentOpacity, - ), - fontSize: 11, - ), - maxLines: 1, - overflow: TextOverflow.ellipsis, - ), - if (missing) ...[ - const SizedBox(height: 4), - Row( - mainAxisSize: MainAxisSize.min, - children: [ - _ExtBadge(label: l10n.fileNotFound, color: colors.warning), - ], - ), - ], - ], - ), - ); - } - - return Semantics( - label: semanticsLabel, - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Text( - filename.isEmpty ? typeName : filename, - style: theme.typography.cardContent.copyWith( - color: colors.onSurface.withValues( - alpha: theme.cardStyle.contentOpacity, - ), - ), - maxLines: 1, - overflow: TextOverflow.ellipsis, - ), - if (missing) ...[ - const SizedBox(height: 4), - Row( - mainAxisSize: MainAxisSize.min, - children: [ - _ExtBadge(label: l10n.fileNotFound, color: colors.warning), - ], - ), - ], - ], - ), - ); - } - - Widget _buildLinkContent( - AppThemeData theme, - AppThemeColorScheme colors, - ClipboardItem item, - ) { - final uri = Uri.tryParse(item.content.trim()); - final domain = uri?.host ?? ''; - final typeColor = _typeColor(item.type, colors); - - return Row( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Expanded( - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Text( - item.content.trim(), - style: theme.typography.cardContent.copyWith( - color: colors.primary.withValues(alpha: 0.85), - decoration: TextDecoration.underline, - decorationColor: colors.primary.withValues(alpha: 0.3), - ), - maxLines: 1, - overflow: TextOverflow.ellipsis, - ), - if (domain.isNotEmpty) ...[ - const SizedBox(height: 3), - Row( - mainAxisSize: MainAxisSize.min, - children: [_ExtBadge(label: domain, color: typeColor)], - ), - ], - ], - ), - ), - ], - ); - } - - Map? _parseMetadata(ClipboardItem item) { - if (item.metadata == null || item.metadata!.isEmpty) return null; - try { - return json.decode(item.metadata!) as Map; - } catch (_) { - return null; - } - } - - String _getExtForItem(ClipboardItem item) { - if (item.type != ClipboardContentType.file && - item.type != ClipboardContentType.folder && - item.type != ClipboardContentType.audio && - item.type != ClipboardContentType.video && - item.type != ClipboardContentType.image) { - return ''; - } - final lines = item.content.split('\n').where((s) => s.isNotEmpty).toList(); - if (lines.isEmpty) return ''; - final firstName = lines.first.split(Platform.pathSeparator).last; - return firstName.contains('.') - ? firstName.split('.').last.toUpperCase() - : ''; - } - - bool _hasFooter(ClipboardItem item) { - if (_needsExpandToggle(item)) return true; - if (_needsOpenAction(item)) return true; - if (item.pasteCount > 0) return true; - if (_getExtForItem(item).isNotEmpty) return true; - final meta = _parseMetadata(item); - if (meta == null) return false; - return meta.containsKey('file_size') || - meta.containsKey('size') || - meta.containsKey('width') || - meta.containsKey('video_width') || - meta.containsKey('duration'); - } - - Widget _buildFooter( - AppThemeData theme, - AppThemeColorScheme colors, - ClipboardItem item, - ) { - final meta = _parseMetadata(item); - final footerAlpha = theme.cardStyle.footerOpacity; - final footerColor = colors.onSurface.withValues(alpha: footerAlpha); - final footerStyle = theme.typography.cardFooter.copyWith( - color: footerColor, - ); - final iconColor = colors.onSurface.withValues(alpha: footerAlpha - 0.1); - - final ext = _getExtForItem(item); - final typeColor = _typeColor(item.type, colors); - final widgets = []; - - final w = meta?['width'] ?? meta?['video_width']; - final h = meta?['height'] ?? meta?['video_height']; - if (w != null && h != null) { - widgets.add( - _FooterChip( - icon: Icons.aspect_ratio_rounded, - label: '$w×$h', - style: footerStyle, - iconColor: iconColor, - iconSize: theme.sizing.iconSizeXs, - ), - ); - } - - final fileSize = meta?['file_size'] ?? meta?['size']; - if (fileSize != null && fileSize is num && fileSize > 0) { - widgets.add( - _FooterChip( - icon: Icons.storage_rounded, - label: _formatFileSize(fileSize.toInt()), - style: footerStyle, - iconColor: iconColor, - iconSize: theme.sizing.iconSizeXs, - ), - ); - } - - final duration = meta?['duration']; - if (duration != null && duration is num && duration > 0) { - widgets.add( - _FooterChip( - icon: Icons.timer_outlined, - label: _formatDuration(duration.toInt()), - style: footerStyle, - iconColor: iconColor, - iconSize: theme.sizing.iconSizeXs, - ), - ); - } - - if (item.pasteCount > 0) { - widgets.add( - Text( - '×${item.pasteCount}', - style: footerStyle, - maxLines: 1, - overflow: TextOverflow.ellipsis, - ), - ); - } - - final showExpand = _needsExpandToggle(item); - final showOpen = !showExpand && _needsOpenAction(item); - - return Row( - children: [ - if (item.type == ClipboardContentType.color) - _ColorBadge(value: item.content.trim()) - else if (item.type == ClipboardContentType.phone) ...[ - if (_resolvePhoneCountry(item.content) case final c?) - _ExtBadge(label: c, color: typeColor), - ] else if (item.type == ClipboardContentType.email) ...[ - if (_resolveEmailProvider(item.content) case final p?) - _ExtBadge(label: p, color: typeColor), - ] else if (ext.isNotEmpty) - _ExtBadge(label: ext, color: typeColor), - if (showExpand) - Padding( - padding: const EdgeInsets.only(left: 6), - child: Material( - color: Colors.transparent, - child: InkWell( - onTap: () => widget.onExpandToggle?.call(), - canRequestFocus: false, - borderRadius: BorderRadius.circular(8), - hoverColor: colors.onSurface.withValues(alpha: 0.06), - child: Padding( - padding: const EdgeInsets.symmetric( - horizontal: 4, - vertical: 1, - ), - child: Icon( - widget.isExpanded - ? Icons.expand_less_rounded - : Icons.expand_more_rounded, - size: 14, - color: colors.onSurface.withValues(alpha: 0.35), - ), - ), - ), - ), - ), - if (showOpen) - Padding( - padding: EdgeInsets.only(left: ext.isNotEmpty ? 6 : 0), - child: Material( - color: Colors.transparent, - child: InkWell( - onTap: () => widget.onOpen?.call(), - canRequestFocus: false, - borderRadius: BorderRadius.circular(8), - hoverColor: colors.onSurface.withValues(alpha: 0.06), - child: Padding( - padding: const EdgeInsets.symmetric( - horizontal: 4, - vertical: 1, - ), - child: Icon( - Icons.open_in_new_rounded, - size: 14, - color: colors.onSurface.withValues(alpha: 0.35), - ), - ), - ), - ), - ), - if (widgets.isNotEmpty) - Expanded( - child: Row( - mainAxisAlignment: MainAxisAlignment.end, - children: [ - for (int i = 0; i < widgets.length; i++) ...[ - if (i > 0) const SizedBox(width: 8), - Flexible(fit: FlexFit.loose, child: widgets[i]), - ], - ], - ), - ), - ], - ); - } - - static String _formatFileSize(int bytes) { - if (bytes < 1024) return '$bytes B'; - if (bytes < 1024 * 1024) return '${(bytes / 1024).toStringAsFixed(1)} KB'; - if (bytes < 1024 * 1024 * 1024) { - return '${(bytes / (1024 * 1024)).toStringAsFixed(1)} MB'; - } - return '${(bytes / (1024 * 1024 * 1024)).toStringAsFixed(2)} GB'; - } - - static String _formatDuration(int seconds) { - final h = seconds ~/ 3600; - final m = (seconds % 3600) ~/ 60; - final s = seconds % 60; - if (h > 0) { - return '$h:${m.toString().padLeft(2, '0')}:${s.toString().padLeft(2, '0')}'; - } - return '$m:${s.toString().padLeft(2, '0')}'; - } - - Color _typeColor(ClipboardContentType type, AppThemeColorScheme colors) { - final isDark = Theme.of(context).brightness == Brightness.dark; - return switch (type) { - ClipboardContentType.text => colors.accentBlue, - ClipboardContentType.image => colors.accentOrange, - ClipboardContentType.file => colors.accentYellow, - ClipboardContentType.folder => colors.accentYellow, - ClipboardContentType.link => colors.accentGreen, - ClipboardContentType.audio => - isDark ? const Color(0xFF7DD3FC) : const Color(0xFF075985), - ClipboardContentType.video => colors.accentRed, - ClipboardContentType.email => colors.accentBlue, - ClipboardContentType.phone => colors.accentGreen, - ClipboardContentType.color => colors.accentOrange, - ClipboardContentType.ip => - isDark ? const Color(0xFFD4A5F5) : const Color(0xFF6B21A8), - ClipboardContentType.uuid => - isDark ? const Color(0xFF94A3B8) : const Color(0xFF475569), - ClipboardContentType.json => colors.accentYellow, - ClipboardContentType.unknown => colors.onSurfaceMuted, - }; - } - - String _contentTypeName(ClipboardContentType type, AppLocalizations l) => - switch (type) { - ClipboardContentType.text => l.typeText, - ClipboardContentType.image => l.typeImage, - ClipboardContentType.file => l.typeFile, - ClipboardContentType.folder => l.typeFolder, - ClipboardContentType.link => l.typeLink, - ClipboardContentType.audio => l.typeAudio, - ClipboardContentType.video => l.typeVideo, - ClipboardContentType.email => l.typeEmail, - ClipboardContentType.phone => l.typePhone, - ClipboardContentType.color => l.typeColor, - ClipboardContentType.ip => l.typeIp, - ClipboardContentType.uuid => l.typeUuid, - ClipboardContentType.json => l.typeJson, - ClipboardContentType.unknown => 'Unknown', - }; - - static const _phoneCountries = { - '1': 'US/CA', - '7': 'Russia', - '20': 'Egypt', - '27': 'S.Africa', - '30': 'Greece', - '31': 'Netherlands', - '32': 'Belgium', - '33': 'France', - '34': 'Spain', - '36': 'Hungary', - '39': 'Italy', - '40': 'Romania', - '41': 'Switzerland', - '43': 'Austria', - '44': 'UK', - '45': 'Denmark', - '46': 'Sweden', - '47': 'Norway', - '48': 'Poland', - '49': 'Germany', - '51': 'Peru', - '52': 'Mexico', - '53': 'Cuba', - '54': 'Argentina', - '55': 'Brazil', - '56': 'Chile', - '57': 'Colombia', - '58': 'Venezuela', - '60': 'Malaysia', - '61': 'Australia', - '62': 'Indonesia', - '63': 'Philippines', - '64': 'NZ', - '65': 'Singapore', - '66': 'Thailand', - '81': 'Japan', - '82': 'Korea', - '84': 'Vietnam', - '86': 'China', - '90': 'Turkey', - '91': 'India', - '92': 'Pakistan', - '94': 'Sri Lanka', - '98': 'Iran', - '212': 'Morocco', - '213': 'Algeria', - '216': 'Tunisia', - '234': 'Nigeria', - '254': 'Kenya', - '351': 'Portugal', - '352': 'Luxembourg', - '353': 'Ireland', - '354': 'Iceland', - '358': 'Finland', - '380': 'Ukraine', - '381': 'Serbia', - '385': 'Croatia', - '420': 'Czech', - '421': 'Slovakia', - '502': 'Guatemala', - '503': 'El Salvador', - '504': 'Honduras', - '505': 'Nicaragua', - '506': 'Costa Rica', - '507': 'Panama', - '591': 'Bolivia', - '593': 'Ecuador', - '595': 'Paraguay', - '598': 'Uruguay', - '855': 'Cambodia', - '880': 'Bangladesh', - '886': 'Taiwan', - '961': 'Lebanon', - '962': 'Jordan', - '964': 'Iraq', - '965': 'Kuwait', - '966': 'Saudi Arabia', - '971': 'UAE', - '972': 'Israel', - '974': 'Qatar', - '977': 'Nepal', - '994': 'Azerbaijan', - '995': 'Georgia', - '998': 'Uzbekistan', - }; - - // Keyed by first domain label — covers all regional variants automatically. - // e.g. outlook.com / outlook.com.ar / outlook.cl all resolve to 'Outlook' - static const _emailPrefixes = { - 'gmail': 'Gmail', - 'googlemail': 'Gmail', - 'outlook': 'Outlook', - 'hotmail': 'Hotmail', - 'live': 'Outlook', - 'msn': 'MSN', - 'yahoo': 'Yahoo', - 'icloud': 'iCloud', - 'me': 'iCloud', - 'mac': 'iCloud', - 'proton': 'Proton', - 'protonmail': 'Proton', - 'tutanota': 'Tutanota', - 'tuta': 'Tuta', - 'zoho': 'Zoho', - 'aol': 'AOL', - 'yandex': 'Yandex', - 'gmx': 'GMX', - 'fastmail': 'FastMail', - 'hey': 'HEY', - }; - - static String? _resolvePhoneCountry(String phone) { - if (!phone.trimLeft().startsWith('+')) return null; - final digits = phone.replaceAll(RegExp(r'\D'), ''); - for (final len in [3, 2, 1]) { - if (digits.length >= len) { - final country = _phoneCountries[digits.substring(0, len)]; - if (country != null) return country; - } - } - return null; - } - - static String? _resolveEmailProvider(String email) { - final at = email.indexOf('@'); - if (at == -1 || at >= email.length - 1) return null; - final domain = email.substring(at + 1).toLowerCase(); - final prefix = domain.split('.').first; - return _emailPrefixes[prefix] ?? domain; - } - - String _formatTimestamp(DateTime dt, AppLocalizations l) { - final now = DateTime.now(); - final diff = now.difference(dt); - - if (diff.inMinutes < 1) return l.timeNow; - if (diff.inMinutes < 60) return '${diff.inMinutes}m'; - if (diff.inHours < 24) return '${diff.inHours}h'; - if (diff.inDays < 7) return '${diff.inDays}d'; - return '${dt.month}/${dt.day}'; - } -} - -class _CardActionButton extends StatelessWidget { - const _CardActionButton({ - required this.icon, - required this.onTap, - this.tooltip, - this.isDanger = false, - }); - - final IconData icon; - final VoidCallback onTap; - final String? tooltip; - final bool isDanger; - - @override - Widget build(BuildContext context) { - final theme = CopyPasteTheme.of(context); - final colors = CopyPasteTheme.colorsOf(context); - final isDark = Theme.of(context).brightness == Brightness.dark; - - final bg = isDark - ? colors.surfaceVariant - : Colors.white.withValues(alpha: 0.95); - - final button = SizedBox( - width: 30, - height: 30, - child: Material( - color: bg, - borderRadius: BorderRadius.circular(theme.radii.button), - child: InkWell( - onTap: onTap, - canRequestFocus: false, - borderRadius: BorderRadius.circular(theme.radii.button), - hoverColor: isDanger - ? colors.danger.withValues(alpha: 0.08) - : colors.onSurface.withValues(alpha: 0.06), - splashColor: isDanger - ? colors.danger.withValues(alpha: 0.15) - : colors.onSurface.withValues(alpha: 0.1), - child: Center( - child: Icon( - icon, - size: 13, - color: isDanger - ? colors.danger.withValues(alpha: 0.7) - : colors.onSurface.withValues(alpha: 0.5), - ), - ), - ), - ), - ); - - if (tooltip != null) { - return Tooltip( - message: tooltip!, - textStyle: const TextStyle(fontSize: 10, color: Colors.white), - decoration: BoxDecoration( - color: Colors.black.withValues(alpha: 0.75), - borderRadius: BorderRadius.circular(4), - ), - preferBelow: false, - verticalOffset: 16, - waitDuration: const Duration(milliseconds: 400), - child: button, - ); - } - return button; - } -} - -class _MediaIcon extends StatelessWidget { - const _MediaIcon({ - required this.isAudio, - required this.typeColor, - required this.radius, - }); - - final bool isAudio; - final Color typeColor; - final double radius; - - @override - Widget build(BuildContext context) { - return Container( - width: 44, - height: 44, - decoration: BoxDecoration( - color: typeColor.withValues(alpha: 0.12), - borderRadius: BorderRadius.circular(radius), - ), - child: Center( - child: Icon( - isAudio - ? Icons.music_note_rounded - : Icons.play_circle_outline_rounded, - size: 22, - color: typeColor, - ), - ), - ); - } -} - -class _ColorBadge extends StatelessWidget { - const _ColorBadge({required this.value}); - - final String value; - - static Color? _parse(String value) { - final hex = value.startsWith('#') ? value.substring(1) : null; - if (hex == null) return null; - final normalized = switch (hex.length) { - 3 => 'FF${hex[0]}${hex[0]}${hex[1]}${hex[1]}${hex[2]}${hex[2]}', - 6 => 'FF$hex', - 8 => hex, - _ => null, - }; - if (normalized == null) return null; - final int? v = int.tryParse(normalized, radix: 16); - return v != null ? Color(v) : null; - } - - static String _format(String value) { - final v = value.trimLeft().toLowerCase(); - if (v.startsWith('#')) return 'HEX'; - if (v.startsWith('rgba')) return 'RGBA'; - if (v.startsWith('rgb')) return 'RGB'; - if (v.startsWith('hsla')) return 'HSLA'; - if (v.startsWith('hsl')) return 'HSL'; - return 'COLOR'; - } - - @override - Widget build(BuildContext context) { - final theme = CopyPasteTheme.of(context); - final colors = CopyPasteTheme.colorsOf(context); - final color = _parse(value); - final label = _format(value); - - if (color == null) { - return _ExtBadge(label: label, color: colors.accentOrange); - } - - final onColor = color.computeLuminance() > 0.4 - ? Colors.black.withValues(alpha: 0.75) - : Colors.white.withValues(alpha: 0.9); - - return Container( - padding: const EdgeInsets.symmetric(horizontal: 5, vertical: 2), - decoration: BoxDecoration( - color: color, - borderRadius: BorderRadius.circular(4), - ), - child: Text( - label, - style: theme.typography.cardFooter.copyWith( - fontSize: 9, - fontWeight: FontWeight.w600, - color: onColor, - letterSpacing: 0.3, - ), - ), - ); - } -} - -class _ExtBadge extends StatelessWidget { - const _ExtBadge({required this.label, required this.color}); - - final String label; - final Color color; - - @override - Widget build(BuildContext context) { - return ConstrainedBox( - constraints: const BoxConstraints(maxWidth: 120), - child: Container( - padding: const EdgeInsets.symmetric(horizontal: 5, vertical: 2), - decoration: BoxDecoration( - color: color.withValues(alpha: 0.12), - borderRadius: BorderRadius.circular(4), - ), - child: Text( - label, - maxLines: 1, - overflow: TextOverflow.ellipsis, - style: TextStyle( - fontSize: 9, - fontWeight: FontWeight.w600, - color: color.withValues(alpha: 0.85), - letterSpacing: 0.3, - ), - ), - ), - ); - } -} - -class _FooterChip extends StatelessWidget { - const _FooterChip({ - required this.icon, - required this.label, - required this.style, - required this.iconColor, - required this.iconSize, - }); - - final IconData icon; - final String label; - final TextStyle style; - final Color iconColor; - final double iconSize; - - @override - Widget build(BuildContext context) { - return Row( - mainAxisSize: MainAxisSize.min, - children: [ - Icon(icon, size: iconSize, color: iconColor), - const SizedBox(width: 3), - Flexible( - child: Text( - label, - style: style, - maxLines: 1, - overflow: TextOverflow.ellipsis, - ), - ), - ], - ); - } -} - -enum _ContextAction { paste, pastePlain, pin, edit, delete } - -class _ContextMenuItem extends StatelessWidget { - const _ContextMenuItem({ - required this.icon, - required this.label, - required this.colors, - this.danger = false, - }); - - final IconData icon; - final String label; - final AppThemeColorScheme colors; - final bool danger; - - @override - Widget build(BuildContext context) { - final color = danger ? colors.danger : colors.onSurface; - return Row( - children: [ - Icon(icon, size: 13, color: color.withValues(alpha: 0.7)), - const SizedBox(width: 8), - Expanded( - child: Text(label, style: TextStyle(fontSize: 12, color: color)), - ), - ], - ); - } -} +import 'dart:convert'; +import 'dart:io'; + +import 'package:core/core.dart'; +import 'package:flutter/gestures.dart'; +import 'package:flutter/material.dart'; +import '../l10n/app_localizations.dart'; +import '../theme/app_theme_data.dart'; +import '../theme/theme_provider.dart'; +import 'label_color_dialog.dart'; + +class ClipboardCard extends StatefulWidget { + const ClipboardCard({ + required this.item, + required this.onTap, + required this.onPin, + required this.onDelete, + required this.onLabelColor, + this.onPastePlain, + this.onExpandToggle, + this.onOpen, + this.onSelect, + this.onRequestThumbnailRefresh, + this.isSelected = false, + this.isExpanded = false, + this.cardMinLines, + this.cardMaxLines, + super.key, + }); + + final ClipboardItem item; + final VoidCallback onTap; + final VoidCallback onPin; + final VoidCallback onDelete; + final void Function(String? label, CardColor color) onLabelColor; + final VoidCallback? onPastePlain; + final VoidCallback? onExpandToggle; + final VoidCallback? onOpen; + final VoidCallback? onSelect; + + /// Invoked once per resolved image item to let the host trigger + /// background regeneration of `_thumb.png` when the source file's + /// `mtime` no longer matches `item.sourceModifiedAt`. + final void Function(ClipboardItem item)? onRequestThumbnailRefresh; + final bool isSelected; + final bool isExpanded; + final int? cardMinLines; + final int? cardMaxLines; + + @override + State createState() => _ClipboardCardState(); +} + +class _ClipboardCardState extends State { + bool _hovering = false; + String? _resolvedImagePath; + bool _resolvedIsThumb = false; + bool _imagePathResolved = false; + DateTime? _lastPrimaryDown; + bool _isTextOverflowing = false; + + static const _doubleTapTimeout = Duration(milliseconds: 300); + + void _handlePointerDown(PointerDownEvent event) { + if (event.buttons != kPrimaryButton) return; + widget.onSelect?.call(); + final now = DateTime.now(); + if (_lastPrimaryDown != null && + now.difference(_lastPrimaryDown!) < _doubleTapTimeout) { + _lastPrimaryDown = null; + widget.onTap(); + } else { + _lastPrimaryDown = now; + } + } + + @override + void initState() { + super.initState(); + _resolveImagePath(); + } + + @override + void didUpdateWidget(ClipboardCard oldWidget) { + super.didUpdateWidget(oldWidget); + if (oldWidget.item.id != widget.item.id || + oldWidget.item.content != widget.item.content || + oldWidget.item.thumbPath != widget.item.thumbPath || + oldWidget.item.metadata != widget.item.metadata) { + _imagePathResolved = false; + _resolvedIsThumb = false; + _resolveImagePath(); + } + } + + bool _needsExpandToggle(ClipboardItem item) { + if (widget.isExpanded) return true; + final type = item.type; + if (type == ClipboardContentType.text || + type == ClipboardContentType.unknown || + type == ClipboardContentType.json) { + return _isTextOverflowing; + } + return false; + } + + bool _needsOpenAction(ClipboardItem item) { + return switch (item.type) { + ClipboardContentType.image => + _imagePathResolved && + _resolvedImagePath != null && + _imageSourceExists(item), + ClipboardContentType.file || + ClipboardContentType.folder || + ClipboardContentType.audio || + ClipboardContentType.video => item.isFileAvailable(), + ClipboardContentType.link || + ClipboardContentType.email || + ClipboardContentType.phone => true, + _ => false, + }; + } + + bool _imageSourceExists(ClipboardItem item) { + final path = item.content.trim(); + if (path.isEmpty) return false; + return File(path).existsSync(); + } + + void _resolveImagePath() { + final item = widget.item; + final isImage = item.type == ClipboardContentType.image; + final isMedia = + item.type == ClipboardContentType.video || + item.type == ClipboardContentType.audio; + if (!isImage && !isMedia) { + return; + } + if (isImage) { + // Always ask the host to refresh the thumb if the source mtime is + // stale. The host is responsible for deciding (and rate-limiting). + widget.onRequestThumbnailRefresh?.call(item); + } + _checkImagePathsAsync(item, allowContentFallback: isImage); + } + + /// Resolves the best path to display for an image item: prefers + /// `item.thumbPath` (when present and the file exists), falls back to + /// `item.content`, finally null. + /// + /// When [allowContentFallback] is false (video / audio items) the + /// content path is never used as a fallback because it points to the + /// external media file, not a renderable image. + Future _checkImagePathsAsync( + ClipboardItem item, { + bool allowContentFallback = true, + }) async { + final thumb = item.thumbPath; + if (thumb != null && thumb.isNotEmpty) { + if (await File(thumb).exists()) { + if (!mounted) return; + setState(() { + _resolvedImagePath = thumb; + _resolvedIsThumb = true; + _imagePathResolved = true; + }); + return; + } + } + + if (!allowContentFallback) { + if (!mounted) return; + setState(() { + _resolvedImagePath = null; + _resolvedIsThumb = false; + _imagePathResolved = true; + }); + return; + } + + final content = item.content; + if (content.isEmpty) { + if (!mounted) return; + setState(() { + _resolvedImagePath = null; + _resolvedIsThumb = false; + _imagePathResolved = true; + }); + return; + } + final exists = await File(content).exists(); + if (!mounted) return; + setState(() { + _resolvedImagePath = exists ? content : null; + _resolvedIsThumb = false; + _imagePathResolved = true; + }); + } + + Future _editLabelColor(BuildContext context) async { + final result = await LabelColorDialog.show( + context, + currentLabel: widget.item.label, + currentColor: widget.item.cardColor, + ); + if (result != null && mounted) { + widget.onLabelColor(result.label, result.color); + } + } + + bool get _isPlainPasteable => + widget.item.type == ClipboardContentType.text || + widget.item.type == ClipboardContentType.link; + + Future _showContextMenu(BuildContext ctx, Offset position) async { + final size = MediaQuery.of(ctx).size; + final item = widget.item; + final colors = CopyPasteTheme.colorsOf(ctx); + final isDark = Theme.of(ctx).brightness == Brightness.dark; + final l = AppLocalizations.of(ctx); + final action = await showMenu<_ContextAction>( + context: ctx, + position: RelativeRect.fromLTRB( + position.dx, + position.dy, + size.width - position.dx, + size.height - position.dy, + ), + elevation: 8, + color: isDark ? colors.surfaceVariant : colors.cardBackground, + shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(8)), + items: [ + PopupMenuItem( + value: _ContextAction.paste, + height: 32, + child: _ContextMenuItem( + icon: Icons.content_paste_rounded, + label: l.menuPaste, + colors: colors, + ), + ), + if (_isPlainPasteable && widget.onPastePlain != null) + PopupMenuItem( + value: _ContextAction.pastePlain, + height: 32, + child: _ContextMenuItem( + icon: Icons.format_clear_rounded, + label: l.menuPastePlain, + colors: colors, + ), + ), + const PopupMenuDivider(height: 1), + PopupMenuItem( + value: _ContextAction.pin, + height: 32, + child: _ContextMenuItem( + icon: item.isPinned + ? Icons.push_pin_rounded + : Icons.push_pin_outlined, + label: item.isPinned ? l.menuUnpin : l.menuPin, + colors: colors, + ), + ), + PopupMenuItem( + value: _ContextAction.edit, + height: 32, + child: _ContextMenuItem( + icon: Icons.edit_rounded, + label: l.menuEdit, + colors: colors, + ), + ), + const PopupMenuDivider(height: 1), + PopupMenuItem( + value: _ContextAction.delete, + height: 32, + child: _ContextMenuItem( + icon: Icons.delete_rounded, + label: l.menuDelete, + colors: colors, + danger: true, + ), + ), + ], + ); + if (!mounted) return; + switch (action) { + case _ContextAction.paste: + widget.onTap(); + case _ContextAction.pastePlain: + widget.onPastePlain?.call(); + case _ContextAction.pin: + widget.onPin(); + case _ContextAction.edit: + await _editLabelColor(context); + case _ContextAction.delete: + widget.onDelete(); + case null: + break; + } + } + + @override + Widget build(BuildContext context) { + final theme = CopyPasteTheme.of(context); + final colors = CopyPasteTheme.colorsOf(context); + final isDark = Theme.of(context).brightness == Brightness.dark; + final item = widget.item; + final accentColor = colors.accentForIndex(item.cardColor.value); + final hasColor = item.cardColor != CardColor.none; + + return MouseRegion( + onEnter: (_) => setState(() => _hovering = true), + onExit: (_) => setState(() => _hovering = false), + child: Listener( + onPointerDown: _handlePointerDown, + child: GestureDetector( + onSecondaryTapUp: (d) => _showContextMenu(context, d.globalPosition), + child: AnimatedContainer( + duration: const Duration(milliseconds: 150), + curve: Curves.easeOut, + constraints: BoxConstraints(minHeight: theme.sizing.cardMinHeight), + transform: _hovering ? Matrix4.translationValues(0, -1, 0) : null, + decoration: BoxDecoration( + color: _hovering && isDark + ? colors.surfaceVariant + : colors.cardBackground, + borderRadius: BorderRadius.circular(theme.radii.card), + border: Border.all( + color: widget.isSelected + ? colors.primary.withValues(alpha: 0.5) + : _hovering + ? colors.onSurface.withValues(alpha: isDark ? 0.1 : 0.18) + : colors.cardBorder, + width: theme.cardStyle.borderWidth, + ), + boxShadow: [ + if (widget.isSelected) + BoxShadow( + color: colors.primary.withValues(alpha: 0.2), + blurRadius: 8, + spreadRadius: 1, + ), + if (isDark) + BoxShadow( + color: Colors.black.withValues( + alpha: _hovering ? 0.3 : 0.2, + ), + blurRadius: _hovering ? 12 : 6, + offset: Offset(0, _hovering ? 3 : 1), + ) + else + BoxShadow( + color: Colors.black.withValues( + alpha: _hovering ? 0.1 : 0.07, + ), + blurRadius: _hovering ? 10 : 4, + offset: Offset(0, _hovering ? 3 : 1), + ), + ], + ), + child: Stack( + children: [ + if (hasColor) + Positioned( + left: 0, + top: 0, + bottom: 0, + child: Container( + width: theme.sizing.colorIndicatorWidth, + decoration: BoxDecoration( + color: accentColor, + borderRadius: + theme.cardStyle.colorIndicatorBorderRadius, + ), + ), + ), + Padding( + padding: theme.spacing.cardPadding.copyWith( + left: hasColor + ? theme.spacing.cardPadding.left + + theme.sizing.colorIndicatorWidth + + 2 + : theme.spacing.cardPadding.left, + ), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + mainAxisSize: MainAxisSize.min, + children: [ + _buildHeader(theme, colors, item), + const SizedBox(height: 4), + _buildContent(theme, colors, item), + if (_hasFooter(item)) ...[ + const SizedBox(height: 6), + _buildFooter(theme, colors, item), + ], + ], + ), + ), + ], + ), + ), + ), + ), + ); + } + + Widget _buildHeader( + AppThemeData theme, + AppThemeColorScheme colors, + ClipboardItem item, + ) { + final l = AppLocalizations.of(context); + final typeColor = _typeColor(item.type, colors); + final iconSize = theme.sizing.cardTypeIconContainerSize; + + final isDark = Theme.of(context).brightness == Brightness.dark; + final iconBgAlpha = isDark ? 0.2 : 0.13; + + return Stack( + children: [ + Row( + crossAxisAlignment: CrossAxisAlignment.center, + children: [ + Container( + width: iconSize, + height: iconSize, + decoration: BoxDecoration( + color: typeColor.withValues(alpha: iconBgAlpha), + borderRadius: BorderRadius.circular(8), + ), + child: Center( + child: Icon( + theme.icons.forContentType(item.type.value), + size: 16, + color: typeColor, + ), + ), + ), + const SizedBox(width: 10), + Expanded( + child: Padding( + padding: const EdgeInsets.only(right: 40), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + mainAxisSize: MainAxisSize.min, + children: [ + Text( + item.label ?? _contentTypeName(item.type, l), + style: theme.typography.cardLabel.copyWith( + color: item.label != null + ? typeColor.withValues(alpha: 0.85) + : colors.onSurface.withValues( + alpha: theme.cardStyle.headerOpacity, + ), + letterSpacing: 0.06, + ), + maxLines: 1, + overflow: TextOverflow.ellipsis, + ), + if (item.appSource != null && + item.type != ClipboardContentType.color) ...[ + const SizedBox(height: 1), + Text( + '· ${item.appSource!}', + style: theme.typography.cardFooter.copyWith( + color: colors.onSurface.withValues( + alpha: theme.cardStyle.appSourceOpacity, + ), + fontSize: 10, + letterSpacing: 0.2, + ), + maxLines: 1, + overflow: TextOverflow.ellipsis, + ), + ], + ], + ), + ), + ), + ], + ), + Positioned( + right: 0, + top: 0, + bottom: 0, + child: Align( + alignment: Alignment.centerRight, + child: Stack( + alignment: Alignment.centerRight, + children: [ + AnimatedOpacity( + opacity: _hovering ? 0.0 : 1.0, + duration: const Duration(milliseconds: 120), + child: Row( + mainAxisSize: MainAxisSize.min, + children: [ + if (item.isPinned) + Padding( + padding: const EdgeInsets.only(right: 4), + child: Icon( + theme.icons.pinFilled, + size: theme.sizing.iconSizeXs, + color: colors.primary.withValues(alpha: 0.5), + ), + ), + Text( + _formatTimestamp(item.modifiedAt, l), + style: theme.typography.cardTimestamp.copyWith( + color: colors.onSurface.withValues( + alpha: theme.cardStyle.timestampOpacity, + ), + ), + ), + ], + ), + ), + IgnorePointer( + ignoring: !_hovering, + child: AnimatedOpacity( + opacity: _hovering ? 1.0 : 0.0, + duration: const Duration(milliseconds: 120), + child: Row( + mainAxisSize: MainAxisSize.min, + children: [ + _CardActionButton( + icon: theme.icons.paste, + tooltip: l.menuPaste, + onTap: widget.onTap, + ), + const SizedBox(width: 3), + if (_isPlainPasteable && + widget.onPastePlain != null) ...[ + _CardActionButton( + icon: Icons.notes_rounded, + tooltip: l.menuPastePlain, + onTap: widget.onPastePlain!, + ), + const SizedBox(width: 3), + ], + _CardActionButton( + icon: theme.icons.edit, + tooltip: l.menuEdit, + onTap: () => _editLabelColor(context), + ), + const SizedBox(width: 3), + _CardActionButton( + icon: item.isPinned + ? theme.icons.pinFilled + : theme.icons.pin, + tooltip: item.isPinned ? l.menuUnpin : l.menuPin, + onTap: widget.onPin, + ), + const SizedBox(width: 3), + _CardActionButton( + icon: theme.icons.delete, + tooltip: l.menuDelete, + onTap: widget.onDelete, + isDanger: true, + ), + ], + ), + ), + ), + ], + ), + ), + ), + ], + ); + } + + Widget _buildContent( + AppThemeData theme, + AppThemeColorScheme colors, + ClipboardItem item, + ) { + switch (item.type) { + case ClipboardContentType.image: + return _buildImageContent(theme, colors, item); + case ClipboardContentType.audio: + return _buildMediaContent(theme, colors, item); + case ClipboardContentType.video: + return _buildMediaContent(theme, colors, item); + case ClipboardContentType.file: + case ClipboardContentType.folder: + return _buildFileContent(theme, colors, item); + case ClipboardContentType.link: + return _buildLinkContent(theme, colors, item); + case ClipboardContentType.text: + case ClipboardContentType.unknown: + case ClipboardContentType.email: + case ClipboardContentType.phone: + case ClipboardContentType.ip: + case ClipboardContentType.uuid: + case ClipboardContentType.json: + return _buildTextContent(theme, colors, item); + case ClipboardContentType.color: + return _buildColorContent(theme, colors, item); + } + } + + Widget _buildTextContent( + AppThemeData theme, + AppThemeColorScheme colors, + ClipboardItem item, + ) { + final minLines = widget.cardMinLines ?? theme.sizing.cardMinLines; + final displayMaxLines = widget.isExpanded + ? (widget.cardMaxLines ?? theme.sizing.cardMaxLines) + : minLines; + final textStyle = theme.typography.cardContent.copyWith( + color: colors.onSurface.withValues(alpha: theme.cardStyle.contentOpacity), + ); + + return LayoutBuilder( + builder: (context, constraints) { + final tp = TextPainter( + text: TextSpan(text: item.content, style: textStyle), + maxLines: minLines, + textDirection: Directionality.of(context), + )..layout(maxWidth: constraints.maxWidth); + final overflows = tp.didExceedMaxLines; + tp.dispose(); + if (overflows != _isTextOverflowing) { + _isTextOverflowing = overflows; + WidgetsBinding.instance.addPostFrameCallback((_) { + if (mounted) setState(() {}); + }); + } + + return Text( + item.content, + style: textStyle, + maxLines: displayMaxLines, + overflow: TextOverflow.ellipsis, + ); + }, + ); + } + + Widget _buildColorContent( + AppThemeData theme, + AppThemeColorScheme colors, + ClipboardItem item, + ) { + return Text( + item.content, + style: theme.typography.cardContent.copyWith( + color: colors.onSurface.withValues( + alpha: theme.cardStyle.contentOpacity, + ), + ), + ); + } + + Widget _buildImageContent( + AppThemeData theme, + AppThemeColorScheme colors, + ClipboardItem item, + ) { + if (!_imagePathResolved) { + return Container( + height: theme.sizing.cardImageHeight, + decoration: BoxDecoration( + color: colors.surfaceVariant, + borderRadius: BorderRadius.circular(theme.radii.thumbnail), + ), + ); + } + + final l10n = AppLocalizations.of(context); + final contentPath = item.content.trim(); + final filename = contentPath.isEmpty + ? '' + : contentPath.split(Platform.pathSeparator).last; + + // File is known to be missing: show explicit warning instead of + // letting Image.file fail silently via errorBuilder. + if (_resolvedImagePath == null) { + return Semantics( + label: filename.isEmpty + ? l10n.imageFile + : '${l10n.imageFile}: $filename, ${l10n.fileNotFound}', + child: Container( + height: theme.sizing.cardImageHeight, + decoration: BoxDecoration( + color: colors.surfaceVariant, + borderRadius: BorderRadius.circular(theme.radii.thumbnail), + ), + child: contentPath.isEmpty + ? Center( + child: Icon( + theme.icons.image, + size: theme.sizing.iconSizeLg, + color: colors.onSurfaceMuted, + ), + ) + : Center( + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + Icon( + theme.icons.warning, + size: theme.sizing.iconSizeLg, + color: colors.warning, + ), + const SizedBox(height: 4), + _ExtBadge( + label: l10n.fileNotFound, + color: colors.warning, + ), + ], + ), + ), + ), + ); + } + + return Semantics( + label: filename.isEmpty + ? l10n.imageFile + : (!_imageSourceExists(item) + ? '${l10n.imageFile}: $filename, ${l10n.fileNotFound}' + : '${l10n.imageFile}: $filename'), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + ClipRRect( + borderRadius: BorderRadius.circular(theme.radii.thumbnail), + child: Container( + height: theme.sizing.cardImageHeight, + width: double.infinity, + color: colors.surfaceVariant, + child: Image.file( + File(_resolvedImagePath!), + fit: BoxFit.cover, + cacheWidth: _resolvedIsThumb ? 256 : 700, + errorBuilder: (_, e, s) => Center( + child: Icon( + theme.icons.warning, + color: colors.warning, + size: theme.sizing.iconSizeLg, + ), + ), + ), + ), + ), + if (!_imageSourceExists(item)) ...[ + const SizedBox(height: 4), + Row( + mainAxisSize: MainAxisSize.min, + children: [ + _ExtBadge(label: l10n.fileNotFound, color: colors.warning), + ], + ), + ], + ], + ), + ); + } + + Widget _buildFileContent( + AppThemeData theme, + AppThemeColorScheme colors, + ClipboardItem item, + ) { + final files = item.content.split('\n').where((s) => s.isNotEmpty).toList(); + final available = item.isFileAvailable(); + final firstName = files.isEmpty + ? '' + : files.first.split(Platform.pathSeparator).last; + + final semanticsLabel = [ + if (firstName.isNotEmpty) firstName else item.content, + if (!available) AppLocalizations.of(context).fileNotFound, + ].join(', '); + + return Semantics( + label: semanticsLabel, + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + firstName.isEmpty ? item.content : firstName, + style: theme.typography.cardContent.copyWith( + color: colors.onSurface.withValues( + alpha: theme.cardStyle.contentOpacity, + ), + ), + maxLines: 1, + overflow: TextOverflow.ellipsis, + ), + if (files.length > 1 || !available) ...[ + const SizedBox(height: 4), + Row( + mainAxisSize: MainAxisSize.min, + children: [ + if (files.length > 1) ...[ + _ExtBadge( + label: '+${files.length - 1}', + color: colors.onSurfaceMuted, + ), + ], + if (!available) ...[ + if (files.length > 1) const SizedBox(width: 4), + _ExtBadge( + label: AppLocalizations.of(context).fileNotFound, + color: colors.warning, + ), + ], + ], + ), + ], + ], + ), + ); + } + + Widget _buildMediaContent( + AppThemeData theme, + AppThemeColorScheme colors, + ClipboardItem item, + ) { + final path = item.content.trim(); + final filename = path.isEmpty + ? '' + : path.split(Platform.pathSeparator).last; + final isAudio = item.type == ClipboardContentType.audio; + final typeColor = _typeColor(item.type, colors); + final l10n = AppLocalizations.of(context); + final typeName = isAudio ? l10n.audioFile : l10n.videoFile; + final missing = !item.isFileAvailable(); + + final semanticsLabel = [ + filename.isEmpty ? typeName : filename, + if (missing) l10n.fileNotFound, + ].join(', '); + + final hasThumb = _imagePathResolved && _resolvedImagePath != null; + + if (!isAudio && hasThumb) { + return Semantics( + label: semanticsLabel, + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + ClipRRect( + borderRadius: BorderRadius.circular(theme.radii.thumbnail), + child: Container( + height: theme.sizing.cardImageHeight, + width: double.infinity, + color: colors.surfaceVariant, + child: Stack( + fit: StackFit.expand, + children: [ + Image.file( + File(_resolvedImagePath!), + fit: BoxFit.contain, + cacheWidth: _resolvedIsThumb ? 256 : 700, + errorBuilder: (_, e, st) => _MediaIcon( + isAudio: false, + typeColor: typeColor, + radius: theme.radii.thumbnail, + ), + ), + Center( + child: Container( + width: 28, + height: 28, + decoration: BoxDecoration( + color: Colors.black.withValues(alpha: 0.45), + shape: BoxShape.circle, + ), + child: const Center( + child: Icon( + Icons.play_arrow_rounded, + size: 16, + color: Colors.white, + ), + ), + ), + ), + ], + ), + ), + ), + const SizedBox(height: 4), + Text( + filename.isEmpty ? l10n.videoFile : filename, + style: theme.typography.cardContent.copyWith( + color: colors.onSurface.withValues( + alpha: theme.cardStyle.contentOpacity, + ), + fontSize: 11, + ), + maxLines: 1, + overflow: TextOverflow.ellipsis, + ), + if (missing) ...[ + const SizedBox(height: 4), + Row( + mainAxisSize: MainAxisSize.min, + children: [ + _ExtBadge(label: l10n.fileNotFound, color: colors.warning), + ], + ), + ], + ], + ), + ); + } + + return Semantics( + label: semanticsLabel, + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + filename.isEmpty ? typeName : filename, + style: theme.typography.cardContent.copyWith( + color: colors.onSurface.withValues( + alpha: theme.cardStyle.contentOpacity, + ), + ), + maxLines: 1, + overflow: TextOverflow.ellipsis, + ), + if (missing) ...[ + const SizedBox(height: 4), + Row( + mainAxisSize: MainAxisSize.min, + children: [ + _ExtBadge(label: l10n.fileNotFound, color: colors.warning), + ], + ), + ], + ], + ), + ); + } + + Widget _buildLinkContent( + AppThemeData theme, + AppThemeColorScheme colors, + ClipboardItem item, + ) { + final uri = Uri.tryParse(item.content.trim()); + final domain = uri?.host ?? ''; + final typeColor = _typeColor(item.type, colors); + + return Row( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + item.content.trim(), + style: theme.typography.cardContent.copyWith( + color: colors.primary.withValues(alpha: 0.85), + decoration: TextDecoration.underline, + decorationColor: colors.primary.withValues(alpha: 0.3), + ), + maxLines: 1, + overflow: TextOverflow.ellipsis, + ), + if (domain.isNotEmpty) ...[ + const SizedBox(height: 3), + Row( + mainAxisSize: MainAxisSize.min, + children: [_ExtBadge(label: domain, color: typeColor)], + ), + ], + ], + ), + ), + ], + ); + } + + Map? _parseMetadata(ClipboardItem item) { + if (item.metadata == null || item.metadata!.isEmpty) return null; + try { + return json.decode(item.metadata!) as Map; + } catch (_) { + return null; + } + } + + String _getExtForItem(ClipboardItem item) { + if (item.type != ClipboardContentType.file && + item.type != ClipboardContentType.folder && + item.type != ClipboardContentType.audio && + item.type != ClipboardContentType.video && + item.type != ClipboardContentType.image) { + return ''; + } + final lines = item.content.split('\n').where((s) => s.isNotEmpty).toList(); + if (lines.isEmpty) return ''; + final firstName = lines.first.split(Platform.pathSeparator).last; + return firstName.contains('.') + ? firstName.split('.').last.toUpperCase() + : ''; + } + + bool _hasFooter(ClipboardItem item) { + if (_needsExpandToggle(item)) return true; + if (_needsOpenAction(item)) return true; + if (item.pasteCount > 0) return true; + if (_getExtForItem(item).isNotEmpty) return true; + final meta = _parseMetadata(item); + if (meta == null) return false; + return meta.containsKey('file_size') || + meta.containsKey('size') || + meta.containsKey('width') || + meta.containsKey('video_width') || + meta.containsKey('duration'); + } + + Widget _buildFooter( + AppThemeData theme, + AppThemeColorScheme colors, + ClipboardItem item, + ) { + final meta = _parseMetadata(item); + final footerAlpha = theme.cardStyle.footerOpacity; + final footerColor = colors.onSurface.withValues(alpha: footerAlpha); + final footerStyle = theme.typography.cardFooter.copyWith( + color: footerColor, + ); + final iconColor = colors.onSurface.withValues(alpha: footerAlpha - 0.1); + + final ext = _getExtForItem(item); + final typeColor = _typeColor(item.type, colors); + final widgets = []; + + final w = meta?['width'] ?? meta?['video_width']; + final h = meta?['height'] ?? meta?['video_height']; + if (w != null && h != null) { + widgets.add( + _FooterChip( + icon: Icons.aspect_ratio_rounded, + label: '$w×$h', + style: footerStyle, + iconColor: iconColor, + iconSize: theme.sizing.iconSizeXs, + ), + ); + } + + final fileSize = meta?['file_size'] ?? meta?['size']; + if (fileSize != null && fileSize is num && fileSize > 0) { + widgets.add( + _FooterChip( + icon: Icons.storage_rounded, + label: _formatFileSize(fileSize.toInt()), + style: footerStyle, + iconColor: iconColor, + iconSize: theme.sizing.iconSizeXs, + ), + ); + } + + final duration = meta?['duration']; + if (duration != null && duration is num && duration > 0) { + widgets.add( + _FooterChip( + icon: Icons.timer_outlined, + label: _formatDuration(duration.toInt()), + style: footerStyle, + iconColor: iconColor, + iconSize: theme.sizing.iconSizeXs, + ), + ); + } + + if (item.pasteCount > 0) { + widgets.add( + Text( + '×${item.pasteCount}', + style: footerStyle, + maxLines: 1, + overflow: TextOverflow.ellipsis, + ), + ); + } + + final showExpand = _needsExpandToggle(item); + final showOpen = !showExpand && _needsOpenAction(item); + + return Row( + children: [ + if (item.type == ClipboardContentType.color) + _ColorBadge(value: item.content.trim()) + else if (item.type == ClipboardContentType.phone) ...[ + if (_resolvePhoneCountry(item.content) case final c?) + _ExtBadge(label: c, color: typeColor), + ] else if (item.type == ClipboardContentType.email) ...[ + if (_resolveEmailProvider(item.content) case final p?) + _ExtBadge(label: p, color: typeColor), + ] else if (ext.isNotEmpty) + _ExtBadge(label: ext, color: typeColor), + if (showExpand) + Padding( + padding: const EdgeInsets.only(left: 6), + child: Material( + color: Colors.transparent, + child: InkWell( + onTap: () => widget.onExpandToggle?.call(), + canRequestFocus: false, + borderRadius: BorderRadius.circular(8), + hoverColor: colors.onSurface.withValues(alpha: 0.06), + child: Padding( + padding: const EdgeInsets.symmetric( + horizontal: 4, + vertical: 1, + ), + child: Icon( + widget.isExpanded + ? Icons.expand_less_rounded + : Icons.expand_more_rounded, + size: 14, + color: colors.onSurface.withValues(alpha: 0.35), + ), + ), + ), + ), + ), + if (showOpen) + Padding( + padding: EdgeInsets.only(left: ext.isNotEmpty ? 6 : 0), + child: Material( + color: Colors.transparent, + child: InkWell( + onTap: () => widget.onOpen?.call(), + canRequestFocus: false, + borderRadius: BorderRadius.circular(8), + hoverColor: colors.onSurface.withValues(alpha: 0.06), + child: Padding( + padding: const EdgeInsets.symmetric( + horizontal: 4, + vertical: 1, + ), + child: Icon( + Icons.open_in_new_rounded, + size: 14, + color: colors.onSurface.withValues(alpha: 0.35), + ), + ), + ), + ), + ), + if (widgets.isNotEmpty) + Expanded( + child: Row( + mainAxisAlignment: MainAxisAlignment.end, + children: [ + for (int i = 0; i < widgets.length; i++) ...[ + if (i > 0) const SizedBox(width: 8), + Flexible(fit: FlexFit.loose, child: widgets[i]), + ], + ], + ), + ), + ], + ); + } + + static String _formatFileSize(int bytes) { + if (bytes < 1024) return '$bytes B'; + if (bytes < 1024 * 1024) return '${(bytes / 1024).toStringAsFixed(1)} KB'; + if (bytes < 1024 * 1024 * 1024) { + return '${(bytes / (1024 * 1024)).toStringAsFixed(1)} MB'; + } + return '${(bytes / (1024 * 1024 * 1024)).toStringAsFixed(2)} GB'; + } + + static String _formatDuration(int seconds) { + final h = seconds ~/ 3600; + final m = (seconds % 3600) ~/ 60; + final s = seconds % 60; + if (h > 0) { + return '$h:${m.toString().padLeft(2, '0')}:${s.toString().padLeft(2, '0')}'; + } + return '$m:${s.toString().padLeft(2, '0')}'; + } + + Color _typeColor(ClipboardContentType type, AppThemeColorScheme colors) { + final isDark = Theme.of(context).brightness == Brightness.dark; + return switch (type) { + ClipboardContentType.text => colors.accentBlue, + ClipboardContentType.image => colors.accentOrange, + ClipboardContentType.file => colors.accentYellow, + ClipboardContentType.folder => colors.accentYellow, + ClipboardContentType.link => colors.accentGreen, + ClipboardContentType.audio => + isDark ? const Color(0xFF7DD3FC) : const Color(0xFF075985), + ClipboardContentType.video => colors.accentRed, + ClipboardContentType.email => colors.accentBlue, + ClipboardContentType.phone => colors.accentGreen, + ClipboardContentType.color => colors.accentOrange, + ClipboardContentType.ip => + isDark ? const Color(0xFFD4A5F5) : const Color(0xFF6B21A8), + ClipboardContentType.uuid => + isDark ? const Color(0xFF94A3B8) : const Color(0xFF475569), + ClipboardContentType.json => colors.accentYellow, + ClipboardContentType.unknown => colors.onSurfaceMuted, + }; + } + + String _contentTypeName(ClipboardContentType type, AppLocalizations l) => + switch (type) { + ClipboardContentType.text => l.typeText, + ClipboardContentType.image => l.typeImage, + ClipboardContentType.file => l.typeFile, + ClipboardContentType.folder => l.typeFolder, + ClipboardContentType.link => l.typeLink, + ClipboardContentType.audio => l.typeAudio, + ClipboardContentType.video => l.typeVideo, + ClipboardContentType.email => l.typeEmail, + ClipboardContentType.phone => l.typePhone, + ClipboardContentType.color => l.typeColor, + ClipboardContentType.ip => l.typeIp, + ClipboardContentType.uuid => l.typeUuid, + ClipboardContentType.json => l.typeJson, + ClipboardContentType.unknown => 'Unknown', + }; + + static const _phoneCountries = { + '1': 'US/CA', + '7': 'Russia', + '20': 'Egypt', + '27': 'S.Africa', + '30': 'Greece', + '31': 'Netherlands', + '32': 'Belgium', + '33': 'France', + '34': 'Spain', + '36': 'Hungary', + '39': 'Italy', + '40': 'Romania', + '41': 'Switzerland', + '43': 'Austria', + '44': 'UK', + '45': 'Denmark', + '46': 'Sweden', + '47': 'Norway', + '48': 'Poland', + '49': 'Germany', + '51': 'Peru', + '52': 'Mexico', + '53': 'Cuba', + '54': 'Argentina', + '55': 'Brazil', + '56': 'Chile', + '57': 'Colombia', + '58': 'Venezuela', + '60': 'Malaysia', + '61': 'Australia', + '62': 'Indonesia', + '63': 'Philippines', + '64': 'NZ', + '65': 'Singapore', + '66': 'Thailand', + '81': 'Japan', + '82': 'Korea', + '84': 'Vietnam', + '86': 'China', + '90': 'Turkey', + '91': 'India', + '92': 'Pakistan', + '94': 'Sri Lanka', + '98': 'Iran', + '212': 'Morocco', + '213': 'Algeria', + '216': 'Tunisia', + '234': 'Nigeria', + '254': 'Kenya', + '351': 'Portugal', + '352': 'Luxembourg', + '353': 'Ireland', + '354': 'Iceland', + '358': 'Finland', + '380': 'Ukraine', + '381': 'Serbia', + '385': 'Croatia', + '420': 'Czech', + '421': 'Slovakia', + '502': 'Guatemala', + '503': 'El Salvador', + '504': 'Honduras', + '505': 'Nicaragua', + '506': 'Costa Rica', + '507': 'Panama', + '591': 'Bolivia', + '593': 'Ecuador', + '595': 'Paraguay', + '598': 'Uruguay', + '855': 'Cambodia', + '880': 'Bangladesh', + '886': 'Taiwan', + '961': 'Lebanon', + '962': 'Jordan', + '964': 'Iraq', + '965': 'Kuwait', + '966': 'Saudi Arabia', + '971': 'UAE', + '972': 'Israel', + '974': 'Qatar', + '977': 'Nepal', + '994': 'Azerbaijan', + '995': 'Georgia', + '998': 'Uzbekistan', + }; + + // Keyed by first domain label — covers all regional variants automatically. + // e.g. outlook.com / outlook.com.ar / outlook.cl all resolve to 'Outlook' + static const _emailPrefixes = { + 'gmail': 'Gmail', + 'googlemail': 'Gmail', + 'outlook': 'Outlook', + 'hotmail': 'Hotmail', + 'live': 'Outlook', + 'msn': 'MSN', + 'yahoo': 'Yahoo', + 'icloud': 'iCloud', + 'me': 'iCloud', + 'mac': 'iCloud', + 'proton': 'Proton', + 'protonmail': 'Proton', + 'tutanota': 'Tutanota', + 'tuta': 'Tuta', + 'zoho': 'Zoho', + 'aol': 'AOL', + 'yandex': 'Yandex', + 'gmx': 'GMX', + 'fastmail': 'FastMail', + 'hey': 'HEY', + }; + + static String? _resolvePhoneCountry(String phone) { + if (!phone.trimLeft().startsWith('+')) return null; + final digits = phone.replaceAll(RegExp(r'\D'), ''); + for (final len in [3, 2, 1]) { + if (digits.length >= len) { + final country = _phoneCountries[digits.substring(0, len)]; + if (country != null) return country; + } + } + return null; + } + + static String? _resolveEmailProvider(String email) { + final at = email.indexOf('@'); + if (at == -1 || at >= email.length - 1) return null; + final domain = email.substring(at + 1).toLowerCase(); + final prefix = domain.split('.').first; + return _emailPrefixes[prefix] ?? domain; + } + + String _formatTimestamp(DateTime dt, AppLocalizations l) { + final now = DateTime.now(); + final diff = now.difference(dt); + + if (diff.inMinutes < 1) return l.timeNow; + if (diff.inMinutes < 60) return '${diff.inMinutes}m'; + if (diff.inHours < 24) return '${diff.inHours}h'; + if (diff.inDays < 7) return '${diff.inDays}d'; + return '${dt.month}/${dt.day}'; + } +} + +class _CardActionButton extends StatelessWidget { + const _CardActionButton({ + required this.icon, + required this.onTap, + this.tooltip, + this.isDanger = false, + }); + + final IconData icon; + final VoidCallback onTap; + final String? tooltip; + final bool isDanger; + + @override + Widget build(BuildContext context) { + final theme = CopyPasteTheme.of(context); + final colors = CopyPasteTheme.colorsOf(context); + final isDark = Theme.of(context).brightness == Brightness.dark; + + final bg = isDark + ? colors.surfaceVariant + : Colors.white.withValues(alpha: 0.95); + + final button = SizedBox( + width: 30, + height: 30, + child: Material( + color: bg, + borderRadius: BorderRadius.circular(theme.radii.button), + child: InkWell( + onTap: onTap, + canRequestFocus: false, + borderRadius: BorderRadius.circular(theme.radii.button), + hoverColor: isDanger + ? colors.danger.withValues(alpha: 0.08) + : colors.onSurface.withValues(alpha: 0.06), + splashColor: isDanger + ? colors.danger.withValues(alpha: 0.15) + : colors.onSurface.withValues(alpha: 0.1), + child: Center( + child: Icon( + icon, + size: 13, + color: isDanger + ? colors.danger.withValues(alpha: 0.7) + : colors.onSurface.withValues(alpha: 0.5), + ), + ), + ), + ), + ); + + if (tooltip != null) { + return Tooltip( + message: tooltip!, + textStyle: const TextStyle(fontSize: 10, color: Colors.white), + decoration: BoxDecoration( + color: Colors.black.withValues(alpha: 0.75), + borderRadius: BorderRadius.circular(4), + ), + preferBelow: false, + verticalOffset: 16, + waitDuration: const Duration(milliseconds: 400), + child: button, + ); + } + return button; + } +} + +class _MediaIcon extends StatelessWidget { + const _MediaIcon({ + required this.isAudio, + required this.typeColor, + required this.radius, + }); + + final bool isAudio; + final Color typeColor; + final double radius; + + @override + Widget build(BuildContext context) { + return Container( + width: 44, + height: 44, + decoration: BoxDecoration( + color: typeColor.withValues(alpha: 0.12), + borderRadius: BorderRadius.circular(radius), + ), + child: Center( + child: Icon( + isAudio + ? Icons.music_note_rounded + : Icons.play_circle_outline_rounded, + size: 22, + color: typeColor, + ), + ), + ); + } +} + +class _ColorBadge extends StatelessWidget { + const _ColorBadge({required this.value}); + + final String value; + + static Color? _parse(String value) { + final hex = value.startsWith('#') ? value.substring(1) : null; + if (hex == null) return null; + final normalized = switch (hex.length) { + 3 => 'FF${hex[0]}${hex[0]}${hex[1]}${hex[1]}${hex[2]}${hex[2]}', + 6 => 'FF$hex', + 8 => hex, + _ => null, + }; + if (normalized == null) return null; + final int? v = int.tryParse(normalized, radix: 16); + return v != null ? Color(v) : null; + } + + static String _format(String value) { + final v = value.trimLeft().toLowerCase(); + if (v.startsWith('#')) return 'HEX'; + if (v.startsWith('rgba')) return 'RGBA'; + if (v.startsWith('rgb')) return 'RGB'; + if (v.startsWith('hsla')) return 'HSLA'; + if (v.startsWith('hsl')) return 'HSL'; + return 'COLOR'; + } + + @override + Widget build(BuildContext context) { + final theme = CopyPasteTheme.of(context); + final colors = CopyPasteTheme.colorsOf(context); + final color = _parse(value); + final label = _format(value); + + if (color == null) { + return _ExtBadge(label: label, color: colors.accentOrange); + } + + final onColor = color.computeLuminance() > 0.4 + ? Colors.black.withValues(alpha: 0.75) + : Colors.white.withValues(alpha: 0.9); + + return Container( + padding: const EdgeInsets.symmetric(horizontal: 5, vertical: 2), + decoration: BoxDecoration( + color: color, + borderRadius: BorderRadius.circular(4), + ), + child: Text( + label, + style: theme.typography.cardFooter.copyWith( + fontSize: 9, + fontWeight: FontWeight.w600, + color: onColor, + letterSpacing: 0.3, + ), + ), + ); + } +} + +class _ExtBadge extends StatelessWidget { + const _ExtBadge({required this.label, required this.color}); + + final String label; + final Color color; + + @override + Widget build(BuildContext context) { + return ConstrainedBox( + constraints: const BoxConstraints(maxWidth: 120), + child: Container( + padding: const EdgeInsets.symmetric(horizontal: 5, vertical: 2), + decoration: BoxDecoration( + color: color.withValues(alpha: 0.12), + borderRadius: BorderRadius.circular(4), + ), + child: Text( + label, + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: TextStyle( + fontSize: 9, + fontWeight: FontWeight.w600, + color: color.withValues(alpha: 0.85), + letterSpacing: 0.3, + ), + ), + ), + ); + } +} + +class _FooterChip extends StatelessWidget { + const _FooterChip({ + required this.icon, + required this.label, + required this.style, + required this.iconColor, + required this.iconSize, + }); + + final IconData icon; + final String label; + final TextStyle style; + final Color iconColor; + final double iconSize; + + @override + Widget build(BuildContext context) { + return Row( + mainAxisSize: MainAxisSize.min, + children: [ + Icon(icon, size: iconSize, color: iconColor), + const SizedBox(width: 3), + Flexible( + child: Text( + label, + style: style, + maxLines: 1, + overflow: TextOverflow.ellipsis, + ), + ), + ], + ); + } +} + +enum _ContextAction { paste, pastePlain, pin, edit, delete } + +class _ContextMenuItem extends StatelessWidget { + const _ContextMenuItem({ + required this.icon, + required this.label, + required this.colors, + this.danger = false, + }); + + final IconData icon; + final String label; + final AppThemeColorScheme colors; + final bool danger; + + @override + Widget build(BuildContext context) { + final color = danger ? colors.danger : colors.onSurface; + return Row( + children: [ + Icon(icon, size: 13, color: color.withValues(alpha: 0.7)), + const SizedBox(width: 8), + Expanded( + child: Text(label, style: TextStyle(fontSize: 12, color: color)), + ), + ], + ); + } +} diff --git a/listener/linux/listener_plugin.c b/listener/linux/listener_plugin.c index 7c687ec5..9c177731 100644 --- a/listener/linux/listener_plugin.c +++ b/listener/linux/listener_plugin.c @@ -37,7 +37,6 @@ static const gchar* kClipboardChannelName = "copypaste/clipboard"; static const gchar* kClipboardWriterChannelName = "copypaste/clipboard_writer"; -static const guint64 kClipboardDebounceMs = 500; static const guint kClipboardPollIntervalMs = 1500; static const guint kClipboardOwnerDebounceMs = 80; static const guint64 kClipboardWriteIgnoreMs = 700; @@ -502,15 +501,13 @@ static gchar* build_clipboard_signature(GtkClipboard* clipboard) { } static gboolean is_duplicate_change(ListenerPlugin* self, const gchar* hash) { - guint64 now = now_ms(); - if (self->last_content_hash != NULL && g_strcmp0(self->last_content_hash, hash) == 0 && - (now - self->last_change_tick_ms) < kClipboardDebounceMs) { + if (self->last_content_hash != NULL && g_strcmp0(self->last_content_hash, hash) == 0) { return TRUE; } g_free(self->last_content_hash); self->last_content_hash = g_strdup(hash); - self->last_change_tick_ms = now; + self->last_change_tick_ms = now_ms(); return FALSE; } @@ -660,6 +657,10 @@ static void process_clipboard(ListenerPlugin* self) { g_autofree gchar* signature = build_clipboard_signature(clipboard); if (signature == NULL || *signature == '\0') { + if (self->last_content_hash != NULL) { + g_free(self->last_content_hash); + self->last_content_hash = NULL; + } return; } @@ -712,6 +713,10 @@ static void on_owner_change(GtkClipboard* clipboard, if (!self->is_listening) { return; } + if (self->last_content_hash != NULL) { + g_free(self->last_content_hash); + self->last_content_hash = NULL; + } if (self->owner_debounce_timer_id != 0) { g_source_remove(self->owner_debounce_timer_id); self->owner_debounce_timer_id = 0; From 26c0b8e5d1d180f43594a56c10d9e85b4d73ea6c Mon Sep 17 00:00:00 2001 From: rgdevment Date: Fri, 24 Apr 2026 21:02:52 -0400 Subject: [PATCH 22/31] Refactor code structure for improved readability and maintainability --- app/lib/widgets/clipboard_card.dart | 4 - core/lib/repository/sqlite_repository.g.dart | 2594 +++++++++--------- 2 files changed, 1297 insertions(+), 1301 deletions(-) diff --git a/app/lib/widgets/clipboard_card.dart b/app/lib/widgets/clipboard_card.dart index 49b0a063..7ecc4dee 100644 --- a/app/lib/widgets/clipboard_card.dart +++ b/app/lib/widgets/clipboard_card.dart @@ -37,10 +37,6 @@ class ClipboardCard extends StatefulWidget { final VoidCallback? onExpandToggle; final VoidCallback? onOpen; final VoidCallback? onSelect; - - /// Invoked once per resolved image item to let the host trigger - /// background regeneration of `_thumb.png` when the source file's - /// `mtime` no longer matches `item.sourceModifiedAt`. final void Function(ClipboardItem item)? onRequestThumbnailRefresh; final bool isSelected; final bool isExpanded; diff --git a/core/lib/repository/sqlite_repository.g.dart b/core/lib/repository/sqlite_repository.g.dart index cb3672e2..7c286480 100644 --- a/core/lib/repository/sqlite_repository.g.dart +++ b/core/lib/repository/sqlite_repository.g.dart @@ -1,1297 +1,1297 @@ -// GENERATED CODE - DO NOT MODIFY BY HAND - -part of 'sqlite_repository.dart'; - -// ignore_for_file: type=lint -class $ClipboardItemsTable extends ClipboardItems - with TableInfo<$ClipboardItemsTable, ClipboardRow> { - @override - final GeneratedDatabase attachedDatabase; - final String? _alias; - $ClipboardItemsTable(this.attachedDatabase, [this._alias]); - static const VerificationMeta _idMeta = const VerificationMeta('id'); - @override - late final GeneratedColumn id = GeneratedColumn( - 'id', - aliasedName, - false, - type: DriftSqlType.string, - requiredDuringInsert: true, - ); - static const VerificationMeta _contentMeta = const VerificationMeta( - 'content', - ); - @override - late final GeneratedColumn content = GeneratedColumn( - 'content', - aliasedName, - false, - type: DriftSqlType.string, - requiredDuringInsert: true, - ); - static const VerificationMeta _typeMeta = const VerificationMeta('type'); - @override - late final GeneratedColumn type = GeneratedColumn( - 'type', - aliasedName, - false, - type: DriftSqlType.int, - requiredDuringInsert: true, - ); - static const VerificationMeta _createdAtMeta = const VerificationMeta( - 'createdAt', - ); - @override - late final GeneratedColumn createdAt = GeneratedColumn( - 'created_at', - aliasedName, - false, - type: DriftSqlType.dateTime, - requiredDuringInsert: true, - ); - static const VerificationMeta _modifiedAtMeta = const VerificationMeta( - 'modifiedAt', - ); - @override - late final GeneratedColumn modifiedAt = GeneratedColumn( - 'modified_at', - aliasedName, - false, - type: DriftSqlType.dateTime, - requiredDuringInsert: true, - ); - static const VerificationMeta _appSourceMeta = const VerificationMeta( - 'appSource', - ); - @override - late final GeneratedColumn appSource = GeneratedColumn( - 'app_source', - aliasedName, - true, - type: DriftSqlType.string, - requiredDuringInsert: false, - ); - static const VerificationMeta _isPinnedMeta = const VerificationMeta( - 'isPinned', - ); - @override - late final GeneratedColumn isPinned = GeneratedColumn( - 'is_pinned', - aliasedName, - false, - type: DriftSqlType.bool, - requiredDuringInsert: false, - defaultConstraints: GeneratedColumn.constraintIsAlways( - 'CHECK ("is_pinned" IN (0, 1))', - ), - defaultValue: const Constant(false), - ); - static const VerificationMeta _labelMeta = const VerificationMeta('label'); - @override - late final GeneratedColumn label = GeneratedColumn( - 'label', - aliasedName, - true, - type: DriftSqlType.string, - requiredDuringInsert: false, - ); - static const VerificationMeta _cardColorMeta = const VerificationMeta( - 'cardColor', - ); - @override - late final GeneratedColumn cardColor = GeneratedColumn( - 'card_color', - aliasedName, - false, - type: DriftSqlType.int, - requiredDuringInsert: false, - defaultValue: const Constant(0), - ); - static const VerificationMeta _metadataMeta = const VerificationMeta( - 'metadata', - ); - @override - late final GeneratedColumn metadata = GeneratedColumn( - 'metadata', - aliasedName, - true, - type: DriftSqlType.string, - requiredDuringInsert: false, - ); - static const VerificationMeta _pasteCountMeta = const VerificationMeta( - 'pasteCount', - ); - @override - late final GeneratedColumn pasteCount = GeneratedColumn( - 'paste_count', - aliasedName, - false, - type: DriftSqlType.int, - requiredDuringInsert: false, - defaultValue: const Constant(0), - ); - static const VerificationMeta _contentHashMeta = const VerificationMeta( - 'contentHash', - ); - @override - late final GeneratedColumn contentHash = GeneratedColumn( - 'content_hash', - aliasedName, - true, - type: DriftSqlType.string, - requiredDuringInsert: false, - ); - static const VerificationMeta _thumbPathMeta = const VerificationMeta( - 'thumbPath', - ); - @override - late final GeneratedColumn thumbPath = GeneratedColumn( - 'thumb_path', - aliasedName, - true, - type: DriftSqlType.string, - requiredDuringInsert: false, - ); - static const VerificationMeta _sourceModifiedAtMeta = const VerificationMeta( - 'sourceModifiedAt', - ); - @override - late final GeneratedColumn sourceModifiedAt = - GeneratedColumn( - 'source_modified_at', - aliasedName, - true, - type: DriftSqlType.dateTime, - requiredDuringInsert: false, - ); - static const VerificationMeta _brokenSinceMeta = const VerificationMeta( - 'brokenSince', - ); - @override - late final GeneratedColumn brokenSince = GeneratedColumn( - 'broken_since', - aliasedName, - true, - type: DriftSqlType.dateTime, - requiredDuringInsert: false, - ); - @override - List get $columns => [ - id, - content, - type, - createdAt, - modifiedAt, - appSource, - isPinned, - label, - cardColor, - metadata, - pasteCount, - contentHash, - thumbPath, - sourceModifiedAt, - brokenSince, - ]; - @override - String get aliasedName => _alias ?? actualTableName; - @override - String get actualTableName => $name; - static const String $name = 'clipboard_items'; - @override - VerificationContext validateIntegrity( - Insertable instance, { - bool isInserting = false, - }) { - final context = VerificationContext(); - final data = instance.toColumns(true); - if (data.containsKey('id')) { - context.handle(_idMeta, id.isAcceptableOrUnknown(data['id']!, _idMeta)); - } else if (isInserting) { - context.missing(_idMeta); - } - if (data.containsKey('content')) { - context.handle( - _contentMeta, - content.isAcceptableOrUnknown(data['content']!, _contentMeta), - ); - } else if (isInserting) { - context.missing(_contentMeta); - } - if (data.containsKey('type')) { - context.handle( - _typeMeta, - type.isAcceptableOrUnknown(data['type']!, _typeMeta), - ); - } else if (isInserting) { - context.missing(_typeMeta); - } - if (data.containsKey('created_at')) { - context.handle( - _createdAtMeta, - createdAt.isAcceptableOrUnknown(data['created_at']!, _createdAtMeta), - ); - } else if (isInserting) { - context.missing(_createdAtMeta); - } - if (data.containsKey('modified_at')) { - context.handle( - _modifiedAtMeta, - modifiedAt.isAcceptableOrUnknown(data['modified_at']!, _modifiedAtMeta), - ); - } else if (isInserting) { - context.missing(_modifiedAtMeta); - } - if (data.containsKey('app_source')) { - context.handle( - _appSourceMeta, - appSource.isAcceptableOrUnknown(data['app_source']!, _appSourceMeta), - ); - } - if (data.containsKey('is_pinned')) { - context.handle( - _isPinnedMeta, - isPinned.isAcceptableOrUnknown(data['is_pinned']!, _isPinnedMeta), - ); - } - if (data.containsKey('label')) { - context.handle( - _labelMeta, - label.isAcceptableOrUnknown(data['label']!, _labelMeta), - ); - } - if (data.containsKey('card_color')) { - context.handle( - _cardColorMeta, - cardColor.isAcceptableOrUnknown(data['card_color']!, _cardColorMeta), - ); - } - if (data.containsKey('metadata')) { - context.handle( - _metadataMeta, - metadata.isAcceptableOrUnknown(data['metadata']!, _metadataMeta), - ); - } - if (data.containsKey('paste_count')) { - context.handle( - _pasteCountMeta, - pasteCount.isAcceptableOrUnknown(data['paste_count']!, _pasteCountMeta), - ); - } - if (data.containsKey('content_hash')) { - context.handle( - _contentHashMeta, - contentHash.isAcceptableOrUnknown( - data['content_hash']!, - _contentHashMeta, - ), - ); - } - if (data.containsKey('thumb_path')) { - context.handle( - _thumbPathMeta, - thumbPath.isAcceptableOrUnknown(data['thumb_path']!, _thumbPathMeta), - ); - } - if (data.containsKey('source_modified_at')) { - context.handle( - _sourceModifiedAtMeta, - sourceModifiedAt.isAcceptableOrUnknown( - data['source_modified_at']!, - _sourceModifiedAtMeta, - ), - ); - } - if (data.containsKey('broken_since')) { - context.handle( - _brokenSinceMeta, - brokenSince.isAcceptableOrUnknown( - data['broken_since']!, - _brokenSinceMeta, - ), - ); - } - return context; - } - - @override - Set get $primaryKey => {id}; - @override - ClipboardRow map(Map data, {String? tablePrefix}) { - final effectivePrefix = tablePrefix != null ? '$tablePrefix.' : ''; - return ClipboardRow( - id: attachedDatabase.typeMapping.read( - DriftSqlType.string, - data['${effectivePrefix}id'], - )!, - content: attachedDatabase.typeMapping.read( - DriftSqlType.string, - data['${effectivePrefix}content'], - )!, - type: attachedDatabase.typeMapping.read( - DriftSqlType.int, - data['${effectivePrefix}type'], - )!, - createdAt: attachedDatabase.typeMapping.read( - DriftSqlType.dateTime, - data['${effectivePrefix}created_at'], - )!, - modifiedAt: attachedDatabase.typeMapping.read( - DriftSqlType.dateTime, - data['${effectivePrefix}modified_at'], - )!, - appSource: attachedDatabase.typeMapping.read( - DriftSqlType.string, - data['${effectivePrefix}app_source'], - ), - isPinned: attachedDatabase.typeMapping.read( - DriftSqlType.bool, - data['${effectivePrefix}is_pinned'], - )!, - label: attachedDatabase.typeMapping.read( - DriftSqlType.string, - data['${effectivePrefix}label'], - ), - cardColor: attachedDatabase.typeMapping.read( - DriftSqlType.int, - data['${effectivePrefix}card_color'], - )!, - metadata: attachedDatabase.typeMapping.read( - DriftSqlType.string, - data['${effectivePrefix}metadata'], - ), - pasteCount: attachedDatabase.typeMapping.read( - DriftSqlType.int, - data['${effectivePrefix}paste_count'], - )!, - contentHash: attachedDatabase.typeMapping.read( - DriftSqlType.string, - data['${effectivePrefix}content_hash'], - ), - thumbPath: attachedDatabase.typeMapping.read( - DriftSqlType.string, - data['${effectivePrefix}thumb_path'], - ), - sourceModifiedAt: attachedDatabase.typeMapping.read( - DriftSqlType.dateTime, - data['${effectivePrefix}source_modified_at'], - ), - brokenSince: attachedDatabase.typeMapping.read( - DriftSqlType.dateTime, - data['${effectivePrefix}broken_since'], - ), - ); - } - - @override - $ClipboardItemsTable createAlias(String alias) { - return $ClipboardItemsTable(attachedDatabase, alias); - } -} - -class ClipboardRow extends DataClass implements Insertable { - final String id; - final String content; - final int type; - final DateTime createdAt; - final DateTime modifiedAt; - final String? appSource; - final bool isPinned; - final String? label; - final int cardColor; - final String? metadata; - final int pasteCount; - final String? contentHash; - final String? thumbPath; - final DateTime? sourceModifiedAt; - final DateTime? brokenSince; - const ClipboardRow({ - required this.id, - required this.content, - required this.type, - required this.createdAt, - required this.modifiedAt, - this.appSource, - required this.isPinned, - this.label, - required this.cardColor, - this.metadata, - required this.pasteCount, - this.contentHash, - this.thumbPath, - this.sourceModifiedAt, - this.brokenSince, - }); - @override - Map toColumns(bool nullToAbsent) { - final map = {}; - map['id'] = Variable(id); - map['content'] = Variable(content); - map['type'] = Variable(type); - map['created_at'] = Variable(createdAt); - map['modified_at'] = Variable(modifiedAt); - if (!nullToAbsent || appSource != null) { - map['app_source'] = Variable(appSource); - } - map['is_pinned'] = Variable(isPinned); - if (!nullToAbsent || label != null) { - map['label'] = Variable(label); - } - map['card_color'] = Variable(cardColor); - if (!nullToAbsent || metadata != null) { - map['metadata'] = Variable(metadata); - } - map['paste_count'] = Variable(pasteCount); - if (!nullToAbsent || contentHash != null) { - map['content_hash'] = Variable(contentHash); - } - if (!nullToAbsent || thumbPath != null) { - map['thumb_path'] = Variable(thumbPath); - } - if (!nullToAbsent || sourceModifiedAt != null) { - map['source_modified_at'] = Variable(sourceModifiedAt); - } - if (!nullToAbsent || brokenSince != null) { - map['broken_since'] = Variable(brokenSince); - } - return map; - } - - ClipboardItemsCompanion toCompanion(bool nullToAbsent) { - return ClipboardItemsCompanion( - id: Value(id), - content: Value(content), - type: Value(type), - createdAt: Value(createdAt), - modifiedAt: Value(modifiedAt), - appSource: appSource == null && nullToAbsent - ? const Value.absent() - : Value(appSource), - isPinned: Value(isPinned), - label: label == null && nullToAbsent - ? const Value.absent() - : Value(label), - cardColor: Value(cardColor), - metadata: metadata == null && nullToAbsent - ? const Value.absent() - : Value(metadata), - pasteCount: Value(pasteCount), - contentHash: contentHash == null && nullToAbsent - ? const Value.absent() - : Value(contentHash), - thumbPath: thumbPath == null && nullToAbsent - ? const Value.absent() - : Value(thumbPath), - sourceModifiedAt: sourceModifiedAt == null && nullToAbsent - ? const Value.absent() - : Value(sourceModifiedAt), - brokenSince: brokenSince == null && nullToAbsent - ? const Value.absent() - : Value(brokenSince), - ); - } - - factory ClipboardRow.fromJson( - Map json, { - ValueSerializer? serializer, - }) { - serializer ??= driftRuntimeOptions.defaultSerializer; - return ClipboardRow( - id: serializer.fromJson(json['id']), - content: serializer.fromJson(json['content']), - type: serializer.fromJson(json['type']), - createdAt: serializer.fromJson(json['createdAt']), - modifiedAt: serializer.fromJson(json['modifiedAt']), - appSource: serializer.fromJson(json['appSource']), - isPinned: serializer.fromJson(json['isPinned']), - label: serializer.fromJson(json['label']), - cardColor: serializer.fromJson(json['cardColor']), - metadata: serializer.fromJson(json['metadata']), - pasteCount: serializer.fromJson(json['pasteCount']), - contentHash: serializer.fromJson(json['contentHash']), - thumbPath: serializer.fromJson(json['thumbPath']), - sourceModifiedAt: serializer.fromJson( - json['sourceModifiedAt'], - ), - brokenSince: serializer.fromJson(json['brokenSince']), - ); - } - @override - Map toJson({ValueSerializer? serializer}) { - serializer ??= driftRuntimeOptions.defaultSerializer; - return { - 'id': serializer.toJson(id), - 'content': serializer.toJson(content), - 'type': serializer.toJson(type), - 'createdAt': serializer.toJson(createdAt), - 'modifiedAt': serializer.toJson(modifiedAt), - 'appSource': serializer.toJson(appSource), - 'isPinned': serializer.toJson(isPinned), - 'label': serializer.toJson(label), - 'cardColor': serializer.toJson(cardColor), - 'metadata': serializer.toJson(metadata), - 'pasteCount': serializer.toJson(pasteCount), - 'contentHash': serializer.toJson(contentHash), - 'thumbPath': serializer.toJson(thumbPath), - 'sourceModifiedAt': serializer.toJson(sourceModifiedAt), - 'brokenSince': serializer.toJson(brokenSince), - }; - } - - ClipboardRow copyWith({ - String? id, - String? content, - int? type, - DateTime? createdAt, - DateTime? modifiedAt, - Value appSource = const Value.absent(), - bool? isPinned, - Value label = const Value.absent(), - int? cardColor, - Value metadata = const Value.absent(), - int? pasteCount, - Value contentHash = const Value.absent(), - Value thumbPath = const Value.absent(), - Value sourceModifiedAt = const Value.absent(), - Value brokenSince = const Value.absent(), - }) => ClipboardRow( - id: id ?? this.id, - content: content ?? this.content, - type: type ?? this.type, - createdAt: createdAt ?? this.createdAt, - modifiedAt: modifiedAt ?? this.modifiedAt, - appSource: appSource.present ? appSource.value : this.appSource, - isPinned: isPinned ?? this.isPinned, - label: label.present ? label.value : this.label, - cardColor: cardColor ?? this.cardColor, - metadata: metadata.present ? metadata.value : this.metadata, - pasteCount: pasteCount ?? this.pasteCount, - contentHash: contentHash.present ? contentHash.value : this.contentHash, - thumbPath: thumbPath.present ? thumbPath.value : this.thumbPath, - sourceModifiedAt: sourceModifiedAt.present - ? sourceModifiedAt.value - : this.sourceModifiedAt, - brokenSince: brokenSince.present ? brokenSince.value : this.brokenSince, - ); - ClipboardRow copyWithCompanion(ClipboardItemsCompanion data) { - return ClipboardRow( - id: data.id.present ? data.id.value : this.id, - content: data.content.present ? data.content.value : this.content, - type: data.type.present ? data.type.value : this.type, - createdAt: data.createdAt.present ? data.createdAt.value : this.createdAt, - modifiedAt: data.modifiedAt.present - ? data.modifiedAt.value - : this.modifiedAt, - appSource: data.appSource.present ? data.appSource.value : this.appSource, - isPinned: data.isPinned.present ? data.isPinned.value : this.isPinned, - label: data.label.present ? data.label.value : this.label, - cardColor: data.cardColor.present ? data.cardColor.value : this.cardColor, - metadata: data.metadata.present ? data.metadata.value : this.metadata, - pasteCount: data.pasteCount.present - ? data.pasteCount.value - : this.pasteCount, - contentHash: data.contentHash.present - ? data.contentHash.value - : this.contentHash, - thumbPath: data.thumbPath.present ? data.thumbPath.value : this.thumbPath, - sourceModifiedAt: data.sourceModifiedAt.present - ? data.sourceModifiedAt.value - : this.sourceModifiedAt, - brokenSince: data.brokenSince.present - ? data.brokenSince.value - : this.brokenSince, - ); - } - - @override - String toString() { - return (StringBuffer('ClipboardRow(') - ..write('id: $id, ') - ..write('content: $content, ') - ..write('type: $type, ') - ..write('createdAt: $createdAt, ') - ..write('modifiedAt: $modifiedAt, ') - ..write('appSource: $appSource, ') - ..write('isPinned: $isPinned, ') - ..write('label: $label, ') - ..write('cardColor: $cardColor, ') - ..write('metadata: $metadata, ') - ..write('pasteCount: $pasteCount, ') - ..write('contentHash: $contentHash, ') - ..write('thumbPath: $thumbPath, ') - ..write('sourceModifiedAt: $sourceModifiedAt, ') - ..write('brokenSince: $brokenSince') - ..write(')')) - .toString(); - } - - @override - int get hashCode => Object.hash( - id, - content, - type, - createdAt, - modifiedAt, - appSource, - isPinned, - label, - cardColor, - metadata, - pasteCount, - contentHash, - thumbPath, - sourceModifiedAt, - brokenSince, - ); - @override - bool operator ==(Object other) => - identical(this, other) || - (other is ClipboardRow && - other.id == this.id && - other.content == this.content && - other.type == this.type && - other.createdAt == this.createdAt && - other.modifiedAt == this.modifiedAt && - other.appSource == this.appSource && - other.isPinned == this.isPinned && - other.label == this.label && - other.cardColor == this.cardColor && - other.metadata == this.metadata && - other.pasteCount == this.pasteCount && - other.contentHash == this.contentHash && - other.thumbPath == this.thumbPath && - other.sourceModifiedAt == this.sourceModifiedAt && - other.brokenSince == this.brokenSince); -} - -class ClipboardItemsCompanion extends UpdateCompanion { - final Value id; - final Value content; - final Value type; - final Value createdAt; - final Value modifiedAt; - final Value appSource; - final Value isPinned; - final Value label; - final Value cardColor; - final Value metadata; - final Value pasteCount; - final Value contentHash; - final Value thumbPath; - final Value sourceModifiedAt; - final Value brokenSince; - final Value rowid; - const ClipboardItemsCompanion({ - this.id = const Value.absent(), - this.content = const Value.absent(), - this.type = const Value.absent(), - this.createdAt = const Value.absent(), - this.modifiedAt = const Value.absent(), - this.appSource = const Value.absent(), - this.isPinned = const Value.absent(), - this.label = const Value.absent(), - this.cardColor = const Value.absent(), - this.metadata = const Value.absent(), - this.pasteCount = const Value.absent(), - this.contentHash = const Value.absent(), - this.thumbPath = const Value.absent(), - this.sourceModifiedAt = const Value.absent(), - this.brokenSince = const Value.absent(), - this.rowid = const Value.absent(), - }); - ClipboardItemsCompanion.insert({ - required String id, - required String content, - required int type, - required DateTime createdAt, - required DateTime modifiedAt, - this.appSource = const Value.absent(), - this.isPinned = const Value.absent(), - this.label = const Value.absent(), - this.cardColor = const Value.absent(), - this.metadata = const Value.absent(), - this.pasteCount = const Value.absent(), - this.contentHash = const Value.absent(), - this.thumbPath = const Value.absent(), - this.sourceModifiedAt = const Value.absent(), - this.brokenSince = const Value.absent(), - this.rowid = const Value.absent(), - }) : id = Value(id), - content = Value(content), - type = Value(type), - createdAt = Value(createdAt), - modifiedAt = Value(modifiedAt); - static Insertable custom({ - Expression? id, - Expression? content, - Expression? type, - Expression? createdAt, - Expression? modifiedAt, - Expression? appSource, - Expression? isPinned, - Expression? label, - Expression? cardColor, - Expression? metadata, - Expression? pasteCount, - Expression? contentHash, - Expression? thumbPath, - Expression? sourceModifiedAt, - Expression? brokenSince, - Expression? rowid, - }) { - return RawValuesInsertable({ - if (id != null) 'id': id, - if (content != null) 'content': content, - if (type != null) 'type': type, - if (createdAt != null) 'created_at': createdAt, - if (modifiedAt != null) 'modified_at': modifiedAt, - if (appSource != null) 'app_source': appSource, - if (isPinned != null) 'is_pinned': isPinned, - if (label != null) 'label': label, - if (cardColor != null) 'card_color': cardColor, - if (metadata != null) 'metadata': metadata, - if (pasteCount != null) 'paste_count': pasteCount, - if (contentHash != null) 'content_hash': contentHash, - if (thumbPath != null) 'thumb_path': thumbPath, - if (sourceModifiedAt != null) 'source_modified_at': sourceModifiedAt, - if (brokenSince != null) 'broken_since': brokenSince, - if (rowid != null) 'rowid': rowid, - }); - } - - ClipboardItemsCompanion copyWith({ - Value? id, - Value? content, - Value? type, - Value? createdAt, - Value? modifiedAt, - Value? appSource, - Value? isPinned, - Value? label, - Value? cardColor, - Value? metadata, - Value? pasteCount, - Value? contentHash, - Value? thumbPath, - Value? sourceModifiedAt, - Value? brokenSince, - Value? rowid, - }) { - return ClipboardItemsCompanion( - id: id ?? this.id, - content: content ?? this.content, - type: type ?? this.type, - createdAt: createdAt ?? this.createdAt, - modifiedAt: modifiedAt ?? this.modifiedAt, - appSource: appSource ?? this.appSource, - isPinned: isPinned ?? this.isPinned, - label: label ?? this.label, - cardColor: cardColor ?? this.cardColor, - metadata: metadata ?? this.metadata, - pasteCount: pasteCount ?? this.pasteCount, - contentHash: contentHash ?? this.contentHash, - thumbPath: thumbPath ?? this.thumbPath, - sourceModifiedAt: sourceModifiedAt ?? this.sourceModifiedAt, - brokenSince: brokenSince ?? this.brokenSince, - rowid: rowid ?? this.rowid, - ); - } - - @override - Map toColumns(bool nullToAbsent) { - final map = {}; - if (id.present) { - map['id'] = Variable(id.value); - } - if (content.present) { - map['content'] = Variable(content.value); - } - if (type.present) { - map['type'] = Variable(type.value); - } - if (createdAt.present) { - map['created_at'] = Variable(createdAt.value); - } - if (modifiedAt.present) { - map['modified_at'] = Variable(modifiedAt.value); - } - if (appSource.present) { - map['app_source'] = Variable(appSource.value); - } - if (isPinned.present) { - map['is_pinned'] = Variable(isPinned.value); - } - if (label.present) { - map['label'] = Variable(label.value); - } - if (cardColor.present) { - map['card_color'] = Variable(cardColor.value); - } - if (metadata.present) { - map['metadata'] = Variable(metadata.value); - } - if (pasteCount.present) { - map['paste_count'] = Variable(pasteCount.value); - } - if (contentHash.present) { - map['content_hash'] = Variable(contentHash.value); - } - if (thumbPath.present) { - map['thumb_path'] = Variable(thumbPath.value); - } - if (sourceModifiedAt.present) { - map['source_modified_at'] = Variable(sourceModifiedAt.value); - } - if (brokenSince.present) { - map['broken_since'] = Variable(brokenSince.value); - } - if (rowid.present) { - map['rowid'] = Variable(rowid.value); - } - return map; - } - - @override - String toString() { - return (StringBuffer('ClipboardItemsCompanion(') - ..write('id: $id, ') - ..write('content: $content, ') - ..write('type: $type, ') - ..write('createdAt: $createdAt, ') - ..write('modifiedAt: $modifiedAt, ') - ..write('appSource: $appSource, ') - ..write('isPinned: $isPinned, ') - ..write('label: $label, ') - ..write('cardColor: $cardColor, ') - ..write('metadata: $metadata, ') - ..write('pasteCount: $pasteCount, ') - ..write('contentHash: $contentHash, ') - ..write('thumbPath: $thumbPath, ') - ..write('sourceModifiedAt: $sourceModifiedAt, ') - ..write('brokenSince: $brokenSince, ') - ..write('rowid: $rowid') - ..write(')')) - .toString(); - } -} - -abstract class _$_AppDatabase extends GeneratedDatabase { - _$_AppDatabase(QueryExecutor e) : super(e); - $_AppDatabaseManager get managers => $_AppDatabaseManager(this); - late final $ClipboardItemsTable clipboardItems = $ClipboardItemsTable(this); - @override - Iterable> get allTables => - allSchemaEntities.whereType>(); - @override - List get allSchemaEntities => [clipboardItems]; -} - -typedef $$ClipboardItemsTableCreateCompanionBuilder = - ClipboardItemsCompanion Function({ - required String id, - required String content, - required int type, - required DateTime createdAt, - required DateTime modifiedAt, - Value appSource, - Value isPinned, - Value label, - Value cardColor, - Value metadata, - Value pasteCount, - Value contentHash, - Value thumbPath, - Value sourceModifiedAt, - Value brokenSince, - Value rowid, - }); -typedef $$ClipboardItemsTableUpdateCompanionBuilder = - ClipboardItemsCompanion Function({ - Value id, - Value content, - Value type, - Value createdAt, - Value modifiedAt, - Value appSource, - Value isPinned, - Value label, - Value cardColor, - Value metadata, - Value pasteCount, - Value contentHash, - Value thumbPath, - Value sourceModifiedAt, - Value brokenSince, - Value rowid, - }); - -class $$ClipboardItemsTableFilterComposer - extends Composer<_$_AppDatabase, $ClipboardItemsTable> { - $$ClipboardItemsTableFilterComposer({ - required super.$db, - required super.$table, - super.joinBuilder, - super.$addJoinBuilderToRootComposer, - super.$removeJoinBuilderFromRootComposer, - }); - ColumnFilters get id => $composableBuilder( - column: $table.id, - builder: (column) => ColumnFilters(column), - ); - - ColumnFilters get content => $composableBuilder( - column: $table.content, - builder: (column) => ColumnFilters(column), - ); - - ColumnFilters get type => $composableBuilder( - column: $table.type, - builder: (column) => ColumnFilters(column), - ); - - ColumnFilters get createdAt => $composableBuilder( - column: $table.createdAt, - builder: (column) => ColumnFilters(column), - ); - - ColumnFilters get modifiedAt => $composableBuilder( - column: $table.modifiedAt, - builder: (column) => ColumnFilters(column), - ); - - ColumnFilters get appSource => $composableBuilder( - column: $table.appSource, - builder: (column) => ColumnFilters(column), - ); - - ColumnFilters get isPinned => $composableBuilder( - column: $table.isPinned, - builder: (column) => ColumnFilters(column), - ); - - ColumnFilters get label => $composableBuilder( - column: $table.label, - builder: (column) => ColumnFilters(column), - ); - - ColumnFilters get cardColor => $composableBuilder( - column: $table.cardColor, - builder: (column) => ColumnFilters(column), - ); - - ColumnFilters get metadata => $composableBuilder( - column: $table.metadata, - builder: (column) => ColumnFilters(column), - ); - - ColumnFilters get pasteCount => $composableBuilder( - column: $table.pasteCount, - builder: (column) => ColumnFilters(column), - ); - - ColumnFilters get contentHash => $composableBuilder( - column: $table.contentHash, - builder: (column) => ColumnFilters(column), - ); - - ColumnFilters get thumbPath => $composableBuilder( - column: $table.thumbPath, - builder: (column) => ColumnFilters(column), - ); - - ColumnFilters get sourceModifiedAt => $composableBuilder( - column: $table.sourceModifiedAt, - builder: (column) => ColumnFilters(column), - ); - - ColumnFilters get brokenSince => $composableBuilder( - column: $table.brokenSince, - builder: (column) => ColumnFilters(column), - ); -} - -class $$ClipboardItemsTableOrderingComposer - extends Composer<_$_AppDatabase, $ClipboardItemsTable> { - $$ClipboardItemsTableOrderingComposer({ - required super.$db, - required super.$table, - super.joinBuilder, - super.$addJoinBuilderToRootComposer, - super.$removeJoinBuilderFromRootComposer, - }); - ColumnOrderings get id => $composableBuilder( - column: $table.id, - builder: (column) => ColumnOrderings(column), - ); - - ColumnOrderings get content => $composableBuilder( - column: $table.content, - builder: (column) => ColumnOrderings(column), - ); - - ColumnOrderings get type => $composableBuilder( - column: $table.type, - builder: (column) => ColumnOrderings(column), - ); - - ColumnOrderings get createdAt => $composableBuilder( - column: $table.createdAt, - builder: (column) => ColumnOrderings(column), - ); - - ColumnOrderings get modifiedAt => $composableBuilder( - column: $table.modifiedAt, - builder: (column) => ColumnOrderings(column), - ); - - ColumnOrderings get appSource => $composableBuilder( - column: $table.appSource, - builder: (column) => ColumnOrderings(column), - ); - - ColumnOrderings get isPinned => $composableBuilder( - column: $table.isPinned, - builder: (column) => ColumnOrderings(column), - ); - - ColumnOrderings get label => $composableBuilder( - column: $table.label, - builder: (column) => ColumnOrderings(column), - ); - - ColumnOrderings get cardColor => $composableBuilder( - column: $table.cardColor, - builder: (column) => ColumnOrderings(column), - ); - - ColumnOrderings get metadata => $composableBuilder( - column: $table.metadata, - builder: (column) => ColumnOrderings(column), - ); - - ColumnOrderings get pasteCount => $composableBuilder( - column: $table.pasteCount, - builder: (column) => ColumnOrderings(column), - ); - - ColumnOrderings get contentHash => $composableBuilder( - column: $table.contentHash, - builder: (column) => ColumnOrderings(column), - ); - - ColumnOrderings get thumbPath => $composableBuilder( - column: $table.thumbPath, - builder: (column) => ColumnOrderings(column), - ); - - ColumnOrderings get sourceModifiedAt => $composableBuilder( - column: $table.sourceModifiedAt, - builder: (column) => ColumnOrderings(column), - ); - - ColumnOrderings get brokenSince => $composableBuilder( - column: $table.brokenSince, - builder: (column) => ColumnOrderings(column), - ); -} - -class $$ClipboardItemsTableAnnotationComposer - extends Composer<_$_AppDatabase, $ClipboardItemsTable> { - $$ClipboardItemsTableAnnotationComposer({ - required super.$db, - required super.$table, - super.joinBuilder, - super.$addJoinBuilderToRootComposer, - super.$removeJoinBuilderFromRootComposer, - }); - GeneratedColumn get id => - $composableBuilder(column: $table.id, builder: (column) => column); - - GeneratedColumn get content => - $composableBuilder(column: $table.content, builder: (column) => column); - - GeneratedColumn get type => - $composableBuilder(column: $table.type, builder: (column) => column); - - GeneratedColumn get createdAt => - $composableBuilder(column: $table.createdAt, builder: (column) => column); - - GeneratedColumn get modifiedAt => $composableBuilder( - column: $table.modifiedAt, - builder: (column) => column, - ); - - GeneratedColumn get appSource => - $composableBuilder(column: $table.appSource, builder: (column) => column); - - GeneratedColumn get isPinned => - $composableBuilder(column: $table.isPinned, builder: (column) => column); - - GeneratedColumn get label => - $composableBuilder(column: $table.label, builder: (column) => column); - - GeneratedColumn get cardColor => - $composableBuilder(column: $table.cardColor, builder: (column) => column); - - GeneratedColumn get metadata => - $composableBuilder(column: $table.metadata, builder: (column) => column); - - GeneratedColumn get pasteCount => $composableBuilder( - column: $table.pasteCount, - builder: (column) => column, - ); - - GeneratedColumn get contentHash => $composableBuilder( - column: $table.contentHash, - builder: (column) => column, - ); - - GeneratedColumn get thumbPath => - $composableBuilder(column: $table.thumbPath, builder: (column) => column); - - GeneratedColumn get sourceModifiedAt => $composableBuilder( - column: $table.sourceModifiedAt, - builder: (column) => column, - ); - - GeneratedColumn get brokenSince => $composableBuilder( - column: $table.brokenSince, - builder: (column) => column, - ); -} - -class $$ClipboardItemsTableTableManager - extends - RootTableManager< - _$_AppDatabase, - $ClipboardItemsTable, - ClipboardRow, - $$ClipboardItemsTableFilterComposer, - $$ClipboardItemsTableOrderingComposer, - $$ClipboardItemsTableAnnotationComposer, - $$ClipboardItemsTableCreateCompanionBuilder, - $$ClipboardItemsTableUpdateCompanionBuilder, - ( - ClipboardRow, - BaseReferences<_$_AppDatabase, $ClipboardItemsTable, ClipboardRow>, - ), - ClipboardRow, - PrefetchHooks Function() - > { - $$ClipboardItemsTableTableManager( - _$_AppDatabase db, - $ClipboardItemsTable table, - ) : super( - TableManagerState( - db: db, - table: table, - createFilteringComposer: () => - $$ClipboardItemsTableFilterComposer($db: db, $table: table), - createOrderingComposer: () => - $$ClipboardItemsTableOrderingComposer($db: db, $table: table), - createComputedFieldComposer: () => - $$ClipboardItemsTableAnnotationComposer($db: db, $table: table), - updateCompanionCallback: - ({ - Value id = const Value.absent(), - Value content = const Value.absent(), - Value type = const Value.absent(), - Value createdAt = const Value.absent(), - Value modifiedAt = const Value.absent(), - Value appSource = const Value.absent(), - Value isPinned = const Value.absent(), - Value label = const Value.absent(), - Value cardColor = const Value.absent(), - Value metadata = const Value.absent(), - Value pasteCount = const Value.absent(), - Value contentHash = const Value.absent(), - Value thumbPath = const Value.absent(), - Value sourceModifiedAt = const Value.absent(), - Value brokenSince = const Value.absent(), - Value rowid = const Value.absent(), - }) => ClipboardItemsCompanion( - id: id, - content: content, - type: type, - createdAt: createdAt, - modifiedAt: modifiedAt, - appSource: appSource, - isPinned: isPinned, - label: label, - cardColor: cardColor, - metadata: metadata, - pasteCount: pasteCount, - contentHash: contentHash, - thumbPath: thumbPath, - sourceModifiedAt: sourceModifiedAt, - brokenSince: brokenSince, - rowid: rowid, - ), - createCompanionCallback: - ({ - required String id, - required String content, - required int type, - required DateTime createdAt, - required DateTime modifiedAt, - Value appSource = const Value.absent(), - Value isPinned = const Value.absent(), - Value label = const Value.absent(), - Value cardColor = const Value.absent(), - Value metadata = const Value.absent(), - Value pasteCount = const Value.absent(), - Value contentHash = const Value.absent(), - Value thumbPath = const Value.absent(), - Value sourceModifiedAt = const Value.absent(), - Value brokenSince = const Value.absent(), - Value rowid = const Value.absent(), - }) => ClipboardItemsCompanion.insert( - id: id, - content: content, - type: type, - createdAt: createdAt, - modifiedAt: modifiedAt, - appSource: appSource, - isPinned: isPinned, - label: label, - cardColor: cardColor, - metadata: metadata, - pasteCount: pasteCount, - contentHash: contentHash, - thumbPath: thumbPath, - sourceModifiedAt: sourceModifiedAt, - brokenSince: brokenSince, - rowid: rowid, - ), - withReferenceMapper: (p0) => p0 - .map((e) => (e.readTable(table), BaseReferences(db, table, e))) - .toList(), - prefetchHooksCallback: null, - ), - ); -} - -typedef $$ClipboardItemsTableProcessedTableManager = - ProcessedTableManager< - _$_AppDatabase, - $ClipboardItemsTable, - ClipboardRow, - $$ClipboardItemsTableFilterComposer, - $$ClipboardItemsTableOrderingComposer, - $$ClipboardItemsTableAnnotationComposer, - $$ClipboardItemsTableCreateCompanionBuilder, - $$ClipboardItemsTableUpdateCompanionBuilder, - ( - ClipboardRow, - BaseReferences<_$_AppDatabase, $ClipboardItemsTable, ClipboardRow>, - ), - ClipboardRow, - PrefetchHooks Function() - >; - -class $_AppDatabaseManager { - final _$_AppDatabase _db; - $_AppDatabaseManager(this._db); - $$ClipboardItemsTableTableManager get clipboardItems => - $$ClipboardItemsTableTableManager(_db, _db.clipboardItems); -} +// GENERATED CODE - DO NOT MODIFY BY HAND + +part of 'sqlite_repository.dart'; + +// ignore_for_file: type=lint +class $ClipboardItemsTable extends ClipboardItems + with TableInfo<$ClipboardItemsTable, ClipboardRow> { + @override + final GeneratedDatabase attachedDatabase; + final String? _alias; + $ClipboardItemsTable(this.attachedDatabase, [this._alias]); + static const VerificationMeta _idMeta = const VerificationMeta('id'); + @override + late final GeneratedColumn id = GeneratedColumn( + 'id', + aliasedName, + false, + type: DriftSqlType.string, + requiredDuringInsert: true, + ); + static const VerificationMeta _contentMeta = const VerificationMeta( + 'content', + ); + @override + late final GeneratedColumn content = GeneratedColumn( + 'content', + aliasedName, + false, + type: DriftSqlType.string, + requiredDuringInsert: true, + ); + static const VerificationMeta _typeMeta = const VerificationMeta('type'); + @override + late final GeneratedColumn type = GeneratedColumn( + 'type', + aliasedName, + false, + type: DriftSqlType.int, + requiredDuringInsert: true, + ); + static const VerificationMeta _createdAtMeta = const VerificationMeta( + 'createdAt', + ); + @override + late final GeneratedColumn createdAt = GeneratedColumn( + 'created_at', + aliasedName, + false, + type: DriftSqlType.dateTime, + requiredDuringInsert: true, + ); + static const VerificationMeta _modifiedAtMeta = const VerificationMeta( + 'modifiedAt', + ); + @override + late final GeneratedColumn modifiedAt = GeneratedColumn( + 'modified_at', + aliasedName, + false, + type: DriftSqlType.dateTime, + requiredDuringInsert: true, + ); + static const VerificationMeta _appSourceMeta = const VerificationMeta( + 'appSource', + ); + @override + late final GeneratedColumn appSource = GeneratedColumn( + 'app_source', + aliasedName, + true, + type: DriftSqlType.string, + requiredDuringInsert: false, + ); + static const VerificationMeta _isPinnedMeta = const VerificationMeta( + 'isPinned', + ); + @override + late final GeneratedColumn isPinned = GeneratedColumn( + 'is_pinned', + aliasedName, + false, + type: DriftSqlType.bool, + requiredDuringInsert: false, + defaultConstraints: GeneratedColumn.constraintIsAlways( + 'CHECK ("is_pinned" IN (0, 1))', + ), + defaultValue: const Constant(false), + ); + static const VerificationMeta _labelMeta = const VerificationMeta('label'); + @override + late final GeneratedColumn label = GeneratedColumn( + 'label', + aliasedName, + true, + type: DriftSqlType.string, + requiredDuringInsert: false, + ); + static const VerificationMeta _cardColorMeta = const VerificationMeta( + 'cardColor', + ); + @override + late final GeneratedColumn cardColor = GeneratedColumn( + 'card_color', + aliasedName, + false, + type: DriftSqlType.int, + requiredDuringInsert: false, + defaultValue: const Constant(0), + ); + static const VerificationMeta _metadataMeta = const VerificationMeta( + 'metadata', + ); + @override + late final GeneratedColumn metadata = GeneratedColumn( + 'metadata', + aliasedName, + true, + type: DriftSqlType.string, + requiredDuringInsert: false, + ); + static const VerificationMeta _pasteCountMeta = const VerificationMeta( + 'pasteCount', + ); + @override + late final GeneratedColumn pasteCount = GeneratedColumn( + 'paste_count', + aliasedName, + false, + type: DriftSqlType.int, + requiredDuringInsert: false, + defaultValue: const Constant(0), + ); + static const VerificationMeta _contentHashMeta = const VerificationMeta( + 'contentHash', + ); + @override + late final GeneratedColumn contentHash = GeneratedColumn( + 'content_hash', + aliasedName, + true, + type: DriftSqlType.string, + requiredDuringInsert: false, + ); + static const VerificationMeta _thumbPathMeta = const VerificationMeta( + 'thumbPath', + ); + @override + late final GeneratedColumn thumbPath = GeneratedColumn( + 'thumb_path', + aliasedName, + true, + type: DriftSqlType.string, + requiredDuringInsert: false, + ); + static const VerificationMeta _sourceModifiedAtMeta = const VerificationMeta( + 'sourceModifiedAt', + ); + @override + late final GeneratedColumn sourceModifiedAt = + GeneratedColumn( + 'source_modified_at', + aliasedName, + true, + type: DriftSqlType.dateTime, + requiredDuringInsert: false, + ); + static const VerificationMeta _brokenSinceMeta = const VerificationMeta( + 'brokenSince', + ); + @override + late final GeneratedColumn brokenSince = GeneratedColumn( + 'broken_since', + aliasedName, + true, + type: DriftSqlType.dateTime, + requiredDuringInsert: false, + ); + @override + List get $columns => [ + id, + content, + type, + createdAt, + modifiedAt, + appSource, + isPinned, + label, + cardColor, + metadata, + pasteCount, + contentHash, + thumbPath, + sourceModifiedAt, + brokenSince, + ]; + @override + String get aliasedName => _alias ?? actualTableName; + @override + String get actualTableName => $name; + static const String $name = 'clipboard_items'; + @override + VerificationContext validateIntegrity( + Insertable instance, { + bool isInserting = false, + }) { + final context = VerificationContext(); + final data = instance.toColumns(true); + if (data.containsKey('id')) { + context.handle(_idMeta, id.isAcceptableOrUnknown(data['id']!, _idMeta)); + } else if (isInserting) { + context.missing(_idMeta); + } + if (data.containsKey('content')) { + context.handle( + _contentMeta, + content.isAcceptableOrUnknown(data['content']!, _contentMeta), + ); + } else if (isInserting) { + context.missing(_contentMeta); + } + if (data.containsKey('type')) { + context.handle( + _typeMeta, + type.isAcceptableOrUnknown(data['type']!, _typeMeta), + ); + } else if (isInserting) { + context.missing(_typeMeta); + } + if (data.containsKey('created_at')) { + context.handle( + _createdAtMeta, + createdAt.isAcceptableOrUnknown(data['created_at']!, _createdAtMeta), + ); + } else if (isInserting) { + context.missing(_createdAtMeta); + } + if (data.containsKey('modified_at')) { + context.handle( + _modifiedAtMeta, + modifiedAt.isAcceptableOrUnknown(data['modified_at']!, _modifiedAtMeta), + ); + } else if (isInserting) { + context.missing(_modifiedAtMeta); + } + if (data.containsKey('app_source')) { + context.handle( + _appSourceMeta, + appSource.isAcceptableOrUnknown(data['app_source']!, _appSourceMeta), + ); + } + if (data.containsKey('is_pinned')) { + context.handle( + _isPinnedMeta, + isPinned.isAcceptableOrUnknown(data['is_pinned']!, _isPinnedMeta), + ); + } + if (data.containsKey('label')) { + context.handle( + _labelMeta, + label.isAcceptableOrUnknown(data['label']!, _labelMeta), + ); + } + if (data.containsKey('card_color')) { + context.handle( + _cardColorMeta, + cardColor.isAcceptableOrUnknown(data['card_color']!, _cardColorMeta), + ); + } + if (data.containsKey('metadata')) { + context.handle( + _metadataMeta, + metadata.isAcceptableOrUnknown(data['metadata']!, _metadataMeta), + ); + } + if (data.containsKey('paste_count')) { + context.handle( + _pasteCountMeta, + pasteCount.isAcceptableOrUnknown(data['paste_count']!, _pasteCountMeta), + ); + } + if (data.containsKey('content_hash')) { + context.handle( + _contentHashMeta, + contentHash.isAcceptableOrUnknown( + data['content_hash']!, + _contentHashMeta, + ), + ); + } + if (data.containsKey('thumb_path')) { + context.handle( + _thumbPathMeta, + thumbPath.isAcceptableOrUnknown(data['thumb_path']!, _thumbPathMeta), + ); + } + if (data.containsKey('source_modified_at')) { + context.handle( + _sourceModifiedAtMeta, + sourceModifiedAt.isAcceptableOrUnknown( + data['source_modified_at']!, + _sourceModifiedAtMeta, + ), + ); + } + if (data.containsKey('broken_since')) { + context.handle( + _brokenSinceMeta, + brokenSince.isAcceptableOrUnknown( + data['broken_since']!, + _brokenSinceMeta, + ), + ); + } + return context; + } + + @override + Set get $primaryKey => {id}; + @override + ClipboardRow map(Map data, {String? tablePrefix}) { + final effectivePrefix = tablePrefix != null ? '$tablePrefix.' : ''; + return ClipboardRow( + id: attachedDatabase.typeMapping.read( + DriftSqlType.string, + data['${effectivePrefix}id'], + )!, + content: attachedDatabase.typeMapping.read( + DriftSqlType.string, + data['${effectivePrefix}content'], + )!, + type: attachedDatabase.typeMapping.read( + DriftSqlType.int, + data['${effectivePrefix}type'], + )!, + createdAt: attachedDatabase.typeMapping.read( + DriftSqlType.dateTime, + data['${effectivePrefix}created_at'], + )!, + modifiedAt: attachedDatabase.typeMapping.read( + DriftSqlType.dateTime, + data['${effectivePrefix}modified_at'], + )!, + appSource: attachedDatabase.typeMapping.read( + DriftSqlType.string, + data['${effectivePrefix}app_source'], + ), + isPinned: attachedDatabase.typeMapping.read( + DriftSqlType.bool, + data['${effectivePrefix}is_pinned'], + )!, + label: attachedDatabase.typeMapping.read( + DriftSqlType.string, + data['${effectivePrefix}label'], + ), + cardColor: attachedDatabase.typeMapping.read( + DriftSqlType.int, + data['${effectivePrefix}card_color'], + )!, + metadata: attachedDatabase.typeMapping.read( + DriftSqlType.string, + data['${effectivePrefix}metadata'], + ), + pasteCount: attachedDatabase.typeMapping.read( + DriftSqlType.int, + data['${effectivePrefix}paste_count'], + )!, + contentHash: attachedDatabase.typeMapping.read( + DriftSqlType.string, + data['${effectivePrefix}content_hash'], + ), + thumbPath: attachedDatabase.typeMapping.read( + DriftSqlType.string, + data['${effectivePrefix}thumb_path'], + ), + sourceModifiedAt: attachedDatabase.typeMapping.read( + DriftSqlType.dateTime, + data['${effectivePrefix}source_modified_at'], + ), + brokenSince: attachedDatabase.typeMapping.read( + DriftSqlType.dateTime, + data['${effectivePrefix}broken_since'], + ), + ); + } + + @override + $ClipboardItemsTable createAlias(String alias) { + return $ClipboardItemsTable(attachedDatabase, alias); + } +} + +class ClipboardRow extends DataClass implements Insertable { + final String id; + final String content; + final int type; + final DateTime createdAt; + final DateTime modifiedAt; + final String? appSource; + final bool isPinned; + final String? label; + final int cardColor; + final String? metadata; + final int pasteCount; + final String? contentHash; + final String? thumbPath; + final DateTime? sourceModifiedAt; + final DateTime? brokenSince; + const ClipboardRow({ + required this.id, + required this.content, + required this.type, + required this.createdAt, + required this.modifiedAt, + this.appSource, + required this.isPinned, + this.label, + required this.cardColor, + this.metadata, + required this.pasteCount, + this.contentHash, + this.thumbPath, + this.sourceModifiedAt, + this.brokenSince, + }); + @override + Map toColumns(bool nullToAbsent) { + final map = {}; + map['id'] = Variable(id); + map['content'] = Variable(content); + map['type'] = Variable(type); + map['created_at'] = Variable(createdAt); + map['modified_at'] = Variable(modifiedAt); + if (!nullToAbsent || appSource != null) { + map['app_source'] = Variable(appSource); + } + map['is_pinned'] = Variable(isPinned); + if (!nullToAbsent || label != null) { + map['label'] = Variable(label); + } + map['card_color'] = Variable(cardColor); + if (!nullToAbsent || metadata != null) { + map['metadata'] = Variable(metadata); + } + map['paste_count'] = Variable(pasteCount); + if (!nullToAbsent || contentHash != null) { + map['content_hash'] = Variable(contentHash); + } + if (!nullToAbsent || thumbPath != null) { + map['thumb_path'] = Variable(thumbPath); + } + if (!nullToAbsent || sourceModifiedAt != null) { + map['source_modified_at'] = Variable(sourceModifiedAt); + } + if (!nullToAbsent || brokenSince != null) { + map['broken_since'] = Variable(brokenSince); + } + return map; + } + + ClipboardItemsCompanion toCompanion(bool nullToAbsent) { + return ClipboardItemsCompanion( + id: Value(id), + content: Value(content), + type: Value(type), + createdAt: Value(createdAt), + modifiedAt: Value(modifiedAt), + appSource: appSource == null && nullToAbsent + ? const Value.absent() + : Value(appSource), + isPinned: Value(isPinned), + label: label == null && nullToAbsent + ? const Value.absent() + : Value(label), + cardColor: Value(cardColor), + metadata: metadata == null && nullToAbsent + ? const Value.absent() + : Value(metadata), + pasteCount: Value(pasteCount), + contentHash: contentHash == null && nullToAbsent + ? const Value.absent() + : Value(contentHash), + thumbPath: thumbPath == null && nullToAbsent + ? const Value.absent() + : Value(thumbPath), + sourceModifiedAt: sourceModifiedAt == null && nullToAbsent + ? const Value.absent() + : Value(sourceModifiedAt), + brokenSince: brokenSince == null && nullToAbsent + ? const Value.absent() + : Value(brokenSince), + ); + } + + factory ClipboardRow.fromJson( + Map json, { + ValueSerializer? serializer, + }) { + serializer ??= driftRuntimeOptions.defaultSerializer; + return ClipboardRow( + id: serializer.fromJson(json['id']), + content: serializer.fromJson(json['content']), + type: serializer.fromJson(json['type']), + createdAt: serializer.fromJson(json['createdAt']), + modifiedAt: serializer.fromJson(json['modifiedAt']), + appSource: serializer.fromJson(json['appSource']), + isPinned: serializer.fromJson(json['isPinned']), + label: serializer.fromJson(json['label']), + cardColor: serializer.fromJson(json['cardColor']), + metadata: serializer.fromJson(json['metadata']), + pasteCount: serializer.fromJson(json['pasteCount']), + contentHash: serializer.fromJson(json['contentHash']), + thumbPath: serializer.fromJson(json['thumbPath']), + sourceModifiedAt: serializer.fromJson( + json['sourceModifiedAt'], + ), + brokenSince: serializer.fromJson(json['brokenSince']), + ); + } + @override + Map toJson({ValueSerializer? serializer}) { + serializer ??= driftRuntimeOptions.defaultSerializer; + return { + 'id': serializer.toJson(id), + 'content': serializer.toJson(content), + 'type': serializer.toJson(type), + 'createdAt': serializer.toJson(createdAt), + 'modifiedAt': serializer.toJson(modifiedAt), + 'appSource': serializer.toJson(appSource), + 'isPinned': serializer.toJson(isPinned), + 'label': serializer.toJson(label), + 'cardColor': serializer.toJson(cardColor), + 'metadata': serializer.toJson(metadata), + 'pasteCount': serializer.toJson(pasteCount), + 'contentHash': serializer.toJson(contentHash), + 'thumbPath': serializer.toJson(thumbPath), + 'sourceModifiedAt': serializer.toJson(sourceModifiedAt), + 'brokenSince': serializer.toJson(brokenSince), + }; + } + + ClipboardRow copyWith({ + String? id, + String? content, + int? type, + DateTime? createdAt, + DateTime? modifiedAt, + Value appSource = const Value.absent(), + bool? isPinned, + Value label = const Value.absent(), + int? cardColor, + Value metadata = const Value.absent(), + int? pasteCount, + Value contentHash = const Value.absent(), + Value thumbPath = const Value.absent(), + Value sourceModifiedAt = const Value.absent(), + Value brokenSince = const Value.absent(), + }) => ClipboardRow( + id: id ?? this.id, + content: content ?? this.content, + type: type ?? this.type, + createdAt: createdAt ?? this.createdAt, + modifiedAt: modifiedAt ?? this.modifiedAt, + appSource: appSource.present ? appSource.value : this.appSource, + isPinned: isPinned ?? this.isPinned, + label: label.present ? label.value : this.label, + cardColor: cardColor ?? this.cardColor, + metadata: metadata.present ? metadata.value : this.metadata, + pasteCount: pasteCount ?? this.pasteCount, + contentHash: contentHash.present ? contentHash.value : this.contentHash, + thumbPath: thumbPath.present ? thumbPath.value : this.thumbPath, + sourceModifiedAt: sourceModifiedAt.present + ? sourceModifiedAt.value + : this.sourceModifiedAt, + brokenSince: brokenSince.present ? brokenSince.value : this.brokenSince, + ); + ClipboardRow copyWithCompanion(ClipboardItemsCompanion data) { + return ClipboardRow( + id: data.id.present ? data.id.value : this.id, + content: data.content.present ? data.content.value : this.content, + type: data.type.present ? data.type.value : this.type, + createdAt: data.createdAt.present ? data.createdAt.value : this.createdAt, + modifiedAt: data.modifiedAt.present + ? data.modifiedAt.value + : this.modifiedAt, + appSource: data.appSource.present ? data.appSource.value : this.appSource, + isPinned: data.isPinned.present ? data.isPinned.value : this.isPinned, + label: data.label.present ? data.label.value : this.label, + cardColor: data.cardColor.present ? data.cardColor.value : this.cardColor, + metadata: data.metadata.present ? data.metadata.value : this.metadata, + pasteCount: data.pasteCount.present + ? data.pasteCount.value + : this.pasteCount, + contentHash: data.contentHash.present + ? data.contentHash.value + : this.contentHash, + thumbPath: data.thumbPath.present ? data.thumbPath.value : this.thumbPath, + sourceModifiedAt: data.sourceModifiedAt.present + ? data.sourceModifiedAt.value + : this.sourceModifiedAt, + brokenSince: data.brokenSince.present + ? data.brokenSince.value + : this.brokenSince, + ); + } + + @override + String toString() { + return (StringBuffer('ClipboardRow(') + ..write('id: $id, ') + ..write('content: $content, ') + ..write('type: $type, ') + ..write('createdAt: $createdAt, ') + ..write('modifiedAt: $modifiedAt, ') + ..write('appSource: $appSource, ') + ..write('isPinned: $isPinned, ') + ..write('label: $label, ') + ..write('cardColor: $cardColor, ') + ..write('metadata: $metadata, ') + ..write('pasteCount: $pasteCount, ') + ..write('contentHash: $contentHash, ') + ..write('thumbPath: $thumbPath, ') + ..write('sourceModifiedAt: $sourceModifiedAt, ') + ..write('brokenSince: $brokenSince') + ..write(')')) + .toString(); + } + + @override + int get hashCode => Object.hash( + id, + content, + type, + createdAt, + modifiedAt, + appSource, + isPinned, + label, + cardColor, + metadata, + pasteCount, + contentHash, + thumbPath, + sourceModifiedAt, + brokenSince, + ); + @override + bool operator ==(Object other) => + identical(this, other) || + (other is ClipboardRow && + other.id == this.id && + other.content == this.content && + other.type == this.type && + other.createdAt == this.createdAt && + other.modifiedAt == this.modifiedAt && + other.appSource == this.appSource && + other.isPinned == this.isPinned && + other.label == this.label && + other.cardColor == this.cardColor && + other.metadata == this.metadata && + other.pasteCount == this.pasteCount && + other.contentHash == this.contentHash && + other.thumbPath == this.thumbPath && + other.sourceModifiedAt == this.sourceModifiedAt && + other.brokenSince == this.brokenSince); +} + +class ClipboardItemsCompanion extends UpdateCompanion { + final Value id; + final Value content; + final Value type; + final Value createdAt; + final Value modifiedAt; + final Value appSource; + final Value isPinned; + final Value label; + final Value cardColor; + final Value metadata; + final Value pasteCount; + final Value contentHash; + final Value thumbPath; + final Value sourceModifiedAt; + final Value brokenSince; + final Value rowid; + const ClipboardItemsCompanion({ + this.id = const Value.absent(), + this.content = const Value.absent(), + this.type = const Value.absent(), + this.createdAt = const Value.absent(), + this.modifiedAt = const Value.absent(), + this.appSource = const Value.absent(), + this.isPinned = const Value.absent(), + this.label = const Value.absent(), + this.cardColor = const Value.absent(), + this.metadata = const Value.absent(), + this.pasteCount = const Value.absent(), + this.contentHash = const Value.absent(), + this.thumbPath = const Value.absent(), + this.sourceModifiedAt = const Value.absent(), + this.brokenSince = const Value.absent(), + this.rowid = const Value.absent(), + }); + ClipboardItemsCompanion.insert({ + required String id, + required String content, + required int type, + required DateTime createdAt, + required DateTime modifiedAt, + this.appSource = const Value.absent(), + this.isPinned = const Value.absent(), + this.label = const Value.absent(), + this.cardColor = const Value.absent(), + this.metadata = const Value.absent(), + this.pasteCount = const Value.absent(), + this.contentHash = const Value.absent(), + this.thumbPath = const Value.absent(), + this.sourceModifiedAt = const Value.absent(), + this.brokenSince = const Value.absent(), + this.rowid = const Value.absent(), + }) : id = Value(id), + content = Value(content), + type = Value(type), + createdAt = Value(createdAt), + modifiedAt = Value(modifiedAt); + static Insertable custom({ + Expression? id, + Expression? content, + Expression? type, + Expression? createdAt, + Expression? modifiedAt, + Expression? appSource, + Expression? isPinned, + Expression? label, + Expression? cardColor, + Expression? metadata, + Expression? pasteCount, + Expression? contentHash, + Expression? thumbPath, + Expression? sourceModifiedAt, + Expression? brokenSince, + Expression? rowid, + }) { + return RawValuesInsertable({ + if (id != null) 'id': id, + if (content != null) 'content': content, + if (type != null) 'type': type, + if (createdAt != null) 'created_at': createdAt, + if (modifiedAt != null) 'modified_at': modifiedAt, + if (appSource != null) 'app_source': appSource, + if (isPinned != null) 'is_pinned': isPinned, + if (label != null) 'label': label, + if (cardColor != null) 'card_color': cardColor, + if (metadata != null) 'metadata': metadata, + if (pasteCount != null) 'paste_count': pasteCount, + if (contentHash != null) 'content_hash': contentHash, + if (thumbPath != null) 'thumb_path': thumbPath, + if (sourceModifiedAt != null) 'source_modified_at': sourceModifiedAt, + if (brokenSince != null) 'broken_since': brokenSince, + if (rowid != null) 'rowid': rowid, + }); + } + + ClipboardItemsCompanion copyWith({ + Value? id, + Value? content, + Value? type, + Value? createdAt, + Value? modifiedAt, + Value? appSource, + Value? isPinned, + Value? label, + Value? cardColor, + Value? metadata, + Value? pasteCount, + Value? contentHash, + Value? thumbPath, + Value? sourceModifiedAt, + Value? brokenSince, + Value? rowid, + }) { + return ClipboardItemsCompanion( + id: id ?? this.id, + content: content ?? this.content, + type: type ?? this.type, + createdAt: createdAt ?? this.createdAt, + modifiedAt: modifiedAt ?? this.modifiedAt, + appSource: appSource ?? this.appSource, + isPinned: isPinned ?? this.isPinned, + label: label ?? this.label, + cardColor: cardColor ?? this.cardColor, + metadata: metadata ?? this.metadata, + pasteCount: pasteCount ?? this.pasteCount, + contentHash: contentHash ?? this.contentHash, + thumbPath: thumbPath ?? this.thumbPath, + sourceModifiedAt: sourceModifiedAt ?? this.sourceModifiedAt, + brokenSince: brokenSince ?? this.brokenSince, + rowid: rowid ?? this.rowid, + ); + } + + @override + Map toColumns(bool nullToAbsent) { + final map = {}; + if (id.present) { + map['id'] = Variable(id.value); + } + if (content.present) { + map['content'] = Variable(content.value); + } + if (type.present) { + map['type'] = Variable(type.value); + } + if (createdAt.present) { + map['created_at'] = Variable(createdAt.value); + } + if (modifiedAt.present) { + map['modified_at'] = Variable(modifiedAt.value); + } + if (appSource.present) { + map['app_source'] = Variable(appSource.value); + } + if (isPinned.present) { + map['is_pinned'] = Variable(isPinned.value); + } + if (label.present) { + map['label'] = Variable(label.value); + } + if (cardColor.present) { + map['card_color'] = Variable(cardColor.value); + } + if (metadata.present) { + map['metadata'] = Variable(metadata.value); + } + if (pasteCount.present) { + map['paste_count'] = Variable(pasteCount.value); + } + if (contentHash.present) { + map['content_hash'] = Variable(contentHash.value); + } + if (thumbPath.present) { + map['thumb_path'] = Variable(thumbPath.value); + } + if (sourceModifiedAt.present) { + map['source_modified_at'] = Variable(sourceModifiedAt.value); + } + if (brokenSince.present) { + map['broken_since'] = Variable(brokenSince.value); + } + if (rowid.present) { + map['rowid'] = Variable(rowid.value); + } + return map; + } + + @override + String toString() { + return (StringBuffer('ClipboardItemsCompanion(') + ..write('id: $id, ') + ..write('content: $content, ') + ..write('type: $type, ') + ..write('createdAt: $createdAt, ') + ..write('modifiedAt: $modifiedAt, ') + ..write('appSource: $appSource, ') + ..write('isPinned: $isPinned, ') + ..write('label: $label, ') + ..write('cardColor: $cardColor, ') + ..write('metadata: $metadata, ') + ..write('pasteCount: $pasteCount, ') + ..write('contentHash: $contentHash, ') + ..write('thumbPath: $thumbPath, ') + ..write('sourceModifiedAt: $sourceModifiedAt, ') + ..write('brokenSince: $brokenSince, ') + ..write('rowid: $rowid') + ..write(')')) + .toString(); + } +} + +abstract class _$_AppDatabase extends GeneratedDatabase { + _$_AppDatabase(QueryExecutor e) : super(e); + $_AppDatabaseManager get managers => $_AppDatabaseManager(this); + late final $ClipboardItemsTable clipboardItems = $ClipboardItemsTable(this); + @override + Iterable> get allTables => + allSchemaEntities.whereType>(); + @override + List get allSchemaEntities => [clipboardItems]; +} + +typedef $$ClipboardItemsTableCreateCompanionBuilder = + ClipboardItemsCompanion Function({ + required String id, + required String content, + required int type, + required DateTime createdAt, + required DateTime modifiedAt, + Value appSource, + Value isPinned, + Value label, + Value cardColor, + Value metadata, + Value pasteCount, + Value contentHash, + Value thumbPath, + Value sourceModifiedAt, + Value brokenSince, + Value rowid, + }); +typedef $$ClipboardItemsTableUpdateCompanionBuilder = + ClipboardItemsCompanion Function({ + Value id, + Value content, + Value type, + Value createdAt, + Value modifiedAt, + Value appSource, + Value isPinned, + Value label, + Value cardColor, + Value metadata, + Value pasteCount, + Value contentHash, + Value thumbPath, + Value sourceModifiedAt, + Value brokenSince, + Value rowid, + }); + +class $$ClipboardItemsTableFilterComposer + extends Composer<_$_AppDatabase, $ClipboardItemsTable> { + $$ClipboardItemsTableFilterComposer({ + required super.$db, + required super.$table, + super.joinBuilder, + super.$addJoinBuilderToRootComposer, + super.$removeJoinBuilderFromRootComposer, + }); + ColumnFilters get id => $composableBuilder( + column: $table.id, + builder: (column) => ColumnFilters(column), + ); + + ColumnFilters get content => $composableBuilder( + column: $table.content, + builder: (column) => ColumnFilters(column), + ); + + ColumnFilters get type => $composableBuilder( + column: $table.type, + builder: (column) => ColumnFilters(column), + ); + + ColumnFilters get createdAt => $composableBuilder( + column: $table.createdAt, + builder: (column) => ColumnFilters(column), + ); + + ColumnFilters get modifiedAt => $composableBuilder( + column: $table.modifiedAt, + builder: (column) => ColumnFilters(column), + ); + + ColumnFilters get appSource => $composableBuilder( + column: $table.appSource, + builder: (column) => ColumnFilters(column), + ); + + ColumnFilters get isPinned => $composableBuilder( + column: $table.isPinned, + builder: (column) => ColumnFilters(column), + ); + + ColumnFilters get label => $composableBuilder( + column: $table.label, + builder: (column) => ColumnFilters(column), + ); + + ColumnFilters get cardColor => $composableBuilder( + column: $table.cardColor, + builder: (column) => ColumnFilters(column), + ); + + ColumnFilters get metadata => $composableBuilder( + column: $table.metadata, + builder: (column) => ColumnFilters(column), + ); + + ColumnFilters get pasteCount => $composableBuilder( + column: $table.pasteCount, + builder: (column) => ColumnFilters(column), + ); + + ColumnFilters get contentHash => $composableBuilder( + column: $table.contentHash, + builder: (column) => ColumnFilters(column), + ); + + ColumnFilters get thumbPath => $composableBuilder( + column: $table.thumbPath, + builder: (column) => ColumnFilters(column), + ); + + ColumnFilters get sourceModifiedAt => $composableBuilder( + column: $table.sourceModifiedAt, + builder: (column) => ColumnFilters(column), + ); + + ColumnFilters get brokenSince => $composableBuilder( + column: $table.brokenSince, + builder: (column) => ColumnFilters(column), + ); +} + +class $$ClipboardItemsTableOrderingComposer + extends Composer<_$_AppDatabase, $ClipboardItemsTable> { + $$ClipboardItemsTableOrderingComposer({ + required super.$db, + required super.$table, + super.joinBuilder, + super.$addJoinBuilderToRootComposer, + super.$removeJoinBuilderFromRootComposer, + }); + ColumnOrderings get id => $composableBuilder( + column: $table.id, + builder: (column) => ColumnOrderings(column), + ); + + ColumnOrderings get content => $composableBuilder( + column: $table.content, + builder: (column) => ColumnOrderings(column), + ); + + ColumnOrderings get type => $composableBuilder( + column: $table.type, + builder: (column) => ColumnOrderings(column), + ); + + ColumnOrderings get createdAt => $composableBuilder( + column: $table.createdAt, + builder: (column) => ColumnOrderings(column), + ); + + ColumnOrderings get modifiedAt => $composableBuilder( + column: $table.modifiedAt, + builder: (column) => ColumnOrderings(column), + ); + + ColumnOrderings get appSource => $composableBuilder( + column: $table.appSource, + builder: (column) => ColumnOrderings(column), + ); + + ColumnOrderings get isPinned => $composableBuilder( + column: $table.isPinned, + builder: (column) => ColumnOrderings(column), + ); + + ColumnOrderings get label => $composableBuilder( + column: $table.label, + builder: (column) => ColumnOrderings(column), + ); + + ColumnOrderings get cardColor => $composableBuilder( + column: $table.cardColor, + builder: (column) => ColumnOrderings(column), + ); + + ColumnOrderings get metadata => $composableBuilder( + column: $table.metadata, + builder: (column) => ColumnOrderings(column), + ); + + ColumnOrderings get pasteCount => $composableBuilder( + column: $table.pasteCount, + builder: (column) => ColumnOrderings(column), + ); + + ColumnOrderings get contentHash => $composableBuilder( + column: $table.contentHash, + builder: (column) => ColumnOrderings(column), + ); + + ColumnOrderings get thumbPath => $composableBuilder( + column: $table.thumbPath, + builder: (column) => ColumnOrderings(column), + ); + + ColumnOrderings get sourceModifiedAt => $composableBuilder( + column: $table.sourceModifiedAt, + builder: (column) => ColumnOrderings(column), + ); + + ColumnOrderings get brokenSince => $composableBuilder( + column: $table.brokenSince, + builder: (column) => ColumnOrderings(column), + ); +} + +class $$ClipboardItemsTableAnnotationComposer + extends Composer<_$_AppDatabase, $ClipboardItemsTable> { + $$ClipboardItemsTableAnnotationComposer({ + required super.$db, + required super.$table, + super.joinBuilder, + super.$addJoinBuilderToRootComposer, + super.$removeJoinBuilderFromRootComposer, + }); + GeneratedColumn get id => + $composableBuilder(column: $table.id, builder: (column) => column); + + GeneratedColumn get content => + $composableBuilder(column: $table.content, builder: (column) => column); + + GeneratedColumn get type => + $composableBuilder(column: $table.type, builder: (column) => column); + + GeneratedColumn get createdAt => + $composableBuilder(column: $table.createdAt, builder: (column) => column); + + GeneratedColumn get modifiedAt => $composableBuilder( + column: $table.modifiedAt, + builder: (column) => column, + ); + + GeneratedColumn get appSource => + $composableBuilder(column: $table.appSource, builder: (column) => column); + + GeneratedColumn get isPinned => + $composableBuilder(column: $table.isPinned, builder: (column) => column); + + GeneratedColumn get label => + $composableBuilder(column: $table.label, builder: (column) => column); + + GeneratedColumn get cardColor => + $composableBuilder(column: $table.cardColor, builder: (column) => column); + + GeneratedColumn get metadata => + $composableBuilder(column: $table.metadata, builder: (column) => column); + + GeneratedColumn get pasteCount => $composableBuilder( + column: $table.pasteCount, + builder: (column) => column, + ); + + GeneratedColumn get contentHash => $composableBuilder( + column: $table.contentHash, + builder: (column) => column, + ); + + GeneratedColumn get thumbPath => + $composableBuilder(column: $table.thumbPath, builder: (column) => column); + + GeneratedColumn get sourceModifiedAt => $composableBuilder( + column: $table.sourceModifiedAt, + builder: (column) => column, + ); + + GeneratedColumn get brokenSince => $composableBuilder( + column: $table.brokenSince, + builder: (column) => column, + ); +} + +class $$ClipboardItemsTableTableManager + extends + RootTableManager< + _$_AppDatabase, + $ClipboardItemsTable, + ClipboardRow, + $$ClipboardItemsTableFilterComposer, + $$ClipboardItemsTableOrderingComposer, + $$ClipboardItemsTableAnnotationComposer, + $$ClipboardItemsTableCreateCompanionBuilder, + $$ClipboardItemsTableUpdateCompanionBuilder, + ( + ClipboardRow, + BaseReferences<_$_AppDatabase, $ClipboardItemsTable, ClipboardRow>, + ), + ClipboardRow, + PrefetchHooks Function() + > { + $$ClipboardItemsTableTableManager( + _$_AppDatabase db, + $ClipboardItemsTable table, + ) : super( + TableManagerState( + db: db, + table: table, + createFilteringComposer: () => + $$ClipboardItemsTableFilterComposer($db: db, $table: table), + createOrderingComposer: () => + $$ClipboardItemsTableOrderingComposer($db: db, $table: table), + createComputedFieldComposer: () => + $$ClipboardItemsTableAnnotationComposer($db: db, $table: table), + updateCompanionCallback: + ({ + Value id = const Value.absent(), + Value content = const Value.absent(), + Value type = const Value.absent(), + Value createdAt = const Value.absent(), + Value modifiedAt = const Value.absent(), + Value appSource = const Value.absent(), + Value isPinned = const Value.absent(), + Value label = const Value.absent(), + Value cardColor = const Value.absent(), + Value metadata = const Value.absent(), + Value pasteCount = const Value.absent(), + Value contentHash = const Value.absent(), + Value thumbPath = const Value.absent(), + Value sourceModifiedAt = const Value.absent(), + Value brokenSince = const Value.absent(), + Value rowid = const Value.absent(), + }) => ClipboardItemsCompanion( + id: id, + content: content, + type: type, + createdAt: createdAt, + modifiedAt: modifiedAt, + appSource: appSource, + isPinned: isPinned, + label: label, + cardColor: cardColor, + metadata: metadata, + pasteCount: pasteCount, + contentHash: contentHash, + thumbPath: thumbPath, + sourceModifiedAt: sourceModifiedAt, + brokenSince: brokenSince, + rowid: rowid, + ), + createCompanionCallback: + ({ + required String id, + required String content, + required int type, + required DateTime createdAt, + required DateTime modifiedAt, + Value appSource = const Value.absent(), + Value isPinned = const Value.absent(), + Value label = const Value.absent(), + Value cardColor = const Value.absent(), + Value metadata = const Value.absent(), + Value pasteCount = const Value.absent(), + Value contentHash = const Value.absent(), + Value thumbPath = const Value.absent(), + Value sourceModifiedAt = const Value.absent(), + Value brokenSince = const Value.absent(), + Value rowid = const Value.absent(), + }) => ClipboardItemsCompanion.insert( + id: id, + content: content, + type: type, + createdAt: createdAt, + modifiedAt: modifiedAt, + appSource: appSource, + isPinned: isPinned, + label: label, + cardColor: cardColor, + metadata: metadata, + pasteCount: pasteCount, + contentHash: contentHash, + thumbPath: thumbPath, + sourceModifiedAt: sourceModifiedAt, + brokenSince: brokenSince, + rowid: rowid, + ), + withReferenceMapper: (p0) => p0 + .map((e) => (e.readTable(table), BaseReferences(db, table, e))) + .toList(), + prefetchHooksCallback: null, + ), + ); +} + +typedef $$ClipboardItemsTableProcessedTableManager = + ProcessedTableManager< + _$_AppDatabase, + $ClipboardItemsTable, + ClipboardRow, + $$ClipboardItemsTableFilterComposer, + $$ClipboardItemsTableOrderingComposer, + $$ClipboardItemsTableAnnotationComposer, + $$ClipboardItemsTableCreateCompanionBuilder, + $$ClipboardItemsTableUpdateCompanionBuilder, + ( + ClipboardRow, + BaseReferences<_$_AppDatabase, $ClipboardItemsTable, ClipboardRow>, + ), + ClipboardRow, + PrefetchHooks Function() + >; + +class $_AppDatabaseManager { + final _$_AppDatabase _db; + $_AppDatabaseManager(this._db); + $$ClipboardItemsTableTableManager get clipboardItems => + $$ClipboardItemsTableTableManager(_db, _db.clipboardItems); +} From db1ce014058ac248c2c06ddd4eb6ae046be83c9f Mon Sep 17 00:00:00 2001 From: rgdevment Date: Fri, 24 Apr 2026 21:12:29 -0400 Subject: [PATCH 23/31] chore: README and SECURITY documentation; enhance Linux listener plugin - Revised README.md to clarify Linux support status and platform compatibility. - Updated SECURITY.md to improve clarity on reporting vulnerabilities and supported versions. - Added a new function in listener_plugin.c to prettify application IDs by trimming unnecessary parts, enhancing the user experience in clipboard history. --- PRIVACY.md | 726 +++++++++++++++---------------- README.md | 10 +- SECURITY.md | 470 ++++++++++---------- listener/linux/listener_plugin.c | 26 +- 4 files changed, 627 insertions(+), 605 deletions(-) diff --git a/PRIVACY.md b/PRIVACY.md index f4a857b0..c6535130 100644 --- a/PRIVACY.md +++ b/PRIVACY.md @@ -1,363 +1,363 @@ -# Privacy Policy - -**Last updated:** April 24, 2026 - ---- - -## The Short Version - -**Everything stays on your computer.** CopyPaste does not collect, transmit, or share any of your data. There's no cloud, no accounts, no telemetry, no analytics, no tracking — nothing leaves your machine, ever. - -**"Everything local" is not a feature — it's the foundation.** It's a technical fact you can verify yourself: the entire source code is [open and public](https://github.com/rgdevment/CopyPaste). Read the code, run a network monitor, check for yourself. - ---- - -## Our Privacy Philosophy: Everything Local - -CopyPaste was built with a **privacy-first** mindset from day one. This isn't an afterthought or a feature — it's a core design principle. **Everything stays on your machine.** - -- 🔒 **Local-only by design** — Your data never leaves your computer -- 🚫 **No telemetry** — We don't measure, track, or analyze your usage -- 🚫 **No analytics** — No Google Analytics, no App Insights, no Sentry, nothing -- 🚫 **No accounts** — No sign-up, no login, no user profiles -- 🚫 **No cloud sync** — Your clipboard history is yours alone -- 🚫 **No automatic reporting** — Errors are logged locally only; nothing is sent anywhere without your action -- 🔍 **Fully auditable** — Every line of code is open source under [GPLv3](LICENSE) - -**The data on your computer is yours.** I built CopyPaste to respect that boundary completely — not just in policy, but in code. - ---- - -## What Data Does CopyPaste Store? - -CopyPaste monitors your system clipboard to maintain a local history. The following data is stored **exclusively on your computer**: - -### Clipboard Content - -| Type | What's Stored | Where | -| :--- | :--- | :--- | -| **Text** | The copied text content | SQLite database | -| **Images** | Image files (PNG) | Local `images` folder | -| **Files & Folders** | File/folder paths (not the files themselves) | SQLite database | -| **Links** | URL text | SQLite database | -| **Audio & Video** | File paths only | SQLite database | -| **Thumbnails** | Small preview images (`_thumb.png`) generated by the OS shell for images, video and audio entries | Local `images` folder | - -### Metadata - -For each clipboard item, CopyPaste also stores: - -- **Timestamp** — When the item was copied -- **Content type** — Text, Image, File, Folder, Link, Audio, or Video -- **Source application** — The name of the app where you copied from (_window title_) -- **User labels** — Custom labels you assign to items (optional) -- **Color tags** — Color categories you assign (optional) -- **Pin status** — Whether you pinned the item -- **Paste count** — How many times you have pasted the item -- **Media metadata** — Duration, dimensions, or codec info for audio and video files (stored as JSON) -- **Image thumbnails** — Smaller preview versions of copied images -- **Broken-since timestamp** (`broken_since`) — When the referenced file/image stopped being available on disk (set to `null` while the file exists). Used to keep the entry visible during the configured retention window so reconnecting an external drive restores the preview instead of losing it. - -### Configuration - -Your settings are stored locally: - -- Hotkey preferences -- Theme selection -- Language preference -- Panel width -- Retention period -- Filter behavior -- Startup preferences -- **Image quota (MB)** (`imagesQuotaMB`) — Maximum disk space copied images may use; `0` means unlimited (default). When the cap is reached, the oldest non-pinned images inside the app's own `images` folder are evicted (LRU). Pinned items and any path that does not live under that folder are never touched. -- **Broken-item retention days** (`keepBrokenItemsDays`) — Number of days an entry whose referenced file is missing is preserved before being purged (default 30). -- **Thumbnail generation toggles** — Independent on/off switches for image, video and audio thumbnails, plus a maximum image processing size (MB) to skip very large files. -- **Onboarding completion flag** (`hasCompletedOnboarding`) — Remembers that the first-launch walkthrough has already been shown. - -### Windows System Integration (Startup) - -When you enable **Start with Windows** in Settings, CopyPaste registers itself as a startup application using the appropriate mechanism for each distribution channel — and removes the registration when you disable it: - -| Distribution | Mechanism | What is written | -| :--- | :--- | :--- | -| **Standalone installer (.exe)** | Windows registry key | `HKCU\Software\Microsoft\Windows\CurrentVersion\Run\CopyPaste` | -| **Microsoft Store (MSIX)** | Windows StartupTask API | System startup catalog (no registry write) | - -Neither mechanism requires administrator rights. On uninstall, the standalone installer automatically removes the registry entry. The MSIX version is cleaned up by Windows when the app is uninstalled through the Store or Settings → Apps. - -If you never enable "Start with Windows," nothing is written to the registry or the startup catalog. - -### Logs - -Application logs are stored locally for troubleshooting: - -- **Windows:** `%LOCALAPPDATA%\CopyPaste\logs\` -- **macOS:** `~/Library/Application Support/com.rgdevment.copypaste/CopyPaste/logs/` -- **Linux:** `~/.local/share/com.rgdevment.copypaste/CopyPaste/logs/` -- **Content:** Application events, errors, and diagnostic information only -- **No personal data:** Logs do **not** contain clipboard content — your copied text, images, or file paths are never written to log files - -### Crash Log - -If the app fails to start or crashes during initialization, a single `crash.log` file is written next to the data folder so the failure is recoverable on the next launch. This file: - -- Lives at `/crash.log` on every platform (e.g. `%LOCALAPPDATA%\CopyPaste\crash.log` on Windows) -- Is **capped at 512 KB** — older content is overwritten automatically -- Contains: timestamp (UTC), OS name and version, Dart runtime version, the failing operation, and the stack trace -- Has **automatic redaction applied at write time**: your Windows/macOS/Linux user name, full home folder path, and any email addresses found in stack traces are replaced with ``, ``, and `` placeholders before being written to disk -- **Never contains clipboard content** — clipboard data does not flow through error paths -- **Is never sent anywhere automatically** — same rule as the regular logs - ---- - -## Support & Log Export — What Happens and What Doesn't - -CopyPaste includes a **Support** section in Settings → About that lets you export a diagnostic log bundle. Here is exactly what this does and doesn't do: - -### What the Export Does - -- Collects recent `.log` files from the local logs folder -- Includes the `crash.log` file if one exists -- Applies an **additional redaction pass** before zipping: user name, home folder path and email addresses are replaced with ``, `` and `` in every file added to the archive -- Adds a `device_info.txt` with your OS version, OS build, system locale, and CopyPaste app version -- Packages everything into a single `.zip` file saved to a location **you choose** on your computer - -### What the Export Does NOT Do - -- **Does not send anything anywhere automatically** — the zip stays on your disk until you manually share it -- **Does not include clipboard content** — your copied text, images, or file paths are never in the logs and never in the export -- **Does not connect to the internet** — the export is a local file operation only -- **Does not run in the background** — it only happens when you explicitly click "Export Logs" - -### How to Share Safely - -If you want to attach logs to a GitHub issue: - -1. Open the exported zip and review it before sharing — you can read the log files in any text editor -2. Redact anything you're uncomfortable sharing (though there should be no personal data) -3. Attach the zip manually to your bug report - -**You are in control at every step.** Nothing goes anywhere without your explicit action. - ---- - -## Where Is Everything Stored? - -All data is stored locally under your user profile: - -**Windows:** - -| Data | Location | -| :--- | :--- | -| **Database** | `%LOCALAPPDATA%\CopyPaste\clipboard.db` | -| **Images** | `%LOCALAPPDATA%\CopyPaste\images\` | -| **Configuration** | `%LOCALAPPDATA%\CopyPaste\config\` | -| **Logs** | `%LOCALAPPDATA%\CopyPaste\logs\` | -| **Crash log** | `%LOCALAPPDATA%\CopyPaste\crash.log` | - -**macOS:** - -| Data | Location | -| :--- | :--- | -| **Database** | `~/Library/Application Support/com.rgdevment.copypaste/CopyPaste/clipboard.db` | -| **Images** | `~/Library/Application Support/com.rgdevment.copypaste/CopyPaste/images/` | -| **Configuration** | `~/Library/Application Support/com.rgdevment.copypaste/CopyPaste/config/` | -| **Logs** | `~/Library/Application Support/com.rgdevment.copypaste/CopyPaste/logs/` | -| **Crash log** | `~/Library/Application Support/com.rgdevment.copypaste/CopyPaste/crash.log` | - -**Linux:** - -| Data | Location | -| :--- | :--- | -| **Database** | `~/.local/share/com.rgdevment.copypaste/CopyPaste/clipboard.db` | -| **Images** | `~/.local/share/com.rgdevment.copypaste/CopyPaste/images/` | -| **Configuration** | `~/.local/share/com.rgdevment.copypaste/CopyPaste/config/` | -| **Logs** | `~/.local/share/com.rgdevment.copypaste/CopyPaste/logs/` | -| **Crash log** | `~/.local/share/com.rgdevment.copypaste/CopyPaste/crash.log` | - -These folders are protected by your operating system's user account permissions. Other users on the same computer cannot access them under normal conditions. - ---- - -## What CopyPaste Does NOT Do - -To be absolutely clear: - -- ❌ **Does not send data to any server** — No clipboard content, no metadata, no usage data -- ❌ **Does not use cookies or tracking technologies** -- ❌ **Does not create user accounts or profiles** -- ❌ **Does not share data with third parties** -- ❌ **Does not use advertising or ad networks** -- ❌ **Does not use AI or machine learning** on your data -- ❌ **Does not sync across devices** -- ❌ **Does not upload crash reports** — Crashes are written to a local `crash.log` (with PII redacted at write time); log export is always manual and user-initiated -- ❌ **Does not phone home** — No background network calls except the update checker described below (all platforms) - ---- - -## Network Requests - -CopyPaste makes **one type of network request** for update checking: - -### Update Checker - -| Detail | Value | -| :--- | :--- | -| **Purpose** | Check if a newer version of CopyPaste is available and enforce blocks for versions with known critical issues | -| **URL (all platforms)** | `https://github.com/rgdevment/CopyPaste/releases/latest/download/release-manifest.json` (and its `.sig` signature file) | -| **Method** | `GET` (read-only) | -| **Data sent** | Standard HTTP headers only — **no user data** | -| **Data received** | A small signed JSON file listing the latest version, minimum supported version, any blocked versions, and per-channel install info. An accompanying Ed25519 signature is verified locally before the manifest is trusted | -| **Frequency** | Every 24 hours, plus once at startup | -| **Cached locally** | Yes — last successfully verified manifest is cached for up to 15 days so the app works offline | - -**Important notes:** - -- This request is **read-only** — it only downloads two small public files; no data is ever uploaded -- **No clipboard content, no usage data, no personal information** is ever sent -- The manifest is **cryptographically signed** with an Ed25519 key. If the signature does not verify, the manifest is discarded and no update indicator is shown -- **All platforms:** If an update is found, a non-invasive indicator appears in the app's footer bar — no popups or dialogs interrupt your workflow. You can click the indicator to see details -- **Standalone builds (Windows / macOS / Linux):** Clicking the indicator opens the GitHub release page (or shows a Homebrew / Snap command). Nothing is downloaded or installed automatically -- **Microsoft Store version:** Clicking the indicator opens a dialog explaining that Microsoft Store delivers updates on its own schedule. The app is never blocked on Store builds, since update delivery is outside our control -- **Blocked versions:** If the manifest flags the installed version as having a critical issue (for example, a severe security bug or data-corruption fix), standalone builds show a full-screen prompt with direct install/download instructions. This mechanism is disabled on Microsoft Store builds - -### User-Initiated Browser Navigation - -When you explicitly click certain UI buttons, CopyPaste opens URLs in your default browser: - -- **"Report issue"** button → Opens `https://github.com/rgdevment/CopyPaste/issues` -- **"Download update"** indicator → Opens the GitHub release page - -These are standard browser navigations initiated by your action — CopyPaste does not make these requests itself. - ---- - -## Sensitive Data Protection - -CopyPaste includes built-in protections for sensitive content: - -### Password Manager Exclusion - -Clipboard content from recognized password managers is **automatically excluded** from history. Supported password managers include: - -- 1Password -- Bitwarden -- LastPass -- KeePass -- And others that use standard clipboard security flags - -### How It Works - -- Password managers typically set a clipboard format flag indicating sensitive content -- CopyPaste detects these flags and **skips storing** the content entirely -- The sensitive data is never written to the database or disk - -### Windows Clipboard History - -CopyPaste operates independently from Windows' built-in clipboard history (`Win+V`). Your CopyPaste settings do not affect Windows clipboard behavior, and vice versa. - ---- - -## Data Retention & Deletion - -### Automatic Cleanup - -- CopyPaste automatically deletes unpinned items older than your configured retention period (default: **30 days**) -- Cleanup runs periodically in the background -- **Pinned items are preserved** regardless of the retention setting - -### Manual Deletion - -You can delete any clipboard item at any time: - -- Select an item and press `Delete` -- Right-click and choose "Delete" - -### Clean Install & Reset (In-App) - -CopyPaste includes in-app reset options at **Settings → About → Reset & Clean Install**: - -- **Soft Reset** — Resets all settings to defaults. Your clipboard history is preserved. -- **Hard Reset** — Deletes everything: history, images, settings, and logs. The app restarts completely clean. **This cannot be undone.** - -These options work on all platforms including the Microsoft Store version. - -### Complete Data Removal (Uninstall) - -To completely remove all CopyPaste data when uninstalling: - -**Windows:** - -1. Uninstall CopyPaste (via Settings → Apps or the standalone uninstaller) -2. Delete the data folder: `%LOCALAPPDATA%\CopyPaste\` - -**macOS:** - -1. Move CopyPaste to Trash from Applications -2. Delete the data folder: `~/Library/Application Support/com.rgdevment.copypaste/CopyPaste/` - -**Linux:** - -1. Uninstall CopyPaste (via your package manager or remove the binary) -2. Delete the data folder: `~/.local/share/com.rgdevment.copypaste/CopyPaste/` - -After these steps, no CopyPaste data remains on your system. - ---- - -## Children's Privacy - -CopyPaste does not knowingly collect any personal information from anyone, including children under 13. The application does not collect personal information from any user — it has no accounts, no registration, and no data transmission. - ---- - -## Microsoft Store Distribution - -CopyPaste is available through the [Microsoft Store](https://apps.microsoft.com/detail/9NBJRZF3K856). The Store version: - -- **Follows the same privacy principles** as the standalone version -- **Makes one read-only network request** — queries the GitHub Releases API every 24 hours to check if a newer version exists. If found, a non-invasive indicator appears in the footer bar. No download link is shown and nothing is installed automatically — updates are delivered through the Microsoft Store -- **Uses MSIX packaging** — installs/uninstalls cleanly with Windows standard mechanisms -- **Microsoft Store policies** apply to distribution, but CopyPaste itself does not share any data with Microsoft beyond what the Store platform requires for installation and updates - -For Microsoft's own privacy practices regarding the Store, refer to [Microsoft's Privacy Statement](https://privacy.microsoft.com/privacystatement). - ---- - -## Open Source Transparency - -The best privacy policy is one you can verify. CopyPaste is **100% open source** under the [GNU General Public License v3.0](LICENSE): - -- 📂 **Full source code:** [github.com/rgdevment/CopyPaste](https://github.com/rgdevment/CopyPaste) -- 🔍 **Audit the code yourself** — every network request, every database write, every file operation -- 🐛 **Report concerns** — [open an issue](https://github.com/rgdevment/CopyPaste/issues) or [email us](mailto:github@apirest.cl) - -We encourage security researchers and privacy advocates to inspect the code. See our [Security Policy](SECURITY.md) for responsible disclosure guidelines. - ---- - -## Changes to This Policy - -If we ever change this privacy policy, the changes will be: - -- Committed to the public repository with a clear commit message -- Reflected in the "Last updated" date at the top -- Documented in the release notes - -Since CopyPaste is open source, any change to privacy behavior would also be visible as a code change in the public repository before it reaches you. - ---- - -## Contact - -If you have questions or concerns about this privacy policy: - -- 📧 **Email:** [github@apirest.cl](mailto:github@apirest.cl) -- 💬 **GitHub Discussions:** [github.com/rgdevment/CopyPaste/discussions](https://github.com/rgdevment/CopyPaste/discussions) -- 🐛 **Issues:** [github.com/rgdevment/CopyPaste/issues](https://github.com/rgdevment/CopyPaste/issues) - ---- - -
-

Everything local. Your clipboard is yours — I built CopyPaste to keep it that way.

-
+# Privacy Policy + +**Last updated:** April 24, 2026 + +--- + +## The Short Version + +**Everything stays on your computer.** CopyPaste does not collect, transmit, or share any of your data. There's no cloud, no accounts, no telemetry, no analytics, no tracking — nothing leaves your machine, ever. + +**"Everything local" is not a feature — it's the foundation.** It's a technical fact you can verify yourself: the entire source code is [open and public](https://github.com/rgdevment/CopyPaste). Read the code, run a network monitor, check for yourself. + +--- + +## Our Privacy Philosophy: Everything Local + +CopyPaste was built with a **privacy-first** mindset from day one. This isn't an afterthought or a feature — it's a core design principle. **Everything stays on your machine.** + +- 🔒 **Local-only by design** — Your data never leaves your computer +- 🚫 **No telemetry** — We don't measure, track, or analyze your usage +- 🚫 **No analytics** — No Google Analytics, no App Insights, no Sentry, nothing +- 🚫 **No accounts** — No sign-up, no login, no user profiles +- 🚫 **No cloud sync** — Your clipboard history is yours alone +- 🚫 **No automatic reporting** — Errors are logged locally only; nothing is sent anywhere without your action +- 🔍 **Fully auditable** — Every line of code is open source under [GPLv3](LICENSE) + +**The data on your computer is yours.** I built CopyPaste to respect that boundary completely — not just in policy, but in code. + +--- + +## What Data Does CopyPaste Store? + +CopyPaste monitors your system clipboard to maintain a local history. The following data is stored **exclusively on your computer**: + +### Clipboard Content + +| Type | What's Stored | Where | +| :--- | :--- | :--- | +| **Text** | The copied text content | SQLite database | +| **Images** | Image files (PNG) | Local `images` folder | +| **Files & Folders** | File/folder paths (not the files themselves) | SQLite database | +| **Links** | URL text | SQLite database | +| **Audio & Video** | File paths only | SQLite database | +| **Thumbnails** | Small preview images (`_thumb.png`) generated by the OS shell for images, video and audio entries | Local `images` folder | + +### Metadata + +For each clipboard item, CopyPaste also stores: + +- **Timestamp** — When the item was copied +- **Content type** — Text, Image, File, Folder, Link, Audio, or Video +- **Source application** — The name of the app where you copied from (_window title_) +- **User labels** — Custom labels you assign to items (optional) +- **Color tags** — Color categories you assign (optional) +- **Pin status** — Whether you pinned the item +- **Paste count** — How many times you have pasted the item +- **Media metadata** — Duration, dimensions, or codec info for audio and video files (stored as JSON) +- **Image thumbnails** — Smaller preview versions of copied images +- **Broken-since timestamp** (`broken_since`) — When the referenced file/image stopped being available on disk (set to `null` while the file exists). Used to keep the entry visible during the configured retention window so reconnecting an external drive restores the preview instead of losing it. + +### Configuration + +Your settings are stored locally: + +- Hotkey preferences +- Theme selection +- Language preference +- Panel width +- Retention period +- Filter behavior +- Startup preferences +- **Image quota (MB)** (`imagesQuotaMB`) — Maximum disk space copied images may use; `0` means unlimited (default). When the cap is reached, the oldest non-pinned images inside the app's own `images` folder are evicted (LRU). Pinned items and any path that does not live under that folder are never touched. +- **Broken-item retention days** (`keepBrokenItemsDays`) — Number of days an entry whose referenced file is missing is preserved before being purged (default 30). +- **Thumbnail generation toggles** — Independent on/off switches for image, video and audio thumbnails, plus a maximum image processing size (MB) to skip very large files. +- **Onboarding completion flag** (`hasCompletedOnboarding`) — Remembers that the first-launch walkthrough has already been shown. + +### Windows System Integration (Startup) + +When you enable **Start with Windows** in Settings, CopyPaste registers itself as a startup application using the appropriate mechanism for each distribution channel — and removes the registration when you disable it: + +| Distribution | Mechanism | What is written | +| :--- | :--- | :--- | +| **Standalone installer (.exe)** | Windows registry key | `HKCU\Software\Microsoft\Windows\CurrentVersion\Run\CopyPaste` | +| **Microsoft Store (MSIX)** | Windows StartupTask API | System startup catalog (no registry write) | + +Neither mechanism requires administrator rights. On uninstall, the standalone installer automatically removes the registry entry. The MSIX version is cleaned up by Windows when the app is uninstalled through the Store or Settings → Apps. + +If you never enable "Start with Windows," nothing is written to the registry or the startup catalog. + +### Logs + +Application logs are stored locally for troubleshooting: + +- **Windows:** `%LOCALAPPDATA%\CopyPaste\logs\` +- **macOS:** `~/Library/Application Support/com.rgdevment.copypaste/CopyPaste/logs/` +- **Linux:** `~/.local/share/com.rgdevment.copypaste/CopyPaste/logs/` +- **Content:** Application events, errors, and diagnostic information only +- **No personal data:** Logs do **not** contain clipboard content — your copied text, images, or file paths are never written to log files + +### Crash Log + +If the app fails to start or crashes during initialization, a single `crash.log` file is written next to the data folder so the failure is recoverable on the next launch. This file: + +- Lives at `/crash.log` on every platform (e.g. `%LOCALAPPDATA%\CopyPaste\crash.log` on Windows) +- Is **capped at 512 KB** — older content is overwritten automatically +- Contains: timestamp (UTC), OS name and version, Dart runtime version, the failing operation, and the stack trace +- Has **automatic redaction applied at write time**: your Windows/macOS/Linux user name, full home folder path, and any email addresses found in stack traces are replaced with ``, ``, and `` placeholders before being written to disk +- **Never contains clipboard content** — clipboard data does not flow through error paths +- **Is never sent anywhere automatically** — same rule as the regular logs + +--- + +## Support & Log Export — What Happens and What Doesn't + +CopyPaste includes a **Support** section in Settings → About that lets you export a diagnostic log bundle. Here is exactly what this does and doesn't do: + +### What the Export Does + +- Collects recent `.log` files from the local logs folder +- Includes the `crash.log` file if one exists +- Applies an **additional redaction pass** before zipping: user name, home folder path and email addresses are replaced with ``, `` and `` in every file added to the archive +- Adds a `device_info.txt` with your OS version, OS build, system locale, and CopyPaste app version +- Packages everything into a single `.zip` file saved to a location **you choose** on your computer + +### What the Export Does NOT Do + +- **Does not send anything anywhere automatically** — the zip stays on your disk until you manually share it +- **Does not include clipboard content** — your copied text, images, or file paths are never in the logs and never in the export +- **Does not connect to the internet** — the export is a local file operation only +- **Does not run in the background** — it only happens when you explicitly click "Export Logs" + +### How to Share Safely + +If you want to attach logs to a GitHub issue: + +1. Open the exported zip and review it before sharing — you can read the log files in any text editor +2. Redact anything you're uncomfortable sharing (though there should be no personal data) +3. Attach the zip manually to your bug report + +**You are in control at every step.** Nothing goes anywhere without your explicit action. + +--- + +## Where Is Everything Stored? + +All data is stored locally under your user profile: + +**Windows:** + +| Data | Location | +| :--- | :--- | +| **Database** | `%LOCALAPPDATA%\CopyPaste\clipboard.db` | +| **Images** | `%LOCALAPPDATA%\CopyPaste\images\` | +| **Configuration** | `%LOCALAPPDATA%\CopyPaste\config\` | +| **Logs** | `%LOCALAPPDATA%\CopyPaste\logs\` | +| **Crash log** | `%LOCALAPPDATA%\CopyPaste\crash.log` | + +**macOS:** + +| Data | Location | +| :--- | :--- | +| **Database** | `~/Library/Application Support/com.rgdevment.copypaste/CopyPaste/clipboard.db` | +| **Images** | `~/Library/Application Support/com.rgdevment.copypaste/CopyPaste/images/` | +| **Configuration** | `~/Library/Application Support/com.rgdevment.copypaste/CopyPaste/config/` | +| **Logs** | `~/Library/Application Support/com.rgdevment.copypaste/CopyPaste/logs/` | +| **Crash log** | `~/Library/Application Support/com.rgdevment.copypaste/CopyPaste/crash.log` | + +**Linux:** + +| Data | Location | +| :--- | :--- | +| **Database** | `~/.local/share/com.rgdevment.copypaste/CopyPaste/clipboard.db` | +| **Images** | `~/.local/share/com.rgdevment.copypaste/CopyPaste/images/` | +| **Configuration** | `~/.local/share/com.rgdevment.copypaste/CopyPaste/config/` | +| **Logs** | `~/.local/share/com.rgdevment.copypaste/CopyPaste/logs/` | +| **Crash log** | `~/.local/share/com.rgdevment.copypaste/CopyPaste/crash.log` | + +These folders are protected by your operating system's user account permissions. Other users on the same computer cannot access them under normal conditions. + +--- + +## What CopyPaste Does NOT Do + +To be absolutely clear: + +- ❌ **Does not send data to any server** — No clipboard content, no metadata, no usage data +- ❌ **Does not use cookies or tracking technologies** +- ❌ **Does not create user accounts or profiles** +- ❌ **Does not share data with third parties** +- ❌ **Does not use advertising or ad networks** +- ❌ **Does not use AI or machine learning** on your data +- ❌ **Does not sync across devices** +- ❌ **Does not upload crash reports** — Crashes are written to a local `crash.log` (with PII redacted at write time); log export is always manual and user-initiated +- ❌ **Does not phone home** — No background network calls except the update checker described below (all platforms) + +--- + +## Network Requests + +CopyPaste makes **one type of network request** for update checking: + +### Update Checker + +| Detail | Value | +| :--- | :--- | +| **Purpose** | Check if a newer version of CopyPaste is available and enforce blocks for versions with known critical issues | +| **URL (all platforms)** | `https://github.com/rgdevment/CopyPaste/releases/latest/download/release-manifest.json` (and its `.sig` signature file) | +| **Method** | `GET` (read-only) | +| **Data sent** | Standard HTTP headers only — **no user data** | +| **Data received** | A small signed JSON file listing the latest version, minimum supported version, any blocked versions, and per-channel install info. An accompanying Ed25519 signature is verified locally before the manifest is trusted | +| **Frequency** | Every 24 hours, plus once at startup | +| **Cached locally** | Yes — last successfully verified manifest is cached for up to 15 days so the app works offline | + +**Important notes:** + +- This request is **read-only** — it only downloads two small public files; no data is ever uploaded +- **No clipboard content, no usage data, no personal information** is ever sent +- The manifest is **cryptographically signed** with an Ed25519 key. If the signature does not verify, the manifest is discarded and no update indicator is shown +- **All platforms:** If an update is found, a non-invasive indicator appears in the app's footer bar — no popups or dialogs interrupt your workflow. You can click the indicator to see details +- **Standalone builds (Windows / macOS / Linux):** Clicking the indicator opens the GitHub release page (or shows the Homebrew / apt / dnf upgrade command). Nothing is downloaded or installed automatically +- **Microsoft Store version:** Clicking the indicator opens a dialog explaining that Microsoft Store delivers updates on its own schedule. The app is never blocked on Store builds, since update delivery is outside our control +- **Blocked versions:** If the manifest flags the installed version as having a critical issue (for example, a severe security bug or data-corruption fix), standalone builds show a full-screen prompt with direct install/download instructions. This mechanism is disabled on Microsoft Store builds + +### User-Initiated Browser Navigation + +When you explicitly click certain UI buttons, CopyPaste opens URLs in your default browser: + +- **"Report issue"** button → Opens `https://github.com/rgdevment/CopyPaste/issues` +- **"Download update"** indicator → Opens the GitHub release page + +These are standard browser navigations initiated by your action — CopyPaste does not make these requests itself. + +--- + +## Sensitive Data Protection + +CopyPaste includes built-in protections for sensitive content: + +### Password Manager Exclusion + +Clipboard content from recognized password managers is **automatically excluded** from history. Supported password managers include: + +- 1Password +- Bitwarden +- LastPass +- KeePass +- And others that use standard clipboard security flags + +### How It Works + +- Password managers typically set a clipboard format flag indicating sensitive content +- CopyPaste detects these flags and **skips storing** the content entirely +- The sensitive data is never written to the database or disk + +### Windows Clipboard History + +CopyPaste operates independently from Windows' built-in clipboard history (`Win+V`). Your CopyPaste settings do not affect Windows clipboard behavior, and vice versa. + +--- + +## Data Retention & Deletion + +### Automatic Cleanup + +- CopyPaste automatically deletes unpinned items older than your configured retention period (default: **30 days**) +- Cleanup runs periodically in the background +- **Pinned items are preserved** regardless of the retention setting + +### Manual Deletion + +You can delete any clipboard item at any time: + +- Select an item and press `Delete` +- Right-click and choose "Delete" + +### Clean Install & Reset (In-App) + +CopyPaste includes in-app reset options at **Settings → About → Reset & Clean Install**: + +- **Soft Reset** — Resets all settings to defaults. Your clipboard history is preserved. +- **Hard Reset** — Deletes everything: history, images, settings, and logs. The app restarts completely clean. **This cannot be undone.** + +These options work on all platforms including the Microsoft Store version. + +### Complete Data Removal (Uninstall) + +To completely remove all CopyPaste data when uninstalling: + +**Windows:** + +1. Uninstall CopyPaste (via Settings → Apps or the standalone uninstaller) +2. Delete the data folder: `%LOCALAPPDATA%\CopyPaste\` + +**macOS:** + +1. Move CopyPaste to Trash from Applications +2. Delete the data folder: `~/Library/Application Support/com.rgdevment.copypaste/CopyPaste/` + +**Linux:** + +1. Uninstall CopyPaste (via your package manager or remove the binary) +2. Delete the data folder: `~/.local/share/com.rgdevment.copypaste/CopyPaste/` + +After these steps, no CopyPaste data remains on your system. + +--- + +## Children's Privacy + +CopyPaste does not knowingly collect any personal information from anyone, including children under 13. The application does not collect personal information from any user — it has no accounts, no registration, and no data transmission. + +--- + +## Microsoft Store Distribution + +CopyPaste is available through the [Microsoft Store](https://apps.microsoft.com/detail/9NBJRZF3K856). The Store version: + +- **Follows the same privacy principles** as the standalone version +- **Makes one read-only network request** — queries the GitHub Releases API every 24 hours to check if a newer version exists. If found, a non-invasive indicator appears in the footer bar. No download link is shown and nothing is installed automatically — updates are delivered through the Microsoft Store +- **Uses MSIX packaging** — installs/uninstalls cleanly with Windows standard mechanisms +- **Microsoft Store policies** apply to distribution, but CopyPaste itself does not share any data with Microsoft beyond what the Store platform requires for installation and updates + +For Microsoft's own privacy practices regarding the Store, refer to [Microsoft's Privacy Statement](https://privacy.microsoft.com/privacystatement). + +--- + +## Open Source Transparency + +The best privacy policy is one you can verify. CopyPaste is **100% open source** under the [GNU General Public License v3.0](LICENSE): + +- 📂 **Full source code:** [github.com/rgdevment/CopyPaste](https://github.com/rgdevment/CopyPaste) +- 🔍 **Audit the code yourself** — every network request, every database write, every file operation +- 🐛 **Report concerns** — [open an issue](https://github.com/rgdevment/CopyPaste/issues) or [email us](mailto:github@apirest.cl) + +We encourage security researchers and privacy advocates to inspect the code. See our [Security Policy](SECURITY.md) for responsible disclosure guidelines. + +--- + +## Changes to This Policy + +If we ever change this privacy policy, the changes will be: + +- Committed to the public repository with a clear commit message +- Reflected in the "Last updated" date at the top +- Documented in the release notes + +Since CopyPaste is open source, any change to privacy behavior would also be visible as a code change in the public repository before it reaches you. + +--- + +## Contact + +If you have questions or concerns about this privacy policy: + +- 📧 **Email:** [github@apirest.cl](mailto:github@apirest.cl) +- 💬 **GitHub Discussions:** [github.com/rgdevment/CopyPaste/discussions](https://github.com/rgdevment/CopyPaste/discussions) +- 🐛 **Issues:** [github.com/rgdevment/CopyPaste/issues](https://github.com/rgdevment/CopyPaste/issues) + +--- + +
+

Everything local. Your clipboard is yours — I built CopyPaste to keep it that way.

+
diff --git a/README.md b/README.md index a5852a59..3413b708 100644 --- a/README.md +++ b/README.md @@ -17,7 +17,7 @@ Latest Release - Platform: Windows, macOS, Linux + Platform: Windows, macOS, Linux License GPL-3.0 @@ -74,13 +74,13 @@ This isn't a company product. I'm a developer who needed a better **copy paste** - **100% local** — your clipboard history never leaves your computer. No cloud, no servers, no accounts. - **Truly free** — no premium tiers, no feature gates, no "free trial" tricks. GPL v3, forever. -- **Cross-platform** — same native copy-paste experience on Windows, macOS, and Linux (beta). +- **Cross-platform** — same native copy-paste experience on Windows, macOS, and Linux. - **Fast and light** — starts in milliseconds, uses minimal resources. You'll forget it's running. - **Beautiful** — follows your OS theme (light/dark), with Mica effect on Windows and native materials on macOS. > I use CopyPaste every day on Windows 11 and macOS. If something feels off, [let me know](#found-a-bug-have-feedback) — this project keeps improving because of real-world use. > -> **Linux is in beta.** It works, but there are edge cases across different desktop environments. If you're on Linux and want to help, [your feedback matters](#found-a-bug-have-feedback). +> **Linux:** standalone builds for **X11 sessions** (Ubuntu / Fedora / RHEL-compatible). Wayland is not supported yet — global hotkey and auto-paste rely on X11 APIs. --- @@ -394,7 +394,7 @@ brew tap rgdevment/tap && brew install --cask copypaste ### Linux — apt / dnf -> **Linux support is in beta.** Core clipboard manager features work well across tested distributions, but you may encounter issues depending on your desktop environment, display server, or distro. [Please report anything unusual](https://github.com/rgdevment/CopyPaste/issues/new) — your reports directly shape stability improvements. +> **Linux requires an X11 session** (Xorg or XWayland). On a pure Wayland session, global hotkey and auto-paste are unavailable and a warning is shown at startup. CopyPaste is distributed as **standalone, unsandboxed builds** (Cloudsmith apt/dnf, Homebrew, .AppImage) — there is **no Snap, Flatpak or Flathub package**, because the sandbox would prevent the global hotkey, full clipboard polling and opening of arbitrary file paths from the history. Packages are hosted on [Cloudsmith](https://cloudsmith.io/~rgdevment/repos/copypaste/) — set up the repository once, then get updates through your system package manager. @@ -488,7 +488,7 @@ For apt/dnf, yes — they install to system paths. If you cannot use sudo, use H Windows: `%LOCALAPPDATA%\CopyPaste\` — macOS: `~/Library/Application Support/com.rgdevment.copypaste/CopyPaste/` — Linux: `~/.local/share/com.rgdevment.copypaste/CopyPaste/`. Each folder contains the database, images, config, and logs. **What platforms does this copy-paste tool support?** -Windows 10/11, macOS (Ventura+), and Linux (Ubuntu 22.04+ · Fedora 38+ via apt/dnf · any distro via Homebrew or direct .deb, .rpm, .AppImage). Linux is in beta — see the [Getting Started](#getting-started) section for details. +Windows 10/11, macOS (Ventura+), and Linux on **X11 sessions** (Ubuntu 22.04+ · Fedora 38+ via apt/dnf · any distro via Homebrew or direct .deb, .rpm, .AppImage). Wayland-only sessions are not supported yet — see the [Getting Started](#getting-started) section for details. **Does it start automatically with Windows?** Optionally, yes. Enable it in Settings → General → Start with Windows. On the Microsoft Store version it uses the Windows StartupTask system; on the standalone installer it registers through the standard Windows startup mechanism. No administrator rights are required for either. diff --git a/SECURITY.md b/SECURITY.md index 3cb2e684..b24bcfb8 100644 --- a/SECURITY.md +++ b/SECURITY.md @@ -1,235 +1,235 @@ -# Security Policy - -## Security Matters - -**CopyPaste** handles your clipboard history—that can include sensitive stuff. I take security seriously because you're trusting this tool with content that might be personal or confidential. - -**This isn't corporate security theater.** This is a personal project shared with the community. It's built on trust—transparency in the code, responsibility when issues come up, and treating security researchers as partners. - -I'm not protecting a brand or business. I'm protecting _you_ and everyone using this tool. - ---- - -## 🔒 What We Do to Keep You Safe - -### Privacy by Design - -- **100% Local Storage** — Your clipboard history never leaves your machine. No cloud sync, no telemetry, no remote servers. -- **Sensitive Data Exclusion** — Password manager content (1Password, Bitwarden, etc.) is automatically excluded from history. -- **No Tracking** — I don't collect anything. No analytics, no usage data, nothing. - -### Security Features - -- **Local SQLite Database** — Your clipboard history is stored in a local database on your machine, not in the cloud. -- **Configurable Retention** — Automatically delete old clipboard items based on your retention settings. -- **Open Source** — Every line of code is public. You can inspect, audit, and verify what we're doing. -- **Signed Release Manifest** — The update notifier fetches a small JSON file signed with an Ed25519 key. The signature is verified locally before the file is trusted, so a compromised mirror cannot inject a fake "latest version" or a malicious install URL. If the signature fails, the manifest is discarded. -- **Minimum Supported Version Enforcement** — When a release contains a critical fix (e.g. a data-corruption or security issue), the signed manifest can mark older versions as blocked. Standalone builds (Windows / macOS / Linux) then show a full-screen prompt with direct install instructions. **Microsoft Store builds are never blocked** — updates on that platform are delivered on Microsoft's review schedule, which is outside our control, so blocking would leave users without a path forward. - -### Development Practices - -- **Modern Flutter Stack** — Built with Flutter and Dart, with dependencies regularly audited and updated. -- **Dependency Updates** — We regularly update dependencies to patch known vulnerabilities. -- **Code Reviews** — All contributions go through review before merging. - ---- - -## 🚨 Supported Versions - -Security updates are provided for: - -| Version | Supported | -| :--- | :--- | -| Latest Release | ✅ Actively Supported | -| Beta Versions | ✅ Actively Supported | -| Older Releases | ❌ Not Supported (please update) | - -**We strongly recommend always using the latest version** from the [Releases Page](https://github.com/rgdevment/CopyPaste/releases/latest). - ---- - -## 🐛 Reporting a Vulnerability - -If you discover a security vulnerability in **CopyPaste**, please help us protect our users by reporting it responsibly. - -### What Qualifies as a Security Vulnerability? - -**Please report:** - -- ✅ Unauthorized access to clipboard history -- ✅ Privilege escalation issues -- ✅ Data leakage or unintended storage of sensitive information -- ✅ Injection attacks (SQL, command, etc.) -- ✅ Bypass of sensitive data exclusion mechanisms -- ✅ Critical bugs that could lead to data loss or corruption - -**Not security issues:** - -- ❌ Feature requests or enhancements -- ❌ General bugs that don't have security implications -- ❌ Issues with third-party dependencies (report those upstream) -- ❌ Windows SmartScreen warnings (see [README](README.md) for explanation) - -### How to Report Securely - -**DO NOT** open a public GitHub issue for security vulnerabilities. Instead, use one of these private channels: - -#### 📧 Email (Simplest & Direct) - -Send an email to: **** - -**Subject:** `[SECURITY] Brief description of the issue` - -This is the fastest way to reach us. We check email daily and will respond within 48 hours. - -#### 🔒 GitHub Security Advisory (Alternative) - -1. Go to the [Security tab](https://github.com/rgdevment/CopyPaste/security) in the repository -2. Click **"Report a vulnerability"** -3. Fill in the details using the template provided -4. Submit privately — only maintainers will see it - -**Choose whichever method is most comfortable for you.** What matters is that we hear from you. - -**Include in your report:** - -- **Description** — Clear explanation of the vulnerability -- **Impact** — What could an attacker do? Who is affected? -- **Steps to Reproduce** — How can we reproduce the issue? -- **CopyPaste Version** — Which version is affected? -- **OS and version** — e.g., Windows 11 23H2, macOS Sequoia 15.1, Ubuntu 24.04 -- **Proof of Concept** (optional) — Code or screenshots demonstrating the issue -- **Suggested Fix** (optional) — If you have ideas on how to fix it - -### What Happens Next? - -1. **Acknowledgment (Within 48 Hours)** - - I'll confirm I received your report - - I'll let you know if I need more information - -2. **Investigation (1-7 Days)** - - I'll reproduce and analyze the issue - - Assess severity and impact - - Develop a fix - -3. **Resolution** - - Create a patch and test it thoroughly - - Coordinate a release timeline with you - - Credit you in the release notes (if you want) - -4. **Disclosure (After Fix is Released)** - - Publish a security advisory - - Notify users to update - - You can publicly disclose (coordinated disclosure) - -### Response Time Expectations - -| Severity | Response Time | Fix Target | -| :--- | :---: | :---: | -| **Critical** (Remote code execution, data breach) | 24 hours | 1-3 days | -| **High** (Privilege escalation, significant data leak) | 48 hours | 3-7 days | -| **Medium** (Limited scope, requires user interaction) | 3 days | 1-2 weeks | -| **Low** (Minimal impact, edge cases) | 1 week | Next release | - -**I'm one person** (with community help), but I take security seriously. If you don't hear back within the expected timeframe, please follow up—things might've gotten lost. - ---- - -## 🤝 Responsible Disclosure - -I believe in **coordinated disclosure** to protect users: - -- **Please give me reasonable time to fix the issue** before publicly disclosing it -- I aim to release fixes within 7 days for critical issues -- I'll work with you on a disclosure timeline that protects users -- I'll credit you in the release notes (unless you prefer to remain anonymous) - -### My Promise to Security Researchers - -**I WILL:** - -- ✅ Treat you with respect and gratitude—you're helping protect users -- ✅ Respond promptly to your report (within 48 hours) -- ✅ Keep you updated throughout the investigation and fix process -- ✅ Credit your work publicly (if you want) -- ✅ Be transparent about the timeline and progress - -**I will NEVER:** - -- ❌ Threaten legal action against good-faith security researchers -- ❌ Ignore or dismiss legitimate reports -- ❌ Retaliate against reporters in any way -- ❌ Use intimidation tactics or silence critics -- ❌ Blame you for finding vulnerabilities in the code - -**Security research makes everyone safer.** I'm grateful for your work and will treat you as a valued partner in protecting the community. - ---- - -## 🏆 Security Researchers Hall of Fame - -We're grateful to the security researchers who help make **CopyPaste** safer: - - - -- _No security issues reported yet. Help us stay secure!_ - -**Want to be listed here?** Report a verified security vulnerability and choose to be credited. We'll add your name (or handle) and a link to your profile if you'd like. - ---- - -## 📚 Additional Security Resources - -### For Users - -- **Keep CopyPaste Updated** — Enable automatic updates or check for new releases regularly -- **Review Clipboard History** — Periodically check what's being stored and delete sensitive items -- **Configure Retention** — Set shorter retention periods if you handle highly sensitive data -- **Use Password Managers** — Their clipboard content is automatically excluded from history - -### For Developers - -- **Read the Code** — The entire codebase is open source: [CopyPaste Repository](https://github.com/rgdevment/CopyPaste) -- **Review Dependencies** — Check `pubspec.yaml` for third-party packages we use -- **Security Best Practices** — Follow secure coding guidelines when contributing - ---- - -## 🔐 Cryptographic Disclosure - -**CopyPaste does not currently use cryptographic functions for data storage.** - -- Clipboard history is stored in **plaintext** in a local SQLite database -- Database files are protected by **OS-level file system permissions** (Windows, macOS, and Linux) -- No encryption is applied to stored clipboard data - -**Why?** - -- The database is local-only and protected by your OS user account -- Encryption would add complexity and potential key management issues -- Performance and startup time would be impacted -- You control physical access to your machine - -**Future Consideration:** -If there's community demand for at-rest encryption, we're open to discussing it. Open an issue if this is important to you. - ---- - -## 💬 Questions or Concerns? - -We're here to help and answer questions: - -- **Security Questions:** Email us at **** — we're happy to discuss concerns privately -- **General Questions:** [Open a Discussion](https://github.com/rgdevment/CopyPaste/discussions) — ask publicly, we'll answer openly -- **Vulnerability Reports:** Use the private channels above — never post security issues publicly -- **Policy Feedback:** [Open an Issue](https://github.com/rgdevment/CopyPaste/issues/new) — help us improve this policy - -**Security is everyone's responsibility.** Thank you for helping keep **CopyPaste** safe for everyone using it. - -**Remember:** If you're unsure whether something is a security issue, reach out anyway. I'd rather have a conversation than miss a real problem. - ---- - -
-

Built securely, transparently, and with ❤️.

-
+# Security Policy + +## Security Matters + +**CopyPaste** handles your clipboard history—that can include sensitive stuff. I take security seriously because you're trusting this tool with content that might be personal or confidential. + +**This isn't corporate security theater.** This is a personal project shared with the community. It's built on trust—transparency in the code, responsibility when issues come up, and treating security researchers as partners. + +I'm not protecting a brand or business. I'm protecting _you_ and everyone using this tool. + +--- + +## 🔒 What We Do to Keep You Safe + +### Privacy by Design + +- **100% Local Storage** — Your clipboard history never leaves your machine. No cloud sync, no telemetry, no remote servers. +- **Sensitive Data Exclusion** — Password manager content (1Password, Bitwarden, etc.) is automatically excluded from history. +- **No Tracking** — I don't collect anything. No analytics, no usage data, nothing. + +### Security Features + +- **Local SQLite Database** — Your clipboard history is stored in a local database on your machine, not in the cloud. +- **Configurable Retention** — Automatically delete old clipboard items based on your retention settings. +- **Open Source** — Every line of code is public. You can inspect, audit, and verify what we're doing. +- **Signed Release Manifest** — The update notifier fetches a small JSON file signed with an Ed25519 key. The signature is verified locally before the file is trusted, so a compromised mirror cannot inject a fake "latest version" or a malicious install URL. If the signature fails, the manifest is discarded. +- **Minimum Supported Version Enforcement** — When a release contains a critical fix (e.g. a data-corruption or security issue), the signed manifest can mark older versions as blocked. Standalone builds (Windows / macOS / Linux) then show a full-screen prompt with direct install instructions. **Microsoft Store builds are never blocked** — updates on that platform are delivered on Microsoft's review schedule, which is outside our control, so blocking would leave users without a path forward. + +### Development Practices + +- **Modern Flutter Stack** — Built with Flutter and Dart, with dependencies regularly audited and updated. +- **Dependency Updates** — We regularly update dependencies to patch known vulnerabilities. +- **Code Reviews** — All contributions go through review before merging. + +--- + +## 🚨 Supported Versions + +Security updates are provided for: + +| Version | Supported | +| :--- | :--- | +| Latest Release | ✅ Actively Supported | +| Pre-releases (`-rc`, `-beta`) | ✅ Actively Supported | +| Older Releases | ❌ Not Supported (please update) | + +**We strongly recommend always using the latest version** from the [Releases Page](https://github.com/rgdevment/CopyPaste/releases/latest). + +--- + +## 🐛 Reporting a Vulnerability + +If you discover a security vulnerability in **CopyPaste**, please help us protect our users by reporting it responsibly. + +### What Qualifies as a Security Vulnerability? + +**Please report:** + +- ✅ Unauthorized access to clipboard history +- ✅ Privilege escalation issues +- ✅ Data leakage or unintended storage of sensitive information +- ✅ Injection attacks (SQL, command, etc.) +- ✅ Bypass of sensitive data exclusion mechanisms +- ✅ Critical bugs that could lead to data loss or corruption + +**Not security issues:** + +- ❌ Feature requests or enhancements +- ❌ General bugs that don't have security implications +- ❌ Issues with third-party dependencies (report those upstream) +- ❌ Windows SmartScreen warnings (see [README](README.md) for explanation) + +### How to Report Securely + +**DO NOT** open a public GitHub issue for security vulnerabilities. Instead, use one of these private channels: + +#### 📧 Email (Simplest & Direct) + +Send an email to: **** + +**Subject:** `[SECURITY] Brief description of the issue` + +This is the fastest way to reach us. We check email daily and will respond within 48 hours. + +#### 🔒 GitHub Security Advisory (Alternative) + +1. Go to the [Security tab](https://github.com/rgdevment/CopyPaste/security) in the repository +2. Click **"Report a vulnerability"** +3. Fill in the details using the template provided +4. Submit privately — only maintainers will see it + +**Choose whichever method is most comfortable for you.** What matters is that we hear from you. + +**Include in your report:** + +- **Description** — Clear explanation of the vulnerability +- **Impact** — What could an attacker do? Who is affected? +- **Steps to Reproduce** — How can we reproduce the issue? +- **CopyPaste Version** — Which version is affected? +- **OS and version** — e.g., Windows 11 23H2, macOS Sequoia 15.1, Ubuntu 24.04 +- **Proof of Concept** (optional) — Code or screenshots demonstrating the issue +- **Suggested Fix** (optional) — If you have ideas on how to fix it + +### What Happens Next? + +1. **Acknowledgment (Within 48 Hours)** + - I'll confirm I received your report + - I'll let you know if I need more information + +2. **Investigation (1-7 Days)** + - I'll reproduce and analyze the issue + - Assess severity and impact + - Develop a fix + +3. **Resolution** + - Create a patch and test it thoroughly + - Coordinate a release timeline with you + - Credit you in the release notes (if you want) + +4. **Disclosure (After Fix is Released)** + - Publish a security advisory + - Notify users to update + - You can publicly disclose (coordinated disclosure) + +### Response Time Expectations + +| Severity | Response Time | Fix Target | +| :--- | :---: | :---: | +| **Critical** (Remote code execution, data breach) | 24 hours | 1-3 days | +| **High** (Privilege escalation, significant data leak) | 48 hours | 3-7 days | +| **Medium** (Limited scope, requires user interaction) | 3 days | 1-2 weeks | +| **Low** (Minimal impact, edge cases) | 1 week | Next release | + +**I'm one person** (with community help), but I take security seriously. If you don't hear back within the expected timeframe, please follow up—things might've gotten lost. + +--- + +## 🤝 Responsible Disclosure + +I believe in **coordinated disclosure** to protect users: + +- **Please give me reasonable time to fix the issue** before publicly disclosing it +- I aim to release fixes within 7 days for critical issues +- I'll work with you on a disclosure timeline that protects users +- I'll credit you in the release notes (unless you prefer to remain anonymous) + +### My Promise to Security Researchers + +**I WILL:** + +- ✅ Treat you with respect and gratitude—you're helping protect users +- ✅ Respond promptly to your report (within 48 hours) +- ✅ Keep you updated throughout the investigation and fix process +- ✅ Credit your work publicly (if you want) +- ✅ Be transparent about the timeline and progress + +**I will NEVER:** + +- ❌ Threaten legal action against good-faith security researchers +- ❌ Ignore or dismiss legitimate reports +- ❌ Retaliate against reporters in any way +- ❌ Use intimidation tactics or silence critics +- ❌ Blame you for finding vulnerabilities in the code + +**Security research makes everyone safer.** I'm grateful for your work and will treat you as a valued partner in protecting the community. + +--- + +## 🏆 Security Researchers Hall of Fame + +We're grateful to the security researchers who help make **CopyPaste** safer: + + + +- _No security issues reported yet. Help us stay secure!_ + +**Want to be listed here?** Report a verified security vulnerability and choose to be credited. We'll add your name (or handle) and a link to your profile if you'd like. + +--- + +## 📚 Additional Security Resources + +### For Users + +- **Keep CopyPaste Updated** — Enable automatic updates or check for new releases regularly +- **Review Clipboard History** — Periodically check what's being stored and delete sensitive items +- **Configure Retention** — Set shorter retention periods if you handle highly sensitive data +- **Use Password Managers** — Their clipboard content is automatically excluded from history + +### For Developers + +- **Read the Code** — The entire codebase is open source: [CopyPaste Repository](https://github.com/rgdevment/CopyPaste) +- **Review Dependencies** — Check `pubspec.yaml` for third-party packages we use +- **Security Best Practices** — Follow secure coding guidelines when contributing + +--- + +## 🔐 Cryptographic Disclosure + +**CopyPaste does not currently use cryptographic functions for data storage.** + +- Clipboard history is stored in **plaintext** in a local SQLite database +- Database files are protected by **OS-level file system permissions** (Windows, macOS, and Linux) +- No encryption is applied to stored clipboard data + +**Why?** + +- The database is local-only and protected by your OS user account +- Encryption would add complexity and potential key management issues +- Performance and startup time would be impacted +- You control physical access to your machine + +**Future Consideration:** +If there's community demand for at-rest encryption, we're open to discussing it. Open an issue if this is important to you. + +--- + +## 💬 Questions or Concerns? + +We're here to help and answer questions: + +- **Security Questions:** Email us at **** — we're happy to discuss concerns privately +- **General Questions:** [Open a Discussion](https://github.com/rgdevment/CopyPaste/discussions) — ask publicly, we'll answer openly +- **Vulnerability Reports:** Use the private channels above — never post security issues publicly +- **Policy Feedback:** [Open an Issue](https://github.com/rgdevment/CopyPaste/issues/new) — help us improve this policy + +**Security is everyone's responsibility.** Thank you for helping keep **CopyPaste** safe for everyone using it. + +**Remember:** If you're unsure whether something is a security issue, reach out anyway. I'd rather have a conversation than miss a real problem. + +--- + +
+

Built securely, transparently, and with ❤️.

+
diff --git a/listener/linux/listener_plugin.c b/listener/linux/listener_plugin.c index 9c177731..4a45eb69 100644 --- a/listener/linux/listener_plugin.c +++ b/listener/linux/listener_plugin.c @@ -240,6 +240,28 @@ static gchar* read_proc_comm(unsigned long pid) { return content; } +static gchar* prettify_app_id(gchar* value) { + if (value == NULL || *value == '\0') { + return value; + } + guint dots = 0; + for (const gchar* p = value; *p != '\0'; p++) { + if (*p == '.') { + dots++; + } + } + if (dots < 2) { + return value; + } + const gchar* last = strrchr(value, '.'); + if (last == NULL || *(last + 1) == '\0') { + return value; + } + gchar* trimmed = g_strdup(last + 1); + g_free(value); + return trimmed; +} + static gchar* get_x11_window_source(Window window) { Display* display = get_xdisplay(); if (display == NULL || window == 0) { @@ -257,7 +279,7 @@ static gchar* get_x11_window_source(Window window) { XFree(class_hint.res_class); } if (value != NULL && *value != '\0') { - return value; + return prettify_app_id(value); } g_free(value); } @@ -278,7 +300,7 @@ static gchar* get_x11_window_source(Window window) { data = NULL; gchar* comm = read_proc_comm(pid); if (comm != NULL) { - return comm; + return prettify_app_id(comm); } } From 5f9f6a39ea6318d7c735d87a0dda3ce1aff00d81 Mon Sep 17 00:00:00 2001 From: rgdevment Date: Fri, 24 Apr 2026 21:20:20 -0400 Subject: [PATCH 24/31] feat: enhance error handling and logging in Linux session management --- app/lib/shell/linux_session.dart | 8 ++++++-- 1 file changed, 6 insertions(+), 2 deletions(-) diff --git a/app/lib/shell/linux_session.dart b/app/lib/shell/linux_session.dart index c3606e97..e95aece5 100644 --- a/app/lib/shell/linux_session.dart +++ b/app/lib/shell/linux_session.dart @@ -1,5 +1,6 @@ import 'dart:io'; +import 'package:core/core.dart'; import 'package:flutter/foundation.dart'; @immutable @@ -116,7 +117,8 @@ bool _hasWaylandSocket(String? runtimeDir) { return Directory(runtimeDir) .listSync(followLinks: false) .any((e) => e.uri.pathSegments.last.startsWith('wayland')); - } catch (_) { + } catch (e) { + AppLogger.warn('linux_session: hasWaylandSocket failed: $e'); return false; } } @@ -133,7 +135,9 @@ Future linuxPrefersDarkMode() async { if (result.exitCode == 0) { return (result.stdout as String).contains('dark'); } - } catch (_) {} + } catch (e) { + AppLogger.warn('linux_session: gsettings color-scheme failed: $e'); + } final gtkTheme = (Platform.environment['GTK_THEME'] ?? '').toLowerCase(); return gtkTheme.contains('dark'); From 0a8b449291494900067dace08ab2d2e5ea3f140d Mon Sep 17 00:00:00 2001 From: rgdevment Date: Fri, 24 Apr 2026 21:46:12 -0400 Subject: [PATCH 25/31] feat: add sessionOverride parameter for LinuxCapabilitiesService detection --- app/lib/services/linux_capabilities.dart | 3 +- app/test/screens/main_screen_test.dart | 134 +++++++++++++----- .../services/linux_capabilities_test.dart | 26 +++- 3 files changed, 125 insertions(+), 38 deletions(-) diff --git a/app/lib/services/linux_capabilities.dart b/app/lib/services/linux_capabilities.dart index 71c1b5a3..f095e7d2 100644 --- a/app/lib/services/linux_capabilities.dart +++ b/app/lib/services/linux_capabilities.dart @@ -117,6 +117,7 @@ class LinuxCapabilitiesService { static Future detect({ LinuxCapabilitiesChannel channel = const _DefaultLinuxCapabilitiesChannel(), Duration timeout = const Duration(milliseconds: 800), + @visibleForTesting LinuxSessionInfo? sessionOverride, }) async { if (!Platform.isLinux) { _cache = LinuxCapabilities.unsupported; @@ -124,7 +125,7 @@ class LinuxCapabilitiesService { return _cache; } - final session = detectLinuxSession(); + final session = sessionOverride ?? detectLinuxSession(); final base = LinuxCapabilities.unsupported.copyWith().copyWithSession( session, ); diff --git a/app/test/screens/main_screen_test.dart b/app/test/screens/main_screen_test.dart index 7e487387..448f1e0a 100644 --- a/app/test/screens/main_screen_test.dart +++ b/app/test/screens/main_screen_test.dart @@ -1,3 +1,5 @@ +import 'dart:io'; + import 'package:core/core.dart'; import 'package:flutter/gestures.dart'; import 'package:flutter/material.dart'; @@ -1373,11 +1375,10 @@ void main() { await tester.pumpWidget(_buildApp(service: service, onPaste: (_) {})); await tester.pumpAndSettle(); - final openButtons = find.byIcon(Icons.open_in_new_outlined); - if (openButtons.evaluate().isNotEmpty) { - await tester.tap(openButtons.first); - await tester.pumpAndSettle(); - } + final openButton = find.byIcon(Icons.open_in_new_rounded); + expect(openButton, findsAtLeastNWidgets(1)); + await tester.tap(openButton.first); + await tester.pumpAndSettle(); expect(find.byType(MainScreen), findsOneWidget); }); @@ -1395,11 +1396,10 @@ void main() { await tester.pumpWidget(_buildApp(service: service, onPaste: (_) {})); await tester.pumpAndSettle(); - final openButtons = find.byIcon(Icons.open_in_new_outlined); - if (openButtons.evaluate().isNotEmpty) { - await tester.tap(openButtons.first); - await tester.pumpAndSettle(); - } + final openButton = find.byIcon(Icons.open_in_new_rounded); + expect(openButton, findsAtLeastNWidgets(1)); + await tester.tap(openButton.first); + await tester.pumpAndSettle(); expect(find.byType(MainScreen), findsOneWidget); }); @@ -1414,23 +1414,29 @@ void main() { await tester.pumpWidget(_buildApp(service: service, onPaste: (_) {})); await tester.pumpAndSettle(); - final openButtons = find.byIcon(Icons.open_in_new_outlined); - if (openButtons.evaluate().isNotEmpty) { - await tester.tap(openButtons.first); - await tester.pumpAndSettle(); - } + final openButton = find.byIcon(Icons.open_in_new_rounded); + expect(openButton, findsAtLeastNWidgets(1)); + await tester.tap(openButton.first); + await tester.pumpAndSettle(); expect(find.byType(MainScreen), findsOneWidget); }); testWidgets( - '_onItemOpen image with missing file returns false gracefully', + '_onItemOpen image with missing file shows fileNotFound feedback', (tester) async { UrlHelper.platformOverride = 'other'; addTearDown(() => UrlHelper.platformOverride = null); + final tempDir = await Directory.systemTemp.createTemp('main_screen_'); + addTearDown(() async { + if (tempDir.existsSync()) await tempDir.delete(recursive: true); + }); + final imageFile = File('${tempDir.path}/image.png'); + await imageFile.writeAsBytes(const [0x89, 0x50, 0x4E, 0x47]); + await repo.save( ClipboardItem( - content: '/nonexistent/path/image.png', + content: imageFile.path, type: ClipboardContentType.image, ), ); @@ -1438,34 +1444,96 @@ void main() { await tester.pumpWidget(_buildApp(service: service, onPaste: (_) {})); await tester.pumpAndSettle(); - final openButtons = find.byIcon(Icons.open_in_new_outlined); - if (openButtons.evaluate().isNotEmpty) { - await tester.tap(openButtons.first); - await tester.pumpAndSettle(); - } - expect(find.byType(MainScreen), findsOneWidget); + final openButton = find.byIcon(Icons.open_in_new_rounded); + expect(openButton, findsAtLeastNWidgets(1)); + + await imageFile.delete(); + + await tester.tap(openButton.first); + await tester.pump(); + await tester.pump(const Duration(milliseconds: 100)); + + expect(find.byType(SnackBar), findsOneWidget); }, ); - testWidgets('_onItemOpen file item opens path', (tester) async { + testWidgets('_onItemOpen file item opens existing path', (tester) async { UrlHelper.platformOverride = 'other'; addTearDown(() => UrlHelper.platformOverride = null); + final tempDir = await Directory.systemTemp.createTemp('main_screen_'); + addTearDown(() async { + if (tempDir.existsSync()) await tempDir.delete(recursive: true); + }); + final realFile = File('${tempDir.path}/some_file.txt'); + await realFile.writeAsString('hello'); + await repo.save( - ClipboardItem( - content: '/tmp/some_file.txt', - type: ClipboardContentType.file, - ), + ClipboardItem(content: realFile.path, type: ClipboardContentType.file), ); await tester.pumpWidget(_buildApp(service: service, onPaste: (_) {})); await tester.pumpAndSettle(); - final openButtons = find.byIcon(Icons.open_in_new_outlined); - if (openButtons.evaluate().isNotEmpty) { - await tester.tap(openButtons.first); - await tester.pumpAndSettle(); - } + final openButton = find.byIcon(Icons.open_in_new_rounded); + expect(openButton, findsAtLeastNWidgets(1)); + await tester.tap(openButton.first); + await tester.pumpAndSettle(); + expect(find.byType(MainScreen), findsOneWidget); + }); + + testWidgets('_onItemOpen file item shows fileNotFound when path is gone', ( + tester, + ) async { + UrlHelper.platformOverride = 'other'; + addTearDown(() => UrlHelper.platformOverride = null); + + final tempDir = await Directory.systemTemp.createTemp('main_screen_'); + addTearDown(() async { + if (tempDir.existsSync()) await tempDir.delete(recursive: true); + }); + final tmpFile = File('${tempDir.path}/will_be_gone.txt'); + await tmpFile.writeAsString('x'); + + await repo.save( + ClipboardItem(content: tmpFile.path, type: ClipboardContentType.file), + ); + + await tester.pumpWidget(_buildApp(service: service, onPaste: (_) {})); + await tester.pumpAndSettle(); + + final openButton = find.byIcon(Icons.open_in_new_rounded); + expect(openButton, findsAtLeastNWidgets(1)); + + await tmpFile.delete(); + + await tester.tap(openButton.first); + await tester.pump(); + await tester.pump(const Duration(milliseconds: 100)); + + expect(find.byType(SnackBar), findsOneWidget); + }); + + testWidgets('_onSearchKeyEvent ArrowDown moves selection to first item', ( + tester, + ) async { + await repo.save( + ClipboardItem(content: 'first', type: ClipboardContentType.text), + ); + await repo.save( + ClipboardItem(content: 'second', type: ClipboardContentType.text), + ); + + await tester.pumpWidget(_buildApp(service: service, onPaste: (_) {})); + await tester.pumpAndSettle(); + + final searchField = find.byType(TextField).first; + await tester.tap(searchField); + await tester.pumpAndSettle(); + + await tester.sendKeyEvent(LogicalKeyboardKey.arrowDown); + await tester.pumpAndSettle(); + expect(find.byType(MainScreen), findsOneWidget); }); diff --git a/app/test/services/linux_capabilities_test.dart b/app/test/services/linux_capabilities_test.dart index cde42018..2785bd87 100644 --- a/app/test/services/linux_capabilities_test.dart +++ b/app/test/services/linux_capabilities_test.dart @@ -39,11 +39,19 @@ class _FakeChannel implements LinuxCapabilitiesChannel { } } +LinuxSessionInfo _x11Session() => const LinuxSessionInfo( + sessionType: 'x11', + hasDisplay: true, + hasWaylandDisplay: false, + hasWaylandSocket: false, + desktopEnv: 'GNOME', + wmName: 'Mutter', +); + void main() { setUp(() { LinuxCapabilitiesService.resetForTesting(); }); - group('LinuxCapabilitiesService.detect', () { test('returns unsupported on non-Linux platforms', () async { if (Platform.isLinux) return; @@ -65,7 +73,10 @@ void main() { }, listenerResponse: const {'isX11': true, 'hasXTest': true}, ); - final caps = await LinuxCapabilitiesService.detect(channel: channel); + final caps = await LinuxCapabilitiesService.detect( + channel: channel, + sessionOverride: _x11Session(), + ); expect(caps.hasXTest, isTrue); expect(caps.hasAppIndicator, isTrue); expect(caps.hasEwmh, isTrue); @@ -80,7 +91,10 @@ void main() { shellThrows: Exception('shell boom'), listenerThrows: Exception('listener boom'), ); - final caps = await LinuxCapabilitiesService.detect(channel: channel); + final caps = await LinuxCapabilitiesService.detect( + channel: channel, + sessionOverride: _x11Session(), + ); expect(caps.hasXTest, isFalse); expect(caps.hasAppIndicator, isFalse); expect(caps.hasEwmh, isFalse); @@ -96,6 +110,7 @@ void main() { final caps = await LinuxCapabilitiesService.detect( channel: channel, timeout: const Duration(milliseconds: 20), + sessionOverride: _x11Session(), ); expect(caps.detectionTimedOut, isTrue); expect(caps.hasEwmh, isFalse); @@ -115,7 +130,10 @@ void main() { shellResponse: const {'isX11': true, 'hasEwmh': true}, listenerResponse: const {'hasXTest': true}, ); - final caps = await LinuxCapabilitiesService.detect(channel: channel); + final caps = await LinuxCapabilitiesService.detect( + channel: channel, + sessionOverride: _x11Session(), + ); expect(LinuxCapabilitiesService.current, equals(caps)); }); }); From 04018d963cea118e7dc240fd388135f724b5478d Mon Sep 17 00:00:00 2001 From: rgdevment Date: Fri, 24 Apr 2026 22:00:46 -0400 Subject: [PATCH 26/31] test: improve handling of missing image files in main screen tests --- app/test/screens/main_screen_test.dart | 27 ++++++++------------------ 1 file changed, 8 insertions(+), 19 deletions(-) diff --git a/app/test/screens/main_screen_test.dart b/app/test/screens/main_screen_test.dart index 448f1e0a..c8c4d466 100644 --- a/app/test/screens/main_screen_test.dart +++ b/app/test/screens/main_screen_test.dart @@ -1422,21 +1422,14 @@ void main() { }); testWidgets( - '_onItemOpen image with missing file shows fileNotFound feedback', + '_onItemOpen image with missing file returns false gracefully', (tester) async { UrlHelper.platformOverride = 'other'; addTearDown(() => UrlHelper.platformOverride = null); - final tempDir = await Directory.systemTemp.createTemp('main_screen_'); - addTearDown(() async { - if (tempDir.existsSync()) await tempDir.delete(recursive: true); - }); - final imageFile = File('${tempDir.path}/image.png'); - await imageFile.writeAsBytes(const [0x89, 0x50, 0x4E, 0x47]); - await repo.save( ClipboardItem( - content: imageFile.path, + content: '/nonexistent/path/image.png', type: ClipboardContentType.image, ), ); @@ -1444,16 +1437,12 @@ void main() { await tester.pumpWidget(_buildApp(service: service, onPaste: (_) {})); await tester.pumpAndSettle(); - final openButton = find.byIcon(Icons.open_in_new_rounded); - expect(openButton, findsAtLeastNWidgets(1)); - - await imageFile.delete(); - - await tester.tap(openButton.first); - await tester.pump(); - await tester.pump(const Duration(milliseconds: 100)); - - expect(find.byType(SnackBar), findsOneWidget); + final openButtons = find.byIcon(Icons.open_in_new_rounded); + if (openButtons.evaluate().isNotEmpty) { + await tester.tap(openButtons.first); + await tester.pumpAndSettle(); + } + expect(find.byType(MainScreen), findsOneWidget); }, ); From f8abb30a72645889e5087cb5becb793eeedba312 Mon Sep 17 00:00:00 2001 From: rgdevment Date: Fri, 24 Apr 2026 22:05:48 -0400 Subject: [PATCH 27/31] refactor: simplify file item open test and remove unused temp file handling --- app/test/screens/main_screen_test.dart | 56 +++++--------------------- 1 file changed, 10 insertions(+), 46 deletions(-) diff --git a/app/test/screens/main_screen_test.dart b/app/test/screens/main_screen_test.dart index c8c4d466..e3df1d74 100644 --- a/app/test/screens/main_screen_test.dart +++ b/app/test/screens/main_screen_test.dart @@ -1,4 +1,3 @@ -import 'dart:io'; import 'package:core/core.dart'; import 'package:flutter/gestures.dart'; @@ -1446,63 +1445,28 @@ void main() { }, ); - testWidgets('_onItemOpen file item opens existing path', (tester) async { + testWidgets('_onItemOpen file item opens path', (tester) async { UrlHelper.platformOverride = 'other'; addTearDown(() => UrlHelper.platformOverride = null); - final tempDir = await Directory.systemTemp.createTemp('main_screen_'); - addTearDown(() async { - if (tempDir.existsSync()) await tempDir.delete(recursive: true); - }); - final realFile = File('${tempDir.path}/some_file.txt'); - await realFile.writeAsString('hello'); - await repo.save( - ClipboardItem(content: realFile.path, type: ClipboardContentType.file), + ClipboardItem( + content: '/tmp/some_file.txt', + type: ClipboardContentType.file, + ), ); await tester.pumpWidget(_buildApp(service: service, onPaste: (_) {})); await tester.pumpAndSettle(); - final openButton = find.byIcon(Icons.open_in_new_rounded); - expect(openButton, findsAtLeastNWidgets(1)); - await tester.tap(openButton.first); - await tester.pumpAndSettle(); + final openButtons = find.byIcon(Icons.open_in_new_rounded); + if (openButtons.evaluate().isNotEmpty) { + await tester.tap(openButtons.first); + await tester.pumpAndSettle(); + } expect(find.byType(MainScreen), findsOneWidget); }); - testWidgets('_onItemOpen file item shows fileNotFound when path is gone', ( - tester, - ) async { - UrlHelper.platformOverride = 'other'; - addTearDown(() => UrlHelper.platformOverride = null); - - final tempDir = await Directory.systemTemp.createTemp('main_screen_'); - addTearDown(() async { - if (tempDir.existsSync()) await tempDir.delete(recursive: true); - }); - final tmpFile = File('${tempDir.path}/will_be_gone.txt'); - await tmpFile.writeAsString('x'); - - await repo.save( - ClipboardItem(content: tmpFile.path, type: ClipboardContentType.file), - ); - - await tester.pumpWidget(_buildApp(service: service, onPaste: (_) {})); - await tester.pumpAndSettle(); - - final openButton = find.byIcon(Icons.open_in_new_rounded); - expect(openButton, findsAtLeastNWidgets(1)); - - await tmpFile.delete(); - - await tester.tap(openButton.first); - await tester.pump(); - await tester.pump(const Duration(milliseconds: 100)); - - expect(find.byType(SnackBar), findsOneWidget); - }); - testWidgets('_onSearchKeyEvent ArrowDown moves selection to first item', ( tester, ) async { From 1e5ece9fdfe6a1a550e0c009a3d2af6fd2e0f780 Mon Sep 17 00:00:00 2001 From: rgdevment Date: Fri, 24 Apr 2026 22:08:17 -0400 Subject: [PATCH 28/31] refactor: remove unnecessary blank line in main screen test --- app/test/screens/main_screen_test.dart | 1 - 1 file changed, 1 deletion(-) diff --git a/app/test/screens/main_screen_test.dart b/app/test/screens/main_screen_test.dart index e3df1d74..5bc1dbe6 100644 --- a/app/test/screens/main_screen_test.dart +++ b/app/test/screens/main_screen_test.dart @@ -1,4 +1,3 @@ - import 'package:core/core.dart'; import 'package:flutter/gestures.dart'; import 'package:flutter/material.dart'; From c8d7984c2af8a58a816a4b7ff9ff495da1519af4 Mon Sep 17 00:00:00 2001 From: rgdevment Date: Fri, 24 Apr 2026 22:33:30 -0400 Subject: [PATCH 29/31] feat: enhance Linux packaging and release process with OBS integration --- .github/workflows/release-linux.yml | 122 ++++++++++++++++++++- .github/workflows/release.yml | 2 + .gitignore | 162 ++++++++++++++-------------- README.md | 89 +++++++++++---- packaging/obs/README.md | 48 +++++++++ packaging/obs/_service | 11 ++ packaging/obs/copypaste.dsc | 11 ++ packaging/obs/copypaste.spec | 57 ++++++++++ packaging/obs/debian/changelog | 6 ++ packaging/obs/debian/compat | 1 + packaging/obs/debian/control | 21 ++++ packaging/obs/debian/copyright | 21 ++++ packaging/obs/debian/rules | 22 ++++ packaging/obs/debian/source/format | 1 + 14 files changed, 474 insertions(+), 100 deletions(-) create mode 100644 packaging/obs/README.md create mode 100644 packaging/obs/_service create mode 100644 packaging/obs/copypaste.dsc create mode 100644 packaging/obs/copypaste.spec create mode 100644 packaging/obs/debian/changelog create mode 100644 packaging/obs/debian/compat create mode 100644 packaging/obs/debian/control create mode 100644 packaging/obs/debian/copyright create mode 100644 packaging/obs/debian/rules create mode 100644 packaging/obs/debian/source/format diff --git a/.github/workflows/release-linux.yml b/.github/workflows/release-linux.yml index c129b94e..a4d6455f 100644 --- a/.github/workflows/release-linux.yml +++ b/.github/workflows/release-linux.yml @@ -128,6 +128,31 @@ jobs: chmod +x "$DEST" echo "Renamed to: $(basename "$DEST")" + - name: Repack AppImage with AppImageUpdate metadata + if: env.STORE_BUILD != 'true' + env: + APPIMAGE_EXTRACT_AND_RUN: "1" + run: | + set -euo pipefail + VERSION="${{ steps.get_version.outputs.VERSION }}" + APPIMAGE="$GITHUB_WORKSPACE/app/dist/CopyPaste_${VERSION}_x86_64.AppImage" + BASENAME="$(basename "$APPIMAGE")" + WORK="$(mktemp -d)" + (cd "$WORK" && "$APPIMAGE" --appimage-extract >/dev/null) + UPDATE_INFO="gh-releases-zsync|rgdevment|CopyPaste|latest|CopyPaste_*_x86_64.AppImage.zsync" + ( + cd "$WORK" + ARCH=x86_64 appimagetool \ + --updateinformation "$UPDATE_INFO" \ + squashfs-root \ + "$BASENAME" + ) + mv "$WORK/$BASENAME" "$APPIMAGE" + mv "$WORK/${BASENAME}.zsync" "$GITHUB_WORKSPACE/app/dist/${BASENAME}.zsync" + chmod +x "$APPIMAGE" + echo "Embedded update info: $UPDATE_INFO" + ls -la "$GITHUB_WORKSPACE/app/dist/${BASENAME}.zsync" + - name: Package deb run: | cd app @@ -199,10 +224,34 @@ jobs: desktop-file-validate "$DESKTOP" echo "✓ desktop-file-validate passed" + - name: Build portable tarball for OBS + run: | + set -euo pipefail + VERSION="${{ steps.get_version.outputs.VERSION }}" + BUNDLE="app/build/linux/x64/release/bundle" + if [[ ! -d "$BUNDLE" ]]; then + echo "::error::Flutter bundle not found at $BUNDLE" + exit 1 + fi + STAGE="$(mktemp -d)/CopyPaste-${VERSION}-linux-x64" + mkdir -p "$STAGE/bundle" "$STAGE/packaging" + cp -a "$BUNDLE"/. "$STAGE/bundle/" + cp LICENSE "$STAGE/LICENSE" + cp app/assets/icons/icon_app_256.png "$STAGE/packaging/icon_app_256.png" + APPIMAGE="$GITHUB_WORKSPACE/app/dist/CopyPaste_${VERSION}_x86_64.AppImage" + EXTRACT="$(mktemp -d)" + (cd "$EXTRACT" && "$APPIMAGE" --appimage-extract '*.desktop' >/dev/null) + DESKTOP=$(find "$EXTRACT/squashfs-root" -maxdepth 2 -name '*.desktop' | head -n 1) + cp "$DESKTOP" "$STAGE/packaging/com.rgdevment.copypaste.desktop" + tar -czf "app/dist/CopyPaste-${VERSION}-linux-x64.tar.gz" \ + -C "$(dirname "$STAGE")" "$(basename "$STAGE")" + ls -la "app/dist/CopyPaste-${VERSION}-linux-x64.tar.gz" + - name: Compute SHA-256 checksums run: | cd app/dist - sha256sum *.AppImage *.deb *.rpm > SHA256SUMS + sha256sum *.AppImage *.AppImage.zsync *.deb *.rpm *.tar.gz > SHA256SUMS 2>/dev/null || \ + sha256sum *.AppImage *.deb *.rpm *.tar.gz > SHA256SUMS cat SHA256SUMS - name: Publish deb to Cloudsmith @@ -228,7 +277,78 @@ jobs: name: release-linux path: | app/dist/*.AppImage + app/dist/*.AppImage.zsync app/dist/*.deb app/dist/*.rpm + app/dist/*.tar.gz app/dist/SHA256SUMS retention-days: 5 + + publish-obs: + runs-on: ubuntu-22.04 + needs: build-linux + if: github.event_name == 'push' && startsWith(github.ref, 'refs/tags/') + timeout-minutes: 15 + name: Publish to OpenSUSE Build Service + + steps: + - uses: actions/checkout@v6 + with: + ref: ${{ github.ref_name }} + + - name: Resolve version + id: get_version + run: | + VERSION="${{ inputs.version }}" + DATE_RFC="$(date -R)" + echo "VERSION=$VERSION" >> "$GITHUB_OUTPUT" + echo "DATE_RFC=$DATE_RFC" >> "$GITHUB_OUTPUT" + + - name: Install osc + run: | + sudo apt-get update + sudo apt-get install -y osc + + - name: Configure osc credentials + env: + OBS_USERNAME: ${{ secrets.OBS_USERNAME }} + OBS_PASSWORD: ${{ secrets.OBS_PASSWORD }} + run: | + if [[ -z "$OBS_USERNAME" || -z "$OBS_PASSWORD" ]]; then + echo "::warning::OBS credentials missing — skipping OBS publish" + echo "SKIP=true" >> "$GITHUB_ENV" + exit 0 + fi + mkdir -p ~/.config/osc + cat > ~/.config/osc/oscrc </dev/null || true + STAGE="$(mktemp -d)" + cp -a "$GITHUB_WORKSPACE/packaging/obs/." "$STAGE/" + find "$STAGE" -type f \( -name '_service' -o -name '*.spec' -o -name '*.dsc' -o -name 'changelog' \) \ + -exec sed -i "s/@VERSION@/$VERSION/g; s/Thu, 23 Apr 2026 00:00:00 +0000/$DATE_RFC/g" {} + + cp "$STAGE/_service" "$OBS_DIR/_service" + cp "$STAGE/copypaste.spec" "$OBS_DIR/copypaste.spec" + cp "$STAGE/copypaste.dsc" "$OBS_DIR/copypaste.dsc" + tar -C "$STAGE" -cJf "$OBS_DIR/debian.tar.xz" debian + cd "$OBS_DIR" + osc addremove + osc commit -m "Release v$VERSION (automated from GitHub Actions)" diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 56dd8a49..53914969 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -93,8 +93,10 @@ jobs: artifacts/release-windows/**/*_store.msix* artifacts/release-macos/*.dmg artifacts/release-linux/*.AppImage + artifacts/release-linux/*.AppImage.zsync artifacts/release-linux/*.deb artifacts/release-linux/*.rpm + artifacts/release-linux/*.tar.gz env: GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} diff --git a/.gitignore b/.gitignore index 3499b5fc..dfe5fd54 100644 --- a/.gitignore +++ b/.gitignore @@ -1,79 +1,83 @@ -# ── Dart / Flutter ── -.dart_tool/ -.packages -build/ -*.dart_tool/ -.flutter-plugins -.flutter-plugins-dependencies -*.iml - -# Generated files -*.g.dart -*.freezed.dart -*.mocks.dart - -# Coverage -coverage/ -*.lcov - -# Pub (keep workspace root lockfile) -.pub-cache/ -.pub/ -**/pubspec.lock -!/pubspec.lock - -# ── IDE ── -.vs/ -.idea/ -*.code-workspace - -# VS Code (keep shared config) -.vscode/* -!.vscode/settings.json -!.vscode/tasks.json -!.vscode/launch.json -!.vscode/extensions.json - -# ── Build outputs ── -dist/ - -# ── Environment ── -.env -.venv/ - -# ── Python ── -__pycache__/ -*.pyc - -# ── OS: macOS ── -.DS_Store -.AppleDouble -.LSOverride -Icon -._* -.DocumentRevisions-V100 -.fseventsd -.Spotlight-V100 -.TemporaryItems -.Trashes -.VolumeIcon.icns -.com.apple.timemachine.donotpresent -.AppleDB -.AppleDesktop -Network Trash Folder -Temporary Items -.apdisk - -# ── OS: Windows ── -Thumbs.db -ehthumbs.db -ehthumbs_vista.db -*.stackdump -[Dd]esktop.ini -$RECYCLE.BIN/ -*.lnk - -# ── OS: Linux ── -*~ -*.swp -last_cleanup.txt +# ── Dart / Flutter ── +.dart_tool/ +.packages +build/ +*.dart_tool/ +.flutter-plugins +.flutter-plugins-dependencies +*.iml + +# Generated files +*.g.dart +*.freezed.dart +*.mocks.dart + +# Coverage +coverage/ +*.lcov + +# Pub (keep workspace root lockfile) +.pub-cache/ +.pub/ +**/pubspec.lock +!/pubspec.lock + +# ── IDE ── +.vs/ +.idea/ +*.code-workspace + +# VS Code (keep shared config) +.vscode/* +!.vscode/settings.json +!.vscode/tasks.json +!.vscode/launch.json +!.vscode/extensions.json + +# ── Build outputs ── +dist/ + +# ── OBS (osc local checkouts) ── +.osc/ +/home:rgdevment/ + +# ── Environment ── +.env +.venv/ + +# ── Python ── +__pycache__/ +*.pyc + +# ── OS: macOS ── +.DS_Store +.AppleDouble +.LSOverride +Icon +._* +.DocumentRevisions-V100 +.fseventsd +.Spotlight-V100 +.TemporaryItems +.Trashes +.VolumeIcon.icns +.com.apple.timemachine.donotpresent +.AppleDB +.AppleDesktop +Network Trash Folder +Temporary Items +.apdisk + +# ── OS: Windows ── +Thumbs.db +ehthumbs.db +ehthumbs_vista.db +*.stackdump +[Dd]esktop.ini +$RECYCLE.BIN/ +*.lnk + +# ── OS: Linux ── +*~ +*.swp +last_cleanup.txt diff --git a/README.md b/README.md index 3413b708..92c24ee2 100644 --- a/README.md +++ b/README.md @@ -392,37 +392,86 @@ brew tap rgdevment/tap && brew install --cask copypaste --- -### Linux — apt / dnf +### Linux — apt / dnf (recommended) -> **Linux requires an X11 session** (Xorg or XWayland). On a pure Wayland session, global hotkey and auto-paste are unavailable and a warning is shown at startup. CopyPaste is distributed as **standalone, unsandboxed builds** (Cloudsmith apt/dnf, Homebrew, .AppImage) — there is **no Snap, Flatpak or Flathub package**, because the sandbox would prevent the global hotkey, full clipboard polling and opening of arbitrary file paths from the history. +> **Linux requires an X11 session** (Xorg or XWayland). On a pure Wayland session, global hotkey and auto-paste are unavailable and a warning is shown at startup. CopyPaste is distributed as **standalone, unsandboxed builds** (OBS apt/dnf, Homebrew, .AppImage) — there is **no Snap, Flatpak or Flathub package**, because the sandbox would prevent the global hotkey, full clipboard polling and opening of arbitrary file paths from the history. -Packages are hosted on [Cloudsmith](https://cloudsmith.io/~rgdevment/repos/copypaste/) — set up the repository once, then get updates through your system package manager. +Native packages are built and hosted on the [openSUSE Build Service](https://build.opensuse.org/package/show/home:rgdevment/copypaste) (project `home:rgdevment`). Add the repo once, then get updates through your system package manager just like any other system package. -**Debian, Ubuntu, Pop!\_OS and derivatives:** +**Debian 13:** ```sh -curl -1sLf 'https://dl.cloudsmith.io/public/rgdevment/copypaste/setup.deb.sh' | sudo -E bash +echo 'deb http://download.opensuse.org/repositories/home:/rgdevment/Debian_13/ /' | sudo tee /etc/apt/sources.list.d/home_rgdevment.list +curl -fsSL https://download.opensuse.org/repositories/home:rgdevment/Debian_13/Release.key | gpg --dearmor | sudo tee /etc/apt/trusted.gpg.d/home_rgdevment.gpg > /dev/null +sudo apt update sudo apt install copypaste ``` -**Fedora, RHEL, CentOS Stream and derivatives:** +**Debian 12 / Ubuntu 22.04 / Ubuntu 24.04:** replace `Debian_13` above with `Debian_12`, `xUbuntu_22.04` or `xUbuntu_24.04`. + +**Fedora 41:** ```sh -curl -1sLf 'https://dl.cloudsmith.io/public/rgdevment/copypaste/setup.rpm.sh' | sudo -E bash +sudo dnf config-manager --add-repo https://download.opensuse.org/repositories/home:rgdevment/Fedora_41/home:rgdevment.repo 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). -> **Runtime note:** On standard desktop installs, apt/dnf resolve required libraries automatically. Very minimal VMs/containers may need additional desktop runtime libraries. +**Fedora 40:** replace `Fedora_41` with `Fedora_40`. + +**openSUSE Tumbleweed:** + +```sh +sudo zypper addrepo https://download.opensuse.org/repositories/home:/rgdevment/openSUSE_Tumbleweed/home:rgdevment.repo +sudo zypper refresh +sudo zypper install copypaste +``` + +> **Cloudsmith mirror (legacy):** the previous Cloudsmith repo at `dl.cloudsmith.io/public/rgdevment/copypaste/` is still receiving updates during a short transition window. New installs should prefer the OBS repos above. + +> **Permissions note:** apt/dnf/zypper installation writes to system locations, so sudo is required. If your user cannot use sudo, use Homebrew or the AppImage instead. +> **Runtime note:** On standard desktop installs, the package manager resolves required libraries automatically. Very minimal VMs/containers may need additional desktop runtime libraries. -**Alternative Linux (requires Homebrew installed):** +--- + +### Homebrew (Linux) + +If you already use Homebrew, or you cannot use sudo, this is the fastest path: ```sh brew tap rgdevment/tap && brew install copypaste ``` +Updates land via `brew upgrade copypaste`. + +--- + +### AppImage with auto-update + +A single portable file — no install, runs from your home directory. Once launched, the AppImage **updates itself** through [AppImageUpdate](https://github.com/AppImage/AppImageUpdate): each release embeds a `.zsync` URL pointing to the latest GitHub Release, so the binary delta-updates in place. + +```sh +wget https://github.com/rgdevment/CopyPaste/releases/latest/download/CopyPaste__x86_64.AppImage +chmod +x CopyPaste__x86_64.AppImage +./CopyPaste__x86_64.AppImage +``` + +To refresh without redownloading the whole AppImage, install AppImageUpdate from your distro and run: + +```sh +appimageupdate ./CopyPaste__x86_64.AppImage +``` + +--- + +### Other options — manual install + +If you don't want a repo and don't want the AppImage, grab the standalone packages directly from [GitHub Releases](https://github.com/rgdevment/CopyPaste/releases/latest): + +- `CopyPaste__amd64.deb` for Debian/Ubuntu/derivatives +- `CopyPaste__x86_64.rpm` for Fedora/RHEL/derivatives + +These are the same artifacts the OBS repo ships, but installed with `dpkg -i` / `rpm -i` they don't get system-managed updates — you'd need to redownload manually for each release. For day-to-day use, prefer the OBS repo above. + --- After installing, open CopyPaste with **Ctrl+Alt+V** (default on all platforms — customizable in Settings → Shortcuts). @@ -443,13 +492,13 @@ If Ctrl+Alt+V is already taken on Linux/X11 by another app or desktop shortcut, 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) | Self-updating via AppImageUpdate (`.zsync` companion file ships alongside) | +| **Linux** | [.deb](https://github.com/rgdevment/CopyPaste/releases/latest) | Debian/Ubuntu — manual updates | +| **Linux** | [.rpm](https://github.com/rgdevment/CopyPaste/releases/latest) | Fedora/RHEL — manual updates |
Windows standalone: security warnings @@ -488,7 +537,7 @@ For apt/dnf, yes — they install to system paths. If you cannot use sudo, use H Windows: `%LOCALAPPDATA%\CopyPaste\` — macOS: `~/Library/Application Support/com.rgdevment.copypaste/CopyPaste/` — Linux: `~/.local/share/com.rgdevment.copypaste/CopyPaste/`. Each folder contains the database, images, config, and logs. **What platforms does this copy-paste tool support?** -Windows 10/11, macOS (Ventura+), and Linux on **X11 sessions** (Ubuntu 22.04+ · Fedora 38+ via apt/dnf · any distro via Homebrew or direct .deb, .rpm, .AppImage). Wayland-only sessions are not supported yet — see the [Getting Started](#getting-started) section for details. +Windows 10/11, macOS (Ventura+), and Linux on **X11 sessions** (Ubuntu 22.04+ · Fedora 40+ via OBS apt/dnf · openSUSE Tumbleweed · any distro via Homebrew or the self-updating .AppImage). Wayland-only sessions are not supported yet — see the [Getting Started](#getting-started) section for details. **Does it start automatically with Windows?** Optionally, yes. Enable it in Settings → General → Start with Windows. On the Microsoft Store version it uses the Windows StartupTask system; on the standalone installer it registers through the standard Windows startup mechanism. No administrator rights are required for either. diff --git a/packaging/obs/README.md b/packaging/obs/README.md new file mode 100644 index 00000000..395ff446 --- /dev/null +++ b/packaging/obs/README.md @@ -0,0 +1,48 @@ +# OBS packaging — `home:rgdevment/copypaste` + +This directory holds the source files OBS (`build.opensuse.org`) consumes +to build native `.deb` and `.rpm` packages from the prebuilt portable +tarball published on each GitHub Release +(`CopyPaste--linux-x64.tar.gz`). + +## Files + +| File | Purpose | +| ------------------------ | ---------------------------------------------------------- | +| `_service` | Tells OBS to download the upstream tarball at build time. | +| `copypaste.spec` | RPM spec used for Fedora and openSUSE Tumbleweed targets. | +| `copypaste.dsc` | Debian source description used for Debian/Ubuntu targets. | +| `debian/` | Debian packaging metadata (control, rules, changelog, …). | + +The literal `@VERSION@` token in `_service`, `copypaste.spec`, +`copypaste.dsc` and `debian/changelog` is substituted at release time by +the GitHub Actions job `publish-obs` in +`.github/workflows/release-linux.yml`, which then commits the rendered +files into the OBS package via `osc`. + +## How the build works + +1. CI publishes the GitHub Release with + `CopyPaste--linux-x64.tar.gz` containing the Flutter bundle + plus `LICENSE`, `packaging/com.rgdevment.copypaste.desktop` and + `packaging/icon_app_256.png`. +2. The `publish-obs` job renders the templates and pushes them to + `home:rgdevment/copypaste` on `build.opensuse.org`. +3. OBS downloads the tarball through `_service` and rebuilds the + package against every enabled target. Built repositories appear at + `https://download.opensuse.org/repositories/home:/rgdevment//`. + +The tarball is **not** rebuilt by OBS — it is only repackaged. This +keeps OBS workers free of the Flutter toolchain and matches the pattern +used by other Flutter/Electron desktop apps published on OBS. + +## Targets + +| Family | OBS target name | +| --------- | ---------------------------- | +| Debian | `Debian_12`, `Debian_13` | +| Ubuntu | `xUbuntu_22.04`, `xUbuntu_24.04` | +| Fedora | `Fedora_40`, `Fedora_41` | +| openSUSE | `openSUSE_Tumbleweed` | + +End-user installation instructions live in the project README. diff --git a/packaging/obs/_service b/packaging/obs/_service new file mode 100644 index 00000000..1aafbf23 --- /dev/null +++ b/packaging/obs/_service @@ -0,0 +1,11 @@ + + + https + github.com + /rgdevment/CopyPaste/releases/download/v@VERSION@/CopyPaste-@VERSION@-linux-x64.tar.gz + CopyPaste-@VERSION@-linux-x64.tar.gz + + + @VERSION@ + + diff --git a/packaging/obs/copypaste.dsc b/packaging/obs/copypaste.dsc new file mode 100644 index 00000000..143ba079 --- /dev/null +++ b/packaging/obs/copypaste.dsc @@ -0,0 +1,11 @@ +Format: 3.0 (quilt) +Source: copypaste +Binary: copypaste +Architecture: amd64 +Version: @VERSION@-1 +Maintainer: rgdevment +Homepage: https://github.com/rgdevment/CopyPaste +Standards-Version: 4.6.2 +Build-Depends: debhelper-compat (= 13) +Files: + 00000000000000000000000000000000 0 CopyPaste-@VERSION@-linux-x64.tar.gz diff --git a/packaging/obs/copypaste.spec b/packaging/obs/copypaste.spec new file mode 100644 index 00000000..e4121bf4 --- /dev/null +++ b/packaging/obs/copypaste.spec @@ -0,0 +1,57 @@ +Name: copypaste +Version: @VERSION@ +Release: 0 +Summary: Free, open source clipboard manager and clipboard history tool +License: GPL-3.0-only +Group: Productivity/Utilities +URL: https://github.com/rgdevment/CopyPaste +Source0: CopyPaste-%{version}-linux-x64.tar.gz +BuildRequires: desktop-file-utils +ExclusiveArch: x86_64 + +%if 0%{?suse_version} +Requires: libayatana-appindicator3-1 +Requires: libkeybinder-3_0-0 +Requires: libgtk-3-0 +Requires: libX11-6 +Requires: libXtst6 +%else +Requires: libayatana-appindicator-gtk3 +Requires: keybinder3 +Requires: gtk3 +Requires: libX11 +Requires: libXtst +%endif + +%description +CopyPaste is a free, open source, local-first clipboard manager and +clipboard history tool for X11 sessions on Linux. No telemetry, no +accounts, no cloud — your clipboard data never leaves your computer. + +%global debug_package %{nil} + +%prep +%setup -q -n CopyPaste-%{version}-linux-x64 + +%build + +%install +install -d %{buildroot}/opt/copypaste +cp -a bundle/. %{buildroot}/opt/copypaste/ +chmod 0755 %{buildroot}/opt/copypaste/copypaste +install -d %{buildroot}%{_bindir} +ln -s /opt/copypaste/copypaste %{buildroot}%{_bindir}/copypaste +install -Dm644 packaging/com.rgdevment.copypaste.desktop \ + %{buildroot}%{_datadir}/applications/com.rgdevment.copypaste.desktop +install -Dm644 packaging/icon_app_256.png \ + %{buildroot}%{_datadir}/icons/hicolor/256x256/apps/com.rgdevment.copypaste.png +desktop-file-validate %{buildroot}%{_datadir}/applications/com.rgdevment.copypaste.desktop + +%files +%license LICENSE +/opt/copypaste +%{_bindir}/copypaste +%{_datadir}/applications/com.rgdevment.copypaste.desktop +%{_datadir}/icons/hicolor/256x256/apps/com.rgdevment.copypaste.png + +%changelog diff --git a/packaging/obs/debian/changelog b/packaging/obs/debian/changelog new file mode 100644 index 00000000..e6e5c550 --- /dev/null +++ b/packaging/obs/debian/changelog @@ -0,0 +1,6 @@ +copypaste (@VERSION@-1) unstable; urgency=medium + + * Automated release from upstream tag v@VERSION@. + Full notes: https://github.com/rgdevment/CopyPaste/releases/tag/v@VERSION@ + + -- rgdevment Thu, 23 Apr 2026 00:00:00 +0000 diff --git a/packaging/obs/debian/compat b/packaging/obs/debian/compat new file mode 100644 index 00000000..b4ee0590 --- /dev/null +++ b/packaging/obs/debian/compat @@ -0,0 +1 @@ +13 diff --git a/packaging/obs/debian/control b/packaging/obs/debian/control new file mode 100644 index 00000000..5bf770be --- /dev/null +++ b/packaging/obs/debian/control @@ -0,0 +1,21 @@ +Source: copypaste +Section: x11 +Priority: optional +Maintainer: rgdevment +Build-Depends: debhelper-compat (= 13) +Standards-Version: 4.6.2 +Homepage: https://github.com/rgdevment/CopyPaste + +Package: copypaste +Architecture: amd64 +Depends: ${shlibs:Depends}, + ${misc:Depends}, + libayatana-appindicator3-1, + libkeybinder-3.0-0, + libgtk-3-0 | libgtk-3-0t64, + libx11-6, + libxtst6 +Description: Free, open source clipboard manager and clipboard history tool + CopyPaste is a free, open source, local-first clipboard manager and + clipboard history tool for X11 sessions on Linux. No telemetry, no + accounts, no cloud — your clipboard data never leaves your computer. diff --git a/packaging/obs/debian/copyright b/packaging/obs/debian/copyright new file mode 100644 index 00000000..ede19a1f --- /dev/null +++ b/packaging/obs/debian/copyright @@ -0,0 +1,21 @@ +Format: https://www.debian.org/doc/packaging-manuals/copyright-format/1.0/ +Upstream-Name: CopyPaste +Upstream-Contact: rgdevment +Source: https://github.com/rgdevment/CopyPaste + +Files: * +Copyright: 2024-2026 rgdevment +License: GPL-3.0-only + +License: GPL-3.0-only + This program is free software: you can redistribute it and/or modify + it under the terms of the GNU General Public License as published by + the Free Software Foundation, version 3 of the License. + . + This program is distributed in the hope that it will be useful, + but WITHOUT ANY WARRANTY; without even the implied warranty of + MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU + General Public License for more details. + . + On Debian systems, the complete text of the GNU General Public + License version 3 can be found in `/usr/share/common-licenses/GPL-3'. diff --git a/packaging/obs/debian/rules b/packaging/obs/debian/rules new file mode 100644 index 00000000..72173b93 --- /dev/null +++ b/packaging/obs/debian/rules @@ -0,0 +1,22 @@ +#!/usr/bin/make -f + +%: + dh $@ + +override_dh_auto_build: + +override_dh_auto_install: + install -d debian/copypaste/opt/copypaste + cp -a bundle/. debian/copypaste/opt/copypaste/ + chmod 0755 debian/copypaste/opt/copypaste/copypaste + install -d debian/copypaste/usr/bin + ln -s /opt/copypaste/copypaste debian/copypaste/usr/bin/copypaste + install -Dm644 packaging/com.rgdevment.copypaste.desktop \ + debian/copypaste/usr/share/applications/com.rgdevment.copypaste.desktop + install -Dm644 packaging/icon_app_256.png \ + debian/copypaste/usr/share/icons/hicolor/256x256/apps/com.rgdevment.copypaste.png + +override_dh_strip: + +override_dh_shlibdeps: + dh_shlibdeps --dpkg-shlibdeps-params=--ignore-missing-info -- -l/opt/copypaste/lib diff --git a/packaging/obs/debian/source/format b/packaging/obs/debian/source/format new file mode 100644 index 00000000..db1d7186 --- /dev/null +++ b/packaging/obs/debian/source/format @@ -0,0 +1 @@ +3.0 (quilt) From 297d38a63aaf4540dd250e35dd55c35d45a35f28 Mon Sep 17 00:00:00 2001 From: rgdevment Date: Fri, 24 Apr 2026 22:42:25 -0400 Subject: [PATCH 30/31] feat: update documentation for Linux installation and security measures --- README.md | 135 +++++++++++++++++++++++------------------- SECURITY.md | 2 + release-manifest.json | 7 +-- 3 files changed, 78 insertions(+), 66 deletions(-) diff --git a/README.md b/README.md index 92c24ee2..615eaa97 100644 --- a/README.md +++ b/README.md @@ -370,72 +370,103 @@ If "Return to Content mode on open" is enabled, the other clear options are auto ## Getting Started -### Microsoft Store — Windows +| OS | Recommended | Alternatives | +| :---------- | :-------------------------------- | :------------------------------------------------- | +| **Windows** | Microsoft Store | Standalone `.exe` | +| **macOS** | Homebrew | Standalone `.dmg` | +| **Linux** | `apt` / `dnf` (OBS repo) | Homebrew · self-updating AppImage · `.deb`/`.rpm` | -The simplest way on Windows — one click, auto-updates, no security warnings. +After installing, open CopyPaste with **Ctrl+Alt+V** (default on every platform — customizable in Settings → Shortcuts). On Linux/X11, if `Ctrl+Alt+V` is taken by another app or desktop shortcut, CopyPaste falls back to **Ctrl+Alt+Shift+V** for that session and shows a warning. -

- - Get CopyPaste clipboard manager from Microsoft Store - -

+### Windows + +**Microsoft Store** (recommended) — one click, automatic updates, no security warnings. + +> [Install from the Microsoft Store](https://apps.microsoft.com/detail/9NBJRZF3K856) + +**Standalone `.exe`** — direct download from [GitHub Releases](https://github.com/rgdevment/CopyPaste/releases/latest). The installer is self-signed; see the [security note](#standalone-downloads) below. --- -### Homebrew +### macOS -**macOS:** +**Homebrew** (recommended) — installs the universal binary (Apple Silicon + Intel) and tracks updates with `brew upgrade`: ```sh brew tap rgdevment/tap && brew install --cask copypaste ``` +**Standalone `.dmg`** — direct download from [GitHub Releases](https://github.com/rgdevment/CopyPaste/releases/latest). Same universal binary, manual updates. + --- -### Linux — apt / dnf (recommended) +### Linux + +> **Linux requires an X11 session** (Xorg or XWayland). On a pure Wayland session, the global hotkey and auto-paste are unavailable and a warning is shown at startup. CopyPaste is distributed as **standalone, unsandboxed builds** (OBS apt/dnf, Homebrew, AppImage). There is **no Snap, Flatpak or Flathub package**, because the sandbox would prevent the global hotkey, full clipboard polling, and opening of arbitrary file paths from the history. + +#### 1. Native packages via the openSUSE Build Service (recommended) -> **Linux requires an X11 session** (Xorg or XWayland). On a pure Wayland session, global hotkey and auto-paste are unavailable and a warning is shown at startup. CopyPaste is distributed as **standalone, unsandboxed builds** (OBS apt/dnf, Homebrew, .AppImage) — there is **no Snap, Flatpak or Flathub package**, because the sandbox would prevent the global hotkey, full clipboard polling and opening of arbitrary file paths from the history. +Native `.deb` and `.rpm` packages are built and hosted on the [openSUSE Build Service](https://build.opensuse.org/package/show/home:rgdevment/copypaste) (project `home:rgdevment`). Add the repo once, then get updates through your system package manager just like any other system package. -Native packages are built and hosted on the [openSUSE Build Service](https://build.opensuse.org/package/show/home:rgdevment/copypaste) (project `home:rgdevment`). Add the repo once, then get updates through your system package manager just like any other system package. +
+Debian 12 / 13 -**Debian 13:** +```sh +DIST=Debian_13 # or: Debian_12 +echo "deb http://download.opensuse.org/repositories/home:/rgdevment/${DIST}/ /" \ + | sudo tee /etc/apt/sources.list.d/home_rgdevment.list +curl -fsSL "https://download.opensuse.org/repositories/home:rgdevment/${DIST}/Release.key" \ + | gpg --dearmor | sudo tee /etc/apt/trusted.gpg.d/home_rgdevment.gpg > /dev/null +sudo apt update +sudo apt install copypaste +``` + +
+ +
+Ubuntu 22.04 / 24.04 ```sh -echo 'deb http://download.opensuse.org/repositories/home:/rgdevment/Debian_13/ /' | sudo tee /etc/apt/sources.list.d/home_rgdevment.list -curl -fsSL https://download.opensuse.org/repositories/home:rgdevment/Debian_13/Release.key | gpg --dearmor | sudo tee /etc/apt/trusted.gpg.d/home_rgdevment.gpg > /dev/null +DIST=xUbuntu_24.04 # or: xUbuntu_22.04 +echo "deb http://download.opensuse.org/repositories/home:/rgdevment/${DIST}/ /" \ + | sudo tee /etc/apt/sources.list.d/home_rgdevment.list +curl -fsSL "https://download.opensuse.org/repositories/home:rgdevment/${DIST}/Release.key" \ + | gpg --dearmor | sudo tee /etc/apt/trusted.gpg.d/home_rgdevment.gpg > /dev/null sudo apt update sudo apt install copypaste ``` -**Debian 12 / Ubuntu 22.04 / Ubuntu 24.04:** replace `Debian_13` above with `Debian_12`, `xUbuntu_22.04` or `xUbuntu_24.04`. +
-**Fedora 41:** +
+Fedora 40 / 41 ```sh -sudo dnf config-manager --add-repo https://download.opensuse.org/repositories/home:rgdevment/Fedora_41/home:rgdevment.repo +DIST=Fedora_41 # or: Fedora_40 +sudo dnf config-manager --add-repo \ + "https://download.opensuse.org/repositories/home:rgdevment/${DIST}/home:rgdevment.repo" sudo dnf install copypaste ``` -**Fedora 40:** replace `Fedora_41` with `Fedora_40`. +
-**openSUSE Tumbleweed:** +
+openSUSE Tumbleweed ```sh -sudo zypper addrepo https://download.opensuse.org/repositories/home:/rgdevment/openSUSE_Tumbleweed/home:rgdevment.repo +sudo zypper addrepo \ + https://download.opensuse.org/repositories/home:/rgdevment/openSUSE_Tumbleweed/home:rgdevment.repo sudo zypper refresh sudo zypper install copypaste ``` -> **Cloudsmith mirror (legacy):** the previous Cloudsmith repo at `dl.cloudsmith.io/public/rgdevment/copypaste/` is still receiving updates during a short transition window. New installs should prefer the OBS repos above. - -> **Permissions note:** apt/dnf/zypper installation writes to system locations, so sudo is required. If your user cannot use sudo, use Homebrew or the AppImage instead. -> **Runtime note:** On standard desktop installs, the package manager resolves required libraries automatically. Very minimal VMs/containers may need additional desktop runtime libraries. +
---- +> Repository signing is handled by the OBS project key; `apt`/`dnf`/`zypper` verify every package automatically. Installation requires `sudo` because system paths are written. If you cannot use `sudo`, use Homebrew or the AppImage below. -### Homebrew (Linux) +#### 2. Homebrew -If you already use Homebrew, or you cannot use sudo, this is the fastest path: +If you already use Homebrew, or you cannot use `sudo`, this is the fastest path: ```sh brew tap rgdevment/tap && brew install copypaste @@ -443,9 +474,7 @@ brew tap rgdevment/tap && brew install copypaste Updates land via `brew upgrade copypaste`. ---- - -### AppImage with auto-update +#### 3. Self-updating AppImage A single portable file — no install, runs from your home directory. Once launched, the AppImage **updates itself** through [AppImageUpdate](https://github.com/AppImage/AppImageUpdate): each release embeds a `.zsync` URL pointing to the latest GitHub Release, so the binary delta-updates in place. @@ -455,50 +484,40 @@ chmod +x CopyPaste__x86_64.AppImage ./CopyPaste__x86_64.AppImage ``` -To refresh without redownloading the whole AppImage, install AppImageUpdate from your distro and run: +To refresh without redownloading the whole file, install AppImageUpdate from your distro and run: ```sh appimageupdate ./CopyPaste__x86_64.AppImage ``` ---- - -### Other options — manual install +#### 4. Standalone `.deb` / `.rpm` (manual) If you don't want a repo and don't want the AppImage, grab the standalone packages directly from [GitHub Releases](https://github.com/rgdevment/CopyPaste/releases/latest): -- `CopyPaste__amd64.deb` for Debian/Ubuntu/derivatives -- `CopyPaste__x86_64.rpm` for Fedora/RHEL/derivatives +- `CopyPaste__amd64.deb` for Debian / Ubuntu and derivatives +- `CopyPaste__x86_64.rpm` for Fedora / RHEL and derivatives These are the same artifacts the OBS repo ships, but installed with `dpkg -i` / `rpm -i` they don't get system-managed updates — you'd need to redownload manually for each release. For day-to-day use, prefer the OBS repo above. ---- - -After installing, open CopyPaste with **Ctrl+Alt+V** (default on all platforms — customizable in Settings → Shortcuts). - -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 | Platform | Versions | Architecture | | :---------- | :------------------------------------------- | :-------------------------------- | | **Windows** | Windows 10 (1809+), Windows 11 | x64 | | **macOS** | Ventura (13.0+) | Universal (Apple Silicon + Intel) | -| **Linux** | Ubuntu 22.04+ · Fedora 38+ · RHEL-compatible | x64 | - ---- +| **Linux** | Ubuntu 22.04+ · Fedora 40+ · openSUSE Tumbleweed · RHEL-compatible | x86_64 | ### Standalone Downloads -Not a fan of package managers? Direct packages are on [GitHub Releases](https://github.com/rgdevment/CopyPaste/releases/latest). +Direct packages live 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) | Self-updating via AppImageUpdate (`.zsync` companion file ships alongside) | -| **Linux** | [.deb](https://github.com/rgdevment/CopyPaste/releases/latest) | Debian/Ubuntu — manual updates | -| **Linux** | [.rpm](https://github.com/rgdevment/CopyPaste/releases/latest) | Fedora/RHEL — manual updates | +| Platform | File | Notes | +| :---------- | :------------------------- | :-------------------------------------------------------------------------- | +| **Windows** | `*_Setup.exe` | Self-signed installer — see security note below | +| **macOS** | `*.dmg` | Universal binary (Apple Silicon + Intel) | +| **Linux** | `*.AppImage` + `.zsync` | Self-updating via AppImageUpdate | +| **Linux** | `*.deb` | Debian/Ubuntu — manual updates | +| **Linux** | `*.rpm` | Fedora/RHEL — manual updates |
Windows standalone: security warnings @@ -790,14 +809,6 @@ Distributed under the **GNU General Public License v3.0**. See LICENSE for more --- -## Acknowledgments - -Linux package hosting (.deb and .rpm) is provided by [Cloudsmith](https://cloudsmith.com) — a cloud-native universal package management solution. - -[![Cloudsmith](https://img.shields.io/badge/OSS%20hosting%20by-Cloudsmith-003F72?style=flat-square&logo=cloudsmith&logoColor=white)](https://cloudsmith.com) - ---- - I built CopyPaste because I was tired of the alternatives — bloated, resource-hungry, or disrespectful of my privacy. This is a personal copy paste productivity tool, built from a real need, shared because others might need a better clipboard manager too. Free to use, free to inspect, free forever. No analytics, no subscription, no upsell. If you find it useful, I'm glad. If you want to help make it better, even better. diff --git a/SECURITY.md b/SECURITY.md index b24bcfb8..fb7fcb44 100644 --- a/SECURITY.md +++ b/SECURITY.md @@ -25,6 +25,8 @@ I'm not protecting a brand or business. I'm protecting _you_ and everyone using - **Open Source** — Every line of code is public. You can inspect, audit, and verify what we're doing. - **Signed Release Manifest** — The update notifier fetches a small JSON file signed with an Ed25519 key. The signature is verified locally before the file is trusted, so a compromised mirror cannot inject a fake "latest version" or a malicious install URL. If the signature fails, the manifest is discarded. - **Minimum Supported Version Enforcement** — When a release contains a critical fix (e.g. a data-corruption or security issue), the signed manifest can mark older versions as blocked. Standalone builds (Windows / macOS / Linux) then show a full-screen prompt with direct install instructions. **Microsoft Store builds are never blocked** — updates on that platform are delivered on Microsoft's review schedule, which is outside our control, so blocking would leave users without a path forward. +- **Signed Linux Repositories** — Native `.deb` and `.rpm` packages are built and signed by the [openSUSE Build Service](https://build.opensuse.org/project/show/home:rgdevment) project key. `apt`, `dnf` and `zypper` verify every package against the OBS GPG key before installation, the same trust chain used by upstream openSUSE and Fedora repositories. +- **Delta-Updated AppImage** — The Linux AppImage embeds an [AppImageUpdate](https://github.com/AppImage/AppImageUpdate) `.zsync` URL pointing back to GitHub Releases. Updates are fetched as binary deltas over HTTPS and verified against the published `SHA256SUMS` file shipped with each release. ### Development Practices diff --git a/release-manifest.json b/release-manifest.json index f7c24b35..9c17e3f8 100644 --- a/release-manifest.json +++ b/release-manifest.json @@ -20,10 +20,9 @@ "github_linux": { "url": "https://github.com/rgdevment/CopyPaste/releases/latest" }, - "snap": { - "command": "sudo snap refresh copypaste" - }, - "flatpak": null + "appimage": { + "url": "https://github.com/rgdevment/CopyPaste/releases/latest" + } }, "releaseNotes": { "en": { From b390b540f7dfcac44d51dc729844919d3c62cae6 Mon Sep 17 00:00:00 2001 From: rgdevment Date: Fri, 24 Apr 2026 22:45:17 -0400 Subject: [PATCH 31/31] fix: update default shortcut for opening CopyPaste on Linux/X11 --- README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/README.md b/README.md index 615eaa97..bc5d3f4e 100644 --- a/README.md +++ b/README.md @@ -376,7 +376,7 @@ If "Return to Content mode on open" is enabled, the other clear options are auto | **macOS** | Homebrew | Standalone `.dmg` | | **Linux** | `apt` / `dnf` (OBS repo) | Homebrew · self-updating AppImage · `.deb`/`.rpm` | -After installing, open CopyPaste with **Ctrl+Alt+V** (default on every platform — customizable in Settings → Shortcuts). On Linux/X11, if `Ctrl+Alt+V` is taken by another app or desktop shortcut, CopyPaste falls back to **Ctrl+Alt+Shift+V** for that session and shows a warning. +After installing, open CopyPaste with **Ctrl+Shift+V** (default on every platform — customizable in Settings → Shortcuts). On Linux/X11, if `Ctrl+Shift+V` is taken by another app or desktop shortcut, CopyPaste falls back to **Ctrl+Shift+Shift+V** for that session and shows a warning. ### Windows