diff --git a/.gitignore b/.gitignore index 155d0e5..5b9180b 100644 --- a/.gitignore +++ b/.gitignore @@ -8,3 +8,6 @@ # Ignore Gradle build output directory build + +Replays +*UsedKeys.txt diff --git a/client/src/main/java/EntropyMain.java b/client/src/main/java/EntropyMain.java index 621d53b..e778e0d 100644 --- a/client/src/main/java/EntropyMain.java +++ b/client/src/main/java/EntropyMain.java @@ -4,17 +4,11 @@ import javax.swing.UIManager.LookAndFeelInfo; import javax.swing.WindowConstants; -import object.EntropyClientDebugExtension; +import logging.LoggerUncaughtExceptionHandler; import online.util.DesktopEntropyClient; import screen.MainScreen; import screen.ScreenCache; -import util.AbstractClient; -import util.Debug; -import util.DebugUncaughtExceptionHandler; -import util.DialogUtil; -import util.EncryptionUtil; -import util.OnlineConstants; -import util.Registry; +import util.*; public class EntropyMain implements Registry { @@ -27,17 +21,13 @@ public static void main(String[] args) try { //Initialise interfaces etc - Debug.initialise(ScreenCache.getDebugConsole()); + Thread.setDefaultUncaughtExceptionHandler(new LoggerUncaughtExceptionHandler()); + MainUtilKt.setLoggingContextFields(); AbstractClient.setInstance(new DesktopEntropyClient()); //Dev mode AbstractClient.parseProgramArguments(args); - //Set Debug variables - Debug.setProductDesc("Entropy " + OnlineConstants.ENTROPY_VERSION_NUMBER); - Debug.setDebugExtension(new EntropyClientDebugExtension()); - Debug.setLogToSystemOut(AbstractClient.devMode); - setLookAndFeel(); if (AbstractClient.devMode) @@ -62,8 +52,6 @@ else if (!bindOnPort(BIND_PORT_NUMBER)) application.setVisible(true); application.setLocationRelativeTo(null); application.setResizable(false); - - Thread.setDefaultUncaughtExceptionHandler(new DebugUncaughtExceptionHandler()); application.onStart(); } catch (Throwable t) @@ -71,7 +59,7 @@ else if (!bindOnPort(BIND_PORT_NUMBER)) Debug.stackTrace(t); } } - + private static void setLookAndFeel() { AbstractClient.setOs(); diff --git a/client/src/main/java/util/MainUtil.java b/client/src/main/java/util/MainUtil.java deleted file mode 100644 index f0e08bd..0000000 --- a/client/src/main/java/util/MainUtil.java +++ /dev/null @@ -1,12 +0,0 @@ -package util; - -import screen.DebugConsole; - -public class MainUtil -{ - public static void initialise() - { - Debug.initialise(new DebugConsole()); - EncryptionUtil.setBase64Interface(new Base64Desktop()); - } -} diff --git a/client/src/main/kotlin/MainUtil.kt b/client/src/main/kotlin/MainUtil.kt new file mode 100644 index 0000000..044b09b --- /dev/null +++ b/client/src/main/kotlin/MainUtil.kt @@ -0,0 +1,26 @@ +import logging.KEY_APP_VERSION +import logging.KEY_DEVICE_ID +import logging.KEY_DEV_MODE +import logging.KEY_OPERATING_SYSTEM +import logging.KEY_USERNAME +import util.AbstractClient +import util.CoreRegistry.INSTANCE_STRING_DEVICE_ID +import util.CoreRegistry.instance +import util.OnlineConstants +import utils.InjectedThings.logger +import java.util.* + +fun setLoggingContextFields() { + logger.addToContext(KEY_USERNAME, System.getProperty("user.name")) + logger.addToContext(KEY_APP_VERSION, OnlineConstants.ENTROPY_VERSION_NUMBER) + logger.addToContext(KEY_OPERATING_SYSTEM, AbstractClient.operatingSystem) + logger.addToContext(KEY_DEVICE_ID, getDeviceId()) + logger.addToContext(KEY_DEV_MODE, AbstractClient.devMode) +} + +fun getDeviceId() = instance.get(INSTANCE_STRING_DEVICE_ID, null) ?: setDeviceId() +private fun setDeviceId(): String { + val deviceId = UUID.randomUUID().toString() + instance.put(INSTANCE_STRING_DEVICE_ID, deviceId) + return deviceId +} diff --git a/core/src/main/java/util/CoreRegistry.java b/core/src/main/java/util/CoreRegistry.java index 0390e6f..e1b191d 100644 --- a/core/src/main/java/util/CoreRegistry.java +++ b/core/src/main/java/util/CoreRegistry.java @@ -9,4 +9,5 @@ public interface CoreRegistry public static final String INSTANCE_STRING_USER_NAME = "userName"; public static final String INSTANCE_BOOLEAN_ENABLE_EMAILS = "enableEmails"; public static final String INSTANCE_INT_REPLAY_CONVERSION = "replayConversion"; + public static final String INSTANCE_STRING_DEVICE_ID = "deviceId"; } diff --git a/core/src/main/kotlin/bean/FocusableWindow.kt b/core/src/main/kotlin/bean/FocusableWindow.kt new file mode 100644 index 0000000..13c6268 --- /dev/null +++ b/core/src/main/kotlin/bean/FocusableWindow.kt @@ -0,0 +1,22 @@ +package bean + +import logging.KEY_ACTIVE_WINDOW +import utils.InjectedThings +import java.awt.event.WindowEvent +import java.awt.event.WindowFocusListener +import javax.swing.JFrame + +abstract class FocusableWindow : JFrame(), WindowFocusListener { + abstract val windowName: String + + init + { + addWindowFocusListener(this) + } + + override fun windowGainedFocus(e: WindowEvent?) { + InjectedThings.logger.addToContext(KEY_ACTIVE_WINDOW, windowName) + } + + override fun windowLostFocus(e: WindowEvent?) {} +} diff --git a/core/src/main/kotlin/bean/WrapLayout.kt b/core/src/main/kotlin/bean/WrapLayout.kt new file mode 100644 index 0000000..b4452bf --- /dev/null +++ b/core/src/main/kotlin/bean/WrapLayout.kt @@ -0,0 +1,117 @@ +package bean + +import java.awt.Container +import java.awt.Dimension +import java.awt.FlowLayout +import javax.swing.JScrollPane +import javax.swing.SwingUtilities + +/** + * FlowLayout subclass that fully supports wrapping of components. + */ +class WrapLayout : FlowLayout() { + /** + * Returns the preferred dimensions for this layout given the + * *visible* components in the specified target container. + * @param target the component which needs to be laid out + * @return the preferred dimensions to lay out the + * subcomponents of the specified container + */ + override fun preferredLayoutSize(target: Container) = layoutSize(target, true) + + /** + * Returns the minimum dimensions needed to layout the *visible* + * components contained in the specified target container. + * @param target the component which needs to be laid out + * @return the minimum dimensions to lay out the + * subcomponents of the specified container + */ + override fun minimumLayoutSize(target: Container): Dimension { + val minimum = layoutSize(target, false) + minimum.width -= hgap + 1 + return minimum + } + + /** + * Returns the minimum or preferred dimension needed to layout the target + * container. + * + * @param target target to get layout size for + * @param preferred should preferred size be calculated + * @return the dimension to layout the target container + */ + private fun layoutSize(target: Container, preferred: Boolean): Dimension { + synchronized(target.treeLock) { + // Each row must fit with the width allocated to the containter. + // When the container width = 0, the preferred width of the container + // has not yet been calculated so lets ask for the maximum. + var container = target + while (container.size.width == 0 && container.parent != null) { + container = container.parent + } + + var targetWidth = container.size.width + if (targetWidth == 0) targetWidth = Int.MAX_VALUE + + val hgap = hgap + val vgap = vgap + val insets = target.insets + val horizontalInsetsAndGap = insets.left + insets.right + hgap * 2 + val maxWidth = targetWidth - horizontalInsetsAndGap + // Fit components into the allowed width + val dim = Dimension(0, 0) + var rowWidth = 0 + var rowHeight = 0 + val nmembers = target.componentCount + for (i in 0 until nmembers) { + val m = target.getComponent(i) + if (m.isVisible) { + val d = if (preferred) m.preferredSize else m.minimumSize + // Can't add the component to current row. Start a new row. + if (rowWidth + d.width > maxWidth) { + addRow(dim, rowWidth, rowHeight) + rowWidth = 0 + rowHeight = 0 + } + // Add a horizontal gap for all components after the first + if (rowWidth != 0) { + rowWidth += hgap + } + rowWidth += d.width + rowHeight = Math.max(rowHeight, d.height) + } + } + addRow(dim, rowWidth, rowHeight) + dim.width += horizontalInsetsAndGap + dim.height += insets.top + insets.bottom + vgap * 2 + // When using a scroll pane or the DecoratedLookAndFeel we need to + // make sure the preferred size is less than the size of the + // target containter so shrinking the container size works + // correctly. Removing the horizontal gap is an easy way to do this. + val scrollPane = SwingUtilities.getAncestorOfClass( + JScrollPane::class.java, + target, + ) + if (scrollPane != null && target.isValid) { + dim.width -= hgap + 1 + } + return dim + } + } + + /** + * A new row has been completed. Use the dimensions of this row + * to update the preferred size for the container. + * + * @param dim update the width and height when appropriate + * @param rowWidth the width of the row to add + * @param rowHeight the height of the row to add + */ + private fun addRow(dim: Dimension, rowWidth: Int, rowHeight: Int) { + dim.width = maxOf(dim.width, rowWidth) + if (dim.height > 0) { + dim.height += vgap + } + dim.height += rowHeight + } +} diff --git a/core/src/main/kotlin/logging/ILogDestination.kt b/core/src/main/kotlin/logging/ILogDestination.kt new file mode 100644 index 0000000..6620a8f --- /dev/null +++ b/core/src/main/kotlin/logging/ILogDestination.kt @@ -0,0 +1,6 @@ +package logging + +interface ILogDestination { + fun log(record: LogRecord) + fun contextUpdated(context: Map) +} diff --git a/core/src/main/kotlin/logging/LogDestinationSystemOut.kt b/core/src/main/kotlin/logging/LogDestinationSystemOut.kt new file mode 100644 index 0000000..51dfd74 --- /dev/null +++ b/core/src/main/kotlin/logging/LogDestinationSystemOut.kt @@ -0,0 +1,11 @@ +package logging + +class LogDestinationSystemOut : ILogDestination { + override fun log(record: LogRecord) { + println(record) + record.getThrowableStr()?.let { println(it) } + record.keyValuePairs[KEY_STACK]?.let { println(it) } + } + + override fun contextUpdated(context: Map) {} +} diff --git a/core/src/main/kotlin/logging/LogRecord.kt b/core/src/main/kotlin/logging/LogRecord.kt new file mode 100644 index 0000000..df7f1e6 --- /dev/null +++ b/core/src/main/kotlin/logging/LogRecord.kt @@ -0,0 +1,34 @@ +package logging + +import java.time.Instant +import java.time.ZoneId +import java.time.format.DateTimeFormatter +import java.util.* + +enum class Severity { + INFO, + WARN, + ERROR, +} + +data class LogRecord( + val timestamp: Instant, + val severity: Severity, + val loggingCode: LoggingCode, + val message: String, + val errorObject: Throwable?, + val keyValuePairs: Map, +) { + private val dateStr = DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm:ss") + .withLocale(Locale.UK) + .withZone(ZoneId.systemDefault()) + .format(timestamp) + + override fun toString(): String { + val durationStr = keyValuePairs[KEY_DURATION]?.let { " (${it}ms) " }.orEmpty() + val rowCountStr = keyValuePairs[KEY_ROW_COUNT]?.let { " ($it rows) " }.orEmpty() + return "$dateStr [$loggingCode] $durationStr$rowCountStr$message" + } + + fun getThrowableStr() = errorObject?.let { "$dateStr ${extractStackTrace(errorObject)}" } +} diff --git a/core/src/main/kotlin/logging/Logger.kt b/core/src/main/kotlin/logging/Logger.kt new file mode 100644 index 0000000..7f9abf3 --- /dev/null +++ b/core/src/main/kotlin/logging/Logger.kt @@ -0,0 +1,72 @@ +package logging + +import getPercentage +import utils.InjectedThings +import java.util.concurrent.ConcurrentHashMap +import java.util.concurrent.Executors +import java.util.concurrent.ThreadFactory +import java.util.concurrent.TimeUnit +import kotlin.math.floor + +private const val LOGGER_THREAD = "Logger" + +class Logger(private val destinations: List) { + val loggingContext = ConcurrentHashMap() + private val loggerFactory = ThreadFactory { r -> Thread(r, LOGGER_THREAD) } + private var logService = Executors.newFixedThreadPool(1, loggerFactory) + + fun addToContext(loggingKey: String, value: Any?) { + loggingContext[loggingKey] = value ?: "" + destinations.forEach { it.contextUpdated(loggingContext.toMap()) } + } + + fun logProgress(code: LoggingCode, workDone: Long, workToDo: Long, percentageToLogAt: Int = 10) { + // Convert 1 to 0.01, 50 to 0.5, etc. + val percentageAsDecimal = percentageToLogAt.toDouble() / 100 + val percentageOfTotal = floor(workToDo * percentageAsDecimal) + val remainder = workDone % percentageOfTotal + if (remainder == 0.0) { + val percentStr = getPercentage(workDone, workToDo) + val logStr = "Done $workDone/$workToDo ($percentStr%)" + info(code, logStr) + } + } + + fun info(code: LoggingCode, message: String, vararg keyValuePairs: Pair) { + log(Severity.INFO, code, message, null, mapOf(*keyValuePairs)) + } + + fun warn(code: LoggingCode, message: String, vararg keyValuePairs: Pair) { + log(Severity.WARN, code, message, null, mapOf(*keyValuePairs)) + } + + fun error(code: LoggingCode, message: String, vararg keyValuePairs: Pair) { + error(code, message, Throwable(message), keyValuePairs = keyValuePairs) + } + + fun error(code: LoggingCode, message: String, errorObject: Throwable = Throwable(message), vararg keyValuePairs: Pair) { + log(Severity.ERROR, code, message, errorObject, mapOf(*keyValuePairs, KEY_EXCEPTION_MESSAGE to errorObject.message)) + } + + private fun log(severity: Severity, code: LoggingCode, message: String, errorObject: Throwable?, keyValuePairs: Map) { + val timestamp = InjectedThings.clock.instant() + val logRecord = LogRecord(timestamp, severity, code, message, errorObject, loggingContext + keyValuePairs) + + val runnable = Runnable { destinations.forEach { it.log(logRecord) } } + if (Thread.currentThread().name != LOGGER_THREAD && !logService.isShutdown && !logService.isTerminated) { + logService.execute(runnable) + } else { + runnable.run() + } + } + + fun waitUntilLoggingFinished() { + try { + logService.shutdown() + logService.awaitTermination(30, TimeUnit.SECONDS) + } catch (_: InterruptedException) { } finally + { + logService = Executors.newFixedThreadPool(1, loggerFactory) + } + } +} diff --git a/core/src/main/kotlin/logging/LoggerUncaughtExceptionHandler.kt b/core/src/main/kotlin/logging/LoggerUncaughtExceptionHandler.kt new file mode 100644 index 0000000..9d8232f --- /dev/null +++ b/core/src/main/kotlin/logging/LoggerUncaughtExceptionHandler.kt @@ -0,0 +1,29 @@ +package logging + +import logging.exceptions.ApplicationFault +import utils.InjectedThings.logger +import java.lang.Thread.UncaughtExceptionHandler + +class LoggerUncaughtExceptionHandler : UncaughtExceptionHandler { + override fun uncaughtException(arg0: Thread, arg1: Throwable) { + if (isSuppressed(arg1)) { + // Still stack trace, but don't show an error message or email a log + logger.warn(CODE_UNCAUGHT_EXCEPTION, "Suppressing uncaught exception: $arg1", KEY_THREAD to arg0.toString(), KEY_EXCEPTION_MESSAGE to arg1.message) + } else if (arg1 is ApplicationFault) { + logger.error(arg1.loggingCode, "Uncaught exception: ${arg1.message}", arg1, KEY_THREAD to arg0.toString()) + } else { + logger.error(CODE_UNCAUGHT_EXCEPTION, "Uncaught exception: $arg1 in thread $arg0", arg1, KEY_THREAD to arg0.toString()) + } + } + + /** + * Some exceptions just can't be prevented, for example some Nimbus L&F exceptions that aren't caused by threading + * issues (I can see it's in the AWT thread) + */ + private fun isSuppressed(t: Throwable): Boolean { + val message = t.message ?: return false + + return message.contains("cannot be cast to class javax.swing.Painter") || + message.contains("UIResource cannot be cast to class java.awt.Color") + } +} diff --git a/core/src/main/kotlin/logging/LoggingCode.kt b/core/src/main/kotlin/logging/LoggingCode.kt new file mode 100644 index 0000000..aaea4b5 --- /dev/null +++ b/core/src/main/kotlin/logging/LoggingCode.kt @@ -0,0 +1,5 @@ +package logging + +import types.StringMicrotype + +class LoggingCode(value: String) : StringMicrotype(value) diff --git a/core/src/main/kotlin/logging/LoggingCodes.kt b/core/src/main/kotlin/logging/LoggingCodes.kt new file mode 100644 index 0000000..450c6a6 --- /dev/null +++ b/core/src/main/kotlin/logging/LoggingCodes.kt @@ -0,0 +1,89 @@ +package logging + +// Info +val CODE_SQL = LoggingCode("sql") +val CODE_BULK_SQL = LoggingCode("bulkSql") +val CODE_JUST_UPDATED = LoggingCode("justUpdated") +val CODE_MEMORY_SETTINGS = LoggingCode("memorySettings") +val CODE_TABLE_CREATED = LoggingCode("tableCreated") +val CODE_TABLE_EXISTS = LoggingCode("tableExists") +val CODE_LOOK_AND_FEEL_SET = LoggingCode("lafSet") +val CODE_DATABASE_UP_TO_DATE = LoggingCode("databaseCurrent") +val CODE_DATABASE_NEEDS_UPDATE = LoggingCode("databaseNeedsUpdate") +val CODE_DATABASE_CREATING = LoggingCode("databaseCreating") +val CODE_DATABASE_CREATED = LoggingCode("databaseCreated") +val CODE_THREAD_STACKS = LoggingCode("threadStacks") +val CODE_THREAD_STACK = LoggingCode("threadStack") +val CODE_NEW_CONNECTION = LoggingCode("newConnection") +val CODE_SANITY_CHECK_STARTED = LoggingCode("sanityCheckStarted") +val CODE_SANITY_CHECK_COMPLETED = LoggingCode("sanityCheckCompleted") +val CODE_SANITY_CHECK_RESULT = LoggingCode("sanityCheckResult") +val CODE_SIMULATION_STARTED = LoggingCode("simulationStarted") +val CODE_SIMULATION_PROGRESS = LoggingCode("simulationProgress") +val CODE_SIMULATION_CANCELLED = LoggingCode("simulationCancelled") +val CODE_SIMULATION_FINISHED = LoggingCode("simulationFinished") +val CODE_DIALOG_SHOWN = LoggingCode("dialogShown") +val CODE_DIALOG_CLOSED = LoggingCode("dialogClosed") +val CODE_COMMAND_ENTERED = LoggingCode("commandEntered") +val CODE_UPDATE_CHECK = LoggingCode("updateCheck") +val CODE_UPDATE_CHECK_RESULT = LoggingCode("updateCheckResult") +val CODE_LOADED_RESOURCES = LoggingCode("loadedResources") +val CODE_STARTING_BACKUP = LoggingCode("startingBackup") +val CODE_STARTING_RESTORE = LoggingCode("startingRestore") +val CODE_SLOW_DARTBOARD_RENDER = LoggingCode("slowDartboardRender") +val CODE_PLAYER_PAUSED = LoggingCode("playerPaused") +val CODE_PLAYER_UNPAUSED = LoggingCode("playerUnpaused") +val CODE_FETCHING_DATABASE = LoggingCode("fetchingDatabase") +val CODE_FETCHED_DATABASE = LoggingCode("fetchedDatabase") +val CODE_UNZIPPED_DATABASE = LoggingCode("unzippedDatabase") +val CODE_PUSHING_DATABASE = LoggingCode("pushingDatabase") +val CODE_ZIPPED_DATABASE = LoggingCode("zippedDatabase") +val CODE_PUSHED_DATABASE = LoggingCode("pushedDatabase") +val CODE_PUSHED_DATABASE_BACKUP = LoggingCode("pushedDatabaseBackup") +val CODE_MERGE_STARTED = LoggingCode("mergeStarted") +val CODE_MERGING_ENTITY = LoggingCode("mergingEntity") +val CODE_ACHIEVEMENT_CONVERSION_STARTED = LoggingCode("achievementConversionStarted") +val CODE_ACHIEVEMENT_CONVERSION_FINISHED = LoggingCode("achievementConversionFinished") +val CODE_SWITCHING_FILES = LoggingCode("switchingFiles") +val CODE_REVERT_TO_PULL = LoggingCode("revertToPull") +val CODE_SWITCHED_SCREEN = LoggingCode("switchedScreen") +val CODE_GAME_LAUNCHED = LoggingCode("gameLaunched") +val CODE_MATCH_LAUNCHED = LoggingCode("matchLaunched") +val CODE_MATCH_FINISHED = LoggingCode("matchFinished") + +// Warn +val CODE_UNEXPECTED_ARGUMENT = LoggingCode("unexpectedArgument") +val CODE_DATABASE_TOO_OLD = LoggingCode("databaseTooOld") +val CODE_RESOURCE_CACHE_NOT_INITIALISED = LoggingCode("resourceCacheNotInitialised") +val CODE_DATABASE_IN_USE = LoggingCode("databaseInUse") +val CODE_NO_STREAMS = LoggingCode("noStreams") + +// Error +val CODE_LOOK_AND_FEEL_ERROR = LoggingCode("lafError") +val CODE_SQL_EXCEPTION = LoggingCode("sqlException") +val CODE_UNCAUGHT_EXCEPTION = LoggingCode("uncaughtException") +val CODE_SIMULATION_ERROR = LoggingCode("simulationError") +val CODE_LOAD_ERROR = LoggingCode("loadError") +val CODE_INSTANTIATION_ERROR = LoggingCode("instantiationError") +val CODE_AUDIO_ERROR = LoggingCode("audioError") +val CODE_SCREEN_LOAD_ERROR = LoggingCode("screenLoadError") +val CODE_UPDATE_ERROR = LoggingCode("updateError") +val CODE_PARSE_ERROR = LoggingCode("parseError") +val CODE_BATCH_ERROR = LoggingCode("batchFileError") +val CODE_TEST_CONNECTION_ERROR = LoggingCode("testConnectionError") +val CODE_RESOURCE_LOAD_ERROR = LoggingCode("resourceLoadError") +val CODE_COMMAND_ERROR = LoggingCode("commandError") +val CODE_RENDER_ERROR = LoggingCode("renderError") +val CODE_FILE_ERROR = LoggingCode("fileError") +val CODE_SWING_ERROR = LoggingCode("swingError") +val CODE_AI_ERROR = LoggingCode("aiError") +val CODE_ELASTICSEARCH_ERROR = LoggingCode("elasticsearchError") +val CODE_MERGE_ERROR = LoggingCode("mergeError") +val CODE_SYNC_ERROR = LoggingCode("syncError") +val CODE_PUSH_ERROR = LoggingCode("pushError") +val CODE_PULL_ERROR = LoggingCode("pullError") +val CODE_BACKUP_ERROR = LoggingCode("backupError") +val CODE_RESTORE_ERROR = LoggingCode("restoreError") +val CODE_DELETE_ERROR = LoggingCode("deleteError") +val CODE_HYPERLINK_ERROR = LoggingCode("hyperlinkError") +val CODE_EDT_FROZEN = LoggingCode("edtFrozen") diff --git a/core/src/main/kotlin/logging/LoggingConsole.kt b/core/src/main/kotlin/logging/LoggingConsole.kt new file mode 100644 index 0000000..5c75c90 --- /dev/null +++ b/core/src/main/kotlin/logging/LoggingConsole.kt @@ -0,0 +1,103 @@ +package logging + +import bean.FocusableWindow +import bean.WrapLayout +import utils.runOnEventThread +import java.awt.BorderLayout +import java.awt.Color +import java.awt.Component +import javax.swing.JComponent +import javax.swing.JLabel +import javax.swing.JPanel +import javax.swing.JScrollPane +import javax.swing.JTextPane +import javax.swing.border.EmptyBorder +import javax.swing.border.LineBorder +import javax.swing.border.MatteBorder +import javax.swing.text.BadLocationException +import javax.swing.text.DefaultStyledDocument +import javax.swing.text.StyleConstants +import javax.swing.text.StyleContext + +class LoggingConsole : FocusableWindow(), ILogDestination { + override val windowName = "Console" + + val doc = DefaultStyledDocument() + val scrollPane = JScrollPane() + private val textArea = JTextPane(doc) + private val contextPanel = JPanel() + + init + { + title = "Console" + setSize(1000, 600) + setLocationRelativeTo(null) + contentPane.layout = BorderLayout(0, 0) + contentPane.add(contextPanel, BorderLayout.NORTH) + contentPane.add(scrollPane) + textArea.foreground = Color.GREEN + textArea.background = Color.BLACK + textArea.isEditable = false + scrollPane.setViewportView(textArea) + + contextPanel.background = Color.BLACK + contextPanel.border = MatteBorder(0, 0, 2, 0, Color.GREEN) + contextPanel.layout = WrapLayout() + } + + override fun log(record: LogRecord) { + val cx = StyleContext() + val text = record.toString() + val style = cx.addStyle(text, null) + + if (record.severity == Severity.ERROR) { + StyleConstants.setForeground(style, Color.RED) + } + + try { + doc.insertString(doc.length, "\n$text", style) + record.getThrowableStr()?.let { doc.insertString(doc.length, "\n$it", style) } + + val threadStack = record.keyValuePairs[KEY_STACK] + threadStack?.let { doc.insertString(doc.length, "\n$it", style) } + + textArea.select(doc.length, doc.length) + } catch (ble: BadLocationException) { + System.err.println("BLE trying to append: $text") + System.err.println(extractStackTrace(ble)) + } + } + + override fun contextUpdated(context: Map) { + contextPanel.removeAll() + val labels = context.map(::factoryLabelForContext) + + runOnEventThread { + labels.forEach { contextPanel.add(it) } + + contextPanel.validate() + contextPanel.repaint() + } + } + + private fun factoryLabelForContext(field: Map.Entry): Component { + val label = JLabel("${field.key}: ${field.value}") + label.foreground = Color.GREEN + label.setMargins(5) + + val panel = JPanel() + panel.border = LineBorder(Color.GREEN) + panel.add(label) + panel.isOpaque = false + panel.background = null + return panel + } + + private fun JComponent.setMargins(margin: Int) { + border = EmptyBorder(margin, margin, margin, margin) + } + + fun clear() { + textArea.text = "" + } +} diff --git a/core/src/main/kotlin/logging/LoggingKeys.kt b/core/src/main/kotlin/logging/LoggingKeys.kt new file mode 100644 index 0000000..6553fd3 --- /dev/null +++ b/core/src/main/kotlin/logging/LoggingKeys.kt @@ -0,0 +1,42 @@ +package logging + +// Logging Context +const val KEY_USERNAME = "username" +const val KEY_APP_VERSION = "appVersion" +const val KEY_DB_VERSION = "dbVersion" +const val KEY_OPERATING_SYSTEM = "os" +const val KEY_DEVICE_ID = "deviceId" +const val KEY_DEV_MODE = "devMode" +const val KEY_CURRENT_SCREEN = "currentScreen" +const val KEY_ACTIVE_WINDOW = "activeWindow" + +// Other +const val KEY_DURATION = "duration" +const val KEY_SQL = "sql" +const val KEY_GENERIC_SQL = "genericSql" +const val KEY_STATEMENT_TYPE = "statementType" +const val KEY_ROW_COUNT = "rowCount" +const val KEY_SQL_STATE = "sqlState" +const val KEY_ERROR_CODE = "errorCode" +const val KEY_EXCEPTION_MESSAGE = "exceptionMessage" +const val KEY_THREAD = "currentThread" +const val KEY_STACK = "stackTrace" +const val KEY_SANITY_DESCRIPTION = "description" +const val KEY_SANITY_COUNT = "count" +const val KEY_DIALOG_TYPE = "dialogType" +const val KEY_DIALOG_TITLE = "title" +const val KEY_DIALOG_MESSAGE = "message" +const val KEY_DIALOG_SELECTION = "selection" +const val KEY_RESPONSE_BODY = "responseBody" +const val KEY_REMOTE_NAME = "remoteName" +const val KEY_TABLE_NAME = "tableName" +const val KEY_PLAYER_IDS = "playerIds" +const val KEY_ACHIEVEMENT_TYPES = "achievementTypes" +const val KEY_ACHIEVEMENT_TIMINGS = "achievementTimings" +const val KEY_DATABASE_NAME = "databaseName" +const val KEY_GAME_IDS = "gameIds" +const val KEY_GAME_ID = "gameId" +const val KEY_GAME_LOCAL_ID = "gameLocalId" +const val KEY_MATCH_ID = "matchId" +const val KEY_MATCH_LOCAL_ID = "matchLocalId" +const val KEY_FROM_MATCH = "fromMatch" diff --git a/core/src/main/kotlin/logging/LoggingUtils.kt b/core/src/main/kotlin/logging/LoggingUtils.kt new file mode 100644 index 0000000..da97dad --- /dev/null +++ b/core/src/main/kotlin/logging/LoggingUtils.kt @@ -0,0 +1,49 @@ +package logging + +import java.io.PrintWriter +import java.io.StringWriter +import java.sql.SQLException + +fun extractStackTrace(t: Throwable): String +{ + if (t is SQLException) + { + return extractSqlException(t) + } + + return getStackString(t) +} + +private fun getStackString(t: Throwable): String +{ + val sw = StringWriter() + val pw = PrintWriter(sw) + t.printStackTrace(pw) + return sw.toString() +} + +fun extractThreadStack(stack: Array): String +{ + val sw = StringWriter() + val pw = PrintWriter(sw) + stack.forEach { + pw.println("\tat $it") + } + + return sw.toString() +} + +private fun extractSqlException(sqle: SQLException): String +{ + val sb = StringBuilder() + sb.append(getStackString(sqle)) + + var childSqle: SQLException? = sqle.nextException + while (childSqle != null) + { + sb.append("Child: ${getStackString(childSqle)}") + childSqle = childSqle.nextException + } + + return sb.toString() +} \ No newline at end of file diff --git a/core/src/main/kotlin/logging/exceptions/ApplicationFault.kt b/core/src/main/kotlin/logging/exceptions/ApplicationFault.kt new file mode 100644 index 0000000..9365daf --- /dev/null +++ b/core/src/main/kotlin/logging/exceptions/ApplicationFault.kt @@ -0,0 +1,8 @@ +package logging.exceptions + +import logging.LoggingCode + +data class ApplicationFault( + val loggingCode: LoggingCode, + override val message: String, +) : Exception() diff --git a/core/src/main/kotlin/types/StringMicrotype.kt b/core/src/main/kotlin/types/StringMicrotype.kt new file mode 100644 index 0000000..05ed860 --- /dev/null +++ b/core/src/main/kotlin/types/StringMicrotype.kt @@ -0,0 +1,11 @@ +package types + +open class StringMicrotype(val value: String) { + override fun equals(other: Any?) = + other is StringMicrotype && + this.javaClass == other.javaClass && + this.value == other.value + + override fun hashCode(): Int = value.hashCode() + override fun toString(): String = value +} diff --git a/core/src/main/kotlin/utils/InjectedThings.kt b/core/src/main/kotlin/utils/InjectedThings.kt new file mode 100644 index 0000000..efd651c --- /dev/null +++ b/core/src/main/kotlin/utils/InjectedThings.kt @@ -0,0 +1,14 @@ +package utils + +import logging.LogDestinationSystemOut +import logging.Logger +import logging.LoggingConsole +import java.time.Clock + +object InjectedThings { + val loggingConsole = LoggingConsole() + + @JvmField + var logger: Logger = Logger(listOf(loggingConsole, LogDestinationSystemOut())) + var clock: Clock = Clock.systemUTC() +} diff --git a/core/src/main/kotlin/utils/MathsUtil.kt b/core/src/main/kotlin/utils/MathsUtil.kt new file mode 100644 index 0000000..04f997c --- /dev/null +++ b/core/src/main/kotlin/utils/MathsUtil.kt @@ -0,0 +1,20 @@ +import kotlin.math.pow + +fun getPercentage(count: Number, total: Number, digits: Int = 1) = + getPercentage(count.toDouble(), total.toDouble(), digits) + +fun getPercentage(count: Double, total: Double, digits: Int = 1): Double { + return if (count == 0.0) { + 0.0 + } else { + round(100 * count / total, digits) + } +} + +private fun round(number: Double, decimalPlaces: Int): Double { + val powerOfTen = 10.0.pow(decimalPlaces.toDouble()) + + val rounded = Math.round(powerOfTen * number) + + return rounded / powerOfTen +} diff --git a/core/src/main/kotlin/utils/ThreadUtil.kt b/core/src/main/kotlin/utils/ThreadUtil.kt new file mode 100644 index 0000000..dd79ce6 --- /dev/null +++ b/core/src/main/kotlin/utils/ThreadUtil.kt @@ -0,0 +1,45 @@ +package utils + +import logging.CODE_THREAD_STACK +import logging.CODE_THREAD_STACKS +import logging.KEY_STACK +import logging.extractThreadStack +import utils.InjectedThings.logger +import javax.swing.SwingUtilities + +fun dumpThreadStacks() { + logger.info(CODE_THREAD_STACKS, "Dumping thread stacks") + + val threads = Thread.getAllStackTraces() + val it = threads.keys.iterator() + while (it.hasNext()) { + val thread = it.next() + val stack = thread.stackTrace + val state = thread.state + if (stack.isNotEmpty()) { + logger.info(CODE_THREAD_STACK, "${thread.name} ($state)", KEY_STACK to extractThreadStack(stack)) + } + } +} + +fun runOnEventThread(r: (() -> Unit)) { + if (SwingUtilities.isEventDispatchThread()) { + r.invoke() + } else { + SwingUtilities.invokeLater(r) + } +} + +fun runOnEventThreadBlocking(r: (() -> Unit)) { + if (SwingUtilities.isEventDispatchThread()) { + r.invoke() + } else { + SwingUtilities.invokeAndWait(r) + } +} + +fun runInOtherThread(r: (() -> Unit)): Thread { + val t = Thread(r) + t.start() + return t +}