Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 4 additions & 4 deletions README.md
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
<div align="center">
<img src="app/assets/icons/icon_app_256.png" width="140" height="140" alt="CopyPaste App Logo"/>

<h1>Copy Paste</h1>
<h1>CopyPaste</h1>
<h3>A modern clipboard manager — minimal, beautiful, and built to feel native</h3>

<p>
Expand Down Expand Up @@ -39,7 +39,7 @@
</a>
</p>

<h4>⬇️ Install</h4>
<h4>⬇️ Install Copy Paste</h4>

<p align="center">
<a href="https://apps.microsoft.com/detail/9NBJRZF3K856">
Expand Down Expand Up @@ -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)
Expand All @@ -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

<div align="center">
<img src="resources/demo.gif" alt="CopyPaste Demo — Fast search, clean cards, multiplatform design"/>
Expand Down
8 changes: 8 additions & 0 deletions app/linux/runner/copypaste_linux_shell.c
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
127 changes: 127 additions & 0 deletions app/test/screens/wayland_unsupported_screen_test.dart
Original file line number Diff line number Diff line change
@@ -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<void> _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);
});
});
}
74 changes: 74 additions & 0 deletions app/test/shell/linux_session_test.dart
Original file line number Diff line number Diff line change
@@ -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<bool>());
});

test('is idempotent — same result on repeated calls', () {
final first = isWaylandSession();
final second = isWaylandSession();
expect(first, equals(second));
});
});
}
54 changes: 54 additions & 0 deletions app/test/shell/startup_helper_linux_test.dart
Original file line number Diff line number Diff line change
Expand Up @@ -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() {
Expand Down Expand Up @@ -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);
},
);
});
}
Loading
Loading