Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
17 commits
Select commit Hold shift + click to select a range
f56fea5
feat: allow disabling of cloud saves per game
xXJSONDeruloXx Feb 21, 2026
4a9511b
fix(cloud-sync): avoid side effects in local-saves-only checks
xXJSONDeruloXx Feb 21, 2026
e11b532
fix(cloud-sync): prevent stale local-saves-only menu state
xXJSONDeruloXx Feb 21, 2026
0969d66
feat: add Steam client sync warning dialog and related strings
xXJSONDeruloXx Feb 22, 2026
ec53063
fix: escape apostrophe in it warning string
xXJSONDeruloXx Feb 22, 2026
91e5ea2
chore: reduce complexity, don't lock force sync out if local only ena…
xXJSONDeruloXx Feb 22, 2026
ff675c3
chore: no need to check for extras
xXJSONDeruloXx Feb 22, 2026
b77eeb1
chore: extract local save only try catchs out from main exit
xXJSONDeruloXx Feb 22, 2026
251d2a9
refactor: simplify handleExitCloudSync
xXJSONDeruloXx Feb 22, 2026
abd8517
Merge remote-tracking branch 'upstream/master' into feat/local-save-only
xXJSONDeruloXx Feb 24, 2026
eac5d74
fix: scope exit cloud sync by source and offline mode
xXJSONDeruloXx Feb 24, 2026
b79fa70
Merge remote-tracking branch 'upstream/master' into pr-596-resolve
xXJSONDeruloXx Mar 1, 2026
58ed285
chore: merge upstream master and resolve conflicts
xXJSONDeruloXx Mar 12, 2026
a20d0fb
Merge upstream/master into feat/local-save-only
xXJSONDeruloXx Mar 16, 2026
c07b48d
Merge remote-tracking branch 'upstream/master' into feat/local-save-only
xXJSONDeruloXx Mar 17, 2026
feaa1f2
fix: address PR review feedback
xXJSONDeruloXx Mar 22, 2026
e0ed9bc
fix: correct manifest driver id
xXJSONDeruloXx Mar 22, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 7 additions & 0 deletions app/src/main/java/app/gamenative/PrefManager.kt
Original file line number Diff line number Diff line change
Expand Up @@ -423,6 +423,13 @@ object PrefManager {
setPref(FORCE_DLC, value)
}

private val LOCAL_SAVES_ONLY = booleanPreferencesKey("local_saves_only")
var localSavesOnly: Boolean
get() = getPref(LOCAL_SAVES_ONLY, false)
set(value) {
setPref(LOCAL_SAVES_ONLY, value)
}

