diff --git a/README.md b/README.md index 4dc3bb8..d0d0b9d 100644 --- a/README.md +++ b/README.md @@ -1,7 +1,7 @@ 
CopyPaste App Logo -

Copy Paste

+

CopyPaste

A modern clipboard manager — minimal, beautiful, and built to feel native

@@ -39,7 +39,7 @@

-

⬇️ Install

+

⬇️ Install Copy Paste

@@ -98,7 +98,7 @@ CopyPaste isn't a "power tool" you learn to tolerate — it's something that sho ## � Table of Contents -- [See It in Action](#-see-it-in-action) +- [See It in Action](#-see-copy-paste-in-action) - [Why I Built This](#-why-i-built-this) - [What It Is / What It Isn't](#-what-it-is---what-it-isnt) - [Who Is This For?](#-who-is-this-for) @@ -115,7 +115,7 @@ CopyPaste isn't a "power tool" you learn to tolerate — it's something that sho - [Tech Stack (For Developers)](#-tech-stack-for-developers) - [License & Spirit](#-license--spirit) -## 📸 See It in Action +## 📸 See Copy Paste in Action

CopyPaste Demo — Fast search, clean cards, multiplatform design diff --git a/app/linux/runner/copypaste_linux_shell.c b/app/linux/runner/copypaste_linux_shell.c index 635e109..d12dadf 100644 --- a/app/linux/runner/copypaste_linux_shell.c +++ b/app/linux/runner/copypaste_linux_shell.c @@ -375,27 +375,35 @@ static gboolean register_hotkey(CopyPasteLinuxShell* shell, FlValue* args) { ? 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, diff --git a/app/test/screens/wayland_unsupported_screen_test.dart b/app/test/screens/wayland_unsupported_screen_test.dart new file mode 100644 index 0000000..cff6c0a --- /dev/null +++ b/app/test/screens/wayland_unsupported_screen_test.dart @@ -0,0 +1,127 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; + +import 'package:copypaste/l10n/app_localizations.dart'; +import 'package:copypaste/screens/wayland_unsupported_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(640, 960); + tester.view.devicePixelRatio = 2.0; + addTearDown(tester.view.reset); + await tester.pumpWidget(_wrap(child, locale: locale)); + await tester.pump(); +} + +void main() { + Widget screen({VoidCallback? onClose}) => + WaylandUnsupportedScreen(onClose: onClose ?? () {}); + + group('WaylandUnsupportedScreen', () { + testWidgets('renders title text', (tester) async { + await _pump(tester, screen()); + + expect(find.text('Wayland is not supported yet'), findsOneWidget); + }); + + testWidgets('renders badge chip', (tester) async { + await _pump(tester, screen()); + + expect(find.text('Open source · X11 only'), findsOneWidget); + }); + + testWidgets('renders body text', (tester) async { + await _pump(tester, screen()); + + expect( + find.textContaining('Linux support is still limited'), + findsOneWidget, + ); + }); + + testWidgets('renders GitHub FilledButton', (tester) async { + await _pump(tester, screen()); + + expect(find.byType(FilledButton), findsOneWidget); + expect(find.text('Contribute on GitHub'), findsOneWidget); + }); + + testWidgets('renders Close OutlinedButton', (tester) async { + await _pump(tester, screen()); + + expect(find.byType(OutlinedButton), findsOneWidget); + expect(find.text('Close'), findsOneWidget); + }); + + testWidgets('tapping close button invokes onClose', (tester) async { + var closed = false; + await _pump(tester, screen(onClose: () => closed = true)); + + await tester.ensureVisible(find.byType(OutlinedButton)); + await tester.tap(find.byType(OutlinedButton)); + await tester.pump(); + + expect(closed, isTrue); + }); + + testWidgets('app icon is displayed', (tester) async { + await _pump(tester, screen()); + + expect(find.byType(Image), findsOneWidget); + }); + + testWidgets('renders both action buttons', (tester) async { + await _pump(tester, screen()); + + expect(find.byType(FilledButton), findsOneWidget); + expect(find.byType(OutlinedButton), findsOneWidget); + }); + + testWidgets('renders in Spanish locale', (tester) async { + await _pump(tester, screen(), locale: const Locale('es')); + + expect(find.text('Wayland no está soportado aún'), findsOneWidget); + expect(find.text('Open source · Solo X11'), findsOneWidget); + expect(find.text('Cerrar'), findsOneWidget); + }); + + testWidgets('renders in dark mode without errors', (tester) async { + tester.view.physicalSize = const Size(640, 960); + 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('Wayland is not supported yet'), findsOneWidget); + }); + + testWidgets('open_in_new icon is present on GitHub button', (tester) async { + await _pump(tester, screen()); + + expect(find.byIcon(Icons.open_in_new_rounded), findsOneWidget); + }); + }); +} diff --git a/app/test/shell/linux_session_test.dart b/app/test/shell/linux_session_test.dart new file mode 100644 index 0000000..1c7613c --- /dev/null +++ b/app/test/shell/linux_session_test.dart @@ -0,0 +1,74 @@ +import 'dart:io'; + +import 'package:flutter_test/flutter_test.dart'; + +import 'package:copypaste/shell/linux_session.dart'; + +// isWaylandSession() reads Platform.environment, which is immutable at +// runtime. These tests verify the logic using the *actual* current +// environment, covering all observable code paths. + +void main() { + group('isWaylandSession', () { + test('returns false on non-Linux platforms', () { + if (Platform.isLinux) return; // non-applicable on Linux + expect(isWaylandSession(), isFalse); + }); + + test('is consistent with current environment variables', () { + // The function's contract: Wayland iff running on Linux AND + // GDK_BACKEND != x11 AND (XDG_SESSION_TYPE == wayland OR + // WAYLAND_DISPLAY is set). + if (!Platform.isLinux) { + expect(isWaylandSession(), isFalse); + return; + } + + final gdkBackend = Platform.environment['GDK_BACKEND'] ?? ''; + final sessionType = Platform.environment['XDG_SESSION_TYPE'] ?? ''; + final waylandDisplay = Platform.environment['WAYLAND_DISPLAY'] ?? ''; + + final expected = + gdkBackend != 'x11' && + (sessionType == 'wayland' || waylandDisplay.isNotEmpty); + + expect(isWaylandSession(), equals(expected)); + }); + + test('returns false when GDK_BACKEND=x11 overrides other indicators', () { + // Can only validate current state — if GDK_BACKEND is set to x11 + // right now, the function must return false even if other vars suggest + // Wayland. This is a documentation/regression test. + if (!Platform.isLinux) return; + if ((Platform.environment['GDK_BACKEND'] ?? '') != 'x11') return; + + expect(isWaylandSession(), isFalse); + }); + + test('returns false on headless / X11 CI environment', () { + // On a typical headless or X11 CI machine, no Wayland vars are set. + final sessionType = Platform.environment['XDG_SESSION_TYPE'] ?? ''; + final waylandDisplay = Platform.environment['WAYLAND_DISPLAY'] ?? ''; + final gdkBackend = Platform.environment['GDK_BACKEND'] ?? ''; + + final isWaylandEnv = + gdkBackend != 'x11' && + (sessionType == 'wayland' || waylandDisplay.isNotEmpty); + + // If the environment has no Wayland indicators the function returns false. + if (!isWaylandEnv) { + expect(isWaylandSession(), isFalse); + } + }); + + test('return type is bool', () { + expect(isWaylandSession(), isA()); + }); + + test('is idempotent — same result on repeated calls', () { + final first = isWaylandSession(); + final second = isWaylandSession(); + expect(first, equals(second)); + }); + }); +} diff --git a/app/test/shell/startup_helper_linux_test.dart b/app/test/shell/startup_helper_linux_test.dart index 900d51c..ef1e14f 100644 --- a/app/test/shell/startup_helper_linux_test.dart +++ b/app/test/shell/startup_helper_linux_test.dart @@ -2,6 +2,7 @@ import 'dart:io'; import 'package:flutter_test/flutter_test.dart'; +import 'package:copypaste/shell/linux_session.dart'; import 'package:copypaste/shell/startup_helper.dart'; String _desktopPath() { @@ -118,4 +119,57 @@ void main() { expect(File(_desktopPath()).parent.path, equals(expectedDir)); }); }); + + group('StartupHelper – Linux Wayland skip', () { + test( + 'apply(true) does NOT create .desktop file on Wayland session', + () async { + if (!Platform.isLinux) return; + if (!isWaylandSession()) return; // only meaningful on Wayland + + final f = File(_desktopPath()); + if (f.existsSync()) f.deleteSync(); + + await StartupHelper.apply(true); + + expect( + f.existsSync(), + isFalse, + reason: 'Autostart must be skipped on Wayland', + ); + }, + ); + + test( + 'apply(true) removes existing .desktop file on Wayland session', + () async { + if (!Platform.isLinux) return; + if (!isWaylandSession()) return; + + // Pre-create the file to simulate a stale entry from a previous X11 session. + final f = File(_desktopPath()); + f.parent.createSync(recursive: true); + f.writeAsStringSync('[Desktop Entry]\nType=Application\n'); + + await StartupHelper.apply(true); + + expect( + f.existsSync(), + isFalse, + reason: 'Stale autostart entry must be removed on Wayland', + ); + }, + ); + + test( + 'on X11 session, apply(true) creates the .desktop file normally', + () async { + if (!Platform.isLinux) return; + if (isWaylandSession()) return; // skip on Wayland + + await StartupHelper.apply(true); + expect(File(_desktopPath()).existsSync(), isTrue); + }, + ); + }); } diff --git a/listener/linux/listener_plugin.c b/listener/linux/listener_plugin.c index c72f0c2..862450f 100644 --- a/listener/linux/listener_plugin.c +++ b/listener/linux/listener_plugin.c @@ -23,6 +23,15 @@ #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)) @@ -94,16 +103,16 @@ static gboolean is_url_text(const gchar* text) { static int detect_file_type(const gchar* path) { if (path == NULL || *path == '\0') { - return 2; + return CLIP_TYPE_FILE; } if (g_file_test(path, G_FILE_TEST_IS_DIR)) { - return 3; + return CLIP_TYPE_FOLDER; } gchar* lower = g_ascii_strdown(path, -1); const gchar* ext = strrchr(lower, '.'); - int type = 2; + int type = CLIP_TYPE_FILE; if (ext != NULL) { if (g_strcmp0(ext, ".png") == 0 || g_strcmp0(ext, ".jpg") == 0 || @@ -111,16 +120,16 @@ static int detect_file_type(const gchar* path) { 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 = 1; + 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 = 5; + 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 = 6; + type = CLIP_TYPE_VIDEO; } } @@ -138,6 +147,42 @@ static gboolean plugin_is_x11(void) { } #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)) { @@ -154,7 +199,7 @@ static ActiveX11Window get_active_x11_window(void) { return result; } - Atom property = XInternAtom(display, "_NET_ACTIVE_WINDOW", False); + Atom property = atom_net_active_window(display); Atom actual_type = None; int actual_format = 0; unsigned long item_count = 0; @@ -215,7 +260,7 @@ static gchar* get_x11_window_source(Window window) { g_free(value); } - Atom pid_atom = XInternAtom(display, "_NET_WM_PID", False); + Atom pid_atom = atom_net_wm_pid(display); Atom actual_type = None; int actual_format = 0; unsigned long item_count = 0; @@ -274,7 +319,7 @@ static gboolean request_activate_x11_window(Window window) { memset(&event, 0, sizeof(event)); event.xclient.type = ClientMessage; event.xclient.window = window; - event.xclient.message_type = XInternAtom(display, "_NET_ACTIVE_WINDOW", False); + 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; @@ -306,6 +351,10 @@ static gboolean simulate_paste_x11(void) { 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) { @@ -457,7 +506,7 @@ static FlValue* build_file_event(GtkClipboard* clipboard, g_autoptr(FlValue) files = fl_value_new_list(); guint count = 0; - gint event_type = 2; + gint event_type = CLIP_TYPE_FILE; gchar* first_path = NULL; for (guint i = 0; uris[i] != NULL; i++) { @@ -503,7 +552,7 @@ static FlValue* build_text_event(GtkClipboard* clipboard, g_autoptr(FlValue) event = fl_value_new_map(); fl_value_set_string_take(event, "type", - fl_value_new_int(is_url_text(text) ? 4 : 0)); + 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)); @@ -548,7 +597,7 @@ static FlValue* build_image_event(GtkClipboard* clipboard, } g_autoptr(FlValue) event = fl_value_new_map(); - fl_value_set_string_take(event, "type", fl_value_new_int(1)); + 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)); @@ -806,17 +855,17 @@ static void listener_plugin_handle_method_call(ListenerPlugin* self, gboolean success = FALSE; switch (type) { - case 0: - case 4: + case CLIP_TYPE_TEXT: + case CLIP_TYPE_LINK: success = set_text_to_clipboard(content); break; - case 1: + case CLIP_TYPE_IMAGE: success = set_image_to_clipboard(content); break; - case 2: - case 3: - case 5: - case 6: + case CLIP_TYPE_FILE: + case CLIP_TYPE_FOLDER: + case CLIP_TYPE_AUDIO: + case CLIP_TYPE_VIDEO: success = set_files_to_clipboard(content); break; default: @@ -869,8 +918,13 @@ static void listener_plugin_handle_method_call(ListenerPlugin* self, if (activated && delay_ms > 0) { FlMethodCall* held_call = FL_METHOD_CALL(g_object_ref(method_call)); - g_timeout_add((guint)delay_ms, paste_after_delay_cb, held_call); - return; + 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) {