diff --git a/.github/copilot-instructions.md b/.github/copilot-instructions.md index 73fb8b8..d7fcbd7 100644 --- a/.github/copilot-instructions.md +++ b/.github/copilot-instructions.md @@ -14,7 +14,7 @@ A JavaFX 21 cross-platform desktop application for learning student names throug - **Model**: Java 21 records for data (Student, GameSession, etc.) - **Service**: Business logic (ConfigService, RosterService, ImageService, etc.) - **Controller**: FXML controllers for views -- **Util**: Animation factory, helpers +- **Util**: Animation factory, AppLogger, helpers ## Key Features - Load student photos from configurable directory @@ -31,6 +31,24 @@ A JavaFX 21 cross-platform desktop application for learning student names throug - All UI updates on JavaFX Application Thread (Platform.runLater) - Store user data in ~/.namegame/ (installed) or ./data/ (portable) +## Logging +- **Never** use `System.err.println()` or `System.out.println()` directly in service or controller code. + On Windows jpackage (MSI/EXE) installs the app runs as a pure GUI process with no console + window, so all console output is silently discarded. +- Use `AppLogger.log(String)` / `AppLogger.log(String, Throwable)` from + `com.example.namegame.util.AppLogger` in every layer (services, controllers, utilities). +- `AppLogger.init(Path)` is called once at startup (`NameGameApplication.main()`) before any + service singleton is created. It directs output to both `stderr` **and** the persistent log + file at `~/.namegame/namegame.log` (or `%TEMP%\namegame.log` as a fallback). +- Fatal startup exceptions are caught in `NameGameApplication.start()` and shown to the user + via a JavaFX error dialog (with an expandable stack-trace area) rather than dying silently. + +## Error Handling +- Wrap `Application.start()` in a broad try/catch and call `showFatalErrorDialog()` so the + user always gets visible feedback when the app cannot start. +- Non-fatal errors in services (e.g. missing sound files, I/O on statistics) should be logged + with `AppLogger.log()` and allow the app to continue running in a degraded state. + ## Build Commands ```bash mvn clean package # Full build diff --git a/src/main/java/com/example/namegame/NameGameApplication.java b/src/main/java/com/example/namegame/NameGameApplication.java index a0181bf..fbff76c 100644 --- a/src/main/java/com/example/namegame/NameGameApplication.java +++ b/src/main/java/com/example/namegame/NameGameApplication.java @@ -6,6 +6,7 @@ import com.example.namegame.service.ImageService; import com.example.namegame.service.KeyboardShortcutService; import com.example.namegame.service.SoundService; +import com.example.namegame.util.AppLogger; import javafx.application.Application; import javafx.application.Platform; import javafx.fxml.FXMLLoader; @@ -17,14 +18,10 @@ import javafx.scene.layout.VBox; import javafx.stage.Stage; -import java.io.IOException; import java.io.PrintWriter; import java.io.StringWriter; import java.nio.file.Files; import java.nio.file.Path; -import java.nio.file.StandardOpenOption; -import java.time.LocalDateTime; -import java.time.format.DateTimeFormatter; /** * Main application entry point for Student Name Game. @@ -36,9 +33,6 @@ public class NameGameApplication extends Application { private static final int DEFAULT_WIDTH = 900; private static final int DEFAULT_HEIGHT = 700; - /** Log file written on every launch; visible even without a console window. */ - private static Path logFile; - // ----------------------------------------------------------------------- // Logging helpers // ----------------------------------------------------------------------- @@ -60,31 +54,6 @@ private static Path resolveLogFile() { return null; // logging completely disabled } - static void log(String message) { - log(message, null); - } - - static void log(String message, Throwable t) { - String timestamp = LocalDateTime.now() - .format(DateTimeFormatter.ISO_LOCAL_DATE_TIME); - StringBuilder sb = new StringBuilder(); - sb.append('[').append(timestamp).append("] ").append(message) - .append(System.lineSeparator()); - if (t != null) { - StringWriter sw = new StringWriter(); - t.printStackTrace(new PrintWriter(sw)); - sb.append(sw).append(System.lineSeparator()); - } - String entry = sb.toString(); - System.err.print(entry); - if (logFile != null) { - try { - Files.writeString(logFile, entry, - StandardOpenOption.CREATE, StandardOpenOption.APPEND); - } catch (IOException ignored) { /* nothing left to do */ } - } - } - // ----------------------------------------------------------------------- // Fatal-error dialog (replaces a missing stderr console in GUI installs) // ----------------------------------------------------------------------- @@ -97,6 +66,7 @@ private void showFatalErrorDialog(Stage owner, Throwable t) { Alert alert = new Alert(Alert.AlertType.ERROR); alert.setTitle(APP_TITLE + " – Fatal Error"); alert.setHeaderText("The application failed to start."); + Path logFile = AppLogger.getLogFile(); String logHint = (logFile != null) ? "\n\nFull details: " + logFile : ""; @@ -116,7 +86,7 @@ private void showFatalErrorDialog(Stage owner, Throwable t) { alert.showAndWait(); } catch (Exception dialogEx) { - log("Failed to show fatal-error dialog", dialogEx); + AppLogger.log("Failed to show fatal-error dialog", dialogEx); } finally { Platform.exit(); } @@ -129,7 +99,7 @@ private void showFatalErrorDialog(Stage owner, Throwable t) { @Override public void start(Stage primaryStage) { try { - log("start() called"); + AppLogger.log("start() called"); ConfigService config = ConfigService.getInstance(); // Initialize SoundService early to load sounds @@ -141,7 +111,7 @@ public void start(Stage primaryStage) { loadImagesAndShowDashboard(primaryStage); } } catch (Exception e) { - log("Fatal error during startup", e); + AppLogger.log("Fatal error during startup", e); showFatalErrorDialog(primaryStage, e); } } @@ -160,7 +130,7 @@ private void showWelcomeDialog(Stage primaryStage) throws Exception { try { loadImagesAndShowDashboard(primaryStage); } catch (Exception e) { - log("Error loading dashboard after welcome dialog", e); + AppLogger.log("Error loading dashboard after welcome dialog", e); Platform.runLater(() -> showFatalErrorDialog(primaryStage, e)); } }); @@ -211,21 +181,22 @@ public void stop() { } public static void main(String[] args) { - // Resolve log file before anything else so every startup attempt is recorded. - logFile = resolveLogFile(); - log("=== Student Name Game starting (version " + APP_VERSION + ") ==="); - log("java.version=" + System.getProperty("java.version") + // Initialise the log file before anything else so every startup attempt + // is recorded — even if it happens before the JavaFX toolkit starts. + AppLogger.init(resolveLogFile()); + AppLogger.log("=== Student Name Game starting (version " + APP_VERSION + ") ==="); + AppLogger.log("java.version=" + System.getProperty("java.version") + " os.name=" + System.getProperty("os.name") + " user.home=" + System.getProperty("user.home")); // Catch any thread that dies without an explicit handler (non-FX threads). Thread.setDefaultUncaughtExceptionHandler((thread, throwable) -> - log("Uncaught exception on thread \"" + thread.getName() + "\"", throwable)); + AppLogger.log("Uncaught exception on thread \"" + thread.getName() + "\"", throwable)); try { launch(args); } catch (Exception e) { - log("Fatal error in launch()", e); + AppLogger.log("Fatal error in launch()", e); } } } diff --git a/src/main/java/com/example/namegame/service/ImageService.java b/src/main/java/com/example/namegame/service/ImageService.java index e58579c..72c0ddb 100644 --- a/src/main/java/com/example/namegame/service/ImageService.java +++ b/src/main/java/com/example/namegame/service/ImageService.java @@ -3,6 +3,7 @@ import com.example.namegame.model.ScoredMatch; import com.example.namegame.model.Student; import com.example.namegame.model.UnmatchedImage; +import com.example.namegame.util.AppLogger; import javafx.application.Platform; import java.io.IOException; @@ -51,13 +52,13 @@ public boolean loadImages() { Path directory = ConfigService.getInstance().getImagesDirectory(); if (directory == null || !Files.isDirectory(directory)) { - System.err.println("Invalid images directory"); + AppLogger.log("Invalid images directory: " + directory); return false; } // Load roster first if (!RosterService.getInstance().loadRoster(directory)) { - System.err.println("Failed to load roster"); + AppLogger.log("Failed to load roster from: " + directory); return false; } @@ -69,12 +70,12 @@ public boolean loadImages() { .filter(this::isImageFile) .forEach(path -> processImageFile(path, squashedToOriginal, manualMappings)); } catch (IOException e) { - System.err.println("Failed to list directory: " + e.getMessage()); + AppLogger.log("Failed to list directory: " + directory, e); return false; } - System.out.println("Loaded " + students.size() + " students, " + - unmatchedImages.size() + " unmatched images"); + AppLogger.log("Loaded " + students.size() + " students, " + + unmatchedImages.size() + " unmatched images"); return !students.isEmpty(); } @@ -201,7 +202,7 @@ public void startWatching(Consumer callback) { watchThread = Thread.startVirtualThread(this::watchLoop); } catch (IOException e) { - System.err.println("Failed to start file watcher: " + e.getMessage()); + AppLogger.log("Failed to start file watcher", e); } } diff --git a/src/main/java/com/example/namegame/service/MappingService.java b/src/main/java/com/example/namegame/service/MappingService.java index 645079c..d40e013 100644 --- a/src/main/java/com/example/namegame/service/MappingService.java +++ b/src/main/java/com/example/namegame/service/MappingService.java @@ -1,5 +1,6 @@ package com.example.namegame.service; +import com.example.namegame.util.AppLogger; import com.google.gson.Gson; import com.google.gson.GsonBuilder; import com.google.gson.reflect.TypeToken; @@ -46,7 +47,7 @@ private void load() { mappings = new HashMap<>(); } } catch (IOException e) { - System.err.println("Failed to load mappings: " + e.getMessage()); + AppLogger.log("Failed to load mappings: " + mappingsPath, e); mappings = new HashMap<>(); } } else { @@ -59,7 +60,7 @@ private void save() { String json = gson.toJson(mappings); Files.writeString(mappingsPath, json); } catch (IOException e) { - System.err.println("Failed to save mappings: " + e.getMessage()); + AppLogger.log("Failed to save mappings: " + mappingsPath, e); } } diff --git a/src/main/java/com/example/namegame/service/RosterService.java b/src/main/java/com/example/namegame/service/RosterService.java index b976f54..5601471 100644 --- a/src/main/java/com/example/namegame/service/RosterService.java +++ b/src/main/java/com/example/namegame/service/RosterService.java @@ -1,5 +1,6 @@ package com.example.namegame.service; +import com.example.namegame.util.AppLogger; import org.apache.poi.ss.usermodel.*; import java.io.*; @@ -39,7 +40,7 @@ public boolean loadRoster(Path directory) { // Find roster file Path rosterFile = findRosterFile(directory); if (rosterFile == null) { - System.err.println("No roster file (roster.xls or roster.xlsx) found in: " + directory); + AppLogger.log("No roster file (roster.xls or roster.xlsx) found in: " + directory); return false; } @@ -53,7 +54,7 @@ public boolean loadRoster(Path directory) { int nameColumnIndex = findNameColumn(headerRow); if (nameColumnIndex < 0) { - System.err.println("'Name' column not found in roster"); + AppLogger.log("'Name' column not found in roster: " + rosterFile); return false; } @@ -74,11 +75,11 @@ public boolean loadRoster(Path directory) { } } - System.out.println("Loaded " + rosterNames.size() + " names from roster"); + AppLogger.log("Loaded " + rosterNames.size() + " names from roster: " + rosterFile); return !rosterNames.isEmpty(); } catch (IOException e) { - System.err.println("Failed to read roster: " + e.getMessage()); + AppLogger.log("Failed to read roster: " + rosterFile, e); return false; } } diff --git a/src/main/java/com/example/namegame/service/SoundService.java b/src/main/java/com/example/namegame/service/SoundService.java index 4efa40d..c3e62e3 100644 --- a/src/main/java/com/example/namegame/service/SoundService.java +++ b/src/main/java/com/example/namegame/service/SoundService.java @@ -1,5 +1,6 @@ package com.example.namegame.service; +import com.example.namegame.util.AppLogger; import javafx.scene.media.AudioClip; import java.net.URL; @@ -41,11 +42,11 @@ private AudioClip loadSound(String resourcePath) { if (url != null) { return new AudioClip(url.toExternalForm()); } else { - System.err.println("Sound not found: " + resourcePath); + AppLogger.log("Sound not found: " + resourcePath); return null; } } catch (Exception e) { - System.err.println("Failed to load sound " + resourcePath + ": " + e.getMessage()); + AppLogger.log("Failed to load sound " + resourcePath, e); return null; } } @@ -71,7 +72,7 @@ private void playSound(AudioClip clip) { try { clip.play(); } catch (Exception e) { - System.err.println("Error playing sound: " + e.getMessage()); + AppLogger.log("Error playing sound", e); } } } diff --git a/src/main/java/com/example/namegame/service/StatisticsService.java b/src/main/java/com/example/namegame/service/StatisticsService.java index e861e99..41b21a2 100644 --- a/src/main/java/com/example/namegame/service/StatisticsService.java +++ b/src/main/java/com/example/namegame/service/StatisticsService.java @@ -2,6 +2,7 @@ import com.example.namegame.model.GameStatistics; import com.example.namegame.model.Student; +import com.example.namegame.util.AppLogger; import com.google.gson.Gson; import com.google.gson.GsonBuilder; @@ -43,7 +44,7 @@ private void load() { statistics = new GameStatistics(); } } catch (IOException e) { - System.err.println("Failed to load statistics: " + e.getMessage()); + AppLogger.log("Failed to load statistics: " + statisticsPath, e); statistics = new GameStatistics(); } } else { @@ -56,7 +57,7 @@ private void save() { String json = gson.toJson(statistics); Files.writeString(statisticsPath, json); } catch (IOException e) { - System.err.println("Failed to save statistics: " + e.getMessage()); + AppLogger.log("Failed to save statistics: " + statisticsPath, e); } } diff --git a/src/main/java/com/example/namegame/util/AppLogger.java b/src/main/java/com/example/namegame/util/AppLogger.java new file mode 100644 index 0000000..cbb7376 --- /dev/null +++ b/src/main/java/com/example/namegame/util/AppLogger.java @@ -0,0 +1,82 @@ +package com.example.namegame.util; + +import java.io.IOException; +import java.io.PrintWriter; +import java.io.StringWriter; +import java.nio.file.Files; +import java.nio.file.Path; +import java.nio.file.StandardOpenOption; +import java.time.LocalDateTime; +import java.time.format.DateTimeFormatter; + +/** + * Lightweight application logger that writes to both stderr and a persistent + * log file. On Windows GUI installations (jpackage MSI) there is no console + * window, so file logging ensures that diagnostics are always retrievable even + * when {@code System.err} output is silently discarded. + * + *

