
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) {