Stores: cloud saves recognise GOG games, wait for a new prefix, never overwrite blindly

On device nothing ran for ELDERBORN: a GOG game without arguments has
no launcher .bat, Steam runs its exe, and droiddeck-store-launch only
looked for the .bat. It now recognises the exe too, looking through the
folders above it for the sidecar, and every path logs a result line.

A first launch has no prefix yet: the download is answered as deferred
and runs once Proton has made the prefix's user folder, within a minute,
without holding the launch.

Uploads are safe now. CloudPlan decides per file against this device's
baseline - the local and cloud state at the last sync: no upload before
a download has completed once, the first download lets the cloud win,
and a file changed on both sides is a conflict that Manage saves offers
to settle with Keep cloud or Keep local. The cloud copy is backed up
before an upload replaces it, as the local folder is before a download.
This commit is contained in:
The412Banner committed 2026-10-09 06:29:36 -04:00
1 parent b323a0b33e
commit 41422407c4
11 files changed
+464 -87

No files matched your search

@@ -0,0 +1,87 @@
package com.droiddeck.launcher.stores
import org.json.JSONObject
/**
* What a cloud sync may do, decided from three views of each save file: the local copy, the cloud
* copy, and the baseline - both as they were when this device last synced the game. Pure, so the
* rules are tested on their own:
*
* - No upload before a download has completed once on this device (no baseline): a first launch
* writes new profile files that must never replace the real saves in the cloud.
* - The first download (no baseline) lets the cloud win where the two differ - the local folder is
* backed up first.
* - A file changed on both sides since the baseline is a conflict: neither side is overwritten; the
* user picks Keep cloud or Keep local in Manage saves.
* - Otherwise the side that changed since the baseline wins; one that changed on neither is left.
*/
object CloudPlan {
/** One file as this device last saw it in sync: the local MD5, and the cloud's MD5 (when known) and time. */
data class Entry(val localMd5: String, val cloudMd5: String?, val cloudModified: Long)
class Local(val md5: String)
class Remote(val md5: String?, val modified: Long)
class Plan(val transfer: List<String>, val conflicts: List<String>, val same: List<String>, val refused: String? = null)
/** The cloud copy differs from what the baseline recorded of it. */
private fun cloudChanged(r: Remote, b: Entry?): Boolean {
if (b == null) return true
if (r.md5 != null && b.cloudMd5 != null) return !r.md5.equals(b.cloudMd5, ignoreCase = true)
// Times only (Epic's listing): the service's own clock, so a couple of seconds of slack.
return r.modified > 0 && b.cloudModified > 0 && kotlin.math.abs(r.modified - b.cloudModified) > 2000
}
private fun localChanged(l: Local, b: Entry?): Boolean = b == null || !l.md5.equals(b.localMd5, ignoreCase = true)
private fun same(l: Local, r: Remote): Boolean = r.md5 != null && r.md5.equals(l.md5, ignoreCase = true)
/** Cloud → device. [baseline] null = this device never synced the game. */
fun down(local: Map<String, Local>, remote: Map<String, Remote>, baseline: Map<String, Entry>?): Plan {
val transfer = ArrayList<String>(); val conflicts = ArrayList<String>(); val same = ArrayList<String>()
for ((name, r) in remote) {
val l = local[name]
val b = baseline?.get(name)
when {
l == null -> transfer += name
same(l, r) -> same += name
baseline == null -> transfer += name
cloudChanged(r, b) && localChanged(l, b) -> conflicts += name
cloudChanged(r, b) -> transfer += name
}
}
return Plan(transfer, conflicts, same)
}
/** Device → cloud. Refused outright without a baseline. */
fun up(local: Map<String, Local>, remote: Map<String, Remote>, baseline: Map<String, Entry>?): Plan {
if (baseline == null) return Plan(emptyList(), emptyList(), emptyList(), refused = "no-baseline")
val transfer = ArrayList<String>(); val conflicts = ArrayList<String>(); val same = ArrayList<String>()
for ((name, l) in local) {
val r = remote[name]
val b = baseline[name]
when {
r == null -> transfer += name
same(l, r) -> same += name
localChanged(l, b) && cloudChanged(r, b) -> conflicts += name
localChanged(l, b) -> transfer += name
}
}
return Plan(transfer, conflicts, same)
}
fun toJson(baseline: Map<String, Entry>): String = JSONObject().apply {
for ((k, e) in baseline) put(k, JSONObject().put("l", e.localMd5).put("c", e.cloudMd5 ?: "").put("t", e.cloudModified))
}.toString()
fun fromJson(text: String?): Map<String, Entry>? {
if (text == null) return null
return runCatching {
val o = JSONObject(text)
o.keys().asSequence().associateWith { k ->
val e = o.getJSONObject(k)
Entry(e.optString("l"), e.optString("c").ifEmpty { null }, e.optLong("t", -1L))
}
}.getOrNull()
}
}
@@ -34,12 +34,18 @@ object CloudSaves {
private const val PARALLEL = 6
/** What a sync did, for the log and the Manage saves row. */
class Result(val ok: Boolean, val files: Int, val bytes: Long, val reason: String) {
val line: String get() = "files=$files bytes=$bytes result=${if (ok) "ok" else reason}"
/**
* What a sync did, for the log and the Manage saves row. [result]: ok, skipped (nothing to do or
* not possible: [reason] says why), deferred (no prefix yet; it runs once Proton makes one),
* failed. [conflicts]: files changed on both sides, left alone.
*/
class Result(val result: String, val files: Int, val bytes: Long, val reason: String, val conflicts: Int = 0) {
val ok: Boolean get() = result != "failed"
val line: String get() = "result=$result files=$files bytes=$bytes reason=$reason" + if (conflicts > 0) " conflicts=$conflicts" else ""
}
/** A game's cloud state for the Manage saves row. */
class Status(val supported: Boolean, val folder: File?, val lastSync: Long, val reason: String)
class Status(val supported: Boolean, val folder: File?, val lastSync: Long, val reason: String, val conflicts: List<String> = emptyList())
private fun game(context: Context, store: Store, id: String): Pair<File, StoreGameSidecar>? =
StoreInstallRoot.gameFolders(context).firstNotNullOfOrNull { f ->
@@ -119,7 +125,7 @@ object CloudSaves {
fun status(context: Context, store: Store, id: String): Status {
val (dir, reason) = saveFolder(context, store, id)
return Status(reason != "no-cloud-saves" && reason != "not-installed", dir, lastSync(context, store, id), reason)
return Status(reason != "no-cloud-saves" && reason != "not-installed", dir, lastSync(context, store, id), reason, conflicts(context, store, id))
}
// ---- transport ------------------------------------------------------------------------------
@@ -157,27 +163,74 @@ object CloudSaves {
// ---- sync -----------------------------------------------------------------------------------
/** Cloud → device where the cloud copy is newer. Blocking. */
fun download(context: Context, store: Store, id: String): Result = run(context, store, id, "down") { dir, cloud -> pull(context, store, id, dir, cloud) }
private fun baseline(context: Context, store: Store, id: String): Map<String, CloudPlan.Entry>? =
CloudPlan.fromJson(prefs(context, store).getString("cloud_baseline_$id", null))
/** Device → cloud where the local copy is newer. Blocking. */
fun upload(context: Context, store: Store, id: String): Result = run(context, store, id, "up") { dir, cloud -> push(dir, cloud) }
private fun saveBaseline(context: Context, store: Store, id: String, b: Map<String, CloudPlan.Entry>) =
prefs(context, store).edit().putString("cloud_baseline_$id", CloudPlan.toJson(b)).apply()
private fun run(context: Context, store: Store, id: String, way: String, body: (File, CloudTransport) -> Result): Result {
/** Files changed on both sides since the last sync, waiting for Keep cloud / Keep local. */
fun conflicts(context: Context, store: Store, id: String): List<String> =
prefs(context, store).getString("cloud_conflicts_$id", "").orEmpty().split('\n').filter { it.isNotEmpty() }
private fun saveConflicts(context: Context, store: Store, id: String, names: Collection<String>) =
prefs(context, store).edit().putString("cloud_conflicts_$id", names.joinToString("\n")).apply()
/** Cloud → device where the cloud copy changed (the first time: wherever it differs). [force]: conflicts too (Keep cloud). Blocking. */
fun download(context: Context, store: Store, id: String, force: Boolean = false): Result =
sync(context, store, id, "down") { dir, cloud -> downWith(context, store, id, dir, cloud, force) }
/** Device → cloud where the local copy changed, once a download has set the baseline. [force]: conflicts too (Keep local). Blocking. */
fun upload(context: Context, store: Store, id: String, force: Boolean = false): Result =
sync(context, store, id, "up") { dir, cloud -> upWith(context, store, id, dir, cloud, force) }
fun keepCloud(context: Context, store: Store, id: String): Result = download(context, store, id, force = true)
fun keepLocal(context: Context, store: Store, id: String): Result = upload(context, store, id, force = true)
/**
* The pre-launch download, or - when Proton has not made the game's prefix yet (a first launch)
* - a deferred one: answered at once, then run in the background as soon as the prefix's user
* folder appears (within [PREFIX_WAIT_MS]), so the launch is not held up for it.
*/
fun downloadOrDefer(context: Context, store: Store, id: String): Result {
val app = context.applicationContext
val g = game(app, store, id) ?: return done(store, id, "down", Result("skipped", 0, 0, "not-installed"))
if (!g.second.cloud) return done(store, id, "down", Result("skipped", 0, 0, "off"))
if (prefix(app, g.first)?.let { CloudSavePaths.profile(it).isDirectory } == true) return download(app, store, id)
Thread({
val end = System.currentTimeMillis() + PREFIX_WAIT_MS
while (System.currentTimeMillis() < end) {
if (prefix(app, g.first)?.let { CloudSavePaths.profile(it).isDirectory } == true) { download(app, store, id); return@Thread }
Thread.sleep(2000)
}
done(store, id, "down", Result("skipped", 0, 0, "no-prefix"))
}, "cloud-deferred").start()
return done(store, id, "down", Result("deferred", 0, 0, "no-prefix"))
}
private const val PREFIX_WAIT_MS = 60_000L
private fun sync(context: Context, store: Store, id: String, way: String, body: (File, CloudTransport) -> Result): Result {
val result = try {
val (folder, sidecar) = game(context, store, id) ?: return done(store, id, way, Result(false, 0, 0, "not-installed"))
if (!sidecar.cloud) return done(store, id, way, Result(true, 0, 0, "off"))
val (dir, reason) = saveFolder(context, store, id)
if (dir == null) Result(reason == "no-cloud-saves", 0, 0, reason)
else {
val cloud = transport(context, store, id, folder) ?: return done(store, id, way, Result(false, 0, 0, "signed-out"))
body(dir, cloud).also { if (it.ok) markSynced(context, store, id) }
val g = game(context, store, id)
when {
g == null -> Result("skipped", 0, 0, "not-installed")
!g.second.cloud -> Result("skipped", 0, 0, "off")
else -> {
val (dir, reason) = saveFolder(context, store, id)
val cloud = if (dir != null) transport(context, store, id, g.first) else null
when {
dir == null -> Result("skipped", 0, 0, reason)
cloud == null -> Result("skipped", 0, 0, "not-signed-in")
else -> body(dir, cloud).also { if (it.result == "ok") markSynced(context, store, id) }
}
}
}
} catch (e: NoCloudSaves) {
prefs(context, store).edit().putString("cloud_template_$id", "").apply()
Result(true, 0, 0, "no-cloud-saves")
Result("skipped", 0, 0, "no-cloud-saves")
} catch (e: Exception) {
Result(false, 0, 0, e.javaClass.simpleName)
Result("failed", 0, 0, e.javaClass.simpleName)
}
return done(store, id, way, result)
}
@@ -192,71 +245,100 @@ object CloudSaves {
if (!dir.isDirectory) emptyMap()
else dir.walkTopDown().filter { it.isFile }.associateBy { it.relativeTo(dir).path.replace(File.separatorChar, '/') }
private fun md5(f: File): String? = runCatching {
val md = MessageDigest.getInstance("MD5")
f.inputStream().use { input -> val buf = ByteArray(8192); while (true) { val n = input.read(buf); if (n < 0) break; md.update(buf, 0, n) } }
md.digest().joinToString("") { "%02x".format(it) }
}.getOrNull()
internal fun md5(f: File): String = md5(f.readBytes())
private fun pull(context: Context, store: Store, id: String, dir: File, cloud: CloudTransport): Result {
val remote = cloud.list()
if (remote.isEmpty()) return Result(true, 0, 0, "empty")
val local = localFiles(dir)
internal fun md5(data: ByteArray): String = MessageDigest.getInstance("MD5").digest(data).joinToString("") { "%02x".format(it) }
/** The cloud listing, with MD5 and time filled in (one HEAD each where the listing lacks them, in parallel). */
private fun remote(cloud: CloudTransport, wanted: (String) -> Boolean): Map<String, CloudFile> {
val files = cloud.list()
val pool = Executors.newFixedThreadPool(PARALLEL, com.droiddeck.launcher.stores.download.DownloadQueue.workerFactory("cloud"))
val wanted = java.util.concurrent.ConcurrentLinkedQueue<CloudFile>()
for (f in remote) pool.execute {
val mine = local[f.name]
if (mine == null) { wanted.add(f); return@execute }
cloud.details(f)
if (f.md5 != null && f.md5.equals(md5(mine), ignoreCase = true)) return@execute
if (f.modifiedMs > mine.lastModified()) wanted.add(f)
}
for (f in files) if (wanted(f.name)) pool.execute { runCatching { cloud.details(f) } }
pool.shutdown(); pool.awaitTermination(2, TimeUnit.MINUTES)
if (wanted.isEmpty()) return Result(true, 0, 0, "up-to-date")
backup(context, store, id, dir)
return files.associateBy { it.name }
}
internal fun downWith(context: Context, store: Store, id: String, dir: File, cloud: CloudTransport, force: Boolean): Result {
val files = localFiles(dir)
val remote = remote(cloud) { it in files }
val local = files.mapValues { CloudPlan.Local(md5(it.value)) }
val base = baseline(context, store, id)
val plan = CloudPlan.down(local, remote.mapValues { CloudPlan.Remote(it.value.md5, it.value.modifiedMs) }, base)
val take = if (force) plan.transfer + plan.conflicts else plan.transfer
if (take.isNotEmpty() && files.isNotEmpty()) backup(context, store, id, dir, "local")
val next = HashMap(base.orEmpty())
var bytes = 0L
for (f in wanted) {
for (name in take) {
val f = remote[name] ?: continue
val (data, modified) = cloud.get(f)
val dest = File(dir, f.name)
val dest = File(dir, name)
if (!dest.canonicalPath.startsWith(dir.canonicalPath + File.separator)) continue
dest.parentFile?.mkdirs()
dest.writeBytes(data)
if (modified > 0) dest.setLastModified(modified)
bytes += data.size
next[name] = CloudPlan.Entry(md5(data), f.md5, if (modified > 0) modified else f.modifiedMs)
}
return Result(true, wanted.size, bytes, "ok")
for (name in plan.same) remote[name]?.let { r -> next[name] = CloudPlan.Entry(local.getValue(name).md5, r.md5, r.modifiedMs) }
// A download that completed sets the baseline even when the cloud was empty: uploads may follow.
saveBaseline(context, store, id, next)
val left = if (force) emptyList() else plan.conflicts
saveConflicts(context, store, id, left)
return Result("ok", take.size, bytes, if (take.isEmpty()) "up-to-date" else "downloaded", left.size)
}
private fun push(dir: File, cloud: CloudTransport): Result {
val local = localFiles(dir)
if (local.isEmpty()) return Result(true, 0, 0, "nothing-local")
val remote = cloud.list().associateBy { it.name }
val pool = Executors.newFixedThreadPool(PARALLEL, com.droiddeck.launcher.stores.download.DownloadQueue.workerFactory("cloud"))
val changed = java.util.concurrent.ConcurrentHashMap<String, File>()
for ((name, file) in local) pool.execute {
val r = remote[name]
if (r == null) { changed[name] = file; return@execute }
cloud.details(r)
if (r.md5 != null && r.md5.equals(md5(file), ignoreCase = true)) {
// Same bytes, touched by the game: line the time up so the next sync skips it cheaply.
if (r.modifiedMs > 0) file.setLastModified(r.modifiedMs)
return@execute
internal fun upWith(context: Context, store: Store, id: String, dir: File, cloud: CloudTransport, force: Boolean): Result {
val base = baseline(context, store, id) ?: return Result("skipped", 0, 0, "no-baseline")
val files = localFiles(dir)
if (files.isEmpty()) return Result("skipped", 0, 0, "nothing-local")
val remote = remote(cloud) { it in files }
val local = files.mapValues { CloudPlan.Local(md5(it.value)) }
val plan = CloudPlan.up(local, remote.mapValues { CloudPlan.Remote(it.value.md5, it.value.modifiedMs) }, base)
val send = if (force) plan.transfer + plan.conflicts else plan.transfer
val next = HashMap(base)
for (name in plan.same) remote[name]?.let { r -> next[name] = CloudPlan.Entry(local.getValue(name).md5, r.md5, r.modifiedMs) }
if (send.isNotEmpty()) {
// The cloud copies about to be replaced are kept first, as a download keeps the local ones.
backupCloud(context, store, id, cloud, send.mapNotNull { remote[it] })
val data = send.associateWith { files.getValue(it).readBytes() }
val stamped = cloud.put(data)
for ((name, t) in stamped) if (t > 0) files[name]?.setLastModified(t)
val after = remote(cloud) { it in data }
for (name in data.keys) {
val r = after[name]
next[name] = CloudPlan.Entry(md5(data.getValue(name)), r?.md5, r?.modifiedMs ?: stamped[name] ?: -1L)
}
if (r.modifiedMs < 0 || file.lastModified() > r.modifiedMs) changed[name] = file
}
pool.shutdown(); pool.awaitTermination(2, TimeUnit.MINUTES)
if (changed.isEmpty()) return Result(true, 0, 0, "up-to-date")
val data = changed.mapValues { it.value.readBytes() }
val stamped = cloud.put(data)
for ((name, t) in stamped) if (t > 0) changed[name]?.setLastModified(t)
return Result(true, stamped.size, data.values.sumOf { it.size.toLong() }, "ok")
saveBaseline(context, store, id, next)
val left = if (force) emptyList() else plan.conflicts
saveConflicts(context, store, id, left)
return Result("ok", send.size, send.sumOf { files.getValue(it).length() }, if (send.isEmpty()) "up-to-date" else "uploaded", left.size)
}
/** The folder as it is, copied before a download changes it; the last [BACKUPS] are kept. */
private fun backup(context: Context, store: Store, id: String, dir: File) {
if (!dir.isDirectory || dir.listFiles().isNullOrEmpty()) return
val root = File(context.filesDir, "stores/cloud-backups/${store.id}-${id.replace(Regex("[^A-Za-z0-9._-]"), "_")}")
runCatching { dir.copyRecursively(File(root, System.currentTimeMillis().toString()), overwrite = true) }
root.listFiles()?.sortedByDescending { it.name }?.drop(BACKUPS)?.forEach { it.deleteRecursively() }
private fun backupRoot(context: Context, store: Store, id: String) =
File(context.filesDir, "stores/cloud-backups/${store.id}-${id.replace(Regex("[^A-Za-z0-9._-]"), "_")}")
/** The local folder as it is, before a download changes it; the last [BACKUPS] of each kind are kept. */
private fun backup(context: Context, store: Store, id: String, dir: File, kind: String) {
val root = backupRoot(context, store, id)
runCatching { dir.copyRecursively(File(root, "$kind-${System.currentTimeMillis()}"), overwrite = true) }
prune(root, kind)
}
private fun backupCloud(context: Context, store: Store, id: String, cloud: CloudTransport, files: List<CloudFile>) {
if (files.isEmpty()) return
val root = backupRoot(context, store, id)
val target = File(root, "cloud-${System.currentTimeMillis()}")
for (f in files) {
val dest = File(target, f.name)
if (!dest.canonicalPath.startsWith(target.canonicalPath + File.separator)) continue
dest.parentFile?.mkdirs()
dest.writeBytes(cloud.get(f).first)
}
prune(root, "cloud")
}
private fun prune(root: File, kind: String) {
root.listFiles { f -> f.name.startsWith("$kind-") }?.sortedByDescending { it.name }?.drop(BACKUPS)?.forEach { it.deleteRecursively() }
}
}
@@ -69,7 +69,7 @@ object StoreLaunchRequests {
val store = Store.byId(request.optString("store"))
val id = request.optString("id")
if (store == null || id.isEmpty()) JSONObject().put("ok", false).put("reason", "no-game")
else CloudSaves.download(app, store, id).let { JSONObject().put("ok", it.ok).put("files", it.files).put("reason", it.reason) }
else CloudSaves.downloadOrDefer(app, store, id).let { JSONObject().put("ok", it.ok).put("result", it.result).put("files", it.files).put("reason", it.reason) }
}
"cloud-up" -> {
val store = Store.byId(request.optString("store"))
@@ -240,14 +240,20 @@ private fun CloudRows(g: Library.SteamGame, open: Boolean) {
!st.supported -> MenuItem(stringResource(R.string.cloud_none), checked = false, enabled = false) {}
else -> {
val last = if (st.lastSync > 0) stringResource(R.string.cloud_last_sync, android.text.format.DateUtils.getRelativeTimeSpanString(st.lastSync).toString()) else stringResource(R.string.cloud_never)
fun sync(up: Boolean) {
fun sync(up: Boolean, force: Boolean = false) {
if (busy != null) return
busy = if (up) "up" else "down"
Thread({
val r = if (up) com.droiddeck.launcher.stores.CloudSaves.upload(context, store, id) else com.droiddeck.launcher.stores.CloudSaves.download(context, store, id)
val r = if (up) com.droiddeck.launcher.stores.CloudSaves.upload(context, store, id, force) else com.droiddeck.launcher.stores.CloudSaves.download(context, store, id, force)
com.droiddeck.launcher.stores.StoresState.post { busy = null; tick++; android.widget.Toast.makeText(context, if (r.ok) context.getString(R.string.cloud_done, r.files) else r.reason, android.widget.Toast.LENGTH_SHORT).show() }
}, "cloud-manual").start()
}
// Files changed on both sides since the last sync: neither is overwritten until the user picks.
if (st.conflicts.isNotEmpty()) {
MenuItem(stringResource(R.string.cloud_conflict), checked = false, enabled = false, detail = stringResource(R.string.cloud_done, st.conflicts.size)) {}
MenuItem(stringResource(R.string.cloud_keep_cloud), checked = false, enabled = busy == null) { sync(up = false, force = true) }
MenuItem(stringResource(R.string.cloud_keep_local), checked = false, enabled = busy == null) { sync(up = true, force = true) }
}
MenuItem(stringResource(R.string.cloud_upload), checked = false, enabled = busy == null, detail = if (busy == "up") stringResource(R.string.cloud_checking) else last) { sync(true) }
MenuItem(stringResource(R.string.cloud_download), checked = false, enabled = busy == null, detail = if (busy == "down") stringResource(R.string.cloud_checking) else null) { sync(false) }
}
+3
View File
@@ -2024,6 +2024,9 @@
<string name="cloud_upload">Upload to cloud</string>
<string name="cloud_download">Download from cloud</string>
<string name="cloud_none">No cloud saves</string>
<string name="cloud_conflict">Cloud conflict</string>
<string name="cloud_keep_cloud">Keep cloud</string>
<string name="cloud_keep_local">Keep local</string>
<string name="cloud_checking">Checking cloud…</string>
<string name="cloud_last_sync">Last sync %1$s</string>
<string name="cloud_never">Not synced yet</string>
@@ -0,0 +1,61 @@
package com.droiddeck.launcher.stores
import com.droiddeck.launcher.stores.CloudPlan.Entry
import com.droiddeck.launcher.stores.CloudPlan.Local
import com.droiddeck.launcher.stores.CloudPlan.Remote
import org.junit.Assert.assertEquals
import org.junit.Assert.assertTrue
import org.junit.Test
/** The rules that keep a cloud sync from replacing real saves. */
class CloudPlanTest {
@Test fun noUploadBeforeADownloadHasSetTheBaseline() {
val plan = CloudPlan.up(mapOf("slot0.sav" to Local("new")), mapOf("slot0.sav" to Remote("real", 1000)), null)
assertEquals("no-baseline", plan.refused)
assertTrue(plan.transfer.isEmpty())
}
@Test fun theFirstDownloadLetsTheCloudWin() {
val plan = CloudPlan.down(
mapOf("slot0.sav" to Local("fresh-profile"), "only-local.cfg" to Local("x")),
mapOf("slot0.sav" to Remote("real", 1000), "slot1.sav" to Remote("other", 1000)),
null,
)
assertEquals(setOf("slot0.sav", "slot1.sav"), plan.transfer.toSet())
assertTrue(plan.conflicts.isEmpty())
}
@Test fun aFileChangedOnBothSidesIsAConflictEitherWay() {
val base = mapOf("slot0.sav" to Entry(localMd5 = "a", cloudMd5 = "a", cloudModified = 1000))
val local = mapOf("slot0.sav" to Local("b"))
val remote = mapOf("slot0.sav" to Remote("c", 5000))
assertEquals(listOf("slot0.sav"), CloudPlan.down(local, remote, base).conflicts)
assertTrue(CloudPlan.down(local, remote, base).transfer.isEmpty())
assertEquals(listOf("slot0.sav"), CloudPlan.up(local, remote, base).conflicts)
assertTrue(CloudPlan.up(local, remote, base).transfer.isEmpty())
}
@Test fun theSideThatChangedWins() {
val base = mapOf("s" to Entry("a", "a", 1000))
// Only local changed: up sends it, down leaves it.
assertEquals(listOf("s"), CloudPlan.up(mapOf("s" to Local("b")), mapOf("s" to Remote("a", 1000)), base).transfer)
assertTrue(CloudPlan.down(mapOf("s" to Local("b")), mapOf("s" to Remote("a", 1000)), base).transfer.isEmpty())
// Only the cloud changed: down takes it, up leaves it.
assertEquals(listOf("s"), CloudPlan.down(mapOf("s" to Local("a")), mapOf("s" to Remote("c", 9000)), base).transfer)
assertTrue(CloudPlan.up(mapOf("s" to Local("a")), mapOf("s" to Remote("c", 9000)), base).transfer.isEmpty())
// The same bytes on both sides: nothing moves.
assertEquals(listOf("s"), CloudPlan.up(mapOf("s" to Local("c")), mapOf("s" to Remote("c", 9000)), base).same)
}
@Test fun timesAloneDecideWhenTheServiceGivesNoHash() {
val base = mapOf("s" to Entry("a", null, 1000))
assertEquals(listOf("s"), CloudPlan.down(mapOf("s" to Local("a")), mapOf("s" to Remote(null, 60_000)), base).transfer)
// Within two seconds of the baseline: the same cloud copy.
assertTrue(CloudPlan.down(mapOf("s" to Local("a")), mapOf("s" to Remote(null, 2500)), base).transfer.isEmpty())
}
@Test fun theBaselineSurvivesItsJson() {
val b = mapOf("a/b.sav" to Entry("l", "c", 5), "x" to Entry("l2", null, -1))
assertEquals(b, CloudPlan.fromJson(CloudPlan.toJson(b)))
}
}
@@ -0,0 +1,74 @@
package com.droiddeck.launcher.stores
import java.io.File
import org.junit.Assert.assertArrayEquals
import org.junit.Assert.assertEquals
import org.junit.Assert.assertTrue
import org.junit.Test
import org.junit.runner.RunWith
import org.robolectric.RobolectricTestRunner
import org.robolectric.RuntimeEnvironment
import org.robolectric.annotation.Config
/** A sync against a cloud held in memory: the baseline gate, conflicts, backups and the no-prefix deferral. */
@RunWith(RobolectricTestRunner::class)
@Config(sdk = [33])
class CloudSavesTest {
private val app get() = RuntimeEnvironment.getApplication()
private class Memory : CloudTransport {
val files = HashMap<String, Pair<ByteArray, Long>>()
val puts = ArrayList<String>()
override fun list() = files.map { (k, v) -> CloudFile(k, v.second, CloudSaves.md5(v.first)) }
override fun get(file: CloudFile) = files.getValue(file.name)
override fun put(files: Map<String, ByteArray>): Map<String, Long> =
files.mapValues { (k, v) -> puts += k; this.files[k] = v to 99_000L; 99_000L }
}
private fun dir() = File(app.filesDir, "saves-${System.nanoTime()}").apply { mkdirs() }
private val id get() = "g${System.nanoTime()}"
@Test fun anUploadBeforeAnyDownloadIsRefused() {
val dir = dir(); File(dir, "slot0.sav").writeText("fresh profile")
val cloud = Memory().apply { files["slot0.sav"] = "real save".toByteArray() to 1000L }
val r = CloudSaves.upWith(app, Store.GOG, id, dir, cloud, force = false)
assertEquals("skipped", r.result); assertEquals("no-baseline", r.reason)
assertArrayEquals("real save".toByteArray(), cloud.files.getValue("slot0.sav").first)
}
@Test fun afterADownloadALocalChangeGoesUpAndTheCloudCopyIsKeptFirst() {
val game = id; val dir = dir()
val cloud = Memory().apply { files["slot0.sav"] = "v1".toByteArray() to 1000L }
assertEquals("ok", CloudSaves.downWith(app, Store.GOG, game, dir, cloud, force = false).result)
assertEquals("v1", File(dir, "slot0.sav").readText())
File(dir, "slot0.sav").writeText("v2 played")
val r = CloudSaves.upWith(app, Store.GOG, game, dir, cloud, force = false)
assertEquals(1, r.files)
assertEquals("v2 played", String(cloud.files.getValue("slot0.sav").first))
val kept = File(app.filesDir, "stores/cloud-backups/gog-$game").listFiles { f -> f.name.startsWith("cloud-") }!!.single()
assertEquals("v1", File(kept, "slot0.sav").readText())
}
@Test fun aFileChangedOnBothSidesIsLeftForTheUser() {
val game = id; val dir = dir()
val cloud = Memory().apply { files["slot0.sav"] = "v1".toByteArray() to 1000L }
CloudSaves.downWith(app, Store.GOG, game, dir, cloud, force = false)
File(dir, "slot0.sav").writeText("local v2")
cloud.files["slot0.sav"] = "cloud v2".toByteArray() to 50_000L
val up = CloudSaves.upWith(app, Store.GOG, game, dir, cloud, force = false)
assertEquals(1, up.conflicts)
assertEquals("cloud v2", String(cloud.files.getValue("slot0.sav").first))
assertEquals(listOf("slot0.sav"), CloudSaves.conflicts(app, Store.GOG, game))
// Keep local resolves it.
CloudSaves.upWith(app, Store.GOG, game, dir, cloud, force = true)
assertEquals("local v2", String(cloud.files.getValue("slot0.sav").first))
assertTrue(CloudSaves.conflicts(app, Store.GOG, game).isEmpty())
}
@Test fun aFirstLaunchWithoutAPrefixIsDeferredNotBlocked() {
val folder = File(StoreInstallRoot.storeDir(StoreInstallRoot.internalRoot(app), Store.GOG), "ELDERBORN").apply { mkdirs() }
File(folder, "ELDERBORN.exe").writeText("MZ")
StoreGameSidecar(Store.GOG, "1207664643", "ELDERBORN", "ELDERBORN.exe").write(folder)
assertEquals("deferred", CloudSaves.downloadOrDefer(app, Store.GOG, "1207664643").result)
}
}
+22 -4
View File
@@ -118,10 +118,25 @@ parked overlay work is on the branch `park/epic-eos-overlay`.
## Cloud saves
GOG and Epic games sync their saves with the store's cloud (`CloudSaves`, after Bannerlator's
managers): before a launch the cloud copy comes down where it is newer, after the game exits what
changed goes up - newest wins, file by file, with an MD5 check so a file the game only touched is
not sent again. Before a download overwrites anything the folder is copied to
`files/stores/cloud-backups/<store>-<id>/` (the last three kept).
managers): before a launch the cloud copy comes down, after the game exits what changed goes up.
`CloudPlan` decides per file against a baseline - each file's local MD5 and cloud MD5/time as of
this device's last sync:
- **No upload before a download has completed once on this device** (no baseline): a first launch
creates new profile files that must never replace the real saves in the cloud.
- **The first download lets the cloud win** where the two differ.
- **A file changed on both sides since the baseline is a conflict:** neither side is overwritten;
Manage saves shows "Cloud conflict" with Keep cloud / Keep local.
- Otherwise the side that changed wins; bytes that match move nothing.
Both sides are backed up before they are replaced: the local folder before a download writes into
it, the cloud copies before an upload replaces them, under
`files/stores/cloud-backups/<store>-<id>/local-*` and `cloud-*` (the last three of each).
A game launched before Proton has made its prefix (a first launch) gets its download deferred: the
request is answered at once (`result=deferred reason=no-prefix`) and the download runs as soon as
the prefix's user folder appears, within a minute. Every path logs
`cloud <store> <id> down|up result=ok|skipped|deferred|failed files= bytes= reason=`.
- **The folder:** GOG's public remote-config gives a location template per game (by its Galaxy client
id), e.g. `<?APPLICATION_DATA_LOCAL_LOW?>/Hyperstrange/ELDERBORN`; Epic's catalog gives
@@ -132,6 +147,9 @@ not sent again. Before a download overwrites anything the folder is copied to
- **The services:** GOG `cloudstorage.gog.com/v1/<user>/<client>` with a token issued to the game's
own client (the client secret is kept at install; the Galaxy token is the fallback); Epic's
savesync data storage with signed read and write links.
- **Which launches:** `droiddeck-store-launch` recognises a store game by the launcher .bat Steam runs
or, for a game without one (most GOG games), by its exe - the folders above it are searched for
the sidecar.
- **When:** every launch runs `droiddeck-store-launch`, which asks the app (`cloud-down`) and waits up
to 15 s; for a store game with cloud saves it returns 10, and the compat tool then waits for
Proton instead of exec'ing it and runs `droiddeck-store-launch --exited` after, which asks for
+7
View File
@@ -150,6 +150,13 @@ without it and Setup says "Stores engine not built").
native start takes the whole set (`cdnBases`; the engine spreads its window across the hosts) and
its `onBytes` - every 250 ms, including files still in flight - drives the bar and the speed, so a
large file no longer moves the bar in one jump.
- **Cloud saves, device round 1 (ELDERBORN, `b323a0b`): nothing ran** - a GOG game without arguments
gets no launcher .bat, Steam runs its exe, and `droiddeck-store-launch` only recognised the .bat.
It now recognises the exe too (searching the folders above it for the sidecar), and every path
logs a result line. A first launch has no prefix yet: the download is deferred until Proton makes
it, without holding the launch. Uploads are now safe: none before a download has set this
device's baseline, a file changed on both sides is a conflict left for Keep cloud / Keep local,
and the cloud copy is backed up before an upload replaces it (`CloudPlan`, tests for each rule).
- **GOG and Epic cloud saves** (ported from Bannerlator's managers, device-proven there in August):
down before every launch where the cloud copy is newer (the compat tool waits up to 15 s), up
after the game exits (the compat tool waits for Proton for such a game, then asks without
@@ -47,20 +47,39 @@ def log(line):
print("droiddeck-store-launch: " + line, file=sys.stderr, flush=True)
MAX_DEPTH = 6
def sidecar_at(folder):
try:
sidecar = json.loads((folder / SIDECAR).read_text())
except (OSError, ValueError):
return None
if not isinstance(sidecar, dict) or not sidecar.get("store") or not sidecar.get("id"):
return None
return sidecar
def find_store_game(argv):
"""The (folder, sidecar) of the store launcher among [argv], or None when this is not one."""
"""
The (folder, sidecar) of a store game among [argv], or None when this is not one. Steam passes
the shortcut's target: the launcher .bat when the game has one (Epic, Amazon, a GOG game with
arguments), else the game's exe itself (most GOG games), which may sit in a subfolder - so the
folders above it are looked through too, up to MAX_DEPTH, for the sidecar.
"""
for raw in argv:
arg = raw.strip().strip('"').strip("'")
if not arg.lower().endswith(LAUNCHER):
arg = raw.strip().strip('"').strip("'").replace("\\", "/")
low = arg.lower()
if not (low.endswith(LAUNCHER) or low.endswith(".exe") or low.endswith(".bat")) or "/" not in arg:
continue
folder = Path(arg.replace("\\", "/")).parent
try:
sidecar = json.loads((folder / SIDECAR).read_text())
except (OSError, ValueError):
continue
if not isinstance(sidecar, dict) or not sidecar.get("store") or not sidecar.get("id"):
continue
return folder, sidecar
folder = Path(arg).parent
for _ in range(MAX_DEPTH):
sidecar = sidecar_at(folder)
if sidecar is not None:
return folder, sidecar
if folder.parent == folder:
break
folder = folder.parent
return None
@@ -275,6 +294,8 @@ def main(argv):
log("epic launch id=%s code=%s reason=%s" % (game, "yes" if got else "no", answer.get("reason", "")))
log("epic prefix=%s %s" % (prefix_id or "?", provision_prefix(compat_data)))
if not cloud_on(sidecar):
if store in CLOUD_STORES:
log("cloud %s %s down result=skipped reason=off" % (store, game))
return 0
if launch_dir:
# The cloud copy where it is newer, before the game reads its saves; bounded, then on regardless.
@@ -282,7 +303,9 @@ def main(argv):
if answer is None:
log("cloud %s %s down result=timeout" % (store, game))
else:
log("cloud %s %s down files=%s result=%s" % (store, game, answer.get("files", 0), answer.get("reason", "")))
log("cloud %s %s down result=%s files=%s reason=%s" % (store, game, answer.get("result", "?"), answer.get("files", 0), answer.get("reason", "")))
else:
log("cloud %s %s down result=skipped reason=no-channel" % (store, game))
return STORE_GAME
+17 -1
View File
@@ -55,7 +55,9 @@ class Detection(unittest.TestCase):
def test_anything_else_is_left_alone(self):
with tempfile.TemporaryDirectory() as root:
folder = game(root)
self.assertIsNone(store.find_store_game([str(folder / 'Metalstorm.exe')]))
# The exe of a store game is one too (a GOG game without a launcher); a stray file is not.
self.assertEqual(folder, store.find_store_game([str(folder / 'Metalstorm.exe')])[0])
self.assertIsNone(store.find_store_game([str(Path(root) / 'elsewhere/readme.txt')]))
(folder / '.droiddeck-store.json').unlink()
self.assertIsNone(store.find_store_game([str(folder / '.droiddeck-launch.bat')]))
@@ -193,6 +195,20 @@ class Browser(unittest.TestCase):
self.assertEqual('https://www.epicgames.com', xdg.where('https://www.epicgames.com/activate?code=SECRET'))
class ExeTarget(unittest.TestCase):
def test_a_game_started_by_its_exe_is_found_from_a_subfolder(self):
# Most GOG games have no launcher .bat: Steam runs the exe, which may sit in a subfolder.
with tempfile.TemporaryDirectory() as root:
folder = game(root, store_id='gog', ident='1207664643')
(folder / 'bin/x64').mkdir(parents=True)
exe = folder / 'bin/x64/ELDERBORN.exe'
exe.write_text('MZ')
found = store.find_store_game(['"%s"' % exe])
self.assertEqual(folder, found[0])
self.assertEqual('gog', found[1]['store'])
self.assertIsNone(store.find_store_game(['/root/Games/Other/x.exe']))
class CloudHooks(unittest.TestCase):
def test_a_store_game_with_cloud_saves_asks_before_and_after(self):
with tempfile.TemporaryDirectory() as root: