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
1 change: 1 addition & 0 deletions .github/workflows/release.yml
Original file line number Diff line number Diff line change
Expand Up @@ -359,6 +359,7 @@ jobs:
class ${FORMULA_CLASS} < Formula
desc "Clipboard history manager"
homepage "https://github.com/${{ github.repository }}"
license "GPL-3.0-only"
version "${VERSION}"

on_linux do
Expand Down
73 changes: 72 additions & 1 deletion app/lib/shell/focus_manager.dart
Original file line number Diff line number Diff line change
Expand Up @@ -42,6 +42,14 @@ typedef _AttachThreadInputNative =
typedef _AttachThreadInputDart =
int Function(int idAttach, int idAttachTo, int fAttach);

typedef _GetGUIThreadInfoNative =
Int32 Function(Uint32 idThread, Pointer<Uint8> lpgui);
typedef _GetGUIThreadInfoDart =
int Function(int idThread, Pointer<Uint8> lpgui);

typedef _GetAncestorNative = IntPtr Function(IntPtr hWnd, Uint32 gaFlags);
typedef _GetAncestorDart = int Function(int hWnd, int gaFlags);

class _Win32 {
_Win32._() {
assert(Platform.isWindows, '_Win32 requires Windows');
Expand All @@ -52,6 +60,13 @@ class _Win32 {
static const int swRestore = 9;
static const int gwlStyle = -16;
static const int wsMinimize = 0x20000000;
static const int gaRoot = 2;

// GUITHREADINFO on 64-bit: cbSize+flags (8 bytes) then six HWNDs and a RECT.
// hwndFocus is the second handle. Flutter dropped 32-bit Windows, so the
// pointer width these offsets assume cannot change under us.
static const int guiThreadInfoSize = 72;
static const int guiThreadInfoFocusOffset = 16;

late final _user32 = DynamicLibrary.open('user32.dll');
late final _kernel32 = DynamicLibrary.open('kernel32.dll');
Expand Down Expand Up @@ -94,6 +109,12 @@ class _Win32 {
.lookupFunction<_AttachThreadInputNative, _AttachThreadInputDart>(
'AttachThreadInput',
);
late final getGUIThreadInfo = _user32
.lookupFunction<_GetGUIThreadInfoNative, _GetGUIThreadInfoDart>(
'GetGUIThreadInfo',
);
late final getAncestor = _user32
.lookupFunction<_GetAncestorNative, _GetAncestorDart>('GetAncestor');
}

class WindowFocusManager {
Expand Down Expand Up @@ -166,6 +187,13 @@ class WindowFocusManager {
}

await Future<void>.delayed(Duration(milliseconds: delayBeforePasteMs));
final focusRoot = _keyboardFocusRoot();
if (focusRoot != _previousWindow) {
AppLogger.warn(
'Paste target is active but lacks keyboard focus: '
'expected=$_previousWindow, focused=$focusRoot',
);
}
final inputResponse = await _simulatePasteWindows();
if (!inputResponse.success) return inputResponse;
AppLogger.info(
Expand Down Expand Up @@ -222,6 +250,19 @@ class WindowFocusManager {
return false;
}

// Hiding the panel already hands the foreground back in the common case.
// Attaching input queues when the destination owns it anyway is not free:
// detaching resets the keyboard focus Windows had just restored, so the
// window stays active but the synthetic Ctrl+V lands nowhere. AppWindow's
// own activation path skips the juggling for the same reason.
if (w.getForegroundWindow() == _previousWindow) {
AppLogger.info(
'Focus restore: destination already in foreground '
'(hwnd=$_previousWindow)',
);
return true;
}

final currentThreadId = w.getCurrentThreadId();
var attached = false;

Expand Down Expand Up @@ -249,12 +290,42 @@ class WindowFocusManager {
Future<bool> _waitForFocusWindows(int maxAttempts) async {
final w = _Win32.instance;
for (var i = 0; i < maxAttempts; i++) {
if (w.getForegroundWindow() == _previousWindow) return true;
if (w.getForegroundWindow() == _previousWindow) {
if (i > 0) {
AppLogger.info('Focus verify: destination active after $i retries');
}
return true;
}
await Future<void>.delayed(const Duration(milliseconds: 10));
}
return false;
}

/// Root window currently owning keyboard focus, or 0 when it cannot be read.
///
/// [_waitForFocusWindows] only proves the destination is the active
/// top-level window. Chromium-based apps activate long before their render
/// process takes keyboard focus, and a stale input-queue attachment can
/// leave a window active with no focus at all — both swallow the Ctrl+V
/// while every call in the paste path still reports success.
int _keyboardFocusRoot() {
final w = _Win32.instance;
final info = calloc<Uint8>(_Win32.guiThreadInfoSize);
try {
info.cast<Uint32>().value = _Win32.guiThreadInfoSize;
if (w.getGUIThreadInfo(0, info) == 0) return 0;
final focused = (info + _Win32.guiThreadInfoFocusOffset)
.cast<IntPtr>()
.value;
return focused == 0 ? 0 : w.getAncestor(focused, _Win32.gaRoot);
} catch (e) {
AppLogger.warn('Keyboard focus probe failed: $e');
return 0;
} finally {
calloc.free(info);
}
}

Future<PasteResponse> _simulatePasteWindows() async {
try {
final response = await WindowsHotkeyChannel.sendPaste();
Expand Down
27 changes: 21 additions & 6 deletions app/lib/shell/single_instance.dart
Original file line number Diff line number Diff line change
Expand Up @@ -178,11 +178,23 @@ const int _genericWrite = 0x40000000;
const int _openExisting = 3;
const int _invalidHandleValue = -1;

const String _pipeName = r'\\.\pipe\CopyPasteSingleInstance';
const String _pipeNameBase = r'\\.\pipe\CopyPasteSingleInstance';

class SingleInstance {
static const String _mutexName = r'Local\CopyPaste_SingleInstance_Mutex';
static const String _wakeupFileName = 'copypaste.wakeup';
static const String _mutexNameBase = r'Local\CopyPaste_SingleInstance_Mutex';
static const String _wakeupFileNameBase = 'copypaste.wakeup';

/// Suffix for every OS-global name this class owns.
///
/// Tests must set it. The production names are shared with any CopyPaste
/// already running on the machine, which holds the mutex and drains the
/// wakeup signals the suite asserts on — so leaving it empty makes the
/// tests fail on exactly the developer machines that use the app.
static String namespace = '';

static String get _mutexName => '$_mutexNameBase$namespace';
static String get _wakeupFileName => '$_wakeupFileNameBase$namespace';
static String get _pipeName => '$_pipeNameBase$namespace';

static int _mutexHandle = 0;
static RandomAccessFile? _lockFile;
Expand Down Expand Up @@ -308,7 +320,7 @@ class SingleInstance {
// Also keep file-based polling as safety net
_listenForWakeupFile(onWakeup);

Isolate.spawn(_pipeServerLoop, _pipeReceivePort!.sendPort)
Isolate.spawn(_pipeServerLoop, (_pipeReceivePort!.sendPort, _pipeName))
.then((isolate) {
_pipeIsolate = isolate;
})
Expand All @@ -319,7 +331,8 @@ class SingleInstance {

/// Runs in a dedicated isolate. Blocks on ConnectNamedPipe waiting for
/// second-instance clients, then reads their message and forwards it.
static void _pipeServerLoop(SendPort sendPort) {
static void _pipeServerLoop((SendPort, String) args) {
final (sendPort, pipeName) = args;
final kernel32 = DynamicLibrary.open('kernel32.dll');
final createNamedPipe = kernel32
.lookupFunction<_CreateNamedPipeWNative, _CreateNamedPipeWDart>(
Expand All @@ -342,7 +355,9 @@ class SingleInstance {
.lookupFunction<_GetLastErrorNative, _GetLastErrorDart>('GetLastError');

while (true) {
final name = _pipeName.toNativeUtf16();
// Statics do not cross isolate boundaries, so the name travels as an
// argument instead of being read from `namespace` again.
final name = pipeName.toNativeUtf16();
final hPipe = createNamedPipe(
name,
_pipeAccessInbound,
Expand Down
9 changes: 7 additions & 2 deletions app/test/services/release_manifest_service_test.dart
Original file line number Diff line number Diff line change
Expand Up @@ -446,10 +446,15 @@ void main() {
'https://example.com/fail.sig';

final emitted = <ManifestState?>[];
final sub = ReleaseManifestService.stream.listen(emitted.add);
// Waiting a fixed slice raced the failing fetch under a loaded suite.
final firstEmit = Completer<void>();
final sub = ReleaseManifestService.stream.listen((state) {
emitted.add(state);
if (!firstEmit.isCompleted) firstEmit.complete();
});

await ReleaseManifestService.initialize(storageConfigDir: tmpDir.path);
await Future<void>.delayed(const Duration(milliseconds: 100));
await firstEmit.future.timeout(const Duration(seconds: 10));

unawaited(sub.cancel());

Expand Down
10 changes: 9 additions & 1 deletion app/test/shell/single_instance_test.dart
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,13 @@ import 'package:flutter_test/flutter_test.dart';

import 'package:copypaste/shell/single_instance.dart';

String get _wakeupFilePath => '${Directory.systemTemp.path}/copypaste.wakeup';
// Keeps the mutex, pipe and wakeup file off the names a CopyPaste running on
// this machine already owns; without it that instance holds the mutex and
// drains the wakeup signals these tests assert on.
final String _namespace = '_test_$pid';

String get _wakeupFilePath =>
'${Directory.systemTemp.path}/copypaste.wakeup$_namespace';

void _cleanupWakeupFile() {
try {
Expand All @@ -14,6 +20,8 @@ void _cleanupWakeupFile() {
}

void main() {
setUpAll(() => SingleInstance.namespace = _namespace);

group('SingleInstance – Windows', () {
setUp(() {
if (!Platform.isWindows) return;
Expand Down
54 changes: 30 additions & 24 deletions core/lib/config/app_config.dart
Original file line number Diff line number Diff line change
Expand Up @@ -59,8 +59,13 @@ class AppConfig {
this.lastWindowY,
});

factory AppConfig.fromJson(Map<String, dynamic> json) {
final defaults = defaultForCurrentPlatform();
/// [platform] overrides the host OS so migrations can be exercised off the
/// platform they target; coverage runs on Linux, where the Windows branches
/// would otherwise never execute.
factory AppConfig.fromJson(Map<String, dynamic> json, {String? platform}) {
final os = platform ?? Platform.operatingSystem;
final isWindows = os == 'windows';
final defaults = defaultForPlatform(os);
final hotkeyUseCtrl =
json['hotkeyUseCtrl'] as bool? ?? defaults.hotkeyUseCtrl;
final hotkeyUseWin = json['hotkeyUseWin'] as bool? ?? defaults.hotkeyUseWin;
Expand Down Expand Up @@ -128,7 +133,7 @@ class AppConfig {
// to Ctrl+Alt+V. Revert only that exact automatic binding; version 1
// custom bindings and every other versioned combination remain untouched.
final versionTwoWindowsOpen =
Platform.isWindows &&
isWindows &&
shortcutDefaultsVersion == 2 &&
hotkeyUseCtrl &&
!hotkeyUseWin &&
Expand All @@ -147,7 +152,7 @@ class AppConfig {
// those two exact automatic Windows bindings to Ctrl+Alt+V; bindings from
// versions where they could have been user-defined remain untouched.
final legacyWindowsPlainPaste =
Platform.isWindows &&
isWindows &&
plainPasteHotkeyUseCtrl &&
!plainPasteHotkeyUseWin &&
plainPasteHotkeyVirtualKey == 0x56 &&
Expand Down Expand Up @@ -176,23 +181,24 @@ class AppConfig {
final storedPasteDefaultsVersion =
json['pasteDefaultsVersion'] as int? ?? 1;

// The previous Windows default always waited 100 ms before restoring
// focus and another 180 ms before sending Ctrl+V. Native focus
// verification now makes that fixed delay unnecessary. Migrate only the
// exact former default tuple so independently tuned values are preserved.
final legacyWindowsSafePaste =
Platform.isWindows &&
// v2 moved Windows onto the Instant preset, assuming native focus
// verification made fixed delays unnecessary. It does not: the check only
// proves the destination is the active top-level window, so the paste can
// still outrun apps that route keyboard focus internally. Undo it for
// anyone left on those exact values; tuned tuples are preserved.
final untouchedInstantPaste =
isWindows &&
storedPasteDefaultsVersion < pasteDefaultsVersion &&
duplicateIgnoreWindowMs == 450 &&
delayBeforeFocusMs == 100 &&
delayBeforePasteMs == 180 &&
duplicateIgnoreWindowMs == 300 &&
delayBeforeFocusMs == 0 &&
delayBeforePasteMs == 20 &&
maxFocusVerifyAttempts == 15;
if (legacyWindowsSafePaste) {
duplicateIgnoreWindowMs = 300;
delayBeforeFocusMs = 0;
delayBeforePasteMs = 20;
maxFocusVerifyAttempts = 15;
AppLogger.info('Updated Windows paste timing to the Instant preset');
if (untouchedInstantPaste) {
duplicateIgnoreWindowMs = 350;
delayBeforeFocusMs = 80;
delayBeforePasteMs = 120;
maxFocusVerifyAttempts = 12;
AppLogger.info('Updated Windows paste timing to the Normal preset');
}

return AppConfig(
Expand Down Expand Up @@ -289,7 +295,7 @@ class AppConfig {
}

static const int shortcutDefaultsVersion = 5;
static const int pasteDefaultsVersion = 2;
static const int pasteDefaultsVersion = 3;

static AppConfig defaultForCurrentPlatform() =>
defaultForPlatform(Platform.operatingSystem);
Expand All @@ -309,10 +315,10 @@ class AppConfig {
plainPasteHotkeyUseCtrl: true,
plainPasteHotkeyUseAlt: true,
plainPasteHotkeyUseShift: false,
duplicateIgnoreWindowMs: 300,
delayBeforeFocusMs: 0,
delayBeforePasteMs: 20,
maxFocusVerifyAttempts: 15,
duplicateIgnoreWindowMs: 350,
delayBeforeFocusMs: 80,
delayBeforePasteMs: 120,
maxFocusVerifyAttempts: 12,
),
// Control+Shift+V opens the panel. The optional global plain-paste binding
// includes every modifier and stays disabled until explicitly enabled.
Expand Down
34 changes: 32 additions & 2 deletions core/lib/services/cleanup_service.dart
Original file line number Diff line number Diff line change
Expand Up @@ -15,9 +15,11 @@ class CleanupService {
StorageConfig? storage,
int Function()? getKeepBrokenDays,
int Function()? getImagesQuotaMB,
bool Function(String)? probePath,
}) : _storage = storage,
_getKeepBrokenDays = getKeepBrokenDays ?? (() => 30),
_getImagesQuotaMB = getImagesQuotaMB ?? (() => 0);
_getImagesQuotaMB = getImagesQuotaMB ?? (() => 0),
_probePath = probePath ?? _probePathOnDisk;

static const Duration _checkInterval = Duration(hours: 18);
static const String _lastCleanupFileName = 'last_cleanup.txt';
Expand All @@ -29,6 +31,7 @@ class CleanupService {
int Function() _getKeepBrokenDays;
int Function() _getImagesQuotaMB;
final StorageConfig? _storage;
final bool Function(String) _probePath;
Timer? _timer;
bool _disposed = false;

Expand Down Expand Up @@ -129,9 +132,16 @@ class CleanupService {
Future<void> _cleanOrphanImages() async {
final storage = _storage;
if (storage == null) return;
// Kept apart from the sweep below: tracking walks user-supplied paths that
// can live on flaky volumes, and its failure must not strand orphan files
// on disk forever.
try {
await _trackBrokenExternalRefs();
} catch (e) {
AppLogger.error('Broken reference tracking failed: $e');
}

try {
final allImageItems = await _repository.getImagePaths();
final allThumbPaths = await _repository.getThumbPaths();
final canonicalImagesDir = p.canonicalize(storage.imagesPath);
Expand Down Expand Up @@ -189,7 +199,10 @@ class CleanupService {
continue;
}

final exists = File(path).existsSync() || Directory(path).existsSync();
final exists = _pathExists(path);
// Probe failed rather than reported absence: same meaning as an offline
// volume, so leave brokenSince untouched instead of starting the clock.
if (exists == null) continue;
if (exists) {
if (item.brokenSince != null) {
await _repository.update(item.copyWith(brokenSince: null));
Expand Down Expand Up @@ -374,6 +387,23 @@ class CleanupService {
/// present. When the volume is offline (drive not mounted, NAS down,
/// removable disk unplugged), callers should skip purge logic so the user
/// does not lose history entries on a temporary disconnection.
static bool _probePathOnDisk(String path) =>
File(path).existsSync() || Directory(path).existsSync();

/// Whether [path] is on disk, or null when the probe itself failed.
///
/// An unreachable network share throws instead of reporting absence, and
/// [isVolumePresent] cannot tell the two apart either: its own catch assumes
/// the volume is present, which lands here.
bool? _pathExists(String path) {
try {
return _probePath(path);
} catch (e) {
AppLogger.warn('[CleanupService] path probe failed for "$path": $e');
return null;
}
}

static bool isVolumePresent(String path) {
try {
if (Platform.isWindows) {
Expand Down
Loading
Loading