private val STEAM_OFFLINE_MODE = booleanPreferencesKey("steam_offline_mode")
var steamOfflineMode: Boolean
get() = getPref(STEAM_OFFLINE_MODE, false)
Expand Down
69 changes: 42 additions & 27 deletions app/src/main/java/app/gamenative/ui/PluviaMain.kt
Original file line number Diff line number Diff line change
Expand Up @@ -719,6 +719,8 @@ fun PluviaMain(
setLoadingMessage = viewModel::setLoadingDialogMessage,
setMessageDialogState = setMessageDialogState,
onSuccess = viewModel::launchApp,
isOffline = viewModel.isOffline.value,
bootToContainer = state.bootToContainer,
)
}
onDismissClick = {
Expand Down Expand Up @@ -1349,6 +1351,7 @@ fun preLaunchApp(
container.clearSessionMetadata()

val gameSource = ContainerUtils.extractGameSourceFromContainerId(appId)
val isLocalSavesOnly = ContainerUtils.isLocalSavesOnly(context, appId)

// When "Open container" is used we boot to desktop/file manager only — skip executable check
if (!bootToContainer) {
Expand Down Expand Up @@ -1600,20 +1603,24 @@ fun preLaunchApp(
// For GOG Games, sync cloud saves before launch (executable already verified above via GOGService.getLaunchExecutable)
val isGOGGame = gameSource == GameSource.GOG
if (isGOGGame) {
Timber.tag("GOG").i("[Cloud Saves] GOG Game detected for $appId — syncing cloud saves before launch")
if (isLocalSavesOnly) {
Timber.tag("GOG").i("[Cloud Saves] Local saves only enabled for $appId — skipping pre-game cloud sync")
} else {
Timber.tag("GOG").i("[Cloud Saves] GOG Game detected for $appId — syncing cloud saves before launch")

// Sync cloud saves (download latest saves before playing)
Timber.tag("GOG").d("[Cloud Saves] Starting pre-game download sync for $appId")
val syncSuccess = app.gamenative.service.gog.GOGService.syncCloudSaves(
context = context,
appId = appId,
)
// Sync cloud saves (download latest saves before playing)
Timber.tag("GOG").d("[Cloud Saves] Starting pre-game download sync for $appId")
val syncSuccess = app.gamenative.service.gog.GOGService.syncCloudSaves(
context = context,
appId = appId,
)

if (!syncSuccess) {
Timber.tag("GOG").w("[Cloud Saves] Download sync failed for $appId, proceeding with launch anyway")
// Don't block launch on sync failure - log warning and continue
} else {
Timber.tag("GOG").i("[Cloud Saves] Download sync completed successfully for $appId")
if (!syncSuccess) {
Timber.tag("GOG").w("[Cloud Saves] Download sync failed for $appId, proceeding with launch anyway")
// Don't block launch on sync failure - log warning and continue
} else {
Timber.tag("GOG").i("[Cloud Saves] Download sync completed successfully for $appId")
}
}

setLoadingDialogVisible(false)
Expand All @@ -1633,20 +1640,24 @@ fun preLaunchApp(
// For Epic Games, sync cloud saves before launch (executable already verified above via EpicService.getLaunchExecutable)
val isEpicGame = gameSource == GameSource.EPIC
if (isEpicGame) {
// Handle Cloud Saves
Timber.tag("Epic").i("[Cloud Saves] Epic Game detected for $appId — syncing cloud saves before launch")
// Sync cloud saves (download latest saves before playing)
Timber.tag("Epic").d("[Cloud Saves] Starting pre-game download sync for $appId")
val syncSuccess = app.gamenative.service.epic.EpicCloudSavesManager.syncCloudSaves(
context = context,
appId = gameId,
)

if (!syncSuccess) {
Timber.tag("Epic").w("[Cloud Saves] Download sync failed for $appId, proceeding with launch anyway")
// Don't block launch on sync failure - log warning and continue
if (isLocalSavesOnly) {
Timber.tag("Epic").i("[Cloud Saves] Local saves only enabled for $appId — skipping pre-game cloud sync")
} else {
Timber.tag("Epic").i("[Cloud Saves] Download sync completed successfully for $appId")
// Handle Cloud Saves
Timber.tag("Epic").i("[Cloud Saves] Epic Game detected for $appId — syncing cloud saves before launch")
// Sync cloud saves (download latest saves before playing)
Timber.tag("Epic").d("[Cloud Saves] Starting pre-game download sync for $appId")
val syncSuccess = app.gamenative.service.epic.EpicCloudSavesManager.syncCloudSaves(
context = context,
appId = gameId,
)

if (!syncSuccess) {
Timber.tag("Epic").w("[Cloud Saves] Download sync failed for $appId, proceeding with launch anyway")
// Don't block launch on sync failure - log warning and continue
} else {
Timber.tag("Epic").i("[Cloud Saves] Download sync completed successfully for $appId")
}
}

// Delete Ownership Token if exists
Expand All @@ -1658,8 +1669,12 @@ fun preLaunchApp(
return@launch
}

if (skipCloudSync) {
Timber.tag("preLaunchApp").w("Skipping Steam Cloud sync for $appId by user request")
if (skipCloudSync || isLocalSavesOnly) {
if (isLocalSavesOnly) {
Timber.tag("preLaunchApp").i("Local saves only enabled for $appId — skipping Steam Cloud sync")
} else {
Timber.tag("preLaunchApp").w("Skipping Steam Cloud sync for $appId by user request")
}
setLoadingDialogVisible(false)
onSuccess(context, appId)
return@launch
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -320,6 +320,13 @@ fun GeneralTabContent(
state = config.forceDlc,
onCheckedChange = { state.config.value = config.copy(forceDlc = it) },
)
SettingsSwitch(
colors = settingsTileColorsAlt(),
title = { Text(text = stringResource(R.string.local_saves_only)) },
subtitle = { Text(text = stringResource(R.string.local_saves_only_description)) },
state = config.localSavesOnly,
onCheckedChange = { state.config.value = config.copy(localSavesOnly = it) },
)
SettingsSwitch(
colors = settingsTileColorsAlt(),
title = { Text(text = stringResource(R.string.use_legacy_drm)) },
Expand Down
119 changes: 66 additions & 53 deletions app/src/main/java/app/gamenative/ui/model/MainViewModel.kt
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@ import app.gamenative.events.SteamEvent
import app.gamenative.ui.enums.Orientation
import java.util.EnumSet
import app.gamenative.service.SteamService
import app.gamenative.service.epic.EpicCloudSavesManager
import app.gamenative.ui.data.MainState
import app.gamenative.ui.enums.ConnectionState
import app.gamenative.ui.screen.PluviaScreen
Expand Down Expand Up @@ -476,59 +477,7 @@ class MainViewModel @Inject constructor(
val gameId = ContainerUtils.extractGameIdFromContainerId(appId)
Timber.tag("Exit").i("Got game id: $gameId")
SteamService.notifyRunningProcesses()

// Check if this is a GOG or Epic game and sync cloud saves
val gameSource = ContainerUtils.extractGameSourceFromContainerId(appId)
if (gameSource == GameSource.GOG) {
Timber.tag("GOG").i("[Cloud Saves] GOG Game detected for $appId — syncing cloud saves after close")
try {
Timber.tag("GOG").d("[Cloud Saves] Starting post-game upload sync for $appId")
val syncSuccess = app.gamenative.service.gog.GOGService.syncCloudSaves(
context = context,
appId = appId,
preferredAction = "upload",
)
if (syncSuccess) {
Timber.tag("GOG").i("[Cloud Saves] Upload sync completed successfully for $appId")
} else {
Timber.tag("GOG").w("[Cloud Saves] Upload sync failed for $appId")
}
} catch (e: CancellationException) {
throw e
} catch (t: Throwable) {
Timber.tag("GOG").e(t, "[Cloud Saves] Exception during upload sync for $appId")
}
} else if (gameSource == GameSource.EPIC) {
Timber.tag("Epic").i("[Cloud Saves] Epic Game detected for $appId — syncing cloud saves after close")
try {
Timber.tag("Epic").d("[Cloud Saves] Starting post-game upload sync for $gameId")
val syncSuccess = app.gamenative.service.epic.EpicCloudSavesManager.syncCloudSaves(
context = context,
appId = gameId,
preferredAction = "upload",
)
if (syncSuccess) {
Timber.tag("Epic").i("[Cloud Saves] Upload sync completed successfully for $gameId")
} else {
Timber.tag("Epic").w("[Cloud Saves] Upload sync failed for $gameId")
}
} catch (e: CancellationException) {
throw e
} catch (t: Throwable) {
Timber.tag("Epic").e(t, "[Cloud Saves] Exception during upload sync for $gameId")
}
} else {
// For Steam games, sync cloud saves
try {
SteamService.closeApp(context, gameId, isOffline.value) { prefix ->
PathType.from(prefix).toAbsPath(context, gameId, SteamService.userSteamId!!.accountID)
}.await()
} catch (e: CancellationException) {
throw e
} catch (t: Throwable) {
Timber.tag("Steam").e(t, "[Cloud Saves] Exception during close app sync for $gameId")
}
}
handleExitCloudSync(context, appId, gameId)

// Prompt user to save temporary container configuration if one was applied
if (hadTemporaryOverride) {
Expand Down Expand Up @@ -572,6 +521,70 @@ class MainViewModel @Inject constructor(
}
}

private suspend fun handleExitCloudSync(context: Context, appId: String, gameId: Int) {
val gameSource = ContainerUtils.extractGameSourceFromContainerId(appId)
if (ContainerUtils.isLocalSavesOnly(context, appId) || isOffline.value) {
Timber.tag("Exit").i("Local saves only or offline mode enabled for $appId — skipping cloud sync on exit")
return
}

if (gameSource == GameSource.GOG) {
Timber.tag("GOG").i("[Cloud Saves] GOG Game detected for $appId — syncing cloud saves after close")
viewModelScope.launch(Dispatchers.IO) {
try {
Timber.tag("GOG").d("[Cloud Saves] Starting post-game upload sync for $appId")
val syncSuccess = app.gamenative.service.gog.GOGService.syncCloudSaves(
context = context,
appId = appId,
preferredAction = "upload",
)
if (syncSuccess) {
Timber.tag("GOG").i("[Cloud Saves] Upload sync completed successfully for $appId")
} else {
Timber.tag("GOG").w("[Cloud Saves] Upload sync failed for $appId")
}
} catch (e: Exception) {
Timber.tag("GOG").e(e, "[Cloud Saves] Exception during upload sync for $appId")
}
}
return
}

if (gameSource == GameSource.EPIC) {
Timber.tag("Epic").i("[Cloud Saves] Epic Game detected for $appId — syncing cloud saves after close")
viewModelScope.launch(Dispatchers.IO) {
try {
Timber.tag("Epic").d("[Cloud Saves] Starting post-game upload sync for $gameId")
val syncSuccess = EpicCloudSavesManager.syncCloudSaves(
context = context,
appId = gameId,
preferredAction = "upload",
)
if (syncSuccess) {
Timber.tag("Epic").i("[Cloud Saves] Upload sync completed successfully for $gameId")
} else {
Timber.tag("Epic").w("[Cloud Saves] Upload sync failed for $gameId")
}
} catch (e: Exception) {
Timber.tag("Epic").e(e, "[Cloud Saves] Exception during upload sync for $gameId")
}
}
return
}

if (gameSource == GameSource.STEAM) {
try {
SteamService.closeApp(context, gameId, isOffline.value) { prefix ->
PathType.from(prefix).toAbsPath(context, gameId, SteamService.userSteamId!!.accountID)
}.await()
} catch (e: CancellationException) {
throw e
} catch (t: Throwable) {
Timber.tag("Steam").e(t, "[Cloud Saves] Exception during close app sync for $gameId")
}
}
}

fun onWindowMapped(context: Context, window: Window, appId: String) {
viewModelScope.launch {
// Hide the booting splash when a window is mapped
Expand Down
10 changes: 10 additions & 0 deletions app/src/main/java/app/gamenative/utils/ContainerUtils.kt
Original file line number Diff line number Diff line change
Expand Up @@ -112,6 +112,7 @@ object ContainerUtils {
language = PrefManager.containerLanguage,
containerVariant = PrefManager.containerVariant,
forceDlc = PrefManager.forceDlc,
localSavesOnly = PrefManager.localSavesOnly,
steamOfflineMode = PrefManager.steamOfflineMode,
useLegacyDRM = PrefManager.useLegacyDRM,
unpackFiles = PrefManager.unpackFiles,
Expand Down Expand Up @@ -194,6 +195,7 @@ object ContainerUtils {
PrefManager.dinputEnabled = containerData.enableDInput
PrefManager.dinputMapperType = containerData.dinputMapperType.toInt()
PrefManager.forceDlc = containerData.forceDlc
PrefManager.localSavesOnly = containerData.localSavesOnly
PrefManager.steamOfflineMode = containerData.steamOfflineMode
PrefManager.useLegacyDRM = containerData.useLegacyDRM
PrefManager.unpackFiles = containerData.unpackFiles
Expand Down Expand Up @@ -290,6 +292,7 @@ object ContainerUtils {
sdlControllerAPI = container.isSdlControllerAPI,
useSteamInput = useSteamInput,
forceDlc = container.isForceDlc,
localSavesOnly = container.isLocalSavesOnly,
steamOfflineMode = container.isSteamOfflineMode(),
useLegacyDRM = container.isUseLegacyDRM(),
unpackFiles = container.isUnpackFiles(),
Expand Down Expand Up @@ -468,6 +471,7 @@ object ContainerUtils {
container.setExternalDisplayMode(containerData.externalDisplayMode)
container.setExternalDisplaySwap(containerData.externalDisplaySwap)
container.setForceDlc(containerData.forceDlc)
container.setLocalSavesOnly(containerData.localSavesOnly)
container.setSteamOfflineMode(containerData.steamOfflineMode)
container.setUseLegacyDRM(containerData.useLegacyDRM)
container.setUnpackFiles(containerData.unpackFiles)
Expand Down Expand Up @@ -1104,6 +1108,12 @@ object ContainerUtils {
}
}

fun isLocalSavesOnly(context: Context, appId: String): Boolean {
if (!hasContainer(context, appId)) return false
val container = getContainer(context, appId)
return container.isLocalSavesOnly
}

/**
* Resolves the display name for a game from its container ID,
* looking up the appropriate store service.
Expand Down
17 changes: 17 additions & 0 deletions app/src/main/java/com/winlator/container/Container.java
Original file line number Diff line number Diff line change
Expand Up @@ -143,6 +143,8 @@ public enum XrControllerMapping {

private boolean forceDlc = false;

private boolean localSavesOnly = false;

private boolean steamOfflineMode = false;

private boolean useLegacyDRM = false;
Expand Down Expand Up @@ -695,6 +697,9 @@ public void saveData() {
// Force DLC setting
data.put("forceDlc", forceDlc);

// Local saves only setting
data.put("localSavesOnly", localSavesOnly);

// Steam offline mode setting
data.put("steamOfflineMode", steamOfflineMode);

Expand Down Expand Up @@ -888,6 +893,9 @@ public void loadData(JSONObject data) throws JSONException {
case "forceDlc":
this.forceDlc = data.getBoolean(key);
break;
case "localSavesOnly":
this.localSavesOnly = data.getBoolean(key);
break;
case "steamOfflineMode":
this.steamOfflineMode = data.getBoolean(key);
break;
Expand All @@ -905,6 +913,7 @@ public void loadData(JSONObject data) throws JSONException {
break;
}
}

}

public static void checkObsoleteOrMissingProperties(JSONObject data) {
Expand Down Expand Up @@ -966,6 +975,14 @@ public void setForceDlc(boolean forceDlc) {
this.forceDlc = forceDlc;
}

public boolean isLocalSavesOnly() {
return localSavesOnly;
}

public void setLocalSavesOnly(boolean localSavesOnly) {
this.localSavesOnly = localSavesOnly;
}

public boolean isSteamOfflineMode() {
return steamOfflineMode;
}
Expand Down
3 changes: 3 additions & 0 deletions app/src/main/java/com/winlator/container/ContainerData.kt
Original file line number Diff line number Diff line change
Expand Up @@ -85,6 +85,7 @@ data class ContainerData(
/** Preferred game language (Goldberg) **/
val language: String = "english",
val forceDlc: Boolean = false,
val localSavesOnly: Boolean = false,
val steamOfflineMode: Boolean = false,
val useLegacyDRM: Boolean = false,
val unpackFiles: Boolean = false,
Expand Down Expand Up @@ -147,6 +148,7 @@ data class ContainerData(
"useDRI3" to state.useDRI3,
"language" to state.language,
"forceDlc" to state.forceDlc,
"localSavesOnly" to state.localSavesOnly,
"steamOfflineMode" to state.steamOfflineMode,
"useLegacyDRM" to state.useLegacyDRM,
"unpackFiles" to state.unpackFiles,
Expand Down Expand Up @@ -208,6 +210,7 @@ data class ContainerData(
useDRI3 = (savedMap["useDRI3"] as? Boolean) ?: true,
language = (savedMap["language"] as? String) ?: "english",
forceDlc = (savedMap["forceDlc"] as? Boolean) ?: false,
localSavesOnly = (savedMap["localSavesOnly"] as? Boolean) ?: false,
steamOfflineMode = (savedMap["steamOfflineMode"] as? Boolean) ?: false,
useLegacyDRM = (savedMap["useLegacyDRM"] as? Boolean) ?: false,
unpackFiles = (savedMap["unpackFiles"] as? Boolean) ?: false,
Expand Down
Loading
Loading