diff --git a/lib/main.dart b/lib/main.dart index a75ea79..a72f687 100644 --- a/lib/main.dart +++ b/lib/main.dart @@ -1,4 +1,5 @@ import 'dart:async'; +import 'dart:io'; import 'package:flutter/material.dart'; import 'package:flutter/services.dart'; import 'package:journaler/db.dart'; @@ -8,6 +9,8 @@ import 'package:window_manager/window_manager.dart'; import 'package:audioplayers/audioplayers.dart'; import 'package:flutter/gestures.dart'; import 'dart:math'; +import 'package:path/path.dart' as path; +import 'package:ps_list/ps_list.dart'; // TODO: Sound does not play when ran from a different workdir? Weird // TODO: Fix saving the same scratch over and over again @@ -16,9 +19,55 @@ import 'dart:math'; const Duration _defaultPopupInterval = Duration(minutes: 20); const String _defaultNotificationSound = 'MeetTheSniper.mp3'; +// Flag to indicate if there's a pending show request from a secondary instance +bool _pendingShowRequest = false; + +// Global navigator key to access the navigator state from anywhere +final GlobalKey navigatorKey = GlobalKey(); + +/// Paths for IPC file in system temp directory +class AppFiles { + static String get ipcFilePath => + path.join(Directory.systemTemp.path, 'journaler_ipc.txt'); +} + void main() async { - await DB.init(); WidgetsFlutterBinding.ensureInitialized(); + + final ipcFile = File(AppFiles.ipcFilePath); + + if (await alreadyRunning()) { + await signalPrimaryInstanceAndExit(ipcFile); + } else { + await runPrimaryInstance(ipcFile); + } +} + +Future alreadyRunning() async { + final processes = await PSList.getRunningProcesses(); + final executable = Platform.resolvedExecutable; + final executableName = path.basename(executable); + final journalers = + processes + .where((process) => process == executableName) + .toList(); + debugPrint("Journalers: $journalers"); + return journalers.length > 1; +} + +/// Run the primary instance of the application +Future runPrimaryInstance(File ipcFile) async { + debugPrint("Starting as primary instance"); + + // Create or clear the IPC file + if (!await ipcFile.exists()) { + await ipcFile.create(); + } + // Start a watcher for the IPC file to detect signals from other instances + startIpcWatcher(ipcFile); + + // Initialize the app + await DB.init(); await windowManager.ensureInitialized(); WindowOptions windowOptions = const WindowOptions( @@ -34,6 +83,114 @@ void main() async { }); runApp(const JournalerApp()); + + // Set up termination handlers to clean up resources + setupTerminationHandlers(ipcFile); +} + +/// Handle termination signals by cleaning up IPC file +void setupTerminationHandlers(File ipcFile) { + // Handle normal termination + ProcessSignal.sigterm.watch().listen((_) async { + await cleanupFiles(ipcFile); + exit(0); + }); + + // Handle Windows and Linux CTRL+C termination + if (Platform.isWindows || Platform.isLinux) { + ProcessSignal.sigint.watch().listen((_) async { + await cleanupFiles(ipcFile); + exit(0); + }); + } +} + +/// Clean up IPC file +Future cleanupFiles(File ipcFile) async { + try { + if (await ipcFile.exists()) { + await ipcFile.delete(); + } + debugPrint("Removed IPC file on app termination"); + } catch (e) { + debugPrint("Error cleaning up files: $e"); + } +} + +/// Signal the primary instance to show itself, then exit +Future signalPrimaryInstanceAndExit(File ipcFile) async { + debugPrint( + "Another instance is already running. Signaling it to show window.", + ); + + try { + // Create the IPC file if it doesn't exist + if (!await ipcFile.exists()) { + await ipcFile.create(); + } + + // Write a 'show' command with a timestamp + final timestamp = DateTime.now().millisecondsSinceEpoch; + await ipcFile.writeAsString('show:$timestamp'); + debugPrint("Signal sent to primary instance via IPC file"); + + // Give the primary instance a moment to process the signal + await Future.delayed(const Duration(seconds: 2)); + } catch (e) { + debugPrint("Error signaling primary instance: $e"); + } + + // Exit this instance + debugPrint("Exiting secondary instance"); + exit(0); +} + +/// Start watching the IPC file for signals from other instances +void startIpcWatcher(File ipcFile) { + // Initial check of the file content + checkIpcFileContent(ipcFile); + + // Set up a file watcher + final watcher = ipcFile.watch(); + watcher.listen((event) { + if (event.type == FileSystemEvent.modify) { + checkIpcFileContent(ipcFile); + } + }); + + // Also set up a periodic check as a fallback + Timer.periodic(const Duration(milliseconds: 200), (_) { + checkIpcFileContent(ipcFile); + }); + + debugPrint("IPC file watcher started"); +} + +/// Check the IPC file content for commands +Future checkIpcFileContent(File ipcFile) async { + try { + if (await ipcFile.exists()) { + final content = await ipcFile.readAsString(); + + if (content.isNotEmpty && content.startsWith('show:')) { + // Clear the file immediately to avoid processing the same command multiple times + await ipcFile.writeAsString(''); + + // Process the show command + if (MainPageState.instance != null) { + debugPrint("Received show command - making window visible"); + MainPageState.instance!.showWindow(); + } else { + debugPrint( + "MainPageState not initialized yet - setting pending flag", + ); + _pendingShowRequest = true; + } + } + } + } catch (e) { + debugPrint("Error processing IPC file: $e"); + } } class JournalerApp extends StatelessWidget { @@ -114,6 +271,7 @@ class JournalerApp extends StatelessWidget { themeMode: ThemeMode.system, home: const MainPage(), debugShowCheckedModeBanner: false, + navigatorKey: navigatorKey, ); } } @@ -126,6 +284,9 @@ class MainPage extends StatefulWidget { } class MainPageState extends State with WindowListener { + // Static reference to the current instance + static MainPageState? instance; + final SystemTray _systemTray = SystemTray(); final Menu _menu = Menu(); final AudioPlayer _audioPlayer = AudioPlayer(); @@ -157,16 +318,31 @@ class MainPageState extends State with WindowListener { @override void initState() { super.initState(); + // Store reference to this instance + instance = this; windowManager.addListener(this); _initSystemTray(); _loadData(); _loadVolume(); windowManager.setPreventClose(true); _setWindowConfig(); + + // Check if there's a pending show request from another instance + WidgetsBinding.instance.addPostFrameCallback((_) { + if (_pendingShowRequest) { + debugPrint("Processing pending show request during initialization"); + _pendingShowRequest = false; + showWindow(); + } + }); } @override void dispose() { + // Clear reference to this instance + if (instance == this) { + instance = null; + } windowManager.removeListener(this); _popupTimer?.cancel(); _debounceTimer?.cancel(); @@ -210,7 +386,7 @@ class MainPageState extends State with WindowListener { _systemTray.registerSystemTrayEventHandler((eventName) { debugPrint("System Tray Event: $eventName"); if (eventName == kSystemTrayEventClick) { - _showWindow(); + showWindow(); } else if (eventName == kSystemTrayEventRightClick) { _systemTray.popUpContextMenu(); } @@ -220,26 +396,56 @@ class MainPageState extends State with WindowListener { void _startPopupTimer() { _popupTimer?.cancel(); _popupTimer = Timer.periodic(_currentPopupInterval, (timer) { - _showWindow(); + showWindow(); }); debugPrint( "Popup timer started with interval: ${_currentPopupInterval.inMinutes} minutes", ); } - Future _showWindow() async { + Future showWindow() async { _loadData(); - bool wasVisible = await windowManager.isVisible(); - if (!wasVisible) { - await windowManager.setSize(const Size(1600, 900)); - await windowManager.center(); - await windowManager.show(); - await windowManager.focus(); - _currentEntryFocusNode.requestFocus(); - await _playSound(); - } else { - await windowManager.focus(); - _currentEntryFocusNode.requestFocus(); + try { + bool wasVisible = await windowManager.isVisible(); + debugPrint("Current window visibility: $wasVisible"); + + if (!wasVisible) { + // First make sure the window has the right size and position + await windowManager.setSize(const Size(1600, 900)); + await windowManager.center(); + + // Now show and focus + await windowManager.show(); + await Future.delayed(const Duration(milliseconds: 100)); // Short delay + await windowManager.focus(); + + // Set input focus + _currentEntryFocusNode.requestFocus(); + + // Play notification sound + await _playSound(); + + debugPrint("Window made visible and focused"); + } else { + // Already visible, just focus + await windowManager.focus(); + _currentEntryFocusNode.requestFocus(); + debugPrint("Window already visible, just focused"); + } + + // Verify the window is now visible + bool isNowVisible = await windowManager.isVisible(); + debugPrint("Window visibility after show attempt: $isNowVisible"); + + if (!isNowVisible) { + debugPrint( + "WARNING: Window still not visible after show attempt, trying again", + ); + await windowManager.show(); + await windowManager.focus(); + } + } catch (e) { + debugPrint("Error showing window: $e"); } } diff --git a/pubspec.lock b/pubspec.lock index a9bbe08..f08dbe8 100644 --- a/pubspec.lock +++ b/pubspec.lock @@ -289,7 +289,7 @@ packages: source: hosted version: "1.16.0" path: - dependency: transitive + dependency: "direct main" description: name: path sha256: "75cca69d1490965be98c73ceaea117e8a04dd21217b37b292c9ddbec0d955bc5" @@ -376,6 +376,14 @@ packages: url: "https://pub.dev" source: hosted version: "6.0.2" + ps_list: + dependency: "direct main" + description: + name: ps_list + sha256: "19d32f6c643313cf4f5101bb144b8978b9ba3dc42c9a01b247e8ed90581bc0ab" + url: "https://pub.dev" + source: hosted + version: "0.0.5" screen_retriever: dependency: transitive description: diff --git a/pubspec.yaml b/pubspec.yaml index 7a5a9f4..cce8c53 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -38,6 +38,8 @@ dependencies: window_manager: ^0.4.3 audioplayers: ^6.4.0 sqflite_common_ffi: ^2.3.5 + path: ^1.8.0 + ps_list: ^0.0.5 dev_dependencies: flutter_test: