diff --git a/client/build.gradle.kts b/client/build.gradle.kts index 058b9f2..e4ab79b 100644 --- a/client/build.gradle.kts +++ b/client/build.gradle.kts @@ -9,7 +9,9 @@ ktfmt { kotlinLangStyle() } dependencies { implementation("com.jgoodies:jgoodies-forms:1.6.0") implementation("com.miglayout:miglayout-swing:5.2") + implementation("com.konghq:unirest-java:3.14.2") implementation(project(":core")) + testImplementation(project(":test-core")) } application { diff --git a/client/src/main/java/bean/HyperlinkAdaptor.java b/client/src/main/java/bean/HyperlinkAdaptor.java deleted file mode 100644 index f46d59a..0000000 --- a/client/src/main/java/bean/HyperlinkAdaptor.java +++ /dev/null @@ -1,50 +0,0 @@ -package bean; - -import java.awt.Component; -import java.awt.Cursor; -import java.awt.event.MouseAdapter; -import java.awt.event.MouseEvent; - -import util.Debug; - -public class HyperlinkAdaptor extends MouseAdapter -{ - private HyperlinkListener listener = null; - private Component listenerWindow = null; - - public HyperlinkAdaptor(HyperlinkListener listener) - { - if (!(listener instanceof Component)) - { - Debug.stackTrace("Creating HyperlinkAdaptor with non-component: " + listener); - } - - this.listener = listener; - this.listenerWindow = (Component)listener; - } - - @Override - public void mouseClicked(MouseEvent arg0) - { - listener.linkClicked(arg0); - } - - @Override - public void mouseMoved(MouseEvent arg0) - { - if (listener.isOverHyperlink(arg0)) - { - listenerWindow.setCursor(Cursor.getPredefinedCursor(Cursor.HAND_CURSOR)); - } - else - { - listenerWindow.setCursor(Cursor.getPredefinedCursor(Cursor.DEFAULT_CURSOR)); - } - } - - @Override - public void mouseExited(MouseEvent arg0) - { - listenerWindow.setCursor(Cursor.getPredefinedCursor(Cursor.DEFAULT_CURSOR)); - } -} diff --git a/client/src/main/java/bean/HyperlinkListener.java b/client/src/main/java/bean/HyperlinkListener.java deleted file mode 100644 index 747565e..0000000 --- a/client/src/main/java/bean/HyperlinkListener.java +++ /dev/null @@ -1,9 +0,0 @@ -package bean; - -import java.awt.event.MouseEvent; - -public interface HyperlinkListener -{ - public void linkClicked(MouseEvent arg0); - public boolean isOverHyperlink(MouseEvent arg0); -} diff --git a/client/src/main/java/online/util/DesktopEntropyClient.java b/client/src/main/java/online/util/DesktopEntropyClient.java index 6a7d147..f5f0e35 100644 --- a/client/src/main/java/online/util/DesktopEntropyClient.java +++ b/client/src/main/java/online/util/DesktopEntropyClient.java @@ -75,13 +75,6 @@ public void sendAsyncInSingleThread(MessageSenderParams message) senderThread.start(); } - /*public void sendAsync(MessageSenderParams message) - { - MessageSender senderRunnable = new MessageSender(this, message); - Thread senderThread = new Thread(senderRunnable, "MessageSender"); - senderThread.start(); - }*/ - @Override public String sendSyncOnDevice(MessageSender runnable) { @@ -91,6 +84,6 @@ public String sendSyncOnDevice(MessageSender runnable) @Override public void checkForUpdates() { - UpdateChecker.checkForUpdates(FILE_NAME_ENTROPY_JAR, SERVER_PORT_NUMBER_DOWNLOAD); + UpdateManager.INSTANCE.checkForUpdates(OnlineConstants.ENTROPY_VERSION_NUMBER); } } diff --git a/client/src/main/java/online/util/ResponseHandler.java b/client/src/main/java/online/util/ResponseHandler.java index 36ef191..e6e606d 100644 --- a/client/src/main/java/online/util/ResponseHandler.java +++ b/client/src/main/java/online/util/ResponseHandler.java @@ -265,7 +265,7 @@ private static void handleConnectFailure(Element root) if (failureReason.contains("out of date")) { - promptForUpdate(root); + promptForUpdate(); } else { @@ -273,20 +273,15 @@ private static void handleConnectFailure(Element root) } } - private static void promptForUpdate(final Element rootElement) + private static void promptForUpdate() { - SwingUtilities.invokeLater(new Runnable() - { - @Override - public void run() - { - int answer = DialogUtil.showQuestion("Entropy needs to update in order to connect. \n\nUpdate now?", false); - if (answer == JOptionPane.YES_OPTION) - { - UpdateChecker.startUpdate(rootElement, OnlineConstants.FILE_NAME_ENTROPY_JAR, OnlineConstants.SERVER_PORT_NUMBER_DOWNLOAD); - } - } - }); + SwingUtilities.invokeLater(() -> { + int answer = DialogUtil.showQuestion("Entropy needs to update in order to connect. \n\nUpdate now?", false); + if (answer == JOptionPane.YES_OPTION) + { + UpdateManager.INSTANCE.checkForUpdates(OnlineConstants.ENTROPY_VERSION_NUMBER); + } + }); } private static void handleKickOff(Element root) diff --git a/client/src/main/java/screen/AbstractAboutDialog.java b/client/src/main/java/screen/AbstractAboutDialog.java index 9ee95f3..f33094a 100644 --- a/client/src/main/java/screen/AbstractAboutDialog.java +++ b/client/src/main/java/screen/AbstractAboutDialog.java @@ -14,10 +14,10 @@ import javax.swing.SwingConstants; import bean.HyperlinkAdaptor; -import bean.HyperlinkListener; +import bean.IHyperlinkListener; public abstract class AbstractAboutDialog extends JDialog - implements HyperlinkListener, + implements IHyperlinkListener, ActionListener { public AbstractAboutDialog() diff --git a/client/src/main/java/util/DialogUtil.java b/client/src/main/java/util/DialogUtil.java index 9639ebe..f00f3f6 100644 --- a/client/src/main/java/util/DialogUtil.java +++ b/client/src/main/java/util/DialogUtil.java @@ -3,13 +3,11 @@ import javax.swing.JOptionPane; import javax.swing.SwingUtilities; -import screen.LoadingDialog; - import static utils.InjectedThings.logger; +@Deprecated() public class DialogUtil { - private static LoadingDialog loadingDialog = new LoadingDialog(); private static boolean shownConnectionLost = false; public static void showInfo(String infoText) @@ -104,14 +102,4 @@ public static void showConnectionLost() shownConnectionLost = true; } } - - public static void showLoadingDialog(String text) - { - logger.info("loaderShown", text); - loadingDialog.showDialog(text); - } - public static void dismissLoadingDialog() - { - loadingDialog.dismissDialog(); - } } diff --git a/client/src/main/java/util/UpdateChecker.java b/client/src/main/java/util/UpdateChecker.java deleted file mode 100644 index 291b49b..0000000 --- a/client/src/main/java/util/UpdateChecker.java +++ /dev/null @@ -1,110 +0,0 @@ -package util; - -import java.io.IOException; - -import javax.swing.JOptionPane; - -import org.w3c.dom.Document; -import org.w3c.dom.Element; - -/** - * Class to check for updates and launch the EntropyUpdater via a batch file if they are available - */ -public class UpdateChecker implements XmlConstants -{ - public static void checkForUpdates(String filename, int portForDownload) - { - try - { - checkForUpdatesAndDoDownloadIfRequired(filename, portForDownload); - } - finally - { - DialogUtil.dismissLoadingDialog(); - } - } - - private static void checkForUpdatesAndDoDownloadIfRequired(String filename, int portForDownload) - { - //Show this here, checking the CRC can take time - DialogUtil.showLoadingDialog("Checking for updates..."); - String crc = FileUtil.getMd5Crc(filename); - if (crc == null) - { - DialogUtil.showError("Failed to check for updates (couldn't find " + filename + ")."); - return; - } - - Debug.append("Checking for updates - fileCrc is " + crc); - - //We have a CRC, so go to the Server and check against it's copy. - Document crcCheck = factoryCrcCheck(crc, filename); - String responseStr = AbstractClient.getInstance().sendSync(crcCheck, false); - if (responseStr == null) - { - DialogUtil.showError("Failed to check for updates (unable to connect)."); - return; - } - - DialogUtil.dismissLoadingDialog(); - - Document xmlResponse = XmlUtil.getDocumentFromXmlString(responseStr); - Element rootElement = xmlResponse.getDocumentElement(); - String responseName = rootElement.getNodeName(); - if (responseName.equals(RESPONSE_TAG_NO_UPDATES)) - { - //No need to show a message, should be pretty obvious - Debug.append("I am up to date"); - return; - } - - //An update is available - int answer = DialogUtil.showQuestion("An update is available. Would you like to download it now?", false); - if (answer == JOptionPane.NO_OPTION) - { - return; - } - - startUpdate(rootElement, filename, portForDownload); - } - - private static Document factoryCrcCheck(String fileCrc, String fileName) - { - Document document = XmlUtil.factoryNewDocument(); - Element rootElement = document.createElement(ROOT_TAG_CRC_CHECK); - rootElement.setAttribute("FileCrc", fileCrc); - rootElement.setAttribute("FileName", fileName); - - document.appendChild(rootElement); - return document; - } - - public static void startUpdate(Element rootElement, String filename, int portForUpdate) - { - int fileSize = XmlUtil.getAttributeInt(rootElement, "FileSize"); - String version = rootElement.getAttribute("VersionNumber"); - String args = fileSize + " " + version + " " + filename + " " + portForUpdate; - - try - { - if (AbstractClient.isAppleOs()) - { - Runtime.getRuntime().exec("update.command " + args); - } - else - { - Runtime.getRuntime().exec("cmd /c start update.bat " + args); - } - } - catch (IOException ioe) - { - Debug.stackTrace(ioe); - String manualCommand = "update.bat " + args; - - String msg = "Failed to launch update.bat - call the following manually to perform the update: \n\n" + manualCommand; - DialogUtil.showError(msg); - } - - System.exit(0); - } -} diff --git a/client/src/main/kotlin/bean/HyperlinkAdaptor.kt b/client/src/main/kotlin/bean/HyperlinkAdaptor.kt new file mode 100644 index 0000000..7510cd0 --- /dev/null +++ b/client/src/main/kotlin/bean/HyperlinkAdaptor.kt @@ -0,0 +1,36 @@ +package bean + +import java.awt.Component +import java.awt.Cursor +import java.awt.event.MouseAdapter +import java.awt.event.MouseEvent + +class HyperlinkAdaptor(private val listener: IHyperlinkListener) : MouseAdapter() { + private val listenerWindow = listener as Component + + override fun mouseClicked(arg0: MouseEvent) = listener.linkClicked(arg0) + + override fun mouseMoved(arg0: MouseEvent) { + if (listener.isOverHyperlink(arg0)) { + listenerWindow.cursor = Cursor.getPredefinedCursor(Cursor.HAND_CURSOR) + } else { + listenerWindow.cursor = Cursor.getPredefinedCursor(Cursor.DEFAULT_CURSOR) + } + } + + override fun mouseEntered(e: MouseEvent) { + if (listener.isOverHyperlink(e)) { + listenerWindow.cursor = Cursor.getPredefinedCursor(Cursor.HAND_CURSOR) + } + } + + override fun mouseDragged(e: MouseEvent?) { + if (listenerWindow.cursor.type == Cursor.HAND_CURSOR) { + listenerWindow.cursor = Cursor.getPredefinedCursor(Cursor.DEFAULT_CURSOR) + } + } + + override fun mouseExited(arg0: MouseEvent?) { + listenerWindow.cursor = Cursor.getPredefinedCursor(Cursor.DEFAULT_CURSOR) + } +} diff --git a/client/src/main/kotlin/bean/IHyperlinkListener.kt b/client/src/main/kotlin/bean/IHyperlinkListener.kt new file mode 100644 index 0000000..58075c7 --- /dev/null +++ b/client/src/main/kotlin/bean/IHyperlinkListener.kt @@ -0,0 +1,9 @@ +package bean + +import java.awt.event.MouseEvent + +interface IHyperlinkListener { + fun linkClicked(arg0: MouseEvent) + + fun isOverHyperlink(arg0: MouseEvent): Boolean +} diff --git a/client/src/main/kotlin/bean/LinkLabel.kt b/client/src/main/kotlin/bean/LinkLabel.kt new file mode 100644 index 0000000..e20a46f --- /dev/null +++ b/client/src/main/kotlin/bean/LinkLabel.kt @@ -0,0 +1,20 @@ +package bean + +import java.awt.Color +import java.awt.event.MouseEvent +import javax.swing.JLabel + +class LinkLabel(text: String, private val linkClicked: () -> Unit) : + JLabel("$text"), IHyperlinkListener { + init { + foreground = Color.BLUE + + val adaptor = HyperlinkAdaptor(this) + addMouseListener(adaptor) + addMouseMotionListener(adaptor) + } + + override fun linkClicked(arg0: MouseEvent) = linkClicked() + + override fun isOverHyperlink(arg0: MouseEvent) = true +} diff --git a/client/src/main/kotlin/util/DialogUtilNew.kt b/client/src/main/kotlin/util/DialogUtilNew.kt new file mode 100644 index 0000000..929e30f --- /dev/null +++ b/client/src/main/kotlin/util/DialogUtilNew.kt @@ -0,0 +1,164 @@ +package util + +import java.awt.Component +import java.io.File +import javax.swing.JFileChooser +import javax.swing.JOptionPane +import javax.swing.SwingUtilities +import screen.LoadingDialog +import screen.ScreenCache +import utils.InjectedThings.logger + +object DialogUtilNew { + private var loadingDialog: LoadingDialog? = null + + fun showInfo(infoText: String, parent: Component = ScreenCache.getMainScreen()) { + logDialogShown("Info", "Information", infoText) + JOptionPane.showMessageDialog( + parent, + infoText, + "Information", + JOptionPane.INFORMATION_MESSAGE + ) + logDialogClosed("Info", null) + } + + fun showCustomMessage(message: Any, parent: Component = ScreenCache.getMainScreen()) { + logDialogShown("CustomInfo", "Information", "?") + JOptionPane.showMessageDialog( + parent, + message, + "Information", + JOptionPane.INFORMATION_MESSAGE + ) + logDialogClosed("CustomInfo", null) + } + + fun showError(errorText: String, parent: Component? = ScreenCache.getMainScreen()) { + dismissLoadingDialog() + + logDialogShown("Error", "Error", errorText) + JOptionPane.showMessageDialog(parent, errorText, "Error", JOptionPane.ERROR_MESSAGE) + logDialogClosed("Error", null) + } + + fun showErrorLater(errorText: String) { + SwingUtilities.invokeLater { showError(errorText) } + } + + @JvmOverloads + fun showQuestion( + message: String, + allowCancel: Boolean = false, + parent: Component = ScreenCache.getMainScreen() + ): Int { + logDialogShown("Question", "Question", message) + val option = + if (allowCancel) JOptionPane.YES_NO_CANCEL_OPTION else JOptionPane.YES_NO_OPTION + val selection = + JOptionPane.showConfirmDialog( + parent, + message, + "Question", + option, + JOptionPane.QUESTION_MESSAGE + ) + logDialogClosed("Question", selection) + return selection + } + + fun showLoadingDialog(text: String) { + logDialogShown("Loading", "", text) + loadingDialog = LoadingDialog() + loadingDialog?.showDialog(text) + } + + fun dismissLoadingDialog() { + val wasVisible = loadingDialog?.isVisible ?: false + loadingDialog?.dismissDialog() + if (wasVisible) { + logDialogClosed("Loading", null) + } + } + + fun showOption(title: String, message: String, options: List): String? { + logDialogShown("Option", title, message) + val typedArray = options.toTypedArray() + val selection = + JOptionPane.showOptionDialog( + null, + message, + title, + JOptionPane.DEFAULT_OPTION, + JOptionPane.QUESTION_MESSAGE, + null, + typedArray, + options.first() + ) + val selectionStr = if (selection > -1) typedArray[selection] else null + + logDialogClosed("Option", selectionStr) + return selectionStr + } + + fun showInput( + title: String, + message: String, + options: Array? = null, + defaultOption: K? = null + ): K? { + logDialogShown("Input", title, message) + val selection = + JOptionPane.showInputDialog( + null, + message, + title, + JOptionPane.PLAIN_MESSAGE, + null, + options, + defaultOption + ) as K? + + logDialogClosed("Input", selection) + return selection + } + + fun chooseDirectory(parent: Component?): File? { + logDialogShown("File selector", "", "") + val fc = JFileChooser() + fc.fileSelectionMode = JFileChooser.DIRECTORIES_ONLY + val option = fc.showDialog(parent, "Select") + if (option != JFileChooser.APPROVE_OPTION) { + return null + } + + val file = fc.selectedFile + logDialogClosed("File selector", file?.absolutePath) + return file + } + + private fun logDialogShown(type: String, title: String, message: String) { + logger.info( + "dialogShown", + "$type dialog shown: $message", + "dialogType" to type, + "dialogTitle" to title, + "dialogMessage" to message + ) + } + + private fun logDialogClosed(type: String, selection: Any?) { + var message = "$type dialog closed" + selection?.let { message += " - selected ${translateOption(it)}" } + + logger.info("dialogClosed", message, "dialogType" to type, "dialogSelection" to selection) + } + + private fun translateOption(option: Any?) = + when (option) { + JOptionPane.YES_OPTION -> "Yes" + JOptionPane.NO_OPTION -> "No" + JOptionPane.CANCEL_OPTION -> "Cancel" + else -> option + } +} diff --git a/client/src/main/kotlin/util/UpdateManager.kt b/client/src/main/kotlin/util/UpdateManager.kt new file mode 100644 index 0000000..f2d7ce9 --- /dev/null +++ b/client/src/main/kotlin/util/UpdateManager.kt @@ -0,0 +1,152 @@ +package util + +import bean.LinkLabel +import java.awt.BorderLayout +import java.io.File +import javax.swing.JLabel +import javax.swing.JOptionPane +import javax.swing.JPanel +import kong.unirest.Unirest +import kong.unirest.json.JSONObject +import kotlin.system.exitProcess +import utils.InjectedThings.logger + +/** + * Automatically check for and download updates using the Github API + * + * https://developer.github.com/v3/repos/releases/#get-the-latest-release + */ +object UpdateManager { + fun checkForUpdates(currentVersion: String) { + // Show this here, checking the CRC can take time + logger.info("updateCheck", "Checking for updates - my version is $currentVersion") + + val jsonResponse = queryLatestReleaseJson(OnlineConstants.ENTROPY_REPOSITORY_URL) + jsonResponse ?: return + + val metadata = parseUpdateMetadata(jsonResponse) + if (metadata == null || !shouldUpdate(currentVersion, metadata)) { + return + } + + startUpdate(metadata.getArgs(), Runtime.getRuntime()) + } + + fun queryLatestReleaseJson(repositoryUrl: String): JSONObject? { + try { + DialogUtilNew.showLoadingDialog("Checking for updates...") + + val response = Unirest.get("$repositoryUrl/releases/latest").asJson() + if (response.status != 200) { + logger.error( + "updateError", + "Received non-success HTTP status: ${response.status} - ${response.statusText}", + "responseBody" to response.body, + ) + DialogUtilNew.showError("Failed to check for updates (unable to connect).") + return null + } + + return response.body.`object` + } catch (t: Throwable) { + logger.error("updateError", "Caught $t checking for updates", t) + DialogUtilNew.showError("Failed to check for updates (unable to connect).") + return null + } finally { + DialogUtilNew.dismissLoadingDialog() + } + } + + fun shouldUpdate(currentVersion: String, metadata: UpdateMetadata): Boolean { + val newVersion = metadata.version + if (newVersion == currentVersion) { + logger.info("updateResult", "Up to date") + return false + } + + // An update is available + logger.info("updateAvailable", "Newer release available - $newVersion") + + if (!AbstractClient.isWindowsOs()) { + showManualDownloadMessage(newVersion) + return false + } + + val answer = + DialogUtilNew.showQuestion( + "An update is available (${metadata.version}). Would you like to download it now?", + false + ) + return answer == JOptionPane.YES_OPTION + } + + private fun showManualDownloadMessage(newVersion: String) { + val fullUrl = "${OnlineConstants.ENTROPY_MANUAL_DOWNLOAD_URL}/tag/$newVersion" + val panel = JPanel() + panel.layout = BorderLayout(0, 0) + val lblOne = + JLabel("An update is available ($newVersion). You can download it manually from:") + val linkLabel = LinkLabel(fullUrl) { launchUrl(fullUrl) } + + panel.add(lblOne, BorderLayout.NORTH) + panel.add(linkLabel, BorderLayout.SOUTH) + + DialogUtilNew.showCustomMessage(panel) + } + + fun parseUpdateMetadata(responseJson: JSONObject): UpdateMetadata? { + return try { + val remoteVersion = responseJson.getString("tag_name") + val assets = responseJson.getJSONArray("assets") + val asset = assets.getJSONObject(0) + + val assetId = asset.getLong("id") + val fileName = asset.getString("name") + val size = asset.getLong("size") + UpdateMetadata(remoteVersion, assetId, fileName, size) + } catch (t: Throwable) { + logger.error( + "parseError", + "Error parsing update response", + t, + "responseBody" to responseJson + ) + null + } + } + + fun startUpdate(args: String, runtime: Runtime) { + prepareBatchFile() + + try { + runtime.exec("cmd /c start update.bat $args") + } catch (t: Throwable) { + logger.error("batchError", "Error running update.bat", t) + val manualCommand = "update.bat $args" + + val msg = + "Failed to launch update.bat - call the following manually to perform the update: \n\n$manualCommand" + DialogUtilNew.showError(msg) + return + } + + exitProcess(0) + } + + fun prepareBatchFile() { + val updateFile = File("update.bat") + + updateFile.delete() + val updateScript = javaClass.getResource("/update/update.bat").readText() + updateFile.writeText(updateScript) + } +} + +data class UpdateMetadata( + val version: String, + val assetId: Long, + val fileName: String, + val size: Long +) { + fun getArgs() = "$size $version $fileName $assetId" +} diff --git a/client/src/main/kotlin/util/UrlUtil.kt b/client/src/main/kotlin/util/UrlUtil.kt new file mode 100644 index 0000000..e8c0a2f --- /dev/null +++ b/client/src/main/kotlin/util/UrlUtil.kt @@ -0,0 +1,12 @@ +package util + +import utils.InjectedThings.logger + +/** N.B. will likely only work on linux */ +fun launchUrl(url: String, runtime: Runtime = Runtime.getRuntime()) { + try { + runtime.exec("xdg-open $url") + } catch (e: Exception) { + logger.error("urlError", "Failed to launch $url", e) + } +} diff --git a/client/src/main/resources/update/update.bat b/client/src/main/resources/update/update.bat new file mode 100644 index 0000000..92f8a7b --- /dev/null +++ b/client/src/main/resources/update/update.bat @@ -0,0 +1,17 @@ +@echo off + +REM %1 = noOfBytes +REM %2 = version number +REM %3 = filename +REM %4 = assetId + +echo Performing download of %1 bytes (Version %2) + +curl -LJO -H "Accept: application/octet-stream" https://api.github.com/repos/alyssaburlton/Dartzee/releases/assets/%4 + +ren Dartzee.jar Dartzee_OLD.jar +ren %3 Dartzee.jar +del Dartzee_OLD.jar + +start javaw -Xms256m -Xmx512m -jar Dartzee.jar justUpdated trueLaunch +exit \ No newline at end of file diff --git a/client/src/test/kotlin/bean/HyperlinkAdaptorTest.kt b/client/src/test/kotlin/bean/HyperlinkAdaptorTest.kt new file mode 100644 index 0000000..e111a1c --- /dev/null +++ b/client/src/test/kotlin/bean/HyperlinkAdaptorTest.kt @@ -0,0 +1,94 @@ +package bean + +import com.github.alyssaburlton.swingtest.makeMouseEvent +import io.kotest.assertions.throwables.shouldThrow +import io.kotest.matchers.shouldBe +import io.mockk.mockk +import io.mockk.verifySequence +import java.awt.Cursor +import java.awt.event.MouseEvent +import javax.swing.JButton +import javax.swing.JPanel +import main.kotlin.testCore.AbstractTest +import org.junit.jupiter.api.Test + +private val mouseEventOverLink = makeMouseEvent(JButton()) +private val mouseEventNotOverLink = makeMouseEvent(JButton()) + +class HyperlinkAdaptorTest : AbstractTest() { + @Test + fun `Should not accept a non-component listener`() { + shouldThrow { HyperlinkAdaptor(NonComponentHyperlinkListener()) } + } + + @Test + fun `Should respond to mouse clicks`() { + val listener = mockk(relaxed = true) + + val adaptor = HyperlinkAdaptor(listener) + adaptor.mouseClicked(mouseEventOverLink) + adaptor.mouseClicked(mouseEventNotOverLink) + + verifySequence { + listener.linkClicked(mouseEventOverLink) + listener.linkClicked(mouseEventNotOverLink) + } + } + + @Test + fun `Should change the cursor on mouse movement`() { + val listener = TestHyperlinkListener() + val adaptor = HyperlinkAdaptor(listener) + + adaptor.mouseMoved(mouseEventNotOverLink) + listener.cursor shouldBe Cursor.getPredefinedCursor(Cursor.DEFAULT_CURSOR) + + adaptor.mouseMoved(mouseEventOverLink) + listener.cursor shouldBe Cursor.getPredefinedCursor(Cursor.HAND_CURSOR) + + adaptor.mouseMoved(mouseEventNotOverLink) + listener.cursor shouldBe Cursor.getPredefinedCursor(Cursor.DEFAULT_CURSOR) + + adaptor.mouseEntered(mouseEventNotOverLink) + listener.cursor shouldBe Cursor.getPredefinedCursor(Cursor.DEFAULT_CURSOR) + + adaptor.mouseEntered(mouseEventOverLink) + listener.cursor shouldBe Cursor.getPredefinedCursor(Cursor.HAND_CURSOR) + } + + @Test + fun `Should revert the cursor on mouseExit`() { + val listener = TestHyperlinkListener() + val adaptor = HyperlinkAdaptor(listener) + + adaptor.mouseMoved(mouseEventOverLink) + listener.cursor shouldBe Cursor.getPredefinedCursor(Cursor.HAND_CURSOR) + + adaptor.mouseExited(null) + listener.cursor shouldBe Cursor.getPredefinedCursor(Cursor.DEFAULT_CURSOR) + } + + @Test + fun `Should revert the cursor on mouseDragged`() { + val listener = TestHyperlinkListener() + val adaptor = HyperlinkAdaptor(listener) + + adaptor.mouseMoved(mouseEventOverLink) + listener.cursor shouldBe Cursor.getPredefinedCursor(Cursor.HAND_CURSOR) + + adaptor.mouseDragged(null) + listener.cursor shouldBe Cursor.getPredefinedCursor(Cursor.DEFAULT_CURSOR) + } +} + +private class TestHyperlinkListener : JPanel(), IHyperlinkListener { + override fun isOverHyperlink(arg0: MouseEvent) = arg0 === mouseEventOverLink + + override fun linkClicked(arg0: MouseEvent) {} +} + +private class NonComponentHyperlinkListener : IHyperlinkListener { + override fun isOverHyperlink(arg0: MouseEvent) = false + + override fun linkClicked(arg0: MouseEvent) {} +} diff --git a/client/src/test/kotlin/bean/LinkLabelTest.kt b/client/src/test/kotlin/bean/LinkLabelTest.kt new file mode 100644 index 0000000..f881a21 --- /dev/null +++ b/client/src/test/kotlin/bean/LinkLabelTest.kt @@ -0,0 +1,37 @@ +package bean + +import com.github.alyssaburlton.swingtest.doClick +import com.github.alyssaburlton.swingtest.doHover +import com.github.alyssaburlton.swingtest.doHoverAway +import io.kotest.matchers.shouldBe +import io.mockk.mockk +import io.mockk.verify +import java.awt.Color +import java.awt.Cursor +import main.kotlin.testCore.AbstractTest +import org.junit.jupiter.api.Test + +class LinkLabelTest : AbstractTest() { + @Test + fun `Should style text as URL`() { + val label = LinkLabel("https://foo.bar") {} + + label.text shouldBe "https://foo.bar" + label.foreground shouldBe Color.BLUE + } + + @Test + fun `Should update cursor on hover, and execute callback on click`() { + val callback = mockk<() -> Unit>(relaxed = true) + val label = LinkLabel("test", callback) + + label.doHover() + label.cursor shouldBe Cursor.getPredefinedCursor(Cursor.HAND_CURSOR) + + label.doHoverAway() + label.cursor shouldBe Cursor.getPredefinedCursor(Cursor.DEFAULT_CURSOR) + + label.doClick() + verify { callback() } + } +} diff --git a/client/src/test/kotlin/util/DialogUtilNewTest.kt b/client/src/test/kotlin/util/DialogUtilNewTest.kt new file mode 100644 index 0000000..7e6219d --- /dev/null +++ b/client/src/test/kotlin/util/DialogUtilNewTest.kt @@ -0,0 +1,106 @@ +package util + +import com.github.alyssaburlton.swingtest.clickCancel +import com.github.alyssaburlton.swingtest.clickNo +import com.github.alyssaburlton.swingtest.clickOk +import com.github.alyssaburlton.swingtest.clickYes +import com.github.alyssaburlton.swingtest.findWindow +import com.github.alyssaburlton.swingtest.flushEdt +import com.github.alyssaburlton.swingtest.purgeWindows +import io.kotest.matchers.shouldBe +import javax.swing.JDialog +import javax.swing.SwingUtilities +import logging.Severity +import main.kotlin.testCore.AbstractTest +import main.kotlin.testCore.getErrorDialog +import main.kotlin.testCore.getInfoDialog +import main.kotlin.testCore.getQuestionDialog +import main.kotlin.testCore.runAsync +import org.junit.jupiter.api.Test + +class DialogUtilNewTest : AbstractTest() { + @Test + fun `Should log for INFO dialogs`() { + runAsync { DialogUtilNew.showInfo("Something useful") } + + verifyLog("dialogShown", Severity.INFO).message shouldBe + "Info dialog shown: Something useful" + + getInfoDialog().clickOk() + flushEdt() + verifyLog("dialogClosed", Severity.INFO).message shouldBe "Info dialog closed" + } + + @Test + fun `Should log for ERROR dialogs`() { + runAsync { DialogUtilNew.showError("Something bad") } + + verifyLog("dialogShown", Severity.INFO).message shouldBe "Error dialog shown: Something bad" + getErrorDialog().clickOk() + flushEdt() + verifyLog("dialogClosed", Severity.INFO).message shouldBe "Error dialog closed" + } + + @Test + fun `Should show an ERROR dialog later`() { + SwingUtilities.invokeLater { Thread.sleep(500) } + DialogUtilNew.showErrorLater("Some error") + + findWindow() shouldBe null + + flushEdt() + getErrorDialog().clickOk() + flushEdt() + verifyLog("dialogClosed", Severity.INFO).message shouldBe "Error dialog closed" + } + + @Test + fun `Should log for QUESTION dialogs, with the correct selection`() { + runAsync { DialogUtilNew.showQuestion("Do you like cheese?") } + verifyLog("dialogShown", Severity.INFO).message shouldBe + "Question dialog shown: Do you like cheese?" + getQuestionDialog().clickYes() + flushEdt() + verifyLog("dialogClosed", Severity.INFO).message shouldBe + "Question dialog closed - selected Yes" + + clearLogs() + purgeWindows() + + runAsync { DialogUtilNew.showQuestion("Do you like mushrooms?") } + verifyLog("dialogShown", Severity.INFO).message shouldBe + "Question dialog shown: Do you like mushrooms?" + getQuestionDialog().clickNo() + flushEdt() + verifyLog("dialogClosed", Severity.INFO).message shouldBe + "Question dialog closed - selected No" + + clearLogs() + purgeWindows() + + runAsync { DialogUtilNew.showQuestion("Do you want to delete all data?", true) } + verifyLog("dialogShown", Severity.INFO).message shouldBe + "Question dialog shown: Do you want to delete all data?" + getQuestionDialog().clickCancel() + flushEdt() + verifyLog("dialogClosed", Severity.INFO).message shouldBe + "Question dialog closed - selected Cancel" + } + + @Test + fun `Should log when showing and dismissing loading dialog`() { + DialogUtilNew.showLoadingDialog("One moment...") + flushEdt() + verifyLog("dialogShown", Severity.INFO).message shouldBe + "Loading dialog shown: One moment..." + + DialogUtilNew.dismissLoadingDialog() + verifyLog("dialogClosed", Severity.INFO).message shouldBe "Loading dialog closed" + } + + @Test + fun `Should not log if loading dialog wasn't visible`() { + DialogUtilNew.dismissLoadingDialog() + verifyNoLogs("dialogClosed") + } +} diff --git a/client/src/test/kotlin/util/UpdateManagerTest.kt b/client/src/test/kotlin/util/UpdateManagerTest.kt new file mode 100644 index 0000000..3456f33 --- /dev/null +++ b/client/src/test/kotlin/util/UpdateManagerTest.kt @@ -0,0 +1,256 @@ +package util + +import bean.LinkLabel +import com.github.alyssaburlton.swingtest.clickNo +import com.github.alyssaburlton.swingtest.clickOk +import com.github.alyssaburlton.swingtest.clickYes +import com.github.alyssaburlton.swingtest.findWindow +import com.github.alyssaburlton.swingtest.flushEdt +import com.github.alyssaburlton.swingtest.getChild +import com.github.alyssaburlton.swingtest.shouldNotBeVisible +import io.kotest.matchers.ints.shouldBeGreaterThan +import io.kotest.matchers.shouldBe +import io.kotest.matchers.string.shouldContain +import io.kotest.matchers.string.shouldStartWith +import io.kotest.matchers.types.shouldBeInstanceOf +import io.mockk.every +import io.mockk.mockk +import java.io.File +import java.io.IOException +import java.util.concurrent.atomic.AtomicBoolean +import javax.swing.SwingUtilities +import kong.unirest.Unirest +import kong.unirest.UnirestException +import kong.unirest.json.JSONException +import kong.unirest.json.JSONObject +import logging.Severity +import main.kotlin.testCore.AbstractTest +import main.kotlin.testCore.getDialogMessage +import main.kotlin.testCore.getErrorDialog +import main.kotlin.testCore.getInfoDialog +import main.kotlin.testCore.getQuestionDialog +import main.kotlin.testCore.runAsync +import org.junit.jupiter.api.BeforeEach +import org.junit.jupiter.api.Tag +import org.junit.jupiter.api.Test +import screen.LoadingDialog +import testCore.assertDoesNotExit +import testCore.assertExits + +class UpdateManagerTest : AbstractTest() { + @BeforeEach + fun beforeEach() { + Unirest.config().reset() + Unirest.config().connectTimeout(2000) + Unirest.config().socketTimeout(2000) + } + + /** Communication */ + @Test + @Tag("integration") + fun `Should log out an unexpected HTTP response, along with the full JSON payload`() { + val errorMessage = + queryLatestReleastJsonExpectingError("https://api.github.com/repos/alyssaburlton/foo") + errorMessage shouldBe "Failed to check for updates (unable to connect)." + + val log = verifyLog("updateError", Severity.ERROR) + log.message shouldBe "Received non-success HTTP status: 404 - Not Found" + log.keyValuePairs["responseBody"].toString() shouldContain """"message":"Not Found"""" + + findWindow()!!.shouldNotBeVisible() + } + + @Test + @Tag("integration") + fun `Should catch and log any exceptions communicating over HTTPS`() { + Unirest.config().connectTimeout(100) + Unirest.config().socketTimeout(100) + + val errorMessage = queryLatestReleastJsonExpectingError("https://ww.blargh.zcss.w") + errorMessage shouldBe "Failed to check for updates (unable to connect)." + + val errorLog = verifyLog("updateError", Severity.ERROR) + errorLog.errorObject.shouldBeInstanceOf() + + findWindow()!!.shouldNotBeVisible() + } + + private fun queryLatestReleastJsonExpectingError(repositoryUrl: String): String { + val result = runAsync { UpdateManager.queryLatestReleaseJson(repositoryUrl) } + + val error = getErrorDialog() + val errorText = error.getDialogMessage() + + error.clickOk() + flushEdt() + + result shouldBe null + + return errorText + } + + @Test + @Tag("integration") + fun `Should retrieve a valid latest asset from the remote repo`() { + val responseJson = + UpdateManager.queryLatestReleaseJson(OnlineConstants.ENTROPY_REPOSITORY_URL)!! + + val version = responseJson.getString("tag_name") + version.shouldStartWith("v") + responseJson.getJSONArray("assets").length() shouldBeGreaterThan 0 + } + + /** Parsing */ + @Test + fun `Should parse correctly formed JSON`() { + val json = + """{ + "tag_name": "foo", + "assets": [ + { + "id": 123456, + "name": "Dartzee_v_foo.jar", + "size": 1 + } + ] + }""" + + val metadata = UpdateManager.parseUpdateMetadata(JSONObject(json))!! + metadata.version shouldBe "foo" + metadata.assetId shouldBe 123456 + metadata.fileName shouldBe "Dartzee_v_foo.jar" + metadata.size shouldBe 1 + } + + @Test + fun `Should log an error if no tag_name is present`() { + val json = "{\"other_tag\":\"foo\"}" + val metadata = UpdateManager.parseUpdateMetadata(JSONObject(json)) + metadata shouldBe null + + val log = verifyLog("parseError", Severity.ERROR) + log.errorObject.shouldBeInstanceOf() + log.keyValuePairs["responseBody"].toString() shouldBe json + } + + @Test + fun `Should log an error if no assets are found`() { + val json = """{"assets":[],"tag_name":"foo"}""" + val metadata = UpdateManager.parseUpdateMetadata(JSONObject(json)) + metadata shouldBe null + + val log = verifyLog("parseError", Severity.ERROR) + log.errorObject.shouldBeInstanceOf() + log.keyValuePairs["responseBody"].toString() shouldBe json + } + + /** Should update? */ + @Test + fun `Should not proceed with the update if the versions match`() { + val metadata = + UpdateMetadata( + OnlineConstants.ENTROPY_VERSION_NUMBER, + 123456, + "EntropyLive_x_y.jar", + 100 + ) + + UpdateManager.shouldUpdate(OnlineConstants.ENTROPY_VERSION_NUMBER, metadata) shouldBe false + val log = verifyLog("updateResult") + log.message shouldBe "Up to date" + } + + @Test + fun `Should show an info and not proceed to auto update if OS is not windows`() { + AbstractClient.operatingSystem = "foo" + + val metadata = UpdateMetadata("v100", 123456, "Dartzee_x_y.jar", 100) + shouldUpdateAsync(OnlineConstants.ENTROPY_VERSION_NUMBER, metadata).get() shouldBe false + + val log = verifyLog("updateAvailable") + log.message shouldBe "Newer release available - v100" + + val info = getInfoDialog() + val linkLabel = info.getChild() + linkLabel.text shouldBe + "${OnlineConstants.ENTROPY_MANUAL_DOWNLOAD_URL}/tag/v100" + } + + @Test + fun `Should not proceed with the update if user selects 'No'`() { + AbstractClient.operatingSystem = "windows" + + val metadata = UpdateMetadata("foo", 123456, "Dartzee_x_y.jar", 100) + val result = shouldUpdateAsync("bar", metadata) + + val question = getQuestionDialog() + question.getDialogMessage() shouldBe + "An update is available (foo). Would you like to download it now?" + question.clickNo() + flushEdt() + + result.get() shouldBe false + } + + @Test + fun `Should proceed with the update if user selects 'Yes'`() { + AbstractClient.operatingSystem = "windows" + + val metadata = UpdateMetadata("foo", 123456, "Dartzee_x_y.jar", 100) + val result = shouldUpdateAsync("bar", metadata) + + val question = getQuestionDialog() + question.getDialogMessage() shouldBe + "An update is available (foo). Would you like to download it now?" + question.clickYes() + flushEdt() + + result.get() shouldBe true + } + + private fun shouldUpdateAsync(currentVersion: String, metadata: UpdateMetadata): AtomicBoolean { + val result = AtomicBoolean(false) + SwingUtilities.invokeLater { + result.set(UpdateManager.shouldUpdate(currentVersion, metadata)) + } + + flushEdt() + return result + } + + /** Prepare batch file */ + @Test + fun `Should overwrite existing batch file with the correct contents`() { + val updateFile = File("update.bat") + updateFile.writeText("blah") + + UpdateManager.prepareBatchFile() + + updateFile.readText() shouldBe javaClass.getResource("/update/update.bat").readText() + updateFile.delete() + } + + /** Run update */ + @Test + fun `Should log an error and not exit if batch file goes wrong`() { + val runtime = mockk() + val error = IOException("Argh") + every { runtime.exec(any()) } throws error + + runAsync { assertDoesNotExit { UpdateManager.startUpdate("foo", runtime) } } + + val errorDialog = getErrorDialog() + errorDialog.getDialogMessage() shouldBe + "Failed to launch update.bat - call the following manually to perform the update: \n\nupdate.bat foo" + + val log = verifyLog("batchError", Severity.ERROR) + log.errorObject shouldBe error + } + + @Test + fun `Should exit normally if batch file succeeds`() { + val runtime = mockk(relaxed = true) + + assertExits(0) { UpdateManager.startUpdate("foo", runtime) } + } +} diff --git a/client/src/test/kotlin/util/UrlUtilTest.kt b/client/src/test/kotlin/util/UrlUtilTest.kt new file mode 100644 index 0000000..e6625df --- /dev/null +++ b/client/src/test/kotlin/util/UrlUtilTest.kt @@ -0,0 +1,34 @@ +package util + +import io.kotest.matchers.shouldBe +import io.mockk.every +import io.mockk.mockk +import io.mockk.verify +import java.io.IOException +import logging.Severity +import main.kotlin.testCore.AbstractTest +import org.junit.jupiter.api.Test + +class UrlUtilTest : AbstractTest() { + @Test + fun `Should execute the expected command`() { + val runtime = mockk(relaxed = true) + launchUrl("foo.bar", runtime) + + verify { runtime.exec("xdg-open foo.bar") } + } + + @Test + fun `Should log an appropriate error if launching the URL fails`() { + val error = IOException("Oops") + + val runtime = mockk() + every { runtime.exec(any()) } throws error + + launchUrl("foo.bar", runtime) + + val log = verifyLog("urlError", Severity.ERROR) + log.message shouldBe "Failed to launch foo.bar" + log.errorObject shouldBe error + } +} diff --git a/core/build.gradle.kts b/core/build.gradle.kts index a766fbf..b5a2465 100644 --- a/core/build.gradle.kts +++ b/core/build.gradle.kts @@ -9,4 +9,5 @@ ktfmt { kotlinLangStyle() } dependencies { implementation("javax.mail:javax.mail-api:1.6.2") implementation("javax.activation:activation:1.1.1") + testImplementation(project(":test-core")) } diff --git a/core/src/main/java/util/AbstractClient.java b/core/src/main/java/util/AbstractClient.java index 548e9a9..36b9417 100644 --- a/core/src/main/java/util/AbstractClient.java +++ b/core/src/main/java/util/AbstractClient.java @@ -76,6 +76,10 @@ public static boolean isAppleOs() { return operatingSystem.contains("mac") || operatingSystem.contains("darwin"); } + public static boolean isWindowsOs() + { + return operatingSystem.contains("windows"); + } public void checkForUpdatesIfRequired() { diff --git a/core/src/main/java/util/Debug.java b/core/src/main/java/util/Debug.java index 5297dc1..7bb4083 100644 --- a/core/src/main/java/util/Debug.java +++ b/core/src/main/java/util/Debug.java @@ -4,29 +4,16 @@ import java.io.PrintWriter; import java.io.StringWriter; import java.text.SimpleDateFormat; -import java.util.List; import java.util.concurrent.ExecutorService; import java.util.concurrent.Executors; import java.util.concurrent.ThreadFactory; public class Debug implements CoreRegistry { - private static final long ERROR_MESSAGE_DELAY_MILLIS = 10000; //10s - - private static long lastErrorMillis = -1; - private static DebugOutput output = null; - private static DebugExtension debugExtension = null; private static boolean logToSystemOut = false; - private static ThreadFactory loggerFactory = new ThreadFactory() - { - @Override - public Thread newThread(Runnable r) - { - return new Thread(r, "Logger"); - } - }; + private static ThreadFactory loggerFactory = r -> new Thread(r, "Logger"); private static ExecutorService logService = Executors.newFixedThreadPool(1, loggerFactory); @@ -117,27 +104,12 @@ public static void stackTrace(Throwable t) { stackTrace(t, ""); } - public static void stackTrace(Throwable t, String message) - { - stackTrace(t, message, false); - } public static void stackTraceNoError(Throwable t) { - stackTrace(t, "", true); + stackTrace(t, ""); } - public static void stackTrace(Throwable t, String message, boolean suppressError) + public static void stackTrace(Throwable t, String message) { - if (debugExtension != null - && !suppressError) - { - boolean showError = System.currentTimeMillis() - lastErrorMillis > ERROR_MESSAGE_DELAY_MILLIS; - debugExtension.exceptionCaught(showError); - if (showError) - { - lastErrorMillis = System.currentTimeMillis(); - } - } - String datetime = getCurrentTimeForLogging(); String trace = ""; @@ -178,11 +150,7 @@ public static void initialise(DebugOutput output) { Debug.output = output; } - - public static void setDebugExtension(DebugExtension debugExtension) - { - Debug.debugExtension = debugExtension; - } + public static void setLogToSystemOut(boolean logToSystemOut) { Debug.logToSystemOut = logToSystemOut; diff --git a/core/src/main/java/util/DebugExtension.java b/core/src/main/java/util/DebugExtension.java deleted file mode 100644 index a193629..0000000 --- a/core/src/main/java/util/DebugExtension.java +++ /dev/null @@ -1,8 +0,0 @@ -package util; - -public interface DebugExtension -{ - public void exceptionCaught(boolean showError); - public void unableToEmailLogs(); - public void sendEmail(String title, String message) throws Exception; -} diff --git a/core/src/main/java/util/FileUtil.java b/core/src/main/java/util/FileUtil.java index c66c49b..0f1bb79 100644 --- a/core/src/main/java/util/FileUtil.java +++ b/core/src/main/java/util/FileUtil.java @@ -24,42 +24,6 @@ public class FileUtil { - public static File createNewFile(String filePath, String contents) - { - File file = new File(filePath); - boolean success = false; - - try - { - success = file.createNewFile(); - } - catch (IOException ioe) - { - Debug.append("Caught " + ioe + " creating file " + filePath); - } - - if (!success) - { - return null; - } - - //We have created the empty file, now fill it - try (FileOutputStream fos = new FileOutputStream(filePath)) - { - byte[] bytes = contents.getBytes("UTF-8"); - fos.write(bytes); - } - catch (IOException ioe) - { - Debug.append("Caught " + ioe + " trying to insert bytes into file " + filePath); - deleteFileIfExists(filePath); - return null; - } - - Debug.append("Successfully created file " + file); - return file; - } - public static String getMd5Crc(String filePath) { String crc = null; @@ -97,61 +61,6 @@ public static long getFileSize(String filePath) return fileSize; } - public static boolean deleteFileIfExists(String filePath) - { - boolean success = false; - - try - { - Path path = Paths.get(filePath); - success = Files.deleteIfExists(path); - } - catch (Throwable t) - { - Debug.stackTrace(t, "Failed to delete file"); - } - - return success; - } - - public static String swapInFile(String oldFilePath, String newFilePath) - { - boolean success = true; - - File oldFile = new File(oldFilePath); - String oldFileName = oldFile.getName(); - File newFile = new File(newFilePath); - - File zzOldFile = new File(oldFile.getParent(), "zz" + oldFileName); - if (oldFile.exists() - && !oldFile.renameTo(zzOldFile)) - { - return "Failed to rename old out of the way."; - } - - success &= newFile.renameTo(new File(oldFile.getParent(), oldFileName)); - if (!success) - { - return "Failed to rename new file to " + oldFileName; - } - - if (zzOldFile.isFile()) - { - success &= deleteFileIfExists(zzOldFile.getPath()); - } - else - { - success &= deleteDirectoryIfExists(zzOldFile); - } - - if (!success) - { - return "Failed to delete zz'd old file: " + oldFile.getPath(); - } - - return null; - } - public static void saveTextToFile(String text, Path destinationPath) { Charset charset = Charset.forName("US-ASCII"); @@ -170,24 +79,6 @@ public static void saveTextToFile(String text, Path destinationPath) } } - public static String getFileContentsAsString(File file) - { - String ret = null; - - try - { - Path path = file.toPath(); - byte[] bytes = Files.readAllBytes(path); - ret = new String(bytes, "UTF-8"); - } - catch (Throwable t) - { - Debug.stackTrace(t); - } - - return ret; - } - public static String getBase64DecodedFileContentsAsString(File file) { try @@ -211,196 +102,9 @@ public static void encodeAndSaveToFile(Path destinationPath, String stringToWrit saveTextToFile(encodedStringToWrite, destinationPath); } - public static Dimension getImageDim(String path) - { - Dimension result = null; - String suffix = getFileSuffix(path); - Iterator iter = ImageIO.getImageReadersBySuffix(suffix); - if (iter.hasNext()) - { - ImageReader reader = iter.next(); - try (ImageInputStream stream = new FileImageInputStream(new File(path));) - { - reader.setInput(stream); - int width = reader.getWidth(reader.getMinIndex()); - int height = reader.getHeight(reader.getMinIndex()); - result = new Dimension(width, height); - } - catch (IOException e) - { - Debug.stackTrace(e); - } - finally - { - reader.dispose(); - } - } - else - { - Debug.stackTrace("No reader found for file extension: " + suffix + " (full path: " + path + ")"); - } - - return result; - } - - private static String getFileSuffix(String path) - { - if (path == null - || path.lastIndexOf('.') == -1) - { - return ""; - } - - int dotIndex = path.lastIndexOf('.'); - return path.substring(dotIndex + 1); - } - - /** - * Helper to create a file object for a URL, e.g. from a classpath resource. - * No longer used - */ - /*public static File getForURL(URL url) - { - try - { - URI uri = url.toURI(); - return new File(uri); - } - catch (Throwable t) - { - Debug.append("Failed to construct file for URL: " + url); - Debug.stackTrace(t); - return null; - } - }*/ - public static String stripFileExtension(String filename) { int ix = filename.indexOf('.'); return filename.substring(0, ix); } - - public static byte[] getByteArrayForResource(String resourcePath) - { - try (InputStream is = FileUtil.class.getResourceAsStream(resourcePath); - ByteArrayOutputStream baos = new ByteArrayOutputStream()) - { - byte[] b = new byte[4096]; - int n = 0; - while ((n = is.read(b)) != -1) - { - baos.write(b, 0, n); - } - - return baos.toByteArray(); - } - catch (IOException ioe) - { - Debug.stackTrace(ioe, "Failed to read classpath resource: " + resourcePath); - return null; - } - } - - /** - * Delete a whole directory, recursively clearing out the files/subfolders too. - */ - public static boolean deleteDirectoryIfExists(File dir) - { - if (!dir.exists() - || !dir.isDirectory()) - { - //Just don't do anything - return true; - } - - boolean success = true; - File[] files = dir.listFiles(); - for (int i=0; i 0) - { - outStream.write(buffer, 0, count); - } - } - catch (SocketException se) - { - Debug.append("Caught " + se + " for JAR download."); - } - catch (Throwable t) - { - Debug.stackTrace(t); - } - finally - { - server.incrementFunctionsHandled(); - server.incrementFunctionsHandledForMessage("DownloadRequest"); - - if (socket != null) - { - try {socket.close();} catch (Throwable t) {} - } - } - } -} diff --git a/server/src/main/java/server/DownloadListener.java b/server/src/main/java/server/DownloadListener.java deleted file mode 100644 index 6c3919b..0000000 --- a/server/src/main/java/server/DownloadListener.java +++ /dev/null @@ -1,21 +0,0 @@ -package server; - -import java.net.Socket; - -public class DownloadListener extends ListenerRunnable -{ - String filename = ""; - - public DownloadListener(EntropyServer server, int port, String filename) - { - super(server, port); - - this.filename = filename; - } - - @Override - public void handleInboundMessage(EntropyServer server, Socket clientSocket) - { - DownloadHandler.handleDownload(server, clientSocket, filename); - } -} diff --git a/server/src/main/java/server/EntropyServer.java b/server/src/main/java/server/EntropyServer.java index d7127ff..00f2682 100644 --- a/server/src/main/java/server/EntropyServer.java +++ b/server/src/main/java/server/EntropyServer.java @@ -103,7 +103,6 @@ public final class EntropyServer extends JFrame //Other private String lastCommand = ""; - private SuperHashMap hmClientJarToVersion = new SuperHashMap<>(); private PrivateKey privateKey = null; @@ -179,7 +178,6 @@ public static void main(String args[]) //Initialise interfaces etc EncryptionUtil.setBase64Interface(new Base64Desktop()); - Debug.setDebugExtension(new ServerDebugExtension()); Debug.initialise(console); //Set other variables on Debug @@ -223,7 +221,6 @@ private void onStart() readInPrivateKey(); readUsedKeysFromFile(); - readClientVersion(); registerDefaultRooms(); Debug.append("Starting permanent threads"); @@ -367,14 +364,6 @@ private void startListenerThreads() Debug.stackTrace(t, "Unable to start listener thread on port " + i); } } - - startDownloadListener(FILE_NAME_ENTROPY_JAR, SERVER_PORT_NUMBER_DOWNLOAD); - } - private void startDownloadListener(String filename, int port) - { - DownloadListener listener = new DownloadListener(this, port, filename); - ServerThread downloadListener = new ServerThread(listener, "DownloadListener-" + port); - downloadListener.start(); } private void startFunctionThread() @@ -857,10 +846,6 @@ public Room getRoomForName(String name) { return hmRoomByName.get(name); } - public String getClientVersion(String jar) - { - return hmClientJarToVersion.get(jar); - } public void toggleOnline() { online = !online; @@ -1061,45 +1046,6 @@ private void readUsedKeysFromFile() } } - private void readClientVersion() - { - try - { - Charset charset = Charset.forName("US-ASCII"); - List jarsAndVersions = Files.readAllLines(FILE_PATH_CLIENT_VERSION, charset); - - for (int i=0; i jarAndVersion = StringUtil.getListFromDelims(jarAndVersionStr, ";"); - - String jar = jarAndVersion.get(0); - String version = jarAndVersion.get(1); - - hmClientJarToVersion.put(jar, version); - } - - logClientVersions(); - } - catch (Throwable t) - { - Debug.append("Caught " + t + " trying to read in client version"); - } - } - private void logClientVersions() - { - Iterator> it = hmClientJarToVersion.entrySet().iterator(); - for (; it.hasNext(); ) - { - Map.Entry jarAndVersion = it.next(); - - String jar = jarAndVersion.getKey(); - String version = jarAndVersion.getValue(); - - Debug.append(FileUtil.stripFileExtension(jar) + " Version: " + version); - } - } - public ConcurrentHashMap getBlacklist() { return blacklist; @@ -1415,11 +1361,6 @@ else if (command.startsWith(COMMAND_NOTIFY_USER)) else if (command.equals(COMMAND_SERVER_VERSION)) { Debug.append("Server version: " + OnlineConstants.SERVER_VERSION); - logClientVersions(); - } - else if (command.equals(COMMAND_SERVER_RESET_CLIENT_VERSION)) - { - readClientVersion(); } else { @@ -1480,7 +1421,6 @@ private void printCommands() Debug.appendWithoutDate(COMMAND_NOTIFICATION_LOGGING); Debug.appendWithoutDate(COMMAND_NOTIFY_USER + ""); Debug.appendWithoutDate(COMMAND_SERVER_VERSION); - Debug.appendWithoutDate(COMMAND_SERVER_RESET_CLIENT_VERSION); } private void dumpMessageStats() diff --git a/server/src/main/java/server/MessageHandlerRunnable.java b/server/src/main/java/server/MessageHandlerRunnable.java index 76659a2..397f6db 100644 --- a/server/src/main/java/server/MessageHandlerRunnable.java +++ b/server/src/main/java/server/MessageHandlerRunnable.java @@ -101,8 +101,7 @@ public void run() Debug.appendWithoutDate("Response: " + responseStr); } - if (symmetricKey != null - && !DownloadHandler.isDownloadMessage(name)) + if (symmetricKey != null) { responseStr = EncryptionUtil.encrypt(responseStr, symmetricKey); } @@ -225,15 +224,6 @@ private Document handleUnencryptedMessage(Document message) Element root = message.getDocumentElement(); String name = root.getNodeName(); - if (DownloadHandler.isDownloadMessage(name)) - { - return DownloadHandler.processMessage(message, server); - } - - if (name.equals(ROOT_TAG_CLIENT_MAIL)) - { - return EntropyEmailUtil.handleClientMail(server, root); - } if (!name.equals(ROOT_TAG_NEW_SYMMETRIC_KEY)) { diff --git a/server/src/main/java/server/ServerCommands.java b/server/src/main/java/server/ServerCommands.java index a427ddd..90601f6 100644 --- a/server/src/main/java/server/ServerCommands.java +++ b/server/src/main/java/server/ServerCommands.java @@ -34,5 +34,4 @@ public interface ServerCommands public static final String COMMAND_NOTIFICATION_LOGGING = "notification logging"; public static final String COMMAND_NOTIFY_USER = "notify "; public static final String COMMAND_SERVER_VERSION = "version"; - public static final String COMMAND_SERVER_RESET_CLIENT_VERSION = "reset version"; } diff --git a/server/src/main/java/util/EntropyEmailUtil.java b/server/src/main/java/util/EntropyEmailUtil.java index 33cf234..813cd38 100644 --- a/server/src/main/java/util/EntropyEmailUtil.java +++ b/server/src/main/java/util/EntropyEmailUtil.java @@ -1,38 +1,12 @@ package util; -import java.io.File; -import java.io.FileOutputStream; -import java.util.ArrayList; - -import javax.crypto.SecretKey; import javax.mail.MessagingException; -import org.w3c.dom.Document; -import org.w3c.dom.Element; -import org.w3c.dom.NodeList; - -import server.EntropyServer; - public class EntropyEmailUtil { - public static final String EMAIL_ADDRESS_ENTROPY = "entropyDebug@gmail.com"; - - private static final String ENTROPY_USERNAME = "entropyDebug"; private static final String NO_REPLY_USERNAME = "entropynoreply"; - private static final String ENTROPY_PASSWORD = "****"; private static final String NO_REPLY_PASSWORD = "****"; - - /** - * Send an email via the usual EntropyDebug account - */ - public static void sendEmail(String title, String message) throws MessagingException - { - sendEmail(title, message, null); - } - public static void sendEmail(String title, String message, ArrayList attachments) throws MessagingException - { - EmailUtil.sendEmail(title, message, EMAIL_ADDRESS_ENTROPY, ENTROPY_USERNAME, ENTROPY_PASSWORD, attachments); - } + /** * Send an email from EntropyNoReply, e.g. a password reset @@ -41,99 +15,4 @@ public static void sendEmailNoReply(String title, String message, String targetE { EmailUtil.sendEmail(title, message, targetEmail, NO_REPLY_USERNAME, NO_REPLY_PASSWORD, null); } - - /** - * Handle a client mail message - */ - public static Document handleClientMail(EntropyServer server, Element rootElement) - { - String encryptedSymetricKeyStr = rootElement.getAttribute("EncryptedKey"); - String symmetricKeyStr = EncryptionUtil.decrypt(encryptedSymetricKeyStr, server.getPrivateKey(), true); - SecretKey symmetricKey = EncryptionUtil.reconstructKeyFromString(symmetricKeyStr); - - if (symmetricKey == null) - { - //Want to know about this. I've seen a client passing up a blank tag for EncryptedKey, and I don't - //know how that's possible. - Debug.stackTrace("Failed to reconstruct symmetricKey for EncryptedKey attribute"); - Debug.append("EncryptedKeyStr: " + encryptedSymetricKeyStr); - Debug.append("SymmetricKeyStr: " + symmetricKeyStr); - } - - String subject = EncryptionUtil.decryptIfPossible(rootElement.getAttribute("Subject"), symmetricKey); - String body = EncryptionUtil.decryptIfPossible(rootElement.getAttribute("Body"), symmetricKey); - - ArrayList attachments = new ArrayList<>(); - NodeList attachmentElements = rootElement.getElementsByTagName("Attachment"); - for (int i=0; i Unit) { + val originalSecurityManager = System.getSecurityManager() + System.setSecurityManager(NoExitSecurityManager(originalSecurityManager)) + + try { + fn() + fail("Expected exitProcess($expectedStatus), but it wasn't called") + } catch (e: ExitException) { + e.status shouldBe expectedStatus + } finally { + System.setSecurityManager(originalSecurityManager) + } +} + +fun assertDoesNotExit(fn: () -> Unit) { + val originalSecurityManager = System.getSecurityManager() + System.setSecurityManager(NoExitSecurityManager(originalSecurityManager)) + + try { + fn() + } catch (e: ExitException) { + fail("Called exitProcess(${e.status})") + } finally { + System.setSecurityManager(originalSecurityManager) + } +} diff --git a/core/src/test/kotlin/helper/FakeLogDestination.kt b/test-core/src/main/kotlin/testCore/FakeLogDestination.kt similarity index 92% rename from core/src/test/kotlin/helper/FakeLogDestination.kt rename to test-core/src/main/kotlin/testCore/FakeLogDestination.kt index ab75c8c..7b5c0cf 100644 --- a/core/src/test/kotlin/helper/FakeLogDestination.kt +++ b/test-core/src/main/kotlin/testCore/FakeLogDestination.kt @@ -1,4 +1,4 @@ -package helper +package main.kotlin.testCore import logging.ILogDestination import logging.LogRecord diff --git a/core/src/test/kotlin/helper/TestConstants.kt b/test-core/src/main/kotlin/testCore/TestConstants.kt similarity index 92% rename from core/src/test/kotlin/helper/TestConstants.kt rename to test-core/src/main/kotlin/testCore/TestConstants.kt index a69de53..6db1008 100644 --- a/core/src/test/kotlin/helper/TestConstants.kt +++ b/test-core/src/main/kotlin/testCore/TestConstants.kt @@ -1,3 +1,5 @@ +package main.kotlin.testCore + import java.time.Instant import java.time.ZoneId import java.time.format.DateTimeFormatter diff --git a/core/src/test/kotlin/helper/TestUtils.kt b/test-core/src/main/kotlin/testCore/TestUtils.kt similarity index 61% rename from core/src/test/kotlin/helper/TestUtils.kt rename to test-core/src/main/kotlin/testCore/TestUtils.kt index fca301b..e628a9c 100644 --- a/core/src/test/kotlin/helper/TestUtils.kt +++ b/test-core/src/main/kotlin/testCore/TestUtils.kt @@ -1,10 +1,15 @@ -package helper +package main.kotlin.testCore -import CURRENT_TIME +import com.github.alyssaburlton.swingtest.findAll +import com.github.alyssaburlton.swingtest.findWindow +import com.github.alyssaburlton.swingtest.flushEdt import io.kotest.matchers.maps.shouldContainExactly import java.awt.Component import java.awt.Container import java.time.Instant +import javax.swing.JDialog +import javax.swing.JLabel +import javax.swing.SwingUtilities import logging.LogRecord import logging.Severity @@ -47,3 +52,24 @@ fun addComponents(ret: MutableList, components: Array, desired } } } + +fun getInfoDialog() = getOptionPaneDialog("Information") + +fun getQuestionDialog() = getOptionPaneDialog("Question") + +fun getErrorDialog() = getOptionPaneDialog("Error") + +private fun getOptionPaneDialog(title: String) = findWindow { it.title == title }!! + +fun JDialog.getDialogMessage(): String { + val messageLabels = findAll().filter { it.name == "OptionPane.label" } + return messageLabels.joinToString("\n\n") { it.text } +} + +fun runAsync(block: () -> T?): T? { + var result: T? = null + SwingUtilities.invokeLater { result = block() } + + flushEdt() + return result +}