mirror of
https://github.com/yairm210/Unciv.git
synced 2025-09-12 05:41:27 -04:00
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:
parent
269335efa7
commit
a26e71b1fa
@ -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. =
|
||||||
|
@ -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()
|
||||||
}
|
}
|
||||||
|
@ -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(' ')
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -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()
|
||||||
|
|
||||||
|
@ -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() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
}
|
}
|
||||||
|
@ -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()
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
@ -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()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@ -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
|
||||||
}
|
}
|
||||||
|
@ -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
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
Loading…
x
Reference in New Issue
Block a user