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... = Saving... =
Overwrite existing file? = Overwrite existing file? =
Overwrite = Overwrite =
The file is marked read-only. =
It looks like your saved game can't be loaded! = It looks like your saved game can't be loaded! =
If you could copy your game data ("Copy saved game to clipboard" - = If you could copy your game data ("Copy saved game to clipboard" - =
paste into an email to yairm210@hotmail.com) = paste into an email to yairm210@hotmail.com) =
@ -767,6 +768,7 @@ Load from custom location =
Save to custom location = Save to custom location =
Could not save game to custom location! = Could not save game to custom location! =
'[saveFileName]' copied to clipboard! = '[saveFileName]' copied to clipboard! =
Current game copied to clipboard! =
Could not save game to clipboard! = Could not save game to clipboard! =
Download missing mods = Download missing mods =
Missing mods are downloaded successfully. = Missing mods are downloaded successfully. =

View File

@ -130,9 +130,7 @@ open class FileChooser(
result = textField.text result = textField.text
enableOKButton() enableOKButton()
} }
fileNameInput.setTextFieldFilter { _, char -> fileNameInput.textFieldFilter = UncivFiles.fileNameTextFieldFilter()
char != File.separatorChar
}
if (title != null) { if (title != null) {
addGoodSizedLabel(title).colspan(2).center().row() addGoodSizedLabel(title).colspan(2).center().row()
@ -283,7 +281,7 @@ open class FileChooser(
fun getSaveEnable(): Boolean { fun getSaveEnable(): Boolean {
if (currentDir?.exists() != true) return false if (currentDir?.exists() != true) return false
if (allowFolderSelect) return true if (allowFolderSelect) return true
return result?.run { isEmpty() || startsWith(' ') || endsWith(' ') } == false return result != null && UncivFiles.isValidFileName(result!!)
} }
okButton.isEnabled = if (fileNameEnabled) getSaveEnable() else getLoadEnable() 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.Files
import com.badlogic.gdx.Gdx import com.badlogic.gdx.Gdx
import com.badlogic.gdx.files.FileHandle 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.GdxRuntimeException
import com.badlogic.gdx.utils.JsonReader import com.badlogic.gdx.utils.JsonReader
import com.badlogic.gdx.utils.SerializationException import com.badlogic.gdx.utils.SerializationException
@ -443,6 +444,25 @@ class UncivFiles(
return Gzip.zip(json().toJson(game)) 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.Table
import com.badlogic.gdx.scenes.scene2d.ui.TextButton import com.badlogic.gdx.scenes.scene2d.ui.TextButton
import com.unciv.Constants import com.unciv.Constants
import com.unciv.logic.files.UncivFiles
import com.unciv.logic.multiplayer.MultiplayerGame import com.unciv.logic.multiplayer.MultiplayerGame
import com.unciv.logic.multiplayer.storage.MultiplayerAuthException import com.unciv.logic.multiplayer.storage.MultiplayerAuthException
import com.unciv.models.ruleset.RulesetCache import com.unciv.models.ruleset.RulesetCache
@ -290,7 +291,7 @@ class MultiplayerScreen : PickerScreen() {
Popup(this).apply { Popup(this).apply {
val textField = UncivTextField("Game name", selectedGame!!.name) val textField = UncivTextField("Game name", selectedGame!!.name)
// slashes in mp names are interpreted as directory separators, so we don't allow them // 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() add(textField).width(stageToShowOn.width / 2).row()
val saveButton = "Save".toTextButton() 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.Gdx
import com.badlogic.gdx.files.FileHandle 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.Table
import com.badlogic.gdx.scenes.scene2d.ui.TextButton import com.badlogic.gdx.scenes.scene2d.ui.TextButton
import com.badlogic.gdx.utils.SerializationException
import com.unciv.Constants import com.unciv.Constants
import com.unciv.UncivGame
import com.unciv.logic.MissingModsException import com.unciv.logic.MissingModsException
import com.unciv.logic.UncivShowableException import com.unciv.logic.UncivShowableException
import com.unciv.logic.files.PlatformSaverLoader import com.unciv.logic.files.PlatformSaverLoader
import com.unciv.logic.files.UncivFiles 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.ruleset.RulesetCache
import com.unciv.models.translations.tr import com.unciv.models.translations.tr
import com.unciv.ui.components.UncivTooltip.Companion.addTooltip import com.unciv.ui.components.UncivTooltip.Companion.addTooltip
import com.unciv.ui.components.extensions.disable import com.unciv.ui.components.extensions.disable
import com.unciv.ui.components.extensions.enable import com.unciv.ui.components.extensions.enable
import com.unciv.ui.components.extensions.isEnabled 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.extensions.toTextButton
import com.unciv.ui.components.input.KeyCharAndCode import com.unciv.ui.components.input.KeyCharAndCode
import com.unciv.ui.components.input.keyShortcuts 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.Concurrency
import com.unciv.utils.Log import com.unciv.utils.Log
import com.unciv.utils.launchOnGLThread import com.unciv.utils.launchOnGLThread
import java.io.FileNotFoundException import kotlinx.coroutines.CoroutineScope
class LoadGameScreen : LoadOrSaveScreen() { class LoadGameScreen : LoadOrSaveScreen() {
private val copySavedGameToClipboardButton = getCopyExistingSaveToClipboardButton() private val copySavedGameToClipboardButton = getCopyExistingSaveToClipboardButton()
private val errorLabel = "".toLabel(Color.RED)
private val loadMissingModsButton = getLoadMissingModsButton() private val loadMissingModsButton = getLoadMissingModsButton()
private var missingModsToLoad: Iterable<String> = emptyList() 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 loadGame = "Load game"
private const val loadFromCustomLocation = "Load from custom location" private const val loadFromCustomLocation = "Load from custom location"
private const val loadFromClipboard = "Load copied data" private const val loadFromClipboard = "Load copied data"
private const val copyExistingSaveToClipboard = "Copy saved game to clipboard" private const val copyExistingSaveToClipboard = "Copy saved game to clipboard"
internal const val downloadMissingMods = "Download missing mods" 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 { init {
@ -133,7 +78,7 @@ class LoadGameScreen : LoadOrSaveScreen() {
private fun Table.initRightSideTable() { private fun Table.initRightSideTable() {
add(getLoadFromClipboardButton()).row() add(getLoadFromClipboardButton()).row()
addLoadFromCustomLocationButton() addLoadFromCustomLocationButton()
add(errorLabel).width(stage.width / 2).row() add(errorLabel).width(stage.width / 2).center().row()
add(loadMissingModsButton).row() add(loadMissingModsButton).row()
add(deleteSaveButton).row() add(deleteSaveButton).row()
add(copySavedGameToClipboardButton).row() add(copySavedGameToClipboardButton).row()
@ -218,18 +163,7 @@ class LoadGameScreen : LoadOrSaveScreen() {
copyButton.onActivation { copyButton.onActivation {
val file = selectedSave ?: return@onActivation val file = selectedSave ?: return@onActivation
Concurrency.run(copyExistingSaveToClipboard) { Concurrency.run(copyExistingSaveToClipboard) {
try { copySaveToClipboard(file)
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)
}
}
} }
} }
copyButton.disable() copyButton.disable()
@ -239,6 +173,31 @@ class LoadGameScreen : LoadOrSaveScreen() {
return copyButton 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 { private fun getLoadMissingModsButton(): TextButton {
val button = downloadMissingMods.toTextButton() val button = downloadMissingMods.toTextButton()
button.onClick { button.onClick {
@ -249,10 +208,7 @@ class LoadGameScreen : LoadOrSaveScreen() {
} }
private fun handleLoadGameException(ex: Exception, primaryText: String = "Could not load game!") { private fun handleLoadGameException(ex: Exception, primaryText: String = "Could not load game!") {
Log.error("Error while loading game", ex) val isUserFixable = handleException(ex, primaryText)
val (errorText, isUserFixable) = getLoadExceptionMessage(ex, primaryText)
Concurrency.runOnGLThread {
if (!isUserFixable) { if (!isUserFixable) {
val cantLoadGamePopup = Popup(this@LoadGameScreen) val cantLoadGamePopup = Popup(this@LoadGameScreen)
cantLoadGamePopup.addGoodSizedLabel("It looks like your saved game can't be loaded!").row() cantLoadGamePopup.addGoodSizedLabel("It looks like your saved game can't be loaded!").row()
@ -263,21 +219,18 @@ class LoadGameScreen : LoadOrSaveScreen() {
cantLoadGamePopup.open() cantLoadGamePopup.open()
} }
errorLabel.setText(errorText)
errorLabel.isVisible = true
if (ex is MissingModsException) { if (ex is MissingModsException) {
loadMissingModsButton.isVisible = true loadMissingModsButton.isVisible = true
missingModsToLoad = ex.missingMods missingModsToLoad = ex.missingMods
} }
} }
}
private fun loadMissingMods() { private fun loadMissingMods() {
loadMissingModsButton.isEnabled = false loadMissingModsButton.isEnabled = false
descriptionLabel.setText(Constants.loading.tr()) descriptionLabel.setText(Constants.loading.tr())
Concurrency.runOnNonDaemonThreadPool(downloadMissingMods) { Concurrency.runOnNonDaemonThreadPool(downloadMissingMods) {
try { try {
Companion.loadMissingMods(missingModsToLoad, loadMissingMods(missingModsToLoad,
onModDownloaded = { onModDownloaded = {
val labelText = descriptionLabel.text // Surprise - a StringBuilder val labelText = descriptionLabel.text // Surprise - a StringBuilder
labelText.appendLine() labelText.appendLine()
@ -296,7 +249,9 @@ class LoadGameScreen : LoadOrSaveScreen() {
} }
) )
} catch (ex: Exception) { } catch (ex: Exception) {
launchOnGLThread {
handleLoadGameException(ex, "Could not load the missing mods!") handleLoadGameException(ex, "Could not load the missing mods!")
}
} finally { } finally {
launchOnGLThread { launchOnGLThread {
loadMissingModsButton.isEnabled = true loadMissingModsButton.isEnabled = true
@ -305,5 +260,4 @@ class LoadGameScreen : LoadOrSaveScreen() {
} }
} }
} }
} }

View File

@ -1,11 +1,18 @@
package com.unciv.ui.screens.savescreens package com.unciv.ui.screens.savescreens
import com.badlogic.gdx.files.FileHandle 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.CheckBox
import com.badlogic.gdx.scenes.scene2d.ui.Table import com.badlogic.gdx.scenes.scene2d.ui.Table
import com.badlogic.gdx.scenes.scene2d.ui.TextButton 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.Constants
import com.unciv.UncivGame 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.models.translations.tr
import com.unciv.ui.components.UncivTooltip.Companion.addTooltip import com.unciv.ui.components.UncivTooltip.Companion.addTooltip
import com.unciv.ui.components.extensions.UncivDateFormat.formatDate 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.popups.ConfirmPopup
import com.unciv.ui.screens.pickerscreens.PickerScreen import com.unciv.ui.screens.pickerscreens.PickerScreen
import com.unciv.utils.Concurrency import com.unciv.utils.Concurrency
import com.unciv.utils.Log
import com.unciv.utils.launchOnGLThread import com.unciv.utils.launchOnGLThread
import java.io.FileNotFoundException
import java.nio.file.attribute.DosFileAttributes
import java.util.Date import java.util.Date
import kotlin.io.path.Path
import kotlin.io.path.readAttributes
abstract class LoadOrSaveScreen( abstract class LoadOrSaveScreen(
@ -41,6 +53,11 @@ abstract class LoadOrSaveScreen(
protected val rightSideTable = Table() protected val rightSideTable = Table()
protected val deleteSaveButton = "Delete save".toTextButton(skin.get("negative", TextButton.TextButtonStyle::class.java)) protected val deleteSaveButton = "Delete save".toTextButton(skin.get("negative", TextButton.TextButtonStyle::class.java))
protected val showAutosavesCheckbox = CheckBox("Show autosaves".tr(), skin) 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 { init {
savesScrollPane.onChange(::selectExistingSave) savesScrollPane.onChange(::selectExistingSave)
@ -103,6 +120,7 @@ abstract class LoadOrSaveScreen(
} }
private fun selectExistingSave(saveGameFile: FileHandle) { private fun selectExistingSave(saveGameFile: FileHandle) {
errorLabel.isVisible = false
deleteSaveButton.enable() deleteSaveButton.enable()
selectedSave = saveGameFile 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.Gdx
import com.badlogic.gdx.files.FileHandle 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.Table
import com.unciv.UncivGame import com.unciv.UncivGame
import com.unciv.logic.GameInfo 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.UncivTooltip.Companion.addTooltip
import com.unciv.ui.components.extensions.disable import com.unciv.ui.components.extensions.disable
import com.unciv.ui.components.extensions.enable 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.toLabel
import com.unciv.ui.components.extensions.toTextButton import com.unciv.ui.components.extensions.toTextButton
import com.unciv.ui.components.input.KeyCharAndCode 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.ConfirmPopup
import com.unciv.ui.popups.ToastPopup import com.unciv.ui.popups.ToastPopup
import com.unciv.utils.Concurrency import com.unciv.utils.Concurrency
import com.unciv.utils.Log
import com.unciv.utils.launchOnGLThread import com.unciv.utils.launchOnGLThread
class SaveGameScreen(val gameInfo: GameInfo) : LoadOrSaveScreen("Current saves") { class SaveGameScreen(private val gameInfo: GameInfo) : LoadOrSaveScreen("Current saves") {
companion object { private val gameNameTextField = UncivTextField(nameFieldLabelText)
companion object : Helpers {
const val nameFieldLabelText = "Saved game name" const val nameFieldLabelText = "Saved game name"
const val saveButtonText = "Save game"
const val savingText = "Saving..." const val savingText = "Saving..."
const val saveToCustomText = "Save to custom location" const val saveToCustomText = "Save to custom location"
} }
private val gameNameTextField = UncivTextField(nameFieldLabelText)
init { init {
errorLabel.isVisible = false
errorLabel.wrap = true
setDefaultCloseAction() setDefaultCloseAction()
rightSideTable.initRightSideTable() rightSideTable.initRightSideTable()
rightSideButton.setText("Save game".tr()) rightSideButton.setText(saveButtonText.tr())
rightSideButton.onActivation { rightSideButton.onActivation {
if (game.files.getSave(gameNameTextField.text).exists()) if (game.files.getSave(gameNameTextField.text).exists())
doubleClickAction() doubleClickAction()
@ -53,19 +58,22 @@ class SaveGameScreen(val gameInfo: GameInfo) : LoadOrSaveScreen("Current saves")
addGameNameField() addGameNameField()
val copyJsonButton = "Copy to clipboard".toTextButton() val copyJsonButton = "Copy to clipboard".toTextButton()
copyJsonButton.onActivation { copyToClipboardHandler() } copyJsonButton.onActivation(::copyToClipboardHandler)
val ctrlC = KeyCharAndCode.ctrl('c') val ctrlC = KeyCharAndCode.ctrl('c')
copyJsonButton.keyShortcuts.add(ctrlC) copyJsonButton.keyShortcuts.add(ctrlC)
copyJsonButton.addTooltip(ctrlC) copyJsonButton.addTooltip(ctrlC)
add(copyJsonButton).row() add(copyJsonButton).row()
addSaveToCustomLocation() 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(deleteSaveButton).row()
add(showAutosavesCheckbox).row() add(showAutosavesCheckbox).row()
} }
private fun Table.addGameNameField() { 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) val defaultSaveName = "[${gameInfo.currentPlayer}] - [${gameInfo.turns}] turns".tr(hideIcons = true)
gameNameTextField.text = defaultSaveName gameNameTextField.text = defaultSaveName
gameNameTextField.setSelection(0, defaultSaveName.length) gameNameTextField.setSelection(0, defaultSaveName.length)
@ -74,15 +82,22 @@ class SaveGameScreen(val gameInfo: GameInfo) : LoadOrSaveScreen("Current saves")
add(gameNameTextField).width(300f).row() add(gameNameTextField).width(300f).row()
} }
private fun enableSaveButton(text: String) {
rightSideButton.isEnabled = UncivFiles.isValidFileName(text)
}
private fun copyToClipboardHandler() { private fun copyToClipboardHandler() {
Concurrency.run("Copy game to clipboard") { Concurrency.run("Copy game to clipboard") {
// the Gzip rarely leads to ANRs // the Gzip rarely leads to ANRs
try { try {
Gdx.app.clipboard.contents = UncivFiles.gameInfoToString(gameInfo, forceZip = true) Gdx.app.clipboard.contents = UncivFiles.gameInfoToString(gameInfo, forceZip = true)
} catch (ex: Throwable) {
ex.printStackTrace()
launchOnGLThread { 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() { private fun Table.addSaveToCustomLocation() {
val saveToCustomLocation = saveToCustomText.toTextButton() val saveToCustomLocation = saveToCustomText.toTextButton()
val errorLabel = "".toLabel(Color.RED)
saveToCustomLocation.onClick { saveToCustomLocation.onClick {
errorLabel.setText("")
saveToCustomLocation.setText(savingText.tr()) saveToCustomLocation.setText(savingText.tr())
saveToCustomLocation.disable() saveToCustomLocation.disable()
errorLabel.isVisible = false
Concurrency.runOnNonDaemonThreadPool(saveToCustomText) { Concurrency.runOnNonDaemonThreadPool(saveToCustomText) {
game.files.saveGameToCustomLocation(gameInfo, gameNameTextField.text, game.files.saveGameToCustomLocation(gameInfo, gameNameTextField.text,
@ -103,8 +117,7 @@ class SaveGameScreen(val gameInfo: GameInfo) : LoadOrSaveScreen("Current saves")
}, },
{ {
if (it !is PlatformSaverLoader.Cancelled) { if (it !is PlatformSaverLoader.Cancelled) {
errorLabel.setText("Could not save game to custom location!".tr()) handleException(it, "Could not save game to custom location!")
it.printStackTrace()
} }
saveToCustomLocation.setText(saveToCustomText.tr()) saveToCustomLocation.setText(saveToCustomText.tr())
saveToCustomLocation.enable() saveToCustomLocation.enable()
@ -113,15 +126,18 @@ class SaveGameScreen(val gameInfo: GameInfo) : LoadOrSaveScreen("Current saves")
} }
} }
add(saveToCustomLocation).row() add(saveToCustomLocation).row()
add(errorLabel).row()
} }
private fun saveGame() { private fun saveGame() {
rightSideButton.setText(savingText.tr()) rightSideButton.setText(savingText.tr())
errorLabel.isVisible = false
Concurrency.runOnNonDaemonThreadPool("SaveGame") { Concurrency.runOnNonDaemonThreadPool("SaveGame") {
game.files.saveGame(gameInfo, gameNameTextField.text) { game.files.saveGame(gameInfo, gameNameTextField.text) {
launchOnGLThread { 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() 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 */ /** If not null, this is the path to the directory in which to store the local files - mods, saves, maps, etc */
var customDataDirectory: String? 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.Gdx
import com.badlogic.gdx.backends.lwjgl3.Lwjgl3ApplicationConfiguration import com.badlogic.gdx.backends.lwjgl3.Lwjgl3ApplicationConfiguration
import com.sun.jna.platform.win32.Kernel32Util
import com.unciv.UncivGame import com.unciv.UncivGame
class DesktopGame(config: Lwjgl3ApplicationConfiguration, override var customDataDirectory: String?) : UncivGame() { class DesktopGame(config: Lwjgl3ApplicationConfiguration, override var customDataDirectory: String?) : UncivGame() {
@ -48,4 +49,14 @@ class DesktopGame(config: Lwjgl3ApplicationConfiguration, override var customDat
discordUpdater.stopUpdates() discordUpdater.stopUpdates()
super.dispose() 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
}
}
} }