mirror of
https://github.com/Droid-Deck/DroidDeck.git
synced 2026-10-14 19:27:09 -04:00
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:
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) }
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
|
||||
@@ -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:
|
||||
|
||||
Reference in new issue
Block a user