diff --git a/android/assets/jsons/translations/template.properties b/android/assets/jsons/translations/template.properties index 6395f6da94..c1f9571fca 100644 --- a/android/assets/jsons/translations/template.properties +++ b/android/assets/jsons/translations/template.properties @@ -758,6 +758,7 @@ Saved at = Saving... = Overwrite existing file? = Overwrite = +The file is marked read-only. = It looks like your saved game can't be loaded! = If you could copy your game data ("Copy saved game to clipboard" - = paste into an email to yairm210@hotmail.com) = @@ -767,6 +768,7 @@ Load from custom location = Save to custom location = Could not save game to custom location! = '[saveFileName]' copied to clipboard! = +Current game copied to clipboard! = Could not save game to clipboard! = Download missing mods = Missing mods are downloaded successfully. = diff --git a/core/src/com/unciv/logic/files/FileChooser.kt b/core/src/com/unciv/logic/files/FileChooser.kt index 47ba9e23c1..299bdb3e09 100644 --- a/core/src/com/unciv/logic/files/FileChooser.kt +++ b/core/src/com/unciv/logic/files/FileChooser.kt @@ -130,9 +130,7 @@ open class FileChooser( result = textField.text enableOKButton() } - fileNameInput.setTextFieldFilter { _, char -> - char != File.separatorChar - } + fileNameInput.textFieldFilter = UncivFiles.fileNameTextFieldFilter() if (title != null) { addGoodSizedLabel(title).colspan(2).center().row() @@ -283,7 +281,7 @@ open class FileChooser( fun getSaveEnable(): Boolean { if (currentDir?.exists() != true) return false if (allowFolderSelect) return true - return result?.run { isEmpty() || startsWith(' ') || endsWith(' ') } == false + return result != null && UncivFiles.isValidFileName(result!!) } okButton.isEnabled = if (fileNameEnabled) getSaveEnable() else getLoadEnable() } diff --git a/core/src/com/unciv/logic/files/UncivFiles.kt b/core/src/com/unciv/logic/files/UncivFiles.kt index 9de52d9ea8..6e4d3c2f49 100644 --- a/core/src/com/unciv/logic/files/UncivFiles.kt +++ b/core/src/com/unciv/logic/files/UncivFiles.kt @@ -3,6 +3,7 @@ package com.unciv.logic.files import com.badlogic.gdx.Files import com.badlogic.gdx.Gdx import com.badlogic.gdx.files.FileHandle +import com.badlogic.gdx.scenes.scene2d.ui.TextField import com.badlogic.gdx.utils.GdxRuntimeException import com.badlogic.gdx.utils.JsonReader import com.badlogic.gdx.utils.SerializationException @@ -443,6 +444,25 @@ class UncivFiles( return Gzip.zip(json().toJson(game)) } + private val charsForbiddenInFileNames = setOf('\\', '/', ':') + private val _fileNameTextFieldFilter = TextField.TextFieldFilter { _, char -> + char !in charsForbiddenInFileNames + } + /** Check characters typed into a file name TextField: Disallows both Unix and Windows path separators, plus the + * ['NTFS alternate streams'](https://learn.microsoft.com/en-us/openspecs/windows_protocols/ms-fscc/b134f29a-6278-4f3f-904f-5e58a713d2c5) + * indicator, irrespective of platform, in case players wish to exchange files cross-platform. + * @see isValidFileName + * @return A `TextFieldFilter` appropriate for `TextField`s used to enter a file name for saving + */ + fun fileNameTextFieldFilter() = _fileNameTextFieldFilter + + /** Determines whether a filename is acceptable. + * - Forbids trailing blanks because Windows has trouble with them. + * - Forbids leading blanks because they might confuse users (neither Windows nor Linux have noteworthy problems with them). + * - Does **not** deal with problems that can be recognized inspecting a single character, use [fileNameTextFieldFilter] for that. + * @param fileName A base file name, not a path. + */ + fun isValidFileName(fileName: String) = fileName.isNotEmpty() && !fileName.endsWith(' ') && !fileName.startsWith(' ') } } diff --git a/core/src/com/unciv/ui/screens/multiplayerscreens/MultiplayerScreen.kt b/core/src/com/unciv/ui/screens/multiplayerscreens/MultiplayerScreen.kt index 82a6bc32d4..36412d069a 100644 --- a/core/src/com/unciv/ui/screens/multiplayerscreens/MultiplayerScreen.kt +++ b/core/src/com/unciv/ui/screens/multiplayerscreens/MultiplayerScreen.kt @@ -4,6 +4,7 @@ import com.badlogic.gdx.Gdx import com.badlogic.gdx.scenes.scene2d.ui.Table import com.badlogic.gdx.scenes.scene2d.ui.TextButton import com.unciv.Constants +import com.unciv.logic.files.UncivFiles import com.unciv.logic.multiplayer.MultiplayerGame import com.unciv.logic.multiplayer.storage.MultiplayerAuthException import com.unciv.models.ruleset.RulesetCache @@ -290,7 +291,7 @@ class MultiplayerScreen : PickerScreen() { Popup(this).apply { val textField = UncivTextField("Game name", selectedGame!!.name) // slashes in mp names are interpreted as directory separators, so we don't allow them - textField.setTextFieldFilter { _, c -> c != '/' && c != '\\' } + textField.textFieldFilter = UncivFiles.fileNameTextFieldFilter() add(textField).width(stageToShowOn.width / 2).row() val saveButton = "Save".toTextButton() diff --git a/core/src/com/unciv/ui/screens/savescreens/LoadGameScreen.kt b/core/src/com/unciv/ui/screens/savescreens/LoadGameScreen.kt index d976a147be..d90a6922f8 100644 --- a/core/src/com/unciv/ui/screens/savescreens/LoadGameScreen.kt +++ b/core/src/com/unciv/ui/screens/savescreens/LoadGameScreen.kt @@ -2,25 +2,19 @@ package com.unciv.ui.screens.savescreens import com.badlogic.gdx.Gdx import com.badlogic.gdx.files.FileHandle -import com.badlogic.gdx.graphics.Color import com.badlogic.gdx.scenes.scene2d.ui.Table import com.badlogic.gdx.scenes.scene2d.ui.TextButton -import com.badlogic.gdx.utils.SerializationException import com.unciv.Constants -import com.unciv.UncivGame import com.unciv.logic.MissingModsException import com.unciv.logic.UncivShowableException import com.unciv.logic.files.PlatformSaverLoader import com.unciv.logic.files.UncivFiles -import com.unciv.logic.github.Github -import com.unciv.logic.github.Github.folderNameToRepoName import com.unciv.models.ruleset.RulesetCache import com.unciv.models.translations.tr import com.unciv.ui.components.UncivTooltip.Companion.addTooltip import com.unciv.ui.components.extensions.disable import com.unciv.ui.components.extensions.enable import com.unciv.ui.components.extensions.isEnabled -import com.unciv.ui.components.extensions.toLabel import com.unciv.ui.components.extensions.toTextButton import com.unciv.ui.components.input.KeyCharAndCode import com.unciv.ui.components.input.keyShortcuts @@ -32,71 +26,22 @@ import com.unciv.ui.popups.ToastPopup import com.unciv.utils.Concurrency import com.unciv.utils.Log import com.unciv.utils.launchOnGLThread -import java.io.FileNotFoundException +import kotlinx.coroutines.CoroutineScope class LoadGameScreen : LoadOrSaveScreen() { private val copySavedGameToClipboardButton = getCopyExistingSaveToClipboardButton() - private val errorLabel = "".toLabel(Color.RED) private val loadMissingModsButton = getLoadMissingModsButton() private var missingModsToLoad: Iterable = emptyList() - companion object { + /** Inheriting here again exposes [getLoadExceptionMessage] and [loadMissingMods] to + * other clients (WorldScreen, QuickSave, Multiplayer) without needing to rewrite many imports + */ + companion object : Helpers { private const val loadGame = "Load game" private const val loadFromCustomLocation = "Load from custom location" private const val loadFromClipboard = "Load copied data" private const val copyExistingSaveToClipboard = "Copy saved game to clipboard" internal const val downloadMissingMods = "Download missing mods" - - /** Gets a translated exception message to show to the user. - * @return The first returned value is the message, the second is signifying if the user can likely fix this problem. */ - fun getLoadExceptionMessage(ex: Throwable, primaryText: String = "Could not load game!"): Pair { - val errorText = StringBuilder(primaryText.tr()) - - val isUserFixable: Boolean - errorText.appendLine() - when (ex) { - is UncivShowableException -> { - errorText.append(ex.localizedMessage) - isUserFixable = true - } - is SerializationException -> { - errorText.append("The file data seems to be corrupted.".tr()) - isUserFixable = false - } - is FileNotFoundException -> { - isUserFixable = if (ex.cause?.message?.contains("Permission denied") == true) { - errorText.append("You do not have sufficient permissions to access the file.".tr()) - true - } else { - false - } - } - else -> { - errorText.append("Unhandled problem, [${ex::class.simpleName} ${ex.stackTraceToString()}]".tr()) - isUserFixable = false - } - } - return Pair(errorText.toString(), isUserFixable) - } - - fun loadMissingMods(missingMods: Iterable, onModDownloaded:(String)->Unit, onCompleted:()->Unit){ - - for (rawName in missingMods) { - val modName = rawName.folderNameToRepoName().lowercase() - val repos = Github.tryGetGithubReposWithTopic(10, 1, modName) - ?: throw UncivShowableException("Could not download mod list.") - val repo = repos.items.firstOrNull { it.name.lowercase() == modName } - ?: throw UncivShowableException("Could not find a mod named \"[$modName]\".") - val modFolder = Github.downloadAndExtract( - repo, - UncivGame.Current.files.getModsFolder() - ) - ?: throw Exception("Unexpected 404 error") // downloadAndExtract returns null for 404 errors and the like -> display something! - Github.rewriteModOptions(repo, modFolder) - onModDownloaded(repo.name) - } - onCompleted() - } } init { @@ -133,7 +78,7 @@ class LoadGameScreen : LoadOrSaveScreen() { private fun Table.initRightSideTable() { add(getLoadFromClipboardButton()).row() addLoadFromCustomLocationButton() - add(errorLabel).width(stage.width / 2).row() + add(errorLabel).width(stage.width / 2).center().row() add(loadMissingModsButton).row() add(deleteSaveButton).row() add(copySavedGameToClipboardButton).row() @@ -218,18 +163,7 @@ class LoadGameScreen : LoadOrSaveScreen() { copyButton.onActivation { val file = selectedSave ?: return@onActivation Concurrency.run(copyExistingSaveToClipboard) { - try { - val gameText = file.readString() - Gdx.app.clipboard.contents = if (gameText[0] == '{') Gzip.zip(gameText) else gameText - launchOnGLThread { - ToastPopup("'[${file.name()}]' copied to clipboard!", this@LoadGameScreen) - } - } catch (ex: Throwable) { - ex.printStackTrace() - launchOnGLThread { - ToastPopup("Could not save game to clipboard!", this@LoadGameScreen) - } - } + copySaveToClipboard(file) } } copyButton.disable() @@ -239,6 +173,31 @@ class LoadGameScreen : LoadOrSaveScreen() { return copyButton } + private fun CoroutineScope.copySaveToClipboard(file: FileHandle) { + val gameText = try { + file.readString() + } catch (ex: Throwable) { + val (errorText, isUserFixable) = getLoadExceptionMessage(ex, saveToClipboardErrorMessage) + if (!isUserFixable) + Log.error(saveToClipboardErrorMessage, ex) + launchOnGLThread { + ToastPopup(errorText, this@LoadGameScreen) + } + return + } + try { + Gdx.app.clipboard.contents = if (gameText[0] == '{') Gzip.zip(gameText) else gameText + launchOnGLThread { + ToastPopup("'[${file.name()}]' copied to clipboard!", this@LoadGameScreen) + } + } catch (ex: Throwable) { + Log.error(saveToClipboardErrorMessage, ex) + launchOnGLThread { + ToastPopup(saveToClipboardErrorMessage, this@LoadGameScreen) + } + } + } + private fun getLoadMissingModsButton(): TextButton { val button = downloadMissingMods.toTextButton() button.onClick { @@ -249,26 +208,20 @@ class LoadGameScreen : LoadOrSaveScreen() { } private fun handleLoadGameException(ex: Exception, primaryText: String = "Could not load game!") { - Log.error("Error while loading game", ex) - val (errorText, isUserFixable) = getLoadExceptionMessage(ex, primaryText) + val isUserFixable = handleException(ex, primaryText) + if (!isUserFixable) { + val cantLoadGamePopup = Popup(this@LoadGameScreen) + cantLoadGamePopup.addGoodSizedLabel("It looks like your saved game can't be loaded!").row() + cantLoadGamePopup.addGoodSizedLabel("If you could copy your game data (\"Copy saved game to clipboard\" - ").row() + cantLoadGamePopup.addGoodSizedLabel(" paste into an email to yairm210@hotmail.com)").row() + cantLoadGamePopup.addGoodSizedLabel("I could maybe help you figure out what went wrong, since this isn't supposed to happen!").row() + cantLoadGamePopup.addCloseButton() + cantLoadGamePopup.open() + } - Concurrency.runOnGLThread { - if (!isUserFixable) { - val cantLoadGamePopup = Popup(this@LoadGameScreen) - cantLoadGamePopup.addGoodSizedLabel("It looks like your saved game can't be loaded!").row() - cantLoadGamePopup.addGoodSizedLabel("If you could copy your game data (\"Copy saved game to clipboard\" - ").row() - cantLoadGamePopup.addGoodSizedLabel(" paste into an email to yairm210@hotmail.com)").row() - cantLoadGamePopup.addGoodSizedLabel("I could maybe help you figure out what went wrong, since this isn't supposed to happen!").row() - cantLoadGamePopup.addCloseButton() - cantLoadGamePopup.open() - } - - errorLabel.setText(errorText) - errorLabel.isVisible = true - if (ex is MissingModsException) { - loadMissingModsButton.isVisible = true - missingModsToLoad = ex.missingMods - } + if (ex is MissingModsException) { + loadMissingModsButton.isVisible = true + missingModsToLoad = ex.missingMods } } @@ -277,7 +230,7 @@ class LoadGameScreen : LoadOrSaveScreen() { descriptionLabel.setText(Constants.loading.tr()) Concurrency.runOnNonDaemonThreadPool(downloadMissingMods) { try { - Companion.loadMissingMods(missingModsToLoad, + loadMissingMods(missingModsToLoad, onModDownloaded = { val labelText = descriptionLabel.text // Surprise - a StringBuilder labelText.appendLine() @@ -296,7 +249,9 @@ class LoadGameScreen : LoadOrSaveScreen() { } ) } catch (ex: Exception) { - handleLoadGameException(ex, "Could not load the missing mods!") + launchOnGLThread { + handleLoadGameException(ex, "Could not load the missing mods!") + } } finally { launchOnGLThread { loadMissingModsButton.isEnabled = true @@ -305,5 +260,4 @@ class LoadGameScreen : LoadOrSaveScreen() { } } } - } diff --git a/core/src/com/unciv/ui/screens/savescreens/LoadOrSaveScreen.kt b/core/src/com/unciv/ui/screens/savescreens/LoadOrSaveScreen.kt index 06d8360143..14f5383739 100644 --- a/core/src/com/unciv/ui/screens/savescreens/LoadOrSaveScreen.kt +++ b/core/src/com/unciv/ui/screens/savescreens/LoadOrSaveScreen.kt @@ -1,11 +1,18 @@ package com.unciv.ui.screens.savescreens import com.badlogic.gdx.files.FileHandle +import com.badlogic.gdx.graphics.Color import com.badlogic.gdx.scenes.scene2d.ui.CheckBox import com.badlogic.gdx.scenes.scene2d.ui.Table import com.badlogic.gdx.scenes.scene2d.ui.TextButton +import com.badlogic.gdx.utils.Align +import com.badlogic.gdx.utils.GdxRuntimeException +import com.badlogic.gdx.utils.SerializationException import com.unciv.Constants import com.unciv.UncivGame +import com.unciv.logic.UncivShowableException +import com.unciv.logic.github.Github +import com.unciv.logic.github.Github.folderNameToRepoName import com.unciv.models.translations.tr import com.unciv.ui.components.UncivTooltip.Companion.addTooltip import com.unciv.ui.components.extensions.UncivDateFormat.formatDate @@ -23,8 +30,13 @@ import com.unciv.ui.components.input.onDoubleClick import com.unciv.ui.popups.ConfirmPopup import com.unciv.ui.screens.pickerscreens.PickerScreen import com.unciv.utils.Concurrency +import com.unciv.utils.Log import com.unciv.utils.launchOnGLThread +import java.io.FileNotFoundException +import java.nio.file.attribute.DosFileAttributes import java.util.Date +import kotlin.io.path.Path +import kotlin.io.path.readAttributes abstract class LoadOrSaveScreen( @@ -41,6 +53,11 @@ abstract class LoadOrSaveScreen( protected val rightSideTable = Table() protected val deleteSaveButton = "Delete save".toTextButton(skin.get("negative", TextButton.TextButtonStyle::class.java)) protected val showAutosavesCheckbox = CheckBox("Show autosaves".tr(), skin) + protected val errorLabel = "".toLabel(Color.RED, alignment = Align.center) + + companion object : Helpers { + internal const val saveToClipboardErrorMessage = "Could not save game to clipboard!" + } init { savesScrollPane.onChange(::selectExistingSave) @@ -103,6 +120,7 @@ abstract class LoadOrSaveScreen( } private fun selectExistingSave(saveGameFile: FileHandle) { + errorLabel.isVisible = false deleteSaveButton.enable() selectedSave = saveGameFile @@ -135,4 +153,83 @@ abstract class LoadOrSaveScreen( } } } + + /** Show appropriate message for an exception and log the severe cases + * @return isUserFixable */ + protected fun handleException(ex: Exception, primaryText: String, file: FileHandle? = null): Boolean { + val (errorText, isUserFixable) = getLoadExceptionMessage(ex, primaryText, file) + if (!isUserFixable) + Log.error(primaryText, ex) + errorLabel.setText(errorText) + errorLabel.isVisible = true + return isUserFixable + } + + interface Helpers { + /** Gets a translated exception message to show to the user. + * @param file Optional, used for detection of local read-only files, only relevant for write operations on Windows desktops. + * @return The first returned value is the message, the second is signifying if the user can likely fix this problem. */ + fun getLoadExceptionMessage(ex: Throwable, primaryText: String = "Could not load game!", file: FileHandle? = null): Pair { + val errorText = StringBuilder(primaryText.tr()) + errorText.appendLine() + var cause = ex + while (cause.cause != null && cause is GdxRuntimeException) cause = cause.cause!! + + fun FileHandle.isReadOnly(): Boolean { + try { + val attr = Path(file().absolutePath).readAttributes() + return attr.isReadOnly + } catch (_: Throwable) { return false } + } + + val isUserFixable = when (cause) { + is UncivShowableException -> { + errorText.append(ex.localizedMessage) + true + } + is SerializationException -> { + errorText.append("The file data seems to be corrupted.".tr()) + false + } + is FileNotFoundException -> { + // This is thrown both for chmod/ACL denials and for writing to a file with the read-only attribute set + // On Windows, `message` is already (and illegally) partially localized. + val localizedMessage = UncivGame.Current.getSystemErrorMessage(5) + val isPermissionDenied = cause.message?.run { + contains("Permission denied") || (localizedMessage != null && contains(localizedMessage)) + } == true + if (isPermissionDenied) { + if (file != null && file.isReadOnly()) + errorText.append("The file is marked read-only.".tr()) + else + errorText.append("You do not have sufficient permissions to access the file.".tr()) + } + isPermissionDenied + } + else -> { + errorText.append("Unhandled problem, [${ex::class.simpleName} ${ex.stackTraceToString()}]".tr()) + false + } + } + return Pair(errorText.toString(), isUserFixable) + } + + fun loadMissingMods(missingMods: Iterable, onModDownloaded:(String)->Unit, onCompleted:()->Unit) { + for (rawName in missingMods) { + val modName = rawName.folderNameToRepoName().lowercase() + val repos = Github.tryGetGithubReposWithTopic(10, 1, modName) + ?: throw UncivShowableException("Could not download mod list.") + val repo = repos.items.firstOrNull { it.name.lowercase() == modName } + ?: throw UncivShowableException("Could not find a mod named \"[$modName]\".") + val modFolder = Github.downloadAndExtract( + repo, + UncivGame.Current.files.getModsFolder() + ) + ?: throw Exception("Unexpected 404 error") // downloadAndExtract returns null for 404 errors and the like -> display something! + Github.rewriteModOptions(repo, modFolder) + onModDownloaded(repo.name) + } + onCompleted() + } + } } diff --git a/core/src/com/unciv/ui/screens/savescreens/SaveGameScreen.kt b/core/src/com/unciv/ui/screens/savescreens/SaveGameScreen.kt index 7275690b05..ab859bd9c3 100644 --- a/core/src/com/unciv/ui/screens/savescreens/SaveGameScreen.kt +++ b/core/src/com/unciv/ui/screens/savescreens/SaveGameScreen.kt @@ -2,7 +2,6 @@ package com.unciv.ui.screens.savescreens import com.badlogic.gdx.Gdx import com.badlogic.gdx.files.FileHandle -import com.badlogic.gdx.graphics.Color import com.badlogic.gdx.scenes.scene2d.ui.Table import com.unciv.UncivGame import com.unciv.logic.GameInfo @@ -13,6 +12,7 @@ import com.unciv.ui.components.widgets.UncivTextField import com.unciv.ui.components.UncivTooltip.Companion.addTooltip import com.unciv.ui.components.extensions.disable import com.unciv.ui.components.extensions.enable +import com.unciv.ui.components.extensions.isEnabled import com.unciv.ui.components.extensions.toLabel import com.unciv.ui.components.extensions.toTextButton import com.unciv.ui.components.input.KeyCharAndCode @@ -22,24 +22,29 @@ import com.unciv.ui.components.input.onClick import com.unciv.ui.popups.ConfirmPopup import com.unciv.ui.popups.ToastPopup import com.unciv.utils.Concurrency +import com.unciv.utils.Log import com.unciv.utils.launchOnGLThread -class SaveGameScreen(val gameInfo: GameInfo) : LoadOrSaveScreen("Current saves") { - companion object { +class SaveGameScreen(private val gameInfo: GameInfo) : LoadOrSaveScreen("Current saves") { + private val gameNameTextField = UncivTextField(nameFieldLabelText) + + companion object : Helpers { const val nameFieldLabelText = "Saved game name" + const val saveButtonText = "Save game" const val savingText = "Saving..." const val saveToCustomText = "Save to custom location" } - private val gameNameTextField = UncivTextField(nameFieldLabelText) - init { + errorLabel.isVisible = false + errorLabel.wrap = true + setDefaultCloseAction() rightSideTable.initRightSideTable() - rightSideButton.setText("Save game".tr()) + rightSideButton.setText(saveButtonText.tr()) rightSideButton.onActivation { if (game.files.getSave(gameNameTextField.text).exists()) doubleClickAction() @@ -53,19 +58,22 @@ class SaveGameScreen(val gameInfo: GameInfo) : LoadOrSaveScreen("Current saves") addGameNameField() val copyJsonButton = "Copy to clipboard".toTextButton() - copyJsonButton.onActivation { copyToClipboardHandler() } + copyJsonButton.onActivation(::copyToClipboardHandler) val ctrlC = KeyCharAndCode.ctrl('c') copyJsonButton.keyShortcuts.add(ctrlC) copyJsonButton.addTooltip(ctrlC) add(copyJsonButton).row() addSaveToCustomLocation() + add(errorLabel).width(stage.width / 2).center().row() + row() // For uniformity with LoadScreen which has a load missing mods button here add(deleteSaveButton).row() add(showAutosavesCheckbox).row() } private fun Table.addGameNameField() { - gameNameTextField.setTextFieldFilter { _, char -> char != '\\' && char != '/' } + gameNameTextField.textFieldFilter = UncivFiles.fileNameTextFieldFilter() + gameNameTextField.setTextFieldListener { textField, _ -> enableSaveButton(textField.text) } val defaultSaveName = "[${gameInfo.currentPlayer}] - [${gameInfo.turns}] turns".tr(hideIcons = true) gameNameTextField.text = defaultSaveName gameNameTextField.setSelection(0, defaultSaveName.length) @@ -74,15 +82,22 @@ class SaveGameScreen(val gameInfo: GameInfo) : LoadOrSaveScreen("Current saves") add(gameNameTextField).width(300f).row() } + private fun enableSaveButton(text: String) { + rightSideButton.isEnabled = UncivFiles.isValidFileName(text) + } + private fun copyToClipboardHandler() { Concurrency.run("Copy game to clipboard") { // the Gzip rarely leads to ANRs try { Gdx.app.clipboard.contents = UncivFiles.gameInfoToString(gameInfo, forceZip = true) - } catch (ex: Throwable) { - ex.printStackTrace() launchOnGLThread { - ToastPopup("Could not save game to clipboard!", this@SaveGameScreen) + ToastPopup("Current game copied to clipboard!", this@SaveGameScreen) + } + } catch (ex: Throwable) { + Log.error(saveToClipboardErrorMessage, ex) + launchOnGLThread { + ToastPopup(saveToClipboardErrorMessage, this@SaveGameScreen) } } } @@ -90,11 +105,10 @@ class SaveGameScreen(val gameInfo: GameInfo) : LoadOrSaveScreen("Current saves") private fun Table.addSaveToCustomLocation() { val saveToCustomLocation = saveToCustomText.toTextButton() - val errorLabel = "".toLabel(Color.RED) saveToCustomLocation.onClick { - errorLabel.setText("") saveToCustomLocation.setText(savingText.tr()) saveToCustomLocation.disable() + errorLabel.isVisible = false Concurrency.runOnNonDaemonThreadPool(saveToCustomText) { game.files.saveGameToCustomLocation(gameInfo, gameNameTextField.text, @@ -103,8 +117,7 @@ class SaveGameScreen(val gameInfo: GameInfo) : LoadOrSaveScreen("Current saves") }, { if (it !is PlatformSaverLoader.Cancelled) { - errorLabel.setText("Could not save game to custom location!".tr()) - it.printStackTrace() + handleException(it, "Could not save game to custom location!") } saveToCustomLocation.setText(saveToCustomText.tr()) saveToCustomLocation.enable() @@ -113,15 +126,18 @@ class SaveGameScreen(val gameInfo: GameInfo) : LoadOrSaveScreen("Current saves") } } add(saveToCustomLocation).row() - add(errorLabel).row() } private fun saveGame() { rightSideButton.setText(savingText.tr()) + errorLabel.isVisible = false Concurrency.runOnNonDaemonThreadPool("SaveGame") { game.files.saveGame(gameInfo, gameNameTextField.text) { launchOnGLThread { - if (it != null) ToastPopup("Could not save game!", this@SaveGameScreen) + if (it != null) { + handleException(it, "Could not save game!", game.files.getSave(gameNameTextField.text)) + rightSideButton.setText(saveButtonText.tr()) + } else UncivGame.Current.popScreen() } } diff --git a/core/src/com/unciv/utils/PlatformSpecific.kt b/core/src/com/unciv/utils/PlatformSpecific.kt index 4a60fc3aed..30b69c1fe9 100644 --- a/core/src/com/unciv/utils/PlatformSpecific.kt +++ b/core/src/com/unciv/utils/PlatformSpecific.kt @@ -10,4 +10,7 @@ interface PlatformSpecific { /** If not null, this is the path to the directory in which to store the local files - mods, saves, maps, etc */ var customDataDirectory: String? + + /** If the OS localizes all error messages, this should provide a lookup */ + fun getSystemErrorMessage(errorCode: Int): String? = null } diff --git a/desktop/src/com/unciv/app/desktop/DesktopGame.kt b/desktop/src/com/unciv/app/desktop/DesktopGame.kt index a664e42d5e..5a08e5354e 100644 --- a/desktop/src/com/unciv/app/desktop/DesktopGame.kt +++ b/desktop/src/com/unciv/app/desktop/DesktopGame.kt @@ -2,6 +2,7 @@ package com.unciv.app.desktop import com.badlogic.gdx.Gdx import com.badlogic.gdx.backends.lwjgl3.Lwjgl3ApplicationConfiguration +import com.sun.jna.platform.win32.Kernel32Util import com.unciv.UncivGame class DesktopGame(config: Lwjgl3ApplicationConfiguration, override var customDataDirectory: String?) : UncivGame() { @@ -48,4 +49,14 @@ class DesktopGame(config: Lwjgl3ApplicationConfiguration, override var customDat discordUpdater.stopUpdates() super.dispose() } + + override fun getSystemErrorMessage(errorCode: Int): String? { + return try { + if (System.getProperty("os.name")?.contains("Windows") == true) + Kernel32Util.formatMessage(errorCode) + else null + } catch (_: Throwable) { + null + } + } }