Call {@link #init(Path)} once at application startup (before any service + * is created) to enable file logging. After that, every layer of the + * application should use {@link #log(String)} / {@link #log(String, Throwable)} + * instead of {@code System.err.println()}. + */ +public final class AppLogger { + + private static volatile Path logFile; + + private AppLogger() {} + + /** + * Sets the log-file path. Must be called exactly once at startup (from + * {@code main()}) before any service singleton is created and before the + * JavaFX toolkit is launched. Subsequent calls are ignored. + * + * @param file writable path for the log file, or {@code null} to disable + * file logging (stderr only) + */ + public static void init(Path file) { + if (logFile == null) { + logFile = file; + } + } + + /** Returns the log-file path, or {@code null} if file logging is disabled. */ + public static Path getLogFile() { + return logFile; + } + + /** Logs a plain message. */ + public static void log(String message) { + log(message, null); + } + + /** + * Logs a message, optionally with a stack trace. + * + * @param message human-readable description + * @param t exception to append (may be {@code null}) + */ + public static void log(String message, Throwable t) { + String timestamp = LocalDateTime.now() + .format(DateTimeFormatter.ISO_LOCAL_DATE_TIME); + StringBuilder sb = new StringBuilder(); + sb.append('[').append(timestamp).append("] ").append(message) + .append(System.lineSeparator()); + if (t != null) { + StringWriter sw = new StringWriter(); + t.printStackTrace(new PrintWriter(sw)); + sb.append(sw).append(System.lineSeparator()); + } + String entry = sb.toString(); + System.err.print(entry); + Path file = logFile; + if (file != null) { + try { + Files.writeString(file, entry, + StandardOpenOption.CREATE, StandardOpenOption.APPEND); + } catch (IOException ignored) { + // Nothing left to do if we can't write the log file + } + } + } +}