diff --git a/app/lib/l10n/app_en.arb b/app/lib/l10n/app_en.arb index 02a2b9e..dba0e23 100644 --- a/app/lib/l10n/app_en.arb +++ b/app/lib/l10n/app_en.arb @@ -498,5 +498,29 @@ "placeholders": { "hotkey": { "type": "String" } } - } + }, + + "onboardingTitle": "Welcome to CopyPaste", + "@onboardingTitle": { "description": "Onboarding screen title" }, + + "onboardingSubtitle": "Everything you copy, saved.", + "@onboardingSubtitle": { "description": "Onboarding screen subtitle" }, + + "onboardingPrivacyBadge": "No cloud · No tracking · 100% local", + "@onboardingPrivacyBadge": { "description": "Onboarding privacy badge chip" }, + + "onboardingDescription": "Runs silently in the background. Press {hotkey} anytime to open your clipboard history.", + "@onboardingDescription": { + "description": "Onboarding main description", + "placeholders": { "hotkey": { "type": "String" } } + }, + + "onboardingTrayHint": "Look for the CP icon next to your clock.", + "@onboardingTrayHint": { "description": "Onboarding tray location hint" }, + + "onboardingSettingsButton": "Settings", + "@onboardingSettingsButton": { "description": "Onboarding settings button" }, + + "onboardingDismissButton": "Get started", + "@onboardingDismissButton": { "description": "Onboarding dismiss button" } } diff --git a/app/lib/l10n/app_es.arb b/app/lib/l10n/app_es.arb index d78b32c..c6e9a54 100644 --- a/app/lib/l10n/app_es.arb +++ b/app/lib/l10n/app_es.arb @@ -224,5 +224,13 @@ "balloonStartupBody": "Ejecut\u00e1ndose en segundo plano. Presiona {hotkey} o haz clic en el \u00edcono de la bandeja.", "balloonWakeupTitle": "CopyPaste ya est\u00e1 abierto", - "balloonWakeupBody": "Presiona {hotkey} o haz clic en el \u00edcono de la bandeja para abrirlo." + "balloonWakeupBody": "Presiona {hotkey} o haz clic en el \u00edcono de la bandeja para abrirlo.", + + "onboardingTitle": "Bienvenido a CopyPaste", + "onboardingSubtitle": "Todo lo que copias, guardado.", + "onboardingPrivacyBadge": "Sin nube \u00b7 Sin rastreo \u00b7 100% local", + "onboardingDescription": "Corre en segundo plano sin que lo notes. Presiona {hotkey} cuando quieras para abrir tu historial.", + "onboardingTrayHint": "Encu\u00e9ntralo junto al reloj, abajo a la derecha.", + "onboardingSettingsButton": "Configuraci\u00f3n", + "onboardingDismissButton": "Empezar" } diff --git a/app/lib/l10n/app_localizations.dart b/app/lib/l10n/app_localizations.dart index fa936cd..be57453 100644 --- a/app/lib/l10n/app_localizations.dart +++ b/app/lib/l10n/app_localizations.dart @@ -1207,6 +1207,48 @@ abstract class AppLocalizations { /// In en, this message translates to: /// **'Press {hotkey} or click the tray icon to bring it up.'** String balloonWakeupBody(String hotkey); + + /// Onboarding screen title + /// + /// In en, this message translates to: + /// **'Welcome to CopyPaste'** + String get onboardingTitle; + + /// Onboarding screen subtitle + /// + /// In en, this message translates to: + /// **'Everything you copy, saved.'** + String get onboardingSubtitle; + + /// Onboarding privacy badge chip + /// + /// In en, this message translates to: + /// **'No cloud · No tracking · 100% local'** + String get onboardingPrivacyBadge; + + /// Onboarding main description + /// + /// In en, this message translates to: + /// **'Runs silently in the background. Press {hotkey} anytime to open your clipboard history.'** + String onboardingDescription(String hotkey); + + /// Onboarding tray location hint + /// + /// In en, this message translates to: + /// **'Look for the CP icon next to your clock.'** + String get onboardingTrayHint; + + /// Onboarding settings button + /// + /// In en, this message translates to: + /// **'Settings'** + String get onboardingSettingsButton; + + /// Onboarding dismiss button + /// + /// In en, this message translates to: + /// **'Get started'** + String get onboardingDismissButton; } class _AppLocalizationsDelegate diff --git a/app/lib/l10n/app_localizations_en.dart b/app/lib/l10n/app_localizations_en.dart index a847250..b142ac6 100644 --- a/app/lib/l10n/app_localizations_en.dart +++ b/app/lib/l10n/app_localizations_en.dart @@ -610,4 +610,27 @@ class AppLocalizationsEn extends AppLocalizations { 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'; } diff --git a/app/lib/l10n/app_localizations_es.dart b/app/lib/l10n/app_localizations_es.dart index 999ef33..f2d7551 100644 --- a/app/lib/l10n/app_localizations_es.dart +++ b/app/lib/l10n/app_localizations_es.dart @@ -614,4 +614,28 @@ class AppLocalizationsEs extends AppLocalizations { 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'; } diff --git a/app/lib/main.dart b/app/lib/main.dart index 8913171..d47a702 100644 --- a/app/lib/main.dart +++ b/app/lib/main.dart @@ -28,6 +28,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'; bool _isMicaDark(String themeMode) => switch (themeMode) { 'dark' => true, @@ -47,6 +48,11 @@ bool isWaylandSession() { void main() async { WidgetsFlutterBinding.ensureInitialized(); + + if (!SingleInstance.acquire()) { + exit(0); + } + await windowManager.ensureInitialized(); if (Platform.isWindows || Platform.isMacOS) { @@ -57,10 +63,6 @@ void main() async { } } - if (!SingleInstance.acquire()) { - exit(0); - } - final storage = await StorageConfig.create(); await storage.ensureDirectories(); AppLogger.initialize(storage.logsPath); @@ -159,6 +161,7 @@ class _CopyPasteAppState extends State StreamSubscription? _listenerSubscription; String? _lastTrayLocale; bool _showPermissionGate = false; + bool _showWindowsOnboarding = false; String? _availableUpdateVersion; bool _programmaticRestore = false; @@ -214,15 +217,11 @@ class _CopyPasteAppState extends State } final showOnStart = - isFirstRun && - (Platform.isLinux || - Platform.isWindows || - (Platform.isMacOS && macosGranted)); + isFirstRun && (Platform.isLinux || (Platform.isMacOS && macosGranted)); await _appWindow.init(startVisible: showOnStart); SingleInstance.listenForWakeup(() { unawaited(_safeShow()); if (Platform.isWindows) unawaited(_showWakeupBalloon()); - _showWakeupHint(); }); try { @@ -241,14 +240,10 @@ class _CopyPasteAppState extends State AppLogger.error('trayIcon.init failed: $e'); } - if (Platform.isWindows) { - if (showOnStart) { - Future.delayed(const Duration(seconds: 2), _showStartupBalloon); - } else { - WidgetsBinding.instance.addPostFrameCallback( - (_) => unawaited(_showStartupBalloon()), - ); - } + if (Platform.isWindows && !isFirstRun && _config.hasSeenWindowsOnboarding) { + WidgetsBinding.instance.addPostFrameCallback( + (_) => unawaited(_showStartupBalloon()), + ); } await _registerHotkeyWithFeedback(); @@ -269,7 +264,11 @@ class _CopyPasteAppState extends State } } } else { - if (isFirstRun) { + if (Platform.isWindows && !_config.hasSeenWindowsOnboarding) { + if (isFirstRun) widget.storage.markAsInitialized(); + setState(() => _showWindowsOnboarding = true); + await _appWindow.enterGateMode(); + } else if (isFirstRun) { widget.storage.markAsInitialized(); } } @@ -356,32 +355,6 @@ class _CopyPasteAppState extends State } } - void _showWakeupHint() { - WidgetsBinding.instance.addPostFrameCallback((_) { - final ctx = _navigatorKey.currentContext; - if (ctx == null || !ctx.mounted) return; - if (_navigatorKey.currentState?.canPop() ?? false) return; - final messenger = ScaffoldMessenger.maybeOf(ctx); - if (messenger == null) return; - final binding = HotkeyBinding( - virtualKey: _config.hotkeyVirtualKey, - keyName: _config.hotkeyKeyName, - useCtrl: _config.hotkeyUseCtrl, - useWin: _config.hotkeyUseWin, - useAlt: _config.hotkeyUseAlt, - useShift: _config.hotkeyUseShift, - ); - messenger - ..clearSnackBars() - ..showSnackBar( - SnackBar( - content: Text(AppLocalizations.of(ctx).wakeupHint(binding.label())), - duration: const Duration(seconds: 10), - ), - ); - }); - } - void _showLinuxNotice(String Function(AppLocalizations l) messageBuilder) { WidgetsBinding.instance.addPostFrameCallback((_) { final ctx = _navigatorKey.currentContext; @@ -840,6 +813,28 @@ class _CopyPasteAppState extends State if (mounted) setState(() => _showPermissionGate = false); } + Future _onOnboardingDismissed() async { + _config = _config.copyWith(hasSeenWindowsOnboarding: true); + unawaited( + _config.save('${widget.storage.configPath}/${AppConfig.fileName}'), + ); + setState(() => _showWindowsOnboarding = false); + await _appWindow.exitGateMode(); + unawaited(_showStartupBalloon()); + } + + Future _onOnboardingGoSettings(BuildContext ctx) async { + _config = _config.copyWith(hasSeenWindowsOnboarding: true); + unawaited( + _config.save('${widget.storage.configPath}/${AppConfig.fileName}'), + ); + setState(() => _showWindowsOnboarding = false); + await _appWindow.exitGateMode(); + await Future.delayed(const Duration(milliseconds: 150)); + if (ctx.mounted) await _openSettings(ctx); + unawaited(_showStartupBalloon()); + } + Future _restartApp() async { await _cleanup(); SingleInstance.release(); @@ -915,6 +910,22 @@ class _CopyPasteAppState extends State } } + if (_showWindowsOnboarding) { + final binding = HotkeyBinding( + virtualKey: _config.hotkeyVirtualKey, + keyName: _config.hotkeyKeyName, + useCtrl: _config.hotkeyUseCtrl, + useWin: _config.hotkeyUseWin, + useAlt: _config.hotkeyUseAlt, + useShift: _config.hotkeyUseShift, + ); + return WindowsOnboardingScreen( + hotkey: binding.label(), + onDismiss: () => unawaited(_onOnboardingDismissed()), + onSettings: () => unawaited(_onOnboardingGoSettings(ctx)), + ); + } + if (_showPermissionGate) { return PermissionGateScreen( previouslyGranted: _config.accessibilityWasGranted, diff --git a/app/lib/screens/windows_onboarding_screen.dart b/app/lib/screens/windows_onboarding_screen.dart new file mode 100644 index 0000000..6a97b43 --- /dev/null +++ b/app/lib/screens/windows_onboarding_screen.dart @@ -0,0 +1,191 @@ +import 'package:flutter/material.dart'; + +import '../l10n/app_localizations.dart'; + +class WindowsOnboardingScreen extends StatelessWidget { + const WindowsOnboardingScreen({ + required this.hotkey, + required this.onDismiss, + required this.onSettings, + super.key, + }); + + final String hotkey; + final VoidCallback onDismiss; + final VoidCallback onSettings; + + @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: 320, + child: SingleChildScrollView( + padding: const EdgeInsets.symmetric(horizontal: 32, vertical: 36), + 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: 24), + Divider(color: cs.outlineVariant, height: 1), + const SizedBox(height: 20), + Text( + l.onboardingDescription(hotkey), + style: tt.bodyMedium?.copyWith( + color: cs.onSurfaceVariant, + height: 1.55, + ), + textAlign: TextAlign.center, + ), + const SizedBox(height: 12), + Text( + l.onboardingTrayHint, + style: tt.bodySmall?.copyWith(color: cs.onSurfaceVariant), + textAlign: TextAlign.center, + ), + const SizedBox(height: 14), + _HotkeyChip(hotkey: hotkey, colorScheme: cs), + const SizedBox(height: 28), + Wrap( + alignment: WrapAlignment.center, + spacing: 10, + runSpacing: 8, + children: [ + OutlinedButton( + onPressed: onSettings, + style: OutlinedButton.styleFrom( + padding: const EdgeInsets.symmetric( + horizontal: 20, + vertical: 12, + ), + ), + child: Text(l.onboardingSettingsButton), + ), + FilledButton( + onPressed: onDismiss, + 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/lib/shell/single_instance.dart b/app/lib/shell/single_instance.dart index db09dbc..acc4bbc 100644 --- a/app/lib/shell/single_instance.dart +++ b/app/lib/shell/single_instance.dart @@ -2,6 +2,7 @@ import 'dart:async'; import 'dart:ffi'; import 'dart:io'; +import 'dart:isolate'; import 'package:ffi/ffi.dart'; @@ -30,12 +31,99 @@ typedef _ReleaseMutexDart = int Function(int hMutex); typedef _AllowSetForegroundWindowNative = Int32 Function(Uint32 dwProcessId); typedef _AllowSetForegroundWindowDart = int Function(int dwProcessId); -class _Win32Mutex { - _Win32Mutex._() { - assert(Platform.isWindows, '_Win32Mutex requires Windows'); +// Named pipe FFI types +typedef _CreateNamedPipeWNative = + IntPtr Function( + Pointer lpName, + Uint32 dwOpenMode, + Uint32 dwPipeMode, + Uint32 nMaxInstances, + Uint32 nOutBufferSize, + Uint32 nInBufferSize, + Uint32 nDefaultTimeOut, + Pointer lpSecurityAttributes, + ); +typedef _CreateNamedPipeWDart = + int Function( + Pointer lpName, + int dwOpenMode, + int dwPipeMode, + int nMaxInstances, + int nOutBufferSize, + int nInBufferSize, + int nDefaultTimeOut, + Pointer lpSecurityAttributes, + ); + +typedef _ConnectNamedPipeNative = + Int32 Function(IntPtr hNamedPipe, Pointer lpOverlapped); +typedef _ConnectNamedPipeDart = + int Function(int hNamedPipe, Pointer lpOverlapped); + +typedef _DisconnectNamedPipeNative = Int32 Function(IntPtr hNamedPipe); +typedef _DisconnectNamedPipeDart = int Function(int hNamedPipe); + +typedef _CreateFileWNative = + IntPtr Function( + Pointer lpFileName, + Uint32 dwDesiredAccess, + Uint32 dwShareMode, + Pointer lpSecurityAttributes, + Uint32 dwCreationDisposition, + Uint32 dwFlagsAndAttributes, + IntPtr hTemplateFile, + ); +typedef _CreateFileWDart = + int Function( + Pointer lpFileName, + int dwDesiredAccess, + int dwShareMode, + Pointer lpSecurityAttributes, + int dwCreationDisposition, + int dwFlagsAndAttributes, + int hTemplateFile, + ); + +typedef _WriteFileNative = + Int32 Function( + IntPtr hFile, + Pointer lpBuffer, + Uint32 nNumberOfBytesToWrite, + Pointer lpNumberOfBytesWritten, + Pointer lpOverlapped, + ); +typedef _WriteFileDart = + int Function( + int hFile, + Pointer lpBuffer, + int nNumberOfBytesToWrite, + Pointer lpNumberOfBytesWritten, + Pointer lpOverlapped, + ); + +typedef _ReadFileNative = + Int32 Function( + IntPtr hFile, + Pointer lpBuffer, + Uint32 nNumberOfBytesToRead, + Pointer lpNumberOfBytesRead, + Pointer lpOverlapped, + ); +typedef _ReadFileDart = + int Function( + int hFile, + Pointer lpBuffer, + int nNumberOfBytesToRead, + Pointer lpNumberOfBytesRead, + Pointer lpOverlapped, + ); + +class _Win32 { + _Win32._() { + assert(Platform.isWindows, '_Win32 requires Windows'); } - static _Win32Mutex? _instance; - static _Win32Mutex get instance => _instance ??= _Win32Mutex._(); + static _Win32? _instance; + static _Win32 get instance => _instance ??= _Win32._(); late final _kernel32 = DynamicLibrary.open('kernel32.dll'); late final createMutex = _kernel32 @@ -46,6 +134,25 @@ class _Win32Mutex { .lookupFunction<_CloseHandleNative, _CloseHandleDart>('CloseHandle'); late final releaseMutex = _kernel32 .lookupFunction<_ReleaseMutexNative, _ReleaseMutexDart>('ReleaseMutex'); + late final createNamedPipe = _kernel32 + .lookupFunction<_CreateNamedPipeWNative, _CreateNamedPipeWDart>( + 'CreateNamedPipeW', + ); + late final connectNamedPipe = _kernel32 + .lookupFunction<_ConnectNamedPipeNative, _ConnectNamedPipeDart>( + 'ConnectNamedPipe', + ); + late final disconnectNamedPipe = _kernel32 + .lookupFunction<_DisconnectNamedPipeNative, _DisconnectNamedPipeDart>( + 'DisconnectNamedPipe', + ); + late final createFile = _kernel32 + .lookupFunction<_CreateFileWNative, _CreateFileWDart>('CreateFileW'); + late final writeFile = _kernel32 + .lookupFunction<_WriteFileNative, _WriteFileDart>('WriteFile'); + late final readFile = _kernel32 + .lookupFunction<_ReadFileNative, _ReadFileDart>('ReadFile'); + late final _user32 = DynamicLibrary.open('user32.dll'); late final allowSetForegroundWindow = _user32 .lookupFunction< @@ -54,6 +161,17 @@ class _Win32Mutex { >('AllowSetForegroundWindow'); } +// Named pipe constants +const int _pipeAccessInbound = 1; +const int _pipeTypeByte = 0; +const int _pipeWait = 0; +const int _pipeUnlimitedInstances = 255; +const int _genericWrite = 0x40000000; +const int _openExisting = 3; +const int _invalidHandleValue = -1; + +const String _pipeName = r'\\.\pipe\CopyPasteSingleInstance'; + class SingleInstance { static const String _mutexName = r'Global\CopyPaste_SingleInstance_Mutex'; static const int _errorAlreadyExists = 183; @@ -62,6 +180,19 @@ class SingleInstance { static int _mutexHandle = 0; static RandomAccessFile? _lockFile; static StreamSubscription? _wakeupSubscription; + static Isolate? _pipeIsolate; + static ReceivePort? _pipeReceivePort; + static DateTime? _lastWakeup; + + static void _callWakeup(void Function() onWakeup) { + final now = DateTime.now(); + if (_lastWakeup != null && + now.difference(_lastWakeup!).inMilliseconds < 2000) { + return; + } + _lastWakeup = now; + onWakeup(); + } static bool acquire() { bool acquired; @@ -84,26 +215,177 @@ class SingleInstance { } } - /// Writes a wakeup signal file so the running instance can show its window. - /// On Windows also grants it foreground permission so SetForegroundWindow works. + /// Writes a wakeup signal so the running instance can show its window. + /// On Windows uses a named pipe; falls back to file on other platforms. + /// Also grants foreground permission so SetForegroundWindow works. static void signalWakeup() { + if (Platform.isWindows) { + _signalWakeupPipe(); + } else { + _signalWakeupFile(); + } + } + + static void _signalWakeupPipe() { + try { + final w = _Win32.instance; + // Grant foreground permission before connecting + w.allowSetForegroundWindow(0xFFFFFFFF); + + final name = _pipeName.toNativeUtf16(); + try { + final hPipe = w.createFile( + name, + _genericWrite, + 0, + nullptr, + _openExisting, + 0, + 0, + ); + if (hPipe == _invalidHandleValue) { + // Pipe not available, fall back to file + _signalWakeupFile(); + return; + } + final msg = 'wakeup'.codeUnits; + final buf = calloc(msg.length); + final written = calloc(1); + try { + for (var i = 0; i < msg.length; i++) { + buf[i] = msg[i]; + } + w.writeFile(hPipe, buf, msg.length, written, nullptr); + } finally { + calloc.free(buf); + calloc.free(written); + w.closeHandle(hPipe); + } + } finally { + calloc.free(name); + } + } catch (_) { + _signalWakeupFile(); + } + } + + static void _signalWakeupFile() { try { File(_wakeupFilePath()).writeAsStringSync('wakeup'); if (Platform.isWindows) { - // ASFW_ANY (-1) lets the first instance bring itself to front - // without Windows silently ignoring SetForegroundWindow. - _Win32Mutex.instance.allowSetForegroundWindow(0xFFFFFFFF); + _Win32.instance.allowSetForegroundWindow(0xFFFFFFFF); } } catch (_) {} } - /// Starts polling for a wakeup signal. Calls [onWakeup] when detected. - /// Safe to call multiple times — cancels any previous subscription. + /// Starts listening for wakeup signals. On Windows uses a named pipe server + /// running in a separate isolate; on other platforms polls a file. static void listenForWakeup(void Function() onWakeup) { _wakeupSubscription?.cancel(); - // Only discard signals older than 30 s — those are genuinely stale from a - // previous crash. Fresh files written by a second instance during our own - // startup must be processed, not silently deleted. + if (Platform.isWindows) { + _listenForWakeupPipe(onWakeup); + } else { + _listenForWakeupFile(onWakeup); + } + } + + static void _listenForWakeupPipe(void Function() onWakeup) { + _pipeReceivePort?.close(); + _pipeIsolate?.kill(priority: Isolate.immediate); + + _pipeReceivePort = ReceivePort(); + _pipeReceivePort!.listen((message) { + if (message == 'wakeup') _callWakeup(onWakeup); + }); + + // Also keep file-based polling as safety net + _listenForWakeupFile(onWakeup); + + Isolate.spawn(_pipeServerLoop, _pipeReceivePort!.sendPort) + .then((isolate) { + _pipeIsolate = isolate; + }) + .catchError((_) { + // Isolate spawn failed; file-based fallback is already running + }); + } + + /// 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) { + final kernel32 = DynamicLibrary.open('kernel32.dll'); + final createNamedPipe = kernel32 + .lookupFunction<_CreateNamedPipeWNative, _CreateNamedPipeWDart>( + 'CreateNamedPipeW', + ); + final connectNamedPipe = kernel32 + .lookupFunction<_ConnectNamedPipeNative, _ConnectNamedPipeDart>( + 'ConnectNamedPipe', + ); + final disconnectNamedPipe = kernel32 + .lookupFunction<_DisconnectNamedPipeNative, _DisconnectNamedPipeDart>( + 'DisconnectNamedPipe', + ); + final readFile = kernel32.lookupFunction<_ReadFileNative, _ReadFileDart>( + 'ReadFile', + ); + final closeHandle = kernel32 + .lookupFunction<_CloseHandleNative, _CloseHandleDart>('CloseHandle'); + final getLastError = kernel32 + .lookupFunction<_GetLastErrorNative, _GetLastErrorDart>('GetLastError'); + + while (true) { + final name = _pipeName.toNativeUtf16(); + final hPipe = createNamedPipe( + name, + _pipeAccessInbound, + _pipeTypeByte | _pipeWait, + _pipeUnlimitedInstances, + 512, + 512, + 5000, + nullptr, + ); + calloc.free(name); + + if (hPipe == _invalidHandleValue) { + // Cannot create pipe; wait and retry + sleep(const Duration(seconds: 2)); + continue; + } + + final connected = connectNamedPipe(hPipe, nullptr); + if (connected == 0) { + const errorPipeConnected = 535; + if (getLastError() != errorPipeConnected) { + disconnectNamedPipe(hPipe); + closeHandle(hPipe); + continue; + } + } + + final buf = calloc(512); + final bytesRead = calloc(1); + try { + final ok = readFile(hPipe, buf, 512, bytesRead, nullptr); + if (ok != 0 && bytesRead.value > 0) { + final data = List.generate(bytesRead.value, (i) => buf[i]); + final msg = String.fromCharCodes(data); + if (msg.contains('wakeup')) { + sendPort.send('wakeup'); + } + } + } finally { + calloc.free(buf); + calloc.free(bytesRead); + } + + disconnectNamedPipe(hPipe); + closeHandle(hPipe); + } + } + + static void _listenForWakeupFile(void Function() onWakeup) { try { final stale = File(_wakeupFilePath()); if (stale.existsSync()) { @@ -118,7 +400,7 @@ class SingleInstance { try { f.deleteSync(); } catch (_) {} - onWakeup(); + _callWakeup(onWakeup); } }); } @@ -127,24 +409,29 @@ class SingleInstance { static void stopListening() { _wakeupSubscription?.cancel(); _wakeupSubscription = null; + _pipeReceivePort?.close(); + _pipeReceivePort = null; + _pipeIsolate?.kill(priority: Isolate.immediate); + _pipeIsolate = null; + _lastWakeup = null; } static String _wakeupFilePath() => '${Directory.systemTemp.path}/$_wakeupFileName'; static bool _acquireWindows() { - final w = _Win32Mutex.instance; + final w = _Win32.instance; final name = _mutexName.toNativeUtf16(); try { - _mutexHandle = w.createMutex(nullptr, 1, name); - if (_mutexHandle == 0) return false; + final handle = w.createMutex(nullptr, 1, name); + if (handle == 0) return false; final error = w.getLastError(); if (error == _errorAlreadyExists) { - w.closeHandle(_mutexHandle); - _mutexHandle = 0; + w.closeHandle(handle); return false; } + _mutexHandle = handle; return true; } finally { calloc.free(name); @@ -152,8 +439,9 @@ class SingleInstance { } static void _releaseWindows() { + stopListening(); if (_mutexHandle != 0) { - final w = _Win32Mutex.instance; + final w = _Win32.instance; w.releaseMutex(_mutexHandle); w.closeHandle(_mutexHandle); _mutexHandle = 0; diff --git a/app/lib/shell/windows_balloon.dart b/app/lib/shell/windows_balloon.dart index cb48f60..879d2de 100644 --- a/app/lib/shell/windows_balloon.dart +++ b/app/lib/shell/windows_balloon.dart @@ -10,6 +10,7 @@ const _nimAdd = 0; const _nimDelete = 2; // NIF flags +const _nifIcon = 0x02; const _nifInfo = 0x10; // NIIF flags (balloon icon + silence) @@ -24,6 +25,7 @@ const _offCbsize = 0; // DWORD (+0) const _offHwnd = 8; // HWND (+8, pointer-aligned) const _offUid = 16; // UINT (+16) const _offUflags = 20; // UINT (+20) +const _offIcon = 24; // HICON (+24, pointer-aligned) const _offSzinfo = 304; // WCHAR[256] (+304) const _offSzinfotitle = 820; // WCHAR[64] (+820) const _offDwinfoflags = 948; // DWORD (+948) @@ -106,24 +108,24 @@ class WindowsBalloon { /// Shows a balloon notification with [title] and [body]. /// - /// The body should include the current hotkey so users know how to open - /// the app without needing to find the tray icon. - static Future show({ + /// Returns true if the notification was shown successfully, false otherwise. + /// Callers can use a false return to trigger an in-app fallback. + static Future show({ required String title, required String body, }) async { - if (!Platform.isWindows) return; + if (!Platform.isWindows) return false; try { _ensureLoaded(); - final winTitle = 'CopyPaste'.toNativeUtf16(); - final hwnd = _findWindow!(nullptr, winTitle); - calloc.free(winTitle); - if (hwnd == 0) return; + final className = 'FLUTTER_RUNNER_WIN32_WINDOW'.toNativeUtf16(); + final hwnd = _findWindow!(className, nullptr); + calloc.free(className); + if (hwnd == 0) return false; // Extract the app's own icon from the running executable. final exePath = Platform.resolvedExecutable.toNativeUtf16(); - final hBalloonIcon = _extractIcon!(0, exePath, 0); + final hIcon = _extractIcon!(0, exePath, 0); calloc.free(exePath); // calloc zero-initialises all bytes. @@ -132,25 +134,31 @@ class WindowsBalloon { _writeUint32(nid, _offCbsize, _nidSize); _writeUint64(nid, _offHwnd, hwnd); _writeUint32(nid, _offUid, _balloonUid); - _writeUint32(nid, _offUflags, _nifInfo); + _writeUint32(nid, _offUflags, _nifIcon | _nifInfo); + if (hIcon != 0) { + _writeUint64(nid, _offIcon, hIcon); + } _writeWString(nid, _offSzinfo, body, 256); _writeWString(nid, _offSzinfotitle, title, 64); - final iconFlags = (hBalloonIcon != 0 ? _niifUser : 0) | _niifNosound; + final iconFlags = (hIcon != 0 ? _niifUser : 0) | _niifNosound; _writeUint32(nid, _offDwinfoflags, iconFlags); - if (hBalloonIcon != 0) { - _writeUint64(nid, _offHBalloonIcon, hBalloonIcon); + if (hIcon != 0) { + _writeUint64(nid, _offHBalloonIcon, hIcon); } - _shellNotify!(_nimAdd, nid); + final result = _shellNotify!(_nimAdd, nid); + if (result == 0) return false; await Future.delayed( const Duration(milliseconds: _cleanupDelayMs), ); + return true; } finally { _shellNotify!(_nimDelete, nid); calloc.free(nid); } } catch (_) { // Balloon is best-effort — a failure must never affect app startup. + return false; } } } diff --git a/app/test/screens/windows_onboarding_screen_test.dart b/app/test/screens/windows_onboarding_screen_test.dart new file mode 100644 index 0000000..8eb9a26 --- /dev/null +++ b/app/test/screens/windows_onboarding_screen_test.dart @@ -0,0 +1,156 @@ +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, + 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, + onDismiss: () {}, + onSettings: () {}, + ), + ), + ); + await tester.pump(); + + expect(find.text(customHotkey), findsOneWidget); + }); + }); +} diff --git a/app/test/shell/single_instance_test.dart b/app/test/shell/single_instance_test.dart index da4334b..f9334fa 100644 --- a/app/test/shell/single_instance_test.dart +++ b/app/test/shell/single_instance_test.dart @@ -1,17 +1,175 @@ +import 'dart:async'; import 'dart:io'; import 'package:flutter_test/flutter_test.dart'; import 'package:copypaste/shell/single_instance.dart'; +String get _wakeupFilePath => '${Directory.systemTemp.path}/copypaste.wakeup'; + +void _cleanupWakeupFile() { + try { + File(_wakeupFilePath).deleteSync(); + } catch (_) {} +} + void main() { + group('SingleInstance – Windows', () { + setUp(() { + if (!Platform.isWindows) return; + SingleInstance.release(); + _cleanupWakeupFile(); + }); + + tearDown(() { + if (!Platform.isWindows) return; + SingleInstance.release(); + _cleanupWakeupFile(); + }); + + test('acquire() returns true on first call', () { + if (!Platform.isWindows) return; + expect(SingleInstance.acquire(), isTrue); + }); + + test('acquire() returns false when mutex already held', () { + if (!Platform.isWindows) return; + expect(SingleInstance.acquire(), isTrue); + // Second call in same process creates/opens the same mutex + // and gets ERROR_ALREADY_EXISTS → false + expect(SingleInstance.acquire(), isFalse); + }); + + test('release() allows re-acquire', () { + if (!Platform.isWindows) return; + expect(SingleInstance.acquire(), isTrue); + SingleInstance.release(); + expect(SingleInstance.acquire(), isTrue); + }); + + test('release() is idempotent', () { + if (!Platform.isWindows) return; + SingleInstance.release(); + SingleInstance.release(); + // After double release, re-acquire must still work + expect(SingleInstance.acquire(), isTrue); + }); + + test('signalWakeup() writes wakeup file as fallback', () { + if (!Platform.isWindows) return; + _cleanupWakeupFile(); + // With no pipe server running, signalWakeup falls back to file + SingleInstance.signalWakeup(); + expect(File(_wakeupFilePath).existsSync(), isTrue); + }); + + test('listenForWakeup() fires callback when wakeup file appears', () async { + if (!Platform.isWindows) return; + + final completer = Completer(); + SingleInstance.listenForWakeup(() { + if (!completer.isCompleted) completer.complete(); + }); + + // Give the listener time to start, then create the file + await Future.delayed(const Duration(milliseconds: 100)); + File(_wakeupFilePath).writeAsStringSync('wakeup'); + + await completer.future.timeout( + const Duration(seconds: 2), + onTimeout: () => fail('Callback was not fired'), + ); + }); + + test('listenForWakeup() fires for fresh pre-existing file', () async { + if (!Platform.isWindows) return; + + // Write file BEFORE starting the listener + File(_wakeupFilePath).writeAsStringSync('wakeup'); + + final completer = Completer(); + SingleInstance.listenForWakeup(() { + if (!completer.isCompleted) completer.complete(); + }); + + await completer.future.timeout( + const Duration(seconds: 2), + onTimeout: () => fail('Callback was not fired for pre-existing file'), + ); + }); + + test('stopListening() prevents further callbacks', () async { + if (!Platform.isWindows) return; + + var callCount = 0; + SingleInstance.listenForWakeup(() => callCount++); + SingleInstance.stopListening(); + + File(_wakeupFilePath).writeAsStringSync('wakeup'); + await Future.delayed(const Duration(seconds: 1)); + expect(callCount, 0); + }); + + test( + 'calling listenForWakeup() twice replaces the first listener', + () async { + if (!Platform.isWindows) return; + + var firstCallCount = 0; + SingleInstance.listenForWakeup(() => firstCallCount++); + + final completer = Completer(); + SingleInstance.listenForWakeup(() { + if (!completer.isCompleted) completer.complete(); + }); + + await Future.delayed(const Duration(milliseconds: 100)); + File(_wakeupFilePath).writeAsStringSync('wakeup'); + + await completer.future.timeout(const Duration(seconds: 2)); + expect(firstCallCount, 0); + }, + ); + + test('debounce: rapid signals fire callback only once', () async { + if (!Platform.isWindows) return; + + var callCount = 0; + SingleInstance.listenForWakeup(() => callCount++); + + await Future.delayed(const Duration(milliseconds: 100)); + + // Write, delete, write again rapidly + File(_wakeupFilePath).writeAsStringSync('wakeup'); + await Future.delayed(const Duration(milliseconds: 600)); + File(_wakeupFilePath).writeAsStringSync('wakeup'); + await Future.delayed(const Duration(milliseconds: 600)); + + expect(callCount, 1); + }); + + test('release() cleans up pipe isolate and subscription', () async { + if (!Platform.isWindows) return; + SingleInstance.acquire(); + var callCount = 0; + SingleInstance.listenForWakeup(() => callCount++); + SingleInstance.release(); + + // After release, writing the signal must not fire the old callback + File(_wakeupFilePath).writeAsStringSync('wakeup'); + await Future.delayed(const Duration(seconds: 1)); + expect(callCount, 0); + }); + }); + group('SingleInstance – Unix (macOS / Linux)', () { setUp(() { - // Ensure a clean state before every test. + if (!Platform.isMacOS && !Platform.isLinux) return; SingleInstance.release(); }); tearDown(() { + if (!Platform.isMacOS && !Platform.isLinux) return; SingleInstance.release(); }); @@ -44,9 +202,10 @@ void main() { test('release() is idempotent — safe to call without prior acquire', () { if (!Platform.isMacOS && !Platform.isLinux) return; - // Must not throw. SingleInstance.release(); SingleInstance.release(); + // After double release, re-acquire must still work + expect(SingleInstance.acquire(), isTrue); }); test('lock file contains the process pid', () { @@ -57,4 +216,101 @@ void main() { expect(content, equals('$pid')); }); }); + + group('SingleInstance – wakeup file (cross-platform)', () { + setUp(() { + SingleInstance.stopListening(); + _cleanupWakeupFile(); + }); + + tearDown(() { + SingleInstance.stopListening(); + _cleanupWakeupFile(); + }); + + test('signalWakeup writes the wakeup file', () { + if (Platform.isWindows) { + // On Windows, signalWakeup tries pipe first; only writes file + // as fallback. Tested in Windows-specific group instead. + return; + } + SingleInstance.signalWakeup(); + expect(File(_wakeupFilePath).existsSync(), isTrue); + }); + + test('file polling fires callback within 600ms', () async { + SingleInstance.listenForWakeup(() {}); + + final completer = Completer(); + SingleInstance.listenForWakeup(() { + if (!completer.isCompleted) completer.complete(); + }); + + await Future.delayed(const Duration(milliseconds: 100)); + File(_wakeupFilePath).writeAsStringSync('wakeup'); + + await completer.future.timeout( + const Duration(milliseconds: 1500), + onTimeout: () => fail('File polling did not fire within expected time'), + ); + }); + + test('wakeup file is deleted after callback fires', () async { + final completer = Completer(); + SingleInstance.listenForWakeup(() { + if (!completer.isCompleted) completer.complete(); + }); + + await Future.delayed(const Duration(milliseconds: 100)); + File(_wakeupFilePath).writeAsStringSync('wakeup'); + + await completer.future.timeout(const Duration(seconds: 2)); + // Allow a tick for the delete to complete + await Future.delayed(const Duration(milliseconds: 50)); + expect(File(_wakeupFilePath).existsSync(), isFalse); + }); + + test('stale file (>30s) is deleted on listenForWakeup startup', () { + // We cannot easily set mtime to 31s ago in pure Dart, so we verify + // the code path indirectly: a freshly written file is NOT deleted + // (proving the age check exists and only targets old files). + File(_wakeupFilePath).writeAsStringSync('wakeup'); + SingleInstance.listenForWakeup(() {}); + // Fresh file should still exist (not deleted by stale check) + expect(File(_wakeupFilePath).existsSync(), isTrue); + }); + + test('stopListening prevents further callbacks', () async { + var callCount = 0; + SingleInstance.listenForWakeup(() => callCount++); + SingleInstance.stopListening(); + + File(_wakeupFilePath).writeAsStringSync('wakeup'); + await Future.delayed(const Duration(seconds: 1)); + expect(callCount, 0); + }); + + test('debounce prevents duplicate callbacks from rapid signals', () async { + var callCount = 0; + final firstFired = Completer(); + SingleInstance.listenForWakeup(() { + callCount++; + if (!firstFired.isCompleted) firstFired.complete(); + }); + + await Future.delayed(const Duration(milliseconds: 100)); + + File(_wakeupFilePath).writeAsStringSync('wakeup'); + await firstFired.future.timeout( + const Duration(seconds: 3), + onTimeout: () => fail('First callback did not fire'), + ); + + // Second signal within 2s debounce window — must be suppressed + File(_wakeupFilePath).writeAsStringSync('wakeup'); + await Future.delayed(const Duration(milliseconds: 600)); + + expect(callCount, 1); + }); + }); } diff --git a/core/lib/config/app_config.dart b/core/lib/config/app_config.dart index cd05737..ead5fd4 100644 --- a/core/lib/config/app_config.dart +++ b/core/lib/config/app_config.dart @@ -11,8 +11,8 @@ class AppConfig { this.runOnStartup = true, this.hotkeyUseCtrl = true, this.hotkeyUseWin = false, - this.hotkeyUseAlt = true, - this.hotkeyUseShift = false, + this.hotkeyUseAlt = false, + this.hotkeyUseShift = true, this.hotkeyVirtualKey = 0x56, this.hotkeyKeyName = 'V', this.pageSize = 30, @@ -38,6 +38,7 @@ class AppConfig { this.showInTaskbar = false, this.accessibilityWasGranted = false, this.lastRunVersion = '', + this.hasSeenWindowsOnboarding = false, }); factory AppConfig.fromJson(Map json) { @@ -98,6 +99,9 @@ class AppConfig { defaults.accessibilityWasGranted, lastRunVersion: json['lastRunVersion'] as String? ?? defaults.lastRunVersion, + hasSeenWindowsOnboarding: + json['hasSeenWindowsOnboarding'] as bool? ?? + defaults.hasSeenWindowsOnboarding, ); } @@ -156,6 +160,7 @@ class AppConfig { final bool showInTaskbar; final bool accessibilityWasGranted; final String lastRunVersion; + final bool hasSeenWindowsOnboarding; AppConfig copyWith({ String? preferredLanguage, @@ -189,6 +194,7 @@ class AppConfig { bool? showInTaskbar, bool? accessibilityWasGranted, String? lastRunVersion, + bool? hasSeenWindowsOnboarding, }) => AppConfig( preferredLanguage: preferredLanguage ?? this.preferredLanguage, runOnStartup: runOnStartup ?? this.runOnStartup, @@ -226,6 +232,8 @@ class AppConfig { accessibilityWasGranted: accessibilityWasGranted ?? this.accessibilityWasGranted, lastRunVersion: lastRunVersion ?? this.lastRunVersion, + hasSeenWindowsOnboarding: + hasSeenWindowsOnboarding ?? this.hasSeenWindowsOnboarding, ); Map toJson() => { @@ -261,6 +269,7 @@ class AppConfig { 'showInTaskbar': showInTaskbar, 'accessibilityWasGranted': accessibilityWasGranted, 'lastRunVersion': lastRunVersion, + 'hasSeenWindowsOnboarding': hasSeenWindowsOnboarding, }; static Future load(String configPath) async { diff --git a/core/test/app_config_test.dart b/core/test/app_config_test.dart index 136aca3..0c15962 100644 --- a/core/test/app_config_test.dart +++ b/core/test/app_config_test.dart @@ -14,7 +14,8 @@ void main() { expect(config.pageSize, equals(30)); expect(config.hotkeyUseCtrl, isTrue); expect(config.hotkeyUseWin, isFalse); - expect(config.hotkeyUseAlt, isTrue); + expect(config.hotkeyUseAlt, isFalse); + expect(config.hotkeyUseShift, isTrue); expect(config.hotkeyKeyName, equals('V')); }); @@ -101,18 +102,19 @@ void main() { const config = AppConfig(); expect(config.hotkeyUseCtrl, isTrue); expect(config.hotkeyUseWin, isFalse); - expect(config.hotkeyUseAlt, isTrue); - expect(config.hotkeyUseShift, isFalse); + expect(config.hotkeyUseAlt, isFalse); + expect(config.hotkeyUseShift, isTrue); expect(config.hotkeyVirtualKey, equals(0x56)); expect(config.hotkeyKeyName, equals('V')); }); - test('all platforms default to Ctrl+Alt+V', () { + test('all platforms default to Ctrl+Shift+V', () { for (final platform in ['default', 'linux', 'windows', 'macos']) { final config = AppConfig.defaultForPlatform(platform); expect(config.hotkeyUseCtrl, isTrue, reason: '$platform: useCtrl'); expect(config.hotkeyUseWin, isFalse, reason: '$platform: useWin'); - expect(config.hotkeyUseAlt, isTrue, reason: '$platform: useAlt'); + expect(config.hotkeyUseAlt, isFalse, reason: '$platform: useAlt'); + expect(config.hotkeyUseShift, isTrue, reason: '$platform: useShift'); expect(config.hotkeyKeyName, equals('V'), reason: '$platform: key'); } }); @@ -237,6 +239,33 @@ void main() { expect(config.showTrayIcon, isTrue); }); + test('hasSeenWindowsOnboarding defaults to false', () { + const config = AppConfig(); + expect(config.hasSeenWindowsOnboarding, isFalse); + }); + + test('hasSeenWindowsOnboarding round-trips via JSON', () { + const config = AppConfig(hasSeenWindowsOnboarding: true); + expect( + AppConfig.fromJson(config.toJson()).hasSeenWindowsOnboarding, + isTrue, + ); + }); + + test('hasSeenWindowsOnboarding absent in JSON defaults to false', () { + expect(AppConfig.fromJson({}).hasSeenWindowsOnboarding, isFalse); + }); + + test('copyWith hasSeenWindowsOnboarding updates value', () { + const config = AppConfig(); + expect( + config + .copyWith(hasSeenWindowsOnboarding: true) + .hasSeenWindowsOnboarding, + isTrue, + ); + }); + test('toJson omits lastBackupDateUtc when null', () { const config = AppConfig(); expect(config.toJson().containsKey('lastBackupDateUtc'), isFalse); diff --git a/core/test/storage_config_test.dart b/core/test/storage_config_test.dart index ab35ae4..2692f08 100644 --- a/core/test/storage_config_test.dart +++ b/core/test/storage_config_test.dart @@ -55,5 +55,21 @@ void main() { test('cleanOrphanImages does not throw when directory is missing', () { expect(() => config.cleanOrphanImages([]), returnsNormally); }); + + test('logsPath is derived from baseDir', () { + expect(config.logsPath, equals(p.join(tempDir.path, 'logs'))); + }); + + test('clearInitialized removes the init flag', () { + config.markAsInitialized(); + expect(config.isFirstRun, isFalse); + config.clearInitialized(); + expect(config.isFirstRun, isTrue); + }); + + test('clearInitialized is safe when flag does not exist', () { + expect(() => config.clearInitialized(), returnsNormally); + expect(config.isFirstRun, isTrue); + }); }); }