Improve save file name and errors handling (#13164)

* Centralize filters for allowed file names

* Improve handling of load/save file errors

* More improvements and reuse of common elements in load/save
This commit is contained in:
SomeTroglodyte 2025-04-08 11:19:46 +02:00 committed by GitHub
parent 269335efa7
commit a26e71b1fa
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
9 changed files with 219 additions and 117 deletions

View File

@ -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. =

View File

@ -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()
}

View File

@ -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(' ')
}
}

View File

@ -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()

View File

@ -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<String> = 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<String, Boolean> {
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<String>, 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() {
}
}
}
}

View File

@ -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<String, Boolean> {
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<DosFileAttributes>()
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<String>, 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()
}
}
}

View File

@ -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()
}
}

View File

@ -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
}

View File

@ -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
}
}
}