diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml
index a9f186c82..1ed508bb0 100644
--- a/.github/workflows/build.yml
+++ b/.github/workflows/build.yml
@@ -348,6 +348,7 @@ jobs:
done
test -f "$D/usr/local/bin/droiddeck-session"
test -f "$D/usr/local/bin/droiddeck-proton-extra"
+ test -f "$D/usr/local/bin/droiddeck-gog"
- name: Cache the CMake build tree
uses: actions/cache@v4
diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml
index f07b62f30..00dd7845e 100644
--- a/app/src/main/AndroidManifest.xml
+++ b/app/src/main/AndroidManifest.xml
@@ -148,6 +148,10 @@
+
+
diff --git a/app/src/main/java/com/droiddeck/launcher/frontend/AddedGames.kt b/app/src/main/java/com/droiddeck/launcher/frontend/AddedGames.kt
index 949602649..d97935237 100644
--- a/app/src/main/java/com/droiddeck/launcher/frontend/AddedGames.kt
+++ b/app/src/main/java/com/droiddeck/launcher/frontend/AddedGames.kt
@@ -6,6 +6,8 @@ import com.droiddeck.launcher.runtime.LinuxRuntime
import org.json.JSONObject
import com.droiddeck.launcher.session.GameStorage
import com.droiddeck.launcher.session.SessionPrefs
+import com.droiddeck.launcher.store.gog.GogGameInfo
+import com.droiddeck.launcher.store.gog.GogManager
import java.io.File
import java.util.zip.CRC32
@@ -23,6 +25,10 @@ object AddedGames {
val gameId: Long,
val candidates: List,
val steamAppId: Int? = null,
+ /** Launch arguments the game's store gives (a GOG play task's); empty for most games. */
+ val args: String = "",
+ /** The store a game was installed from ("gog"), or null for a folder the user added. */
+ val store: String? = null,
) {
fun folderName(): String = folder.name
}
@@ -69,6 +75,9 @@ object AddedGames {
GameStorage.effective(context)?.let { lib ->
if (path.startsWith("${lib.path}/")) return "$LIBRARY/" + path.removePrefix("${lib.path}/")
}
+ // The runtime's own disk (the store's internal GOG folder) is the session's root.
+ val rootfs = LinuxRuntime.rootDir(context).absolutePath
+ if (path.startsWith("$rootfs/")) return "/" + path.removePrefix("$rootfs/")
return null
}
@@ -90,15 +99,17 @@ object AddedGames {
fun scan(context: Context): List {
val out = ArrayList()
+ val stores = GogManager.bases(context).map { it.host }
val folders = roots(context).map { it.host } + listOfNotNull(
GameStorage.effective(context)?.let { File(it.path) },
GameStorage.effective(context)?.let { File(it.path, "steamapps/common") },
- )
+ ) + stores.filter { it.isDirectory }
+ val storePaths = stores.map { it.absolutePath }.toSet()
for (dir in folders.distinctBy { it.absolutePath }) {
if (!dir.isDirectory) { Log.w(TAG, "$dir is not a folder; skipped"); continue }
val steamInstalls = steamInstallDirs(dir)
for (folder in dir.listFiles { f -> f.isDirectory }?.sortedBy { it.name.lowercase() } ?: emptyList()) {
- if (folder.name.lowercase() in steamInstalls) continue
+ if (folder.name.lowercase() in steamInstalls || folder.absolutePath in storePaths) continue
scanGame(context, folder, out)
}
}
@@ -125,12 +136,19 @@ object AddedGames {
private fun scanGame(context: Context, folder: File, out: MutableList) {
run {
+ // A store download still running (or stopped partway) is not a game yet.
+ if (GogManager.incomplete(folder)) return
+ // A game the store installed says which exe it is, where it starts and with what
+ // arguments. Folders the user added keep the guess: changing their exe would change
+ // their shortcut id, and with it the prefix their saves are in.
+ val task = if (File(folder, GogManager.MARKER).isFile) GogGameInfo.read(folder)?.task else null
val candidates = candidates(folder)
val chosen = SessionPrefs.addedGameExe(context, folder.path).takeIf { it.isNotEmpty() }?.let { File(it) }?.takeIf { it.isFile }
- val exe = chosen ?: candidates.firstOrNull() ?: return
+ val exe = chosen ?: task?.exe ?: candidates.firstOrNull() ?: return
val guestExe = guestPath(context, exe)
if (guestExe == null) { Log.w(TAG, "${folder.name}: the session cannot see ${exe.path}"); return }
- val guestDir = guestPath(context, exe.parentFile ?: folder) ?: return
+ val startDir = if (chosen == null && task != null) task.workingDir else exe.parentFile ?: folder
+ val guestDir = guestPath(context, startDir) ?: return
val name = folder.name
// Keyed by the pre-rename path so shortcut ids, and the prefixes and saves under them, stay put.
val crc = CRC32().apply { update(("\"${guestExe.replaceFirst(Regex("^$LIBRARY/"), "$LEGACY_LIBRARY/")}\"" + name).toByteArray()) }.value
@@ -138,7 +156,8 @@ object AddedGames {
val steamRoot = File(LinuxRuntime.rootDir(context), "root/.local/share/Steam")
val steamId = steamRoute(steamRoot, appId)
out.add(Game(folder, name, exe, guestExe, guestDir, appId,
- steamId?.toLong() ?: ((appId shl 32) or 0x02000000L), candidates, steamId))
+ steamId?.toLong() ?: ((appId shl 32) or 0x02000000L), candidates, steamId,
+ args = if (chosen == null) task?.arguments.orEmpty() else "", store = if (task != null) "gog" else null))
}
}
@@ -161,6 +180,8 @@ object AddedGames {
json.append("{\"name\":").append(quote(g.name)).append(",\"exe\":").append(quote(g.guestExe))
.append(",\"folder\":").append(quote(guestPath(context, g.folder) ?: g.guestDir))
.append(",\"dir\":").append(quote(g.guestDir)).append(",\"appid\":").append(g.appId)
+ if (g.args.isNotEmpty()) json.append(",\"args\":").append(quote(g.args))
+ g.store?.let { json.append(",\"store\":").append(quote(it)) }
// The art, as the session sees it: the app's cache is bound at its own path, a file in
// the game's folder at the folder's guest path.
val art = AddedGameArt.resolve(context, g)
diff --git a/app/src/main/java/com/droiddeck/launcher/runtime/GuestCommand.kt b/app/src/main/java/com/droiddeck/launcher/runtime/GuestCommand.kt
index a5a653c3e..03fab2522 100644
--- a/app/src/main/java/com/droiddeck/launcher/runtime/GuestCommand.kt
+++ b/app/src/main/java/com/droiddeck/launcher/runtime/GuestCommand.kt
@@ -19,10 +19,12 @@ object GuestCommand {
* for the package tools, which refuse any uid but 0. With [logName], everything the command
* says also goes to Download/DroidDeck/tools/.log beside the session logs, so a problem can
* be handed over like a session's. With [linkDir], hard links (which Android denies apps)
- * become symlinks to files proot keeps there (its link2symlink).
+ * become symlinks to files proot keeps there (its link2symlink). [extraBinds] adds library
+ * mounts, and [onProcess] lets a caller stop its command.
*/
fun run(context: Context, argv: List, fakeRoot: Boolean = false, logName: String? = null,
- linkDir: File? = null, onLine: (String) -> Unit): Int {
+ linkDir: File? = null, extraBinds: List = emptyList(),
+ onProcess: ((Process) -> Unit)? = null, onLine: (String) -> Unit): Int {
val log = logName?.let {
try {
File(File(LinuxRuntime.debugLogDir(), SessionPaths.TOOLS_DIR).apply { mkdirs() }, "$it.log").printWriter()
@@ -30,13 +32,14 @@ object GuestCommand {
}
log?.println("== ${java.util.Date()} ${argv.joinToString(" ")}")
try {
- return runLogged(context, argv, fakeRoot, linkDir) { line -> log?.println(line); log?.flush(); onLine(line) }
+ return runLogged(context, argv, fakeRoot, linkDir, extraBinds, onProcess) { line -> log?.println(line); log?.flush(); onLine(line) }
} finally {
log?.close()
}
}
- private fun runLogged(context: Context, argv: List, fakeRoot: Boolean, linkDir: File?, onLine: (String) -> Unit): Int {
+ private fun runLogged(context: Context, argv: List, fakeRoot: Boolean, linkDir: File?, extraBinds: List,
+ onProcess: ((Process) -> Unit)?, onLine: (String) -> Unit): Int {
val root = LinuxRuntime.rootDir(context)
LinuxRuntime.writeAccounts(context)
SessionFiles.stage(context, root)
@@ -47,6 +50,7 @@ object GuestCommand {
if (linkDir != null) cmd.add(1, "--link2symlink")
LinuxRuntime.binds(context, null, runtimeDir, Environment.getExternalStorageDirectory(), null)
.forEach { cmd.add("-b"); cmd.add(it) }
+ extraBinds.forEach { cmd.add("-b"); cmd.add(it) }
cmd += listOf(
"/usr/bin/env", "-i", "HOME=/root", "USER=root", "LANG=C.UTF-8",
"PATH=/usr/local/bin:/usr/bin:/bin", "XDG_RUNTIME_DIR=${LinuxRuntime.GUEST_RUNTIME_DIR}",
@@ -65,6 +69,7 @@ object GuestCommand {
val process = builder.start()
val pid = HostProcess.pidOf(process)
OrphanReaper.keep(pid)
+ onProcess?.invoke(process)
try {
process.inputStream.bufferedReader().useLines { lines -> lines.forEach(onLine) }
return process.waitFor()
diff --git a/app/src/main/java/com/droiddeck/launcher/session/SessionFiles.kt b/app/src/main/java/com/droiddeck/launcher/session/SessionFiles.kt
index 1a0d935cd..bede53ee9 100644
--- a/app/src/main/java/com/droiddeck/launcher/session/SessionFiles.kt
+++ b/app/src/main/java/com/droiddeck/launcher/session/SessionFiles.kt
@@ -53,6 +53,7 @@ object SessionFiles {
"usr/local/bin/droiddeck-steam-shortcuts" to "usr/local/bin/droiddeck-steam-shortcuts",
"usr/local/bin/droiddeck-steam-games" to "usr/local/bin/droiddeck-steam-games",
"usr/local/bin/droiddeck-pad-defaults" to "usr/local/bin/droiddeck-pad-defaults",
+ "usr/local/bin/droiddeck-gog" to "usr/local/bin/droiddeck-gog",
// Flatpak: the bwrap stand-in, the store's helper and setup, and the front end's launcher.
"usr/local/bin/droiddeck-bwrap" to "usr/local/bin/droiddeck-bwrap",
"usr/local/bin/droiddeck-flatpak" to "usr/local/bin/droiddeck-flatpak",
diff --git a/app/src/main/java/com/droiddeck/launcher/store/gog/GogApi.kt b/app/src/main/java/com/droiddeck/launcher/store/gog/GogApi.kt
new file mode 100644
index 000000000..594d861ac
--- /dev/null
+++ b/app/src/main/java/com/droiddeck/launcher/store/gog/GogApi.kt
@@ -0,0 +1,117 @@
+package com.droiddeck.launcher.store.gog
+
+import android.util.Log
+import org.json.JSONObject
+import java.io.File
+import java.net.HttpURLConnection
+import java.net.URL
+
+/**
+ * GOG's web APIs, as Heroic uses them: the signed-in account's games (embed.gog.com, with the
+ * token gogdl keeps) and a game's art (gamesdb.gog.com, public). Downloads are gogdl's.
+ */
+object GogApi {
+ private const val TAG = "GogApi"
+ private const val TIMEOUT_MS = 20_000
+ private const val LIBRARY = "https://embed.gog.com/account/getFilteredProducts?mediaType=1&sortBy=title&page="
+ private const val GAMESDB = "https://gamesdb.gog.com/platforms/gog/external_releases/"
+
+ /** An owned game; [image] is the store's wide tile. Only games with a Windows build are listed. */
+ class Game(val id: String, val title: String, val image: String?)
+
+ class Art(val cover: String?, val header: String?, val hero: String?, val logo: String?)
+
+ /** Every game the account owns that runs on Windows, by title; null when GOG could not be asked. */
+ fun library(token: String): List? {
+ val games = ArrayList()
+ var page = 1
+ var pages = 1
+ while (page <= pages) {
+ val body = get(LIBRARY + page, token) ?: return null
+ val (items, total) = parseLibraryPage(body) ?: return null
+ games += items
+ pages = total.coerceAtMost(100)
+ page++
+ }
+ return games.distinctBy { it.id }.sortedBy { it.title.lowercase() }
+ }
+
+ /** One page: its games and how many pages there are. */
+ internal fun parseLibraryPage(body: String): Pair, Int>? = runCatching {
+ val o = JSONObject(body)
+ val products = o.optJSONArray("products")
+ val games = (0 until (products?.length() ?: 0)).mapNotNull { i ->
+ val p = products!!.optJSONObject(i) ?: return@mapNotNull null
+ val windows = p.optJSONObject("worksOn")?.optBoolean("Windows") ?: false
+ val id = p.opt("id")?.toString()?.takeIf { it.isNotEmpty() && it != "null" } ?: return@mapNotNull null
+ if (!windows) return@mapNotNull null
+ Game(id, p.optString("title").ifEmpty { id }, image(p.optString("image")))
+ }
+ games to o.optInt("totalPages", 1).coerceAtLeast(1)
+ }.getOrNull()
+
+ /** The store's image ids come protocol-relative and without a size; 392 is its wide tile. */
+ internal fun image(raw: String): String? {
+ if (raw.isBlank()) return null
+ val url = if (raw.startsWith("//")) "https:$raw" else raw
+ return if (url.endsWith(".jpg") || url.endsWith(".png")) url else "${url}_392.jpg"
+ }
+
+ /** The game's art from GOG's games database, or null when it has none. */
+ fun art(id: String): Art? = get(GAMESDB + id, null)?.let(::parseArt)
+
+ internal fun parseArt(body: String): Art? = runCatching {
+ val game = JSONObject(body).optJSONObject("game") ?: return@runCatching null
+ fun url(key: String, ext: String) = game.optJSONObject(key)?.optString("url_format")
+ ?.takeIf { it.isNotEmpty() }?.replace("{formatter}", "")?.replace("{ext}", ext)
+ Art(url("vertical_cover", "jpg"), url("horizontal_artwork", "jpg"), url("background", "jpg"), url("logo", "png"))
+ }.getOrNull()
+
+ /**
+ * Puts the game's art into its folder's .art/ under the names the Added Games art reader looks
+ * for first (cover, header, hero, logo), so Steam shows GOG's own art for it. Best effort.
+ */
+ fun saveArt(id: String, dir: File) {
+ val art = art(id) ?: return
+ val target = File(dir, ".art").apply { mkdirs() }
+ listOf("cover.jpg" to art.cover, "header.jpg" to art.header, "hero.jpg" to art.hero, "logo.png" to art.logo)
+ .forEach { (name, url) ->
+ val file = File(target, name)
+ if (url != null && !file.isFile) download(url, file)
+ }
+ }
+
+ private fun download(url: String, file: File) {
+ var c: HttpURLConnection? = null
+ try {
+ c = (URL(url).openConnection() as HttpURLConnection).apply { connectTimeout = TIMEOUT_MS; readTimeout = TIMEOUT_MS }
+ if (c.responseCode / 100 != 2) return
+ val part = File(file.path + ".part")
+ c.inputStream.use { input -> part.outputStream().use { input.copyTo(it) } }
+ part.renameTo(file)
+ } catch (e: Exception) {
+ Log.w(TAG, "$url: $e")
+ } finally {
+ c?.disconnect()
+ }
+ }
+
+ private fun get(url: String, token: String?): String? {
+ var c: HttpURLConnection? = null
+ return try {
+ c = (URL(url).openConnection() as HttpURLConnection).apply {
+ connectTimeout = TIMEOUT_MS
+ readTimeout = TIMEOUT_MS
+ setRequestProperty("Accept", "application/json")
+ if (token != null) setRequestProperty("Authorization", "Bearer $token")
+ }
+ // The URL is logged; the token never is.
+ if (c.responseCode / 100 != 2) { Log.w(TAG, "${url.substringBefore('?')} -> HTTP ${c.responseCode}"); null }
+ else c.inputStream.bufferedReader().use { it.readText() }
+ } catch (e: Exception) {
+ Log.w(TAG, "${url.substringBefore('?')}: ${e.javaClass.simpleName}"); null
+ } finally {
+ c?.disconnect()
+ }
+ }
+}
diff --git a/app/src/main/java/com/droiddeck/launcher/store/gog/GogGameInfo.kt b/app/src/main/java/com/droiddeck/launcher/store/gog/GogGameInfo.kt
new file mode 100644
index 000000000..e5e3990c4
--- /dev/null
+++ b/app/src/main/java/com/droiddeck/launcher/store/gog/GogGameInfo.kt
@@ -0,0 +1,56 @@
+package com.droiddeck.launcher.store.gog
+
+import org.json.JSONObject
+import java.io.File
+
+/**
+ * What an installed GOG game says about itself: goggame-.info in its folder, the file GOG Galaxy
+ * and Heroic launch from. Its primary play task names the exe, the folder to start in and the
+ * arguments, so a store-installed game needs no guessing at which .exe is the game.
+ */
+object GogGameInfo {
+ class Task(val exe: File, val workingDir: File, val arguments: String)
+ class Info(val id: String, val name: String, val task: Task?)
+
+ /** The root game's info in [folder] (DLCs add their own files beside it), or null. */
+ fun read(folder: File): Info? {
+ val files = folder.listFiles { f -> f.isFile && f.name.startsWith("goggame-") && f.name.endsWith(".info") } ?: return null
+ val parsed = files.sortedBy { it.name }.mapNotNull { f -> runCatching { JSONObject(f.readText()) }.getOrNull() }
+ val root = parsed.firstOrNull { it.optString("gameId").let { id -> id.isNotEmpty() && id == it.optString("rootGameId") } }
+ ?: parsed.singleOrNull() ?: return null
+ return parse(folder, root)
+ }
+
+ internal fun parse(folder: File, json: JSONObject): Info? {
+ val id = json.optString("gameId").ifEmpty { return null }
+ val name = json.optString("name").ifEmpty { folder.name }
+ val tasks = json.optJSONArray("playTasks")
+ val files = (0 until (tasks?.length() ?: 0)).mapNotNull { tasks!!.optJSONObject(it) }
+ .filter { it.optString("type") == "FileTask" && it.optString("path").isNotEmpty() }
+ // Galaxy's own choice: the primary game task; else the first game task; else the first at all.
+ val chosen = files.firstOrNull { it.optBoolean("isPrimary") && it.optString("category", "game") == "game" }
+ ?: files.firstOrNull { it.optString("category", "game") == "game" }
+ ?: files.firstOrNull()
+ val task = chosen?.let { t ->
+ val exe = resolve(folder, t.optString("path")) ?: return@let null
+ val dir = t.optString("workingDir").takeIf { it.isNotBlank() }?.let { resolve(folder, it) } ?: folder
+ Task(exe, dir, t.optString("arguments").trim())
+ }
+ return Info(id, name, task)
+ }
+
+ /**
+ * [relative] (a Windows path, backslashes and all) under [base], matched case-insensitively as
+ * Windows would; null when no such file or folder exists.
+ */
+ internal fun resolve(base: File, relative: String): File? {
+ var at = base
+ for (part in relative.replace('\\', '/').split('/').filter { it.isNotEmpty() && it != "." }) {
+ // The name as the folder lists it: a caseless filesystem (an SD card) would accept any
+ // spelling, but the session sees the game through proot, where only the real one works.
+ val names = at.listFiles().orEmpty()
+ at = names.firstOrNull { it.name == part } ?: names.firstOrNull { it.name.equals(part, ignoreCase = true) } ?: return null
+ }
+ return at
+ }
+}
diff --git a/app/src/main/java/com/droiddeck/launcher/store/gog/GogInstallService.kt b/app/src/main/java/com/droiddeck/launcher/store/gog/GogInstallService.kt
new file mode 100644
index 000000000..46f92efc8
--- /dev/null
+++ b/app/src/main/java/com/droiddeck/launcher/store/gog/GogInstallService.kt
@@ -0,0 +1,99 @@
+package com.droiddeck.launcher.store.gog
+
+import android.app.Notification
+import android.app.NotificationChannel
+import android.app.NotificationManager
+import android.app.PendingIntent
+import android.app.Service
+import android.content.Context
+import android.content.Intent
+import android.os.Build
+import android.os.IBinder
+import com.droiddeck.launcher.MainActivity
+import com.droiddeck.launcher.R
+
+/**
+ * Keeps the process in the foreground while a GOG game downloads: games run to tens of gigabytes,
+ * and without a foreground service Android may kill the app (and the download's proot with it) the
+ * moment the user switches away. A killed or stopped download resumes when installed again.
+ */
+class GogInstallService : Service() {
+ /** One download: [kind] is "install" or "update"; [base] a [GogManager.Base] id; [dir] the game's folder for an update. */
+ class Job(val kind: String, val id: String, val title: String, val base: String, val dir: String?)
+
+ override fun onBind(intent: Intent?): IBinder? = null
+
+ override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int {
+ if (intent?.action == ACTION_CANCEL) {
+ GogState.cancel()
+ return START_NOT_STICKY
+ }
+ val job = intent?.let(::jobFrom)
+ startForeground(NOTIFICATION_ID, notification(job?.title ?: "GOG", "Starting…", -1))
+ if (job == null) {
+ stopSelf(startId)
+ return START_NOT_STICKY
+ }
+ Thread({
+ val manager = getSystemService(NotificationManager::class.java)
+ var shown = Int.MIN_VALUE
+ GogState.run(applicationContext, job) { stage, percent ->
+ // Every percent at most once: a notification per line would flood the system.
+ if (percent != shown) {
+ shown = percent
+ manager?.notify(NOTIFICATION_ID, notification(job.title, stage, percent))
+ }
+ }
+ @Suppress("DEPRECATION")
+ stopForeground(true)
+ stopSelf(startId)
+ }, "gog-${job.kind}").start()
+ return START_NOT_STICKY
+ }
+
+ private fun notification(title: String, stage: String, percent: Int): Notification {
+ val manager = getSystemService(NotificationManager::class.java)
+ manager?.createNotificationChannel(NotificationChannel(CHANNEL_ID, "GOG downloads",
+ NotificationManager.IMPORTANCE_LOW).apply {
+ description = "Shows while a GOG game downloads"
+ setShowBadge(false)
+ setSound(null, null)
+ enableVibration(false)
+ })
+ val open = PendingIntent.getActivity(this, 0,
+ Intent(this, MainActivity::class.java).setFlags(Intent.FLAG_ACTIVITY_SINGLE_TOP or Intent.FLAG_ACTIVITY_REORDER_TO_FRONT),
+ PendingIntent.FLAG_IMMUTABLE)
+ val cancel = PendingIntent.getService(this, 1,
+ Intent(this, GogInstallService::class.java).setAction(ACTION_CANCEL), PendingIntent.FLAG_IMMUTABLE)
+ return Notification.Builder(this, CHANNEL_ID)
+ .setSmallIcon(R.drawable.ic_stat_session)
+ .setContentTitle(title)
+ .setContentText(stage)
+ .setProgress(100, percent.coerceIn(0, 100), percent < 0)
+ .setContentIntent(open)
+ .addAction(Notification.Action.Builder(null, "Stop", cancel).build())
+ .setOngoing(true)
+ .setOnlyAlertOnce(true)
+ .setShowWhen(false)
+ .build()
+ }
+
+ companion object {
+ private const val CHANNEL_ID = "gog-download"
+ private const val NOTIFICATION_ID = 4
+ private const val ACTION_CANCEL = "com.droiddeck.launcher.gog.CANCEL"
+
+ fun start(context: Context, job: Job) {
+ val intent = Intent(context, GogInstallService::class.java)
+ .putExtra("kind", job.kind).putExtra("id", job.id).putExtra("title", job.title)
+ .putExtra("base", job.base).putExtra("dir", job.dir)
+ if (Build.VERSION.SDK_INT >= 26) context.startForegroundService(intent) else context.startService(intent)
+ }
+
+ private fun jobFrom(intent: Intent): Job? {
+ val kind = intent.getStringExtra("kind") ?: return null
+ val id = intent.getStringExtra("id") ?: return null
+ return Job(kind, id, intent.getStringExtra("title") ?: id, intent.getStringExtra("base") ?: "internal", intent.getStringExtra("dir"))
+ }
+ }
+}
diff --git a/app/src/main/java/com/droiddeck/launcher/store/gog/GogManager.kt b/app/src/main/java/com/droiddeck/launcher/store/gog/GogManager.kt
new file mode 100644
index 000000000..37bc7b2e5
--- /dev/null
+++ b/app/src/main/java/com/droiddeck/launcher/store/gog/GogManager.kt
@@ -0,0 +1,235 @@
+package com.droiddeck.launcher.store.gog
+
+import android.content.Context
+import android.util.Log
+import com.droiddeck.launcher.core.Downloader
+import com.droiddeck.launcher.core.Hashes
+import com.droiddeck.launcher.runtime.GuestCommand
+import com.droiddeck.launcher.runtime.LinuxRuntime
+import com.droiddeck.launcher.session.GameStorage
+import org.json.JSONObject
+import java.io.File
+
+/**
+ * GOG in the Linux runtime, through Heroic's gogdl (GOG's own download protocol, sign-in tokens,
+ * resumable installs): putting gogdl there, signing in, and installing, updating and removing
+ * games. A game lands in a GOG folder the Added Games scan reads, so the session puts it in Steam
+ * as a shortcut like any other added game - the store only fetches; Steam still launches.
+ *
+ * Each command runs in a proot of its own (GuestCommand), so a download carries on whether or
+ * not a session is up.
+ */
+object GogManager {
+ private const val TAG = "GogManager"
+ /** Heroic's gogdl release the app puts in the runtime: a Python zipapp, so any architecture. */
+ const val GOGDL_VERSION = "1.3.0"
+ private const val GOGDL_URL = "https://github.com/Heroic-Games-Launcher/heroic-gogdl/releases/download/v$GOGDL_VERSION/gogdl_linux_arm64"
+ private const val GOGDL_SHA256 = "c49e1519146523ec94f33e2d21eedcc9a167004d7da218621e6d5fb84a7a0f4c"
+ /** Where gogdl lives in the runtime (droiddeck-gog runs it from there). */
+ private const val GOGDL = "opt/droiddeck/gogdl/gogdl"
+ private const val HELPER = "/usr/local/bin/droiddeck-gog"
+ /** The folder, in each place games can go, that store installs live under. */
+ const val FOLDER = "GOG"
+ /** Written by the helper into a game's folder before its download starts. */
+ const val MARKER = ".droiddeck-gog.json"
+ private const val RESUME = ".gogdl-resume"
+
+ /** One store operation at a time. */
+ @Volatile var busy: String? = null
+ private set
+
+ /** One place games can go: internal (the runtime's own disk) or the SD card's game library. */
+ class Base(val id: String, val label: String, val host: File, val guest: String, val binds: List)
+
+ /** A GOG game in one of the [bases]; [complete] is false for a download that stopped partway. */
+ class Installed(val id: String, val name: String, val dir: File, val base: Base, val complete: Boolean)
+
+ /** The sign-in gogdl keeps (tokens), in the app's private files - never on shared storage. */
+ fun authFile(context: Context) = File(context.filesDir, "gog/auth.json")
+
+ private fun gogdl(context: Context) = File(LinuxRuntime.rootDir(context), GOGDL)
+ private fun stamp(context: Context) = File(LinuxRuntime.rootDir(context), "$GOGDL.version")
+
+ fun ready(context: Context): Boolean =
+ gogdl(context).isFile && runCatching { stamp(context).readText().trim() }.getOrNull() == GOGDL_VERSION
+
+ fun signedIn(context: Context): Boolean = authFile(context).isFile
+
+ fun bases(context: Context): List {
+ val internal = Base("internal", "Internal storage", File(LinuxRuntime.rootDir(context), "root/$FOLDER"), "/root/$FOLDER", emptyList())
+ val sd = GameStorage.effective(context)?.let { lib ->
+ Base("sd", lib.label, File(lib.path, FOLDER), "/mnt/droiddeck-sd/$FOLDER", listOf("${lib.path}:/mnt/droiddeck-sd"))
+ }
+ return listOfNotNull(internal, sd)
+ }
+
+ fun installed(context: Context): List = bases(context).flatMap { base ->
+ base.host.listFiles { f -> f.isDirectory }.orEmpty().mapNotNull { dir -> installedAt(dir, base) }
+ }.sortedBy { it.name.lowercase() }
+
+ private fun installedAt(dir: File, base: Base): Installed? {
+ val marker = runCatching { JSONObject(File(dir, MARKER).readText()) }.getOrNull()
+ val info = GogGameInfo.read(dir)
+ val id = marker?.optString("id")?.ifEmpty { null } ?: info?.id ?: return null
+ val complete = info?.task != null && !File(dir, RESUME).exists()
+ return Installed(id, info?.name ?: dir.name, dir, base, complete)
+ }
+
+ /** True for a folder the store put there and whose download has not finished: not a game yet. */
+ fun incomplete(dir: File): Boolean =
+ File(dir, MARKER).isFile && (File(dir, RESUME).exists() || GogGameInfo.read(dir)?.task == null)
+
+ internal fun exclusive(what: String, block: () -> String?): String? {
+ synchronized(this) {
+ if (busy != null) return "Another GOG task is running"
+ busy = what
+ }
+ try { return block() } finally { busy = null }
+ }
+
+ /** Downloads the pinned gogdl into the runtime and checks it. Null on success, else why not. */
+ fun setup(context: Context, onProgress: (String, Int) -> Unit): String? {
+ if (!LinuxRuntime.isInstalled(context)) return "Install the Linux runtime first"
+ return exclusive("setup") {
+ val target = gogdl(context)
+ val part = File(target.path + ".part")
+ target.parentFile?.mkdirs()
+ onProgress("Downloading gogdl $GOGDL_VERSION", -1)
+ if (!Downloader.downloadFile(GOGDL_URL, part, true) { f -> if (f >= 0) onProgress("Downloading gogdl $GOGDL_VERSION", (f * 100).toInt()) }) {
+ return@exclusive "Could not download gogdl"
+ }
+ if (!Hashes.sha256(part).equals(GOGDL_SHA256, ignoreCase = true)) {
+ part.delete()
+ return@exclusive "gogdl did not match its checksum"
+ }
+ if (!part.renameTo(target)) return@exclusive "Could not put gogdl in place"
+ target.setExecutable(true, false)
+ stamp(context).writeText(GOGDL_VERSION)
+ null
+ }
+ }
+
+ /** Runs the helper. Commands that touch tokens pass no [logName]: their output stays off shared storage. */
+ private fun helper(context: Context, args: List, logName: String?, binds: List = emptyList(),
+ onProcess: ((Process) -> Unit)? = null, onEvent: (JSONObject) -> Unit): Int {
+ val argv = listOf("/usr/bin/python3", HELPER, args[0], authFile(context).path) + args.drop(1)
+ return GuestCommand.run(context, argv, logName = logName, extraBinds = binds, onProcess = onProcess) { line ->
+ val o = runCatching { JSONObject(line) }.getOrNull()
+ if (o != null && o.has("e")) onEvent(o) else if (logName != null) Log.i(TAG, line)
+ }
+ }
+
+ /** Trades the sign-in page's code for gogdl's tokens. Null on success, else why not. */
+ fun login(context: Context, code: String): String? {
+ if (!ready(context)) return "Set up GOG first"
+ authFile(context).parentFile?.mkdirs()
+ return exclusive("login") {
+ var error: String? = null
+ var done = false
+ val status = helper(context, listOf("login", code), logName = null) { o ->
+ when (o.optString("e")) {
+ "done" -> done = true
+ "error" -> error = o.optString("message")
+ }
+ }
+ when {
+ done -> null
+ error != null -> error
+ else -> "Signing in failed (exit $status)"
+ }
+ }
+ }
+
+ fun signOut(context: Context) {
+ authFile(context).delete()
+ token = null
+ }
+
+ private class Token(val value: String, val userId: String, val expires: Long)
+ @Volatile private var token: Token? = null
+
+ /** A current access token for GOG's APIs, refreshed by gogdl when due; null when signed out. */
+ fun accessToken(context: Context): String? {
+ token?.takeIf { it.expires * 1000 > System.currentTimeMillis() + 60_000 }?.let { return it.value }
+ if (!ready(context) || !signedIn(context)) return null
+ var fresh: Token? = null
+ // Not exclusive: a token is wanted while a download runs, and gogdl only rewrites the
+ // file when it refreshes.
+ helper(context, listOf("token"), logName = null) { o ->
+ if (o.optString("e") == "token") fresh = Token(o.optString("access_token"), o.optString("user_id"), o.optLong("expires"))
+ }
+ token = fresh
+ return fresh?.value
+ }
+
+ class Sizes(val download: Long, val disk: Long)
+
+ /** The Windows build's download and installed sizes, or null when it cannot be read. */
+ fun sizes(context: Context, id: String): Sizes? {
+ if (!ready(context)) return null
+ var sizes: Sizes? = null
+ helper(context, listOf("info", id), logName = null) { o ->
+ if (o.optString("e") == "info") sizes = Sizes(o.optLong("download_size"), o.optLong("disk_size"))
+ }
+ return sizes
+ }
+
+ /**
+ * Downloads [id] into [base]; a stopped download picks up where it left off. [onProcess] gets
+ * the running command so the caller can stop it. Null on success, else why not.
+ */
+ fun install(context: Context, id: String, base: Base, onProcess: (Process) -> Unit, onProgress: (String, Int) -> Unit): String? =
+ transfer(context, "install", listOf("install", id, base.guest), base, onProcess, onProgress)
+
+ fun update(context: Context, game: Installed, onProcess: (Process) -> Unit, onProgress: (String, Int) -> Unit): String? {
+ val guest = game.base.guest + "/" + game.dir.name
+ return transfer(context, "update", listOf("update", game.id, guest), game.base, onProcess, onProgress)
+ }
+
+ private fun transfer(context: Context, verb: String, args: List, base: Base,
+ onProcess: (Process) -> Unit, onProgress: (String, Int) -> Unit): String? {
+ if (!ready(context)) return "Set up GOG first"
+ if (!signedIn(context)) return "Sign in to GOG first"
+ return exclusive("$verb:${args[1]}") {
+ base.host.mkdirs()
+ // proot binds the SD library onto this folder, as a session does.
+ if (base.binds.isNotEmpty()) File(LinuxRuntime.rootDir(context), "mnt/droiddeck-sd").mkdirs()
+ var error: String? = null
+ var done = false
+ var stage = if (verb == "update") "Updating" else "Downloading"
+ onProgress("Preparing", -1)
+ val status = helper(context, args, logName = "gog-$verb", binds = base.binds, onProcess = onProcess) { o ->
+ when (o.optString("e")) {
+ "info" -> {
+ stage = "${if (verb == "update") "Updating" else "Downloading"} ${formatSize(o.optLong("download_size"))}"
+ onProgress(stage, 0)
+ }
+ "progress" -> onProgress(stage, o.optInt("percent"))
+ "error" -> error = o.optString("message")
+ "done" -> done = true
+ }
+ }
+ when {
+ done -> null
+ error != null -> error
+ else -> "Stopped (exit $status)"
+ }
+ }
+ }
+
+ /**
+ * Removes the game's files and gogdl's record of them. The game's Proton prefix (its saves and
+ * settings) stays where Steam keeps it, as for any added game taken out of the library.
+ */
+ fun uninstall(context: Context, game: Installed): String? = exclusive("uninstall:${game.id}") {
+ if (!game.dir.deleteRecursively() && game.dir.exists()) return@exclusive "Could not remove ${game.dir.name}"
+ File(LinuxRuntime.rootDir(context), "root/.config/heroic_gogdl/manifests/${game.id}").delete()
+ null
+ }
+
+ fun formatSize(bytes: Long): String = when {
+ bytes >= 1L shl 30 -> "%.1f GB".format(bytes / (1L shl 30).toDouble())
+ bytes >= 1L shl 20 -> "%.0f MB".format(bytes / (1L shl 20).toDouble())
+ else -> "%.0f KB".format(bytes / 1024.0)
+ }
+}
diff --git a/app/src/main/java/com/droiddeck/launcher/store/gog/GogState.kt b/app/src/main/java/com/droiddeck/launcher/store/gog/GogState.kt
new file mode 100644
index 000000000..813f0eddb
--- /dev/null
+++ b/app/src/main/java/com/droiddeck/launcher/store/gog/GogState.kt
@@ -0,0 +1,172 @@
+package com.droiddeck.launcher.store.gog
+
+import android.content.Context
+import android.os.Handler
+import android.os.Looper
+import android.widget.Toast
+import androidx.compose.runtime.getValue
+import androidx.compose.runtime.mutableStateMapOf
+import androidx.compose.runtime.mutableStateOf
+import androidx.compose.runtime.setValue
+
+/**
+ * The GOG store's state for the life of the process, so a download carries on - and its progress
+ * shows - while the user moves around the launcher or plays something meanwhile. Downloads run in
+ * [GogInstallService], which keeps the process in the foreground for as long as they take.
+ */
+object GogState {
+ private val main = Handler(Looper.getMainLooper())
+
+ var ready by mutableStateOf(false)
+ private set
+ var signedIn by mutableStateOf(false)
+ private set
+ /** The account's Windows games; null until loaded. */
+ var library by mutableStateOf?>(null)
+ private set
+ var libraryFailed by mutableStateOf(false)
+ private set
+ var loadingLibrary by mutableStateOf(false)
+ private set
+ var installed by mutableStateOf>(emptyList())
+ private set
+ /** What is running: "setup", "login", or a game id; null when idle. */
+ var busy by mutableStateOf(null)
+ private set
+ var stage by mutableStateOf(null)
+ private set
+ var percent by mutableStateOf(-1)
+ private set
+ /** Download and installed sizes by game id; absent until asked, null while being read. */
+ val sizes = mutableStateMapOf()
+
+ @Volatile private var process: Process? = null
+ @Volatile private var cancelled = false
+
+ fun refresh(context: Context) {
+ val app = context.applicationContext
+ Thread({
+ val r = GogManager.ready(app)
+ val s = GogManager.signedIn(app)
+ val list = GogManager.installed(app)
+ main.post { ready = r; signedIn = s; installed = list }
+ }, "gog-refresh").start()
+ }
+
+ fun loadLibrary(context: Context, force: Boolean = false) {
+ if (loadingLibrary || (!force && library != null)) return
+ val app = context.applicationContext
+ loadingLibrary = true
+ libraryFailed = false
+ Thread({
+ val token = GogManager.accessToken(app)
+ val games = token?.let { GogApi.library(it) }
+ main.post {
+ loadingLibrary = false
+ if (games != null) library = games else libraryFailed = true
+ }
+ }, "gog-library").start()
+ }
+
+ fun loadSizes(context: Context, id: String) {
+ if (sizes.containsKey(id)) return
+ val app = context.applicationContext
+ sizes[id] = null
+ Thread({
+ val s = GogManager.sizes(app, id)
+ main.post { sizes[id] = s; if (s == null) sizes.remove(id) }
+ }, "gog-sizes").start()
+ }
+
+ private fun background(context: Context, what: String, label: String, work: (Context, (String, Int) -> Unit) -> String?) {
+ if (busy != null) return
+ val app = context.applicationContext
+ busy = what; stage = "Starting…"; percent = -1
+ Thread({
+ val problem = try {
+ work(app) { s, p -> main.post { stage = s; percent = p } }
+ } catch (e: Exception) {
+ e.message ?: e.toString()
+ }
+ val r = GogManager.ready(app)
+ val s = GogManager.signedIn(app)
+ val list = GogManager.installed(app)
+ main.post {
+ busy = null; stage = null; percent = -1
+ ready = r; signedIn = s; installed = list
+ if (problem != null) Toast.makeText(app, "$label: $problem", Toast.LENGTH_LONG).show()
+ }
+ }, "gog-$what").start()
+ }
+
+ fun setup(context: Context) = background(context, "setup", "GOG setup") { c, p -> GogManager.setup(c, p) }
+
+ fun login(context: Context, code: String) = background(context, "login", "GOG sign-in") { c, p ->
+ p("Signing in…", -1)
+ GogManager.login(c, code).also { if (it == null) main.post { library = null; loadLibrary(c, force = true) } }
+ }
+
+ fun signOut(context: Context) {
+ GogManager.signOut(context)
+ library = null
+ signedIn = false
+ }
+
+ fun uninstall(context: Context, game: GogManager.Installed) =
+ background(context, game.id, game.name) { c, p -> p("Removing…", -1); GogManager.uninstall(c, game) }
+
+ fun install(context: Context, game: GogApi.Game, base: GogManager.Base) {
+ if (busy != null) return
+ busy = game.id; stage = "Starting…"; percent = -1
+ GogInstallService.start(context, GogInstallService.Job("install", game.id, game.title, base.id, null))
+ }
+
+ fun update(context: Context, game: GogManager.Installed) {
+ if (busy != null) return
+ busy = game.id; stage = "Starting…"; percent = -1
+ GogInstallService.start(context, GogInstallService.Job("update", game.id, game.name, game.base.id, game.dir.path))
+ }
+
+ /** Stops the running download; installing again resumes it. */
+ fun cancel() {
+ cancelled = true
+ process?.destroy()
+ }
+
+ /** The service's half of [install] and [update]; runs on the service's thread. */
+ internal fun run(context: Context, job: GogInstallService.Job, notify: (String, Int) -> Unit) {
+ val app = context.applicationContext
+ cancelled = false
+ main.post { busy = job.id }
+ val base = GogManager.bases(app).firstOrNull { it.id == job.base }
+ val problem = try {
+ when {
+ base == null -> "That storage is not available"
+ job.kind == "update" -> {
+ val game = GogManager.installed(app).firstOrNull { it.dir.path == job.dir }
+ if (game == null) "The game is no longer installed"
+ else GogManager.update(app, game, { process = it }) { s, p -> main.post { stage = s; percent = p }; notify(s, p) }
+ }
+ else -> GogManager.install(app, job.id, base, { process = it }) { s, p -> main.post { stage = s; percent = p }; notify(s, p) }
+ }
+ } catch (e: Exception) {
+ e.message ?: e.toString()
+ } finally {
+ process = null
+ }
+ val list = GogManager.installed(app)
+ // GOG's own art for Steam to show; the Steam store's is the fallback if this finds none.
+ if (problem == null) list.firstOrNull { it.id == job.id }?.let { runCatching { GogApi.saveArt(job.id, it.dir) } }
+ main.post {
+ busy = null; stage = null; percent = -1
+ installed = list
+ val message = when {
+ cancelled -> "${job.title}: stopped. Install again to pick up where it left off."
+ problem != null -> "${job.title}: $problem"
+ job.kind == "update" -> "${job.title} is up to date."
+ else -> "${job.title} is installed. It shows in Steam the next time Steam starts."
+ }
+ Toast.makeText(app, message, Toast.LENGTH_LONG).show()
+ }
+ }
+}
diff --git a/app/src/main/java/com/droiddeck/launcher/ui/GogStorePage.kt b/app/src/main/java/com/droiddeck/launcher/ui/GogStorePage.kt
new file mode 100644
index 000000000..f2aa75938
--- /dev/null
+++ b/app/src/main/java/com/droiddeck/launcher/ui/GogStorePage.kt
@@ -0,0 +1,328 @@
+package com.droiddeck.launcher.ui
+
+import android.annotation.SuppressLint
+import android.graphics.Bitmap
+import android.net.Uri
+import android.view.ViewGroup
+import android.webkit.CookieManager
+import android.webkit.WebResourceRequest
+import android.webkit.WebStorage
+import android.webkit.WebView
+import android.webkit.WebViewClient
+import androidx.activity.compose.BackHandler
+import androidx.compose.foundation.LocalIndication
+import androidx.compose.foundation.background
+import androidx.compose.foundation.border
+import androidx.compose.foundation.clickable
+import androidx.compose.foundation.hoverable
+import androidx.compose.foundation.interaction.MutableInteractionSource
+import androidx.compose.foundation.layout.Arrangement
+import androidx.compose.foundation.layout.Box
+import androidx.compose.foundation.layout.Column
+import androidx.compose.foundation.layout.Row
+import androidx.compose.foundation.layout.Spacer
+import androidx.compose.foundation.layout.aspectRatio
+import androidx.compose.foundation.layout.fillMaxSize
+import androidx.compose.foundation.layout.fillMaxWidth
+import androidx.compose.foundation.layout.height
+import androidx.compose.foundation.layout.padding
+import androidx.compose.foundation.layout.width
+import androidx.compose.material3.LinearProgressIndicator
+import androidx.compose.material3.MaterialTheme
+import androidx.compose.material3.Text
+import androidx.compose.runtime.Composable
+import androidx.compose.runtime.LaunchedEffect
+import androidx.compose.runtime.getValue
+import androidx.compose.runtime.key
+import androidx.compose.runtime.mutableStateOf
+import androidx.compose.runtime.remember
+import androidx.compose.runtime.saveable.rememberSaveable
+import androidx.compose.runtime.setValue
+import androidx.compose.ui.Alignment
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.draw.clip
+import androidx.compose.ui.layout.ContentScale
+import androidx.compose.ui.platform.LocalContext
+import androidx.compose.ui.semantics.Role
+import androidx.compose.ui.text.font.FontWeight
+import androidx.compose.ui.text.style.TextOverflow
+import androidx.compose.ui.unit.dp
+import androidx.compose.ui.unit.sp
+import androidx.compose.ui.viewinterop.AndroidView
+import androidx.compose.ui.window.Dialog
+import androidx.compose.ui.window.DialogProperties
+import coil.compose.AsyncImage
+import com.droiddeck.launcher.session.GameStorage
+import com.droiddeck.launcher.store.gog.GogApi
+import com.droiddeck.launcher.store.gog.GogManager
+import com.droiddeck.launcher.store.gog.GogState
+
+// The Store's GOG side: sign in, the account's Windows games, and installing them into a GOG
+// folder the Added Games scan reads - so each shows in Steam as a non-Steam game, launched by
+// Steam through Proton like any other added game. gogdl (Heroic's) does the downloading.
+
+/** GOG's own sign-in page; it hands back a code on embed.gog.com, which gogdl trades for tokens. */
+private const val GOG_LOGIN = "https://auth.gog.com/auth?client_id=46899977096215655" +
+ "&redirect_uri=https%3A%2F%2Fembed.gog.com%2Fon_login_success%3Forigin%3Dclient&response_type=code&layout=galaxy"
+
+@Composable
+internal fun GogStore(s: FrontEndState) {
+ val ctx = LocalContext.current
+ var open by rememberSaveable { mutableStateOf(null) }
+ var signingIn by remember { mutableStateOf(false) }
+ LaunchedEffect(s.ready) { GogState.refresh(ctx) }
+ BackHandler(enabled = open != null) { open = null }
+ if (signingIn) GogLoginDialog(onCode = { signingIn = false; GogState.login(ctx, it) }, onDismiss = { signingIn = false })
+ if (!s.ready) {
+ Rise(1) { Note("Install the Linux runtime from Setup first; GOG games install into it.") }
+ return
+ }
+ val id = open
+ if (id != null) {
+ GogDetail(id) { open = null }
+ return
+ }
+ GogBusyBar()
+ when {
+ !GogState.ready -> Rise(1) {
+ GogCard(
+ "Set up GOG",
+ "Adds gogdl, the GOG downloader Heroic Games Launcher uses (about 2 MB), to the Linux runtime. " +
+ "Games you own on GOG then install from here and show in Steam's library as non-Steam games.",
+ ) {
+ PrimaryButton(if (GogState.busy == "setup") "Setting up…" else "Set up GOG", enabled = GogState.busy == null, main = true) { GogState.setup(ctx) }
+ }
+ }
+ !GogState.signedIn -> Rise(1) {
+ GogCard("Sign in to GOG", "Sign in with your GOG account to list and install the games you own. The sign-in stays in the app's private storage.") {
+ PrimaryButton(if (GogState.busy == "login") "Signing in…" else "Sign in", enabled = GogState.busy == null, main = true) { signingIn = true }
+ }
+ }
+ else -> GogLibrary(onOpen = { open = it }, onSignOut = {
+ GogState.signOut(ctx)
+ CookieManager.getInstance().removeAllCookies(null)
+ WebStorage.getInstance().deleteAllData()
+ })
+ }
+}
+
+@Composable
+private fun GogCard(title: String, body: String, actions: @Composable () -> Unit) {
+ val colors = MaterialTheme.colorScheme
+ val pal = LocalPalette.current
+ Column(
+ verticalArrangement = Arrangement.spacedBy(12.dp),
+ modifier = Modifier.fillMaxWidth().padding(top = 12.dp).clip(Shape16).background(colors.surface).border(1.dp, pal.line, Shape16).padding(16.dp),
+ ) {
+ Text(title, fontSize = 18.sp, fontWeight = FontWeight.SemiBold, color = colors.onBackground)
+ Text(body, fontSize = 14.sp, color = colors.onSurfaceVariant)
+ Actions { actions() }
+ }
+}
+
+/** The running setup, sign-in or download, whichever page it was started from. */
+@Composable
+private fun GogBusyBar() {
+ val colors = MaterialTheme.colorScheme
+ val busy = GogState.busy ?: return
+ val name = GogState.library?.firstOrNull { it.id == busy }?.title ?: GogState.installed.firstOrNull { it.id == busy }?.name
+ ?: when (busy) { "setup" -> "GOG"; "login" -> "GOG"; else -> busy }
+ Column(modifier = Modifier.fillMaxWidth().padding(top = 10.dp, bottom = 4.dp)) {
+ val stage = GogState.stage
+ Text(
+ "$name · " + (if (stage != null && GogState.percent >= 0) "$stage · ${GogState.percent}%" else stage ?: "Starting…"),
+ fontSize = 12.sp, color = colors.onSurfaceVariant, maxLines = 1, overflow = TextOverflow.Ellipsis,
+ modifier = Modifier.padding(bottom = 6.dp),
+ )
+ if (GogState.percent >= 0) LinearProgressIndicator(progress = { GogState.percent / 100f }, modifier = Modifier.fillMaxWidth().height(4.dp))
+ else LinearProgressIndicator(modifier = Modifier.fillMaxWidth().height(4.dp))
+ }
+}
+
+@Composable
+private fun GogLibrary(onOpen: (String) -> Unit, onSignOut: () -> Unit) {
+ val ctx = LocalContext.current
+ LaunchedEffect(Unit) { GogState.loadLibrary(ctx) }
+ val library = GogState.library
+ val installed = GogState.installed
+ Rise(2) {
+ Actions {
+ SecondaryButton(if (GogState.loadingLibrary) "Refreshing…" else "Refresh", enabled = !GogState.loadingLibrary) { GogState.loadLibrary(ctx, force = true) }
+ SecondaryButton("Sign out", enabled = GogState.busy == null, onClick = onSignOut)
+ }
+ }
+ if (installed.isNotEmpty()) {
+ Rise(3) { SectionTitle("Installed", installed.size.toString()) }
+ Rise(3) {
+ GogGrid(installed.map { i ->
+ Triple(i.id, i.name, library?.firstOrNull { it.id == i.id }?.image)
+ }, first = true, status = { gid -> installed.firstOrNull { it.id == gid }?.let { if (it.complete) "Installed · ${it.base.label}" else "Download stopped" } }, onOpen = onOpen)
+ }
+ Rise(3) { Note("Installed games show in Steam's library as non-Steam games the next time Steam starts.") }
+ }
+ when {
+ library == null && GogState.libraryFailed -> Rise(4) { Note("GOG could not be reached, or the sign-in has expired. Refresh, or sign out and in again.") }
+ library == null -> Rise(4) { SectionTitle("Your games", "loading…") }
+ library.isEmpty() -> Rise(4) { Note("No Windows games on this GOG account yet.") }
+ else -> {
+ Rise(4) { SectionTitle("Your games", library.size.toString()) }
+ Rise(5) {
+ GogGrid(library.map { Triple(it.id, it.title, it.image) }, first = installed.isEmpty(),
+ status = { gid -> installed.firstOrNull { it.id == gid }?.let { if (it.complete) "Installed" else "Download stopped" } }, onOpen = onOpen)
+ }
+ }
+ }
+}
+
+/** Games as tiles with GOG's wide art: three across, or one on a narrow page. */
+@Composable
+private fun GogGrid(games: List>, first: Boolean, status: (String) -> String?, onOpen: (String) -> Unit) {
+ val colors = MaterialTheme.colorScheme
+ val pal = LocalPalette.current
+ val columns = if (LocalNarrowPane.current) 1 else 3
+ Column(verticalArrangement = Arrangement.spacedBy(10.dp), modifier = Modifier.fillMaxWidth().padding(top = 4.dp, bottom = 8.dp)) {
+ games.chunked(columns).forEachIndexed { r, row ->
+ Row(horizontalArrangement = Arrangement.spacedBy(10.dp), modifier = Modifier.fillMaxWidth()) {
+ row.forEachIndexed { i, (id, title, image) ->
+ key(id) {
+ val src = remember { MutableInteractionSource() }
+ val hot = rememberHot(src)
+ Column(
+ modifier = Modifier.weight(1f).paneItem("tile:gog:$id").then(if (first && r == 0 && i == 0) Modifier.firstTile() else Modifier)
+ .clip(Shape14).background(if (hot) pal.signal.copy(alpha = 0.10f) else colors.surface)
+ .glideBorder(hot, Shape14, pal.signal, pal.line)
+ .hoverable(src).clickable(interactionSource = src, indication = LocalIndication.current, role = Role.Button) { onOpen(id) },
+ ) {
+ AsyncImage(
+ model = image, contentDescription = null, contentScale = ContentScale.Crop,
+ modifier = Modifier.fillMaxWidth().aspectRatio(16f / 9f).background(colors.surfaceVariant),
+ )
+ Column(modifier = Modifier.padding(horizontal = 12.dp, vertical = 8.dp)) {
+ Text(title, fontSize = 15.sp, fontWeight = FontWeight.SemiBold, color = colors.onBackground, maxLines = 1, overflow = TextOverflow.Ellipsis)
+ val st = status(id)
+ Text(st ?: "GOG", fontSize = 13.sp, color = if (st != null) pal.good else colors.onSurfaceVariant, maxLines = 1, overflow = TextOverflow.Ellipsis)
+ }
+ }
+ }
+ }
+ repeat(columns - row.size) { Spacer(Modifier.weight(1f)) }
+ }
+ }
+ }
+}
+
+@Composable
+private fun GogDetail(id: String, onBack: () -> Unit) {
+ val ctx = LocalContext.current
+ val colors = MaterialTheme.colorScheme
+ val narrow = LocalNarrowPane.current
+ val game = GogState.library?.firstOrNull { it.id == id }
+ val local = GogState.installed.firstOrNull { it.id == id }
+ val title = game?.title ?: local?.name ?: id
+ val bases = remember { GogManager.bases(ctx) }
+ var baseId by rememberSaveable(id) { mutableStateOf(bases.first().id) }
+ LaunchedEffect(id) { GogState.loadSizes(ctx, id) }
+ val sizes = GogState.sizes[id]
+ val busyHere = GogState.busy == id
+ // Uninstall asks twice: one stray press of A should not cost a download.
+ var confirmRemove by remember(id) { mutableStateOf(false) }
+ LaunchedEffect(confirmRemove) { if (confirmRemove) { kotlinx.coroutines.delay(4000); confirmRemove = false } }
+ Rise(0) { BackLink("GOG", onClick = onBack) }
+ Rise(1) {
+ Row(verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(16.dp), modifier = Modifier.padding(top = 12.dp, bottom = 8.dp)) {
+ AsyncImage(
+ model = game?.image, contentDescription = null, contentScale = ContentScale.Crop,
+ modifier = Modifier.width(if (narrow) 140.dp else 220.dp).aspectRatio(16f / 9f).clip(Shape12).background(colors.surfaceVariant),
+ )
+ Column(modifier = Modifier.weight(1f)) {
+ Text(title, fontSize = if (narrow) 22.sp else 26.sp, fontWeight = FontWeight.Bold, color = colors.onBackground, maxLines = 2, overflow = TextOverflow.Ellipsis)
+ Text(
+ when {
+ local?.complete == true -> "Installed on ${local.base.label}"
+ local != null -> "Download stopped on ${local.base.label}"
+ else -> "Windows · runs through Proton in Steam"
+ },
+ fontSize = 14.sp, color = colors.onSurfaceVariant,
+ )
+ }
+ }
+ }
+ if (local == null && bases.size > 1) {
+ Rise(2) { SectionTitle("Install to", null) }
+ Rise(2) {
+ Actions {
+ bases.forEach { b ->
+ val label = "${b.label} · ${GameStorage.free(b.host.parentFile ?: b.host)} free"
+ if (b.id == baseId) PrimaryButton(label, compact = true) {} else SecondaryButton(label, compact = true) { baseId = b.id }
+ }
+ }
+ }
+ }
+ Rise(3) {
+ Actions {
+ when {
+ busyHere -> SecondaryButton("Stop") { GogState.cancel() }
+ local == null || !local.complete -> PrimaryButton(if (local == null) "Install" else "Resume download", main = true, enabled = GogState.busy == null && game != null) {
+ val base = local?.base ?: bases.first { it.id == baseId }
+ game?.let { GogState.install(ctx, it, base) }
+ }
+ else -> SecondaryButton("Check for update", enabled = GogState.busy == null) { GogState.update(ctx, local) }
+ }
+ if (local != null && !busyHere) {
+ SecondaryButton(if (confirmRemove) "Press again to uninstall" else "Uninstall", enabled = GogState.busy == null) {
+ if (confirmRemove) { confirmRemove = false; GogState.uninstall(ctx, local) } else confirmRemove = true
+ }
+ }
+ sizes?.let {
+ ActionChip("${GogManager.formatSize(it.download)} download", ok = false)
+ ActionChip("${GogManager.formatSize(it.disk)} installed", ok = false)
+ }
+ if (local?.complete == true) ActionChip("● Installed", ok = true)
+ }
+ }
+ if (busyHere) GogBusyBar()
+ Rise(4) {
+ Box(Modifier.padding(top = 12.dp)) {
+ Note(
+ if (local?.complete == true) "It is in Steam's library as a non-Steam game (after Steam next starts). Uninstalling removes the game's files; " +
+ "its saves and settings stay in its Proton prefix."
+ else "Installs the Windows build. Once it finishes, the game shows in Steam's library as a non-Steam game the next time Steam starts, " +
+ "and runs through Proton like the rest of your added games.",
+ )
+ }
+ }
+}
+
+/** A sign-in that ends on GOG's success page hands its code to [onCode]; GOG's page does the rest. */
+@SuppressLint("SetJavaScriptEnabled")
+@Composable
+private fun GogLoginDialog(onCode: (String) -> Unit, onDismiss: () -> Unit) {
+ var done by remember { mutableStateOf(false) }
+ fun check(url: String?): Boolean {
+ val uri = url?.let(Uri::parse) ?: return false
+ if (uri.host != "embed.gog.com" || uri.path?.startsWith("/on_login_success") != true) return false
+ val code = uri.getQueryParameter("code") ?: return false
+ if (!done) { done = true; onCode(code) }
+ return true
+ }
+ Dialog(onDismissRequest = onDismiss, properties = DialogProperties(usePlatformDefaultWidth = false)) {
+ AndroidView(
+ modifier = Modifier.fillMaxSize(),
+ factory = { c ->
+ WebView(c).apply {
+ layoutParams = ViewGroup.LayoutParams(ViewGroup.LayoutParams.MATCH_PARENT, ViewGroup.LayoutParams.MATCH_PARENT)
+ settings.javaScriptEnabled = true
+ settings.domStorageEnabled = true
+ webViewClient = object : WebViewClient() {
+ override fun shouldOverrideUrlLoading(view: WebView, request: WebResourceRequest): Boolean = check(request.url.toString())
+ override fun onPageStarted(view: WebView, url: String?, favicon: Bitmap?) {
+ if (check(url)) view.stopLoading()
+ }
+ }
+ loadUrl(GOG_LOGIN)
+ }
+ },
+ onRelease = { it.destroy() },
+ )
+ }
+}
diff --git a/app/src/main/java/com/droiddeck/launcher/ui/StorePage.kt b/app/src/main/java/com/droiddeck/launcher/ui/StorePage.kt
index d8ea15892..309c3dd6b 100644
--- a/app/src/main/java/com/droiddeck/launcher/ui/StorePage.kt
+++ b/app/src/main/java/com/droiddeck/launcher/ui/StorePage.kt
@@ -78,6 +78,8 @@ internal fun StorePage(s: FrontEndState, a: FrontEndActions, modifier: Modifier)
var openApp by rememberSaveable { mutableStateOf(null) }
var query by rememberSaveable { mutableStateOf("") }
var category by rememberSaveable { mutableStateOf(null) }
+ // Which store the page shows: Flathub's apps, or the user's GOG games.
+ var store by rememberSaveable { mutableStateOf("flathub") }
LaunchedEffect(s.ready) { StoreState.refresh(ctx) }
LaunchedEffect(Unit) { StoreState.loadSections() }
// A pad's focus sits on something the page is about to replace - the tile that opens an app,
@@ -105,8 +107,8 @@ internal fun StorePage(s: FrontEndState, a: FrontEndActions, modifier: Modifier)
}
Column(
modifier = modifier.verticalScroll(scroll).padding(horizontal = padH, vertical = padV)
- .bumpers(onPrevious = { if (openApp == null) switchTab((tab + TABS.size - 1) % TABS.size) },
- onNext = { if (openApp == null) switchTab((tab + 1) % TABS.size) }),
+ .bumpers(onPrevious = { if (openApp == null && store == "flathub") switchTab((tab + TABS.size - 1) % TABS.size) },
+ onNext = { if (openApp == null && store == "flathub") switchTab((tab + 1) % TABS.size) }),
) {
val id = openApp
if (id != null) {
@@ -115,10 +117,25 @@ internal fun StorePage(s: FrontEndState, a: FrontEndActions, modifier: Modifier)
}
Rise(0) {
PageHeader(stringResource(R.string.store_title)) {
- Chip(stringResource(R.string.store_chip_flathub), ok = false)
- if (StoreState.ready) Chip(stringResource(R.string.store_chip_ready), ok = true)
+ if (store == "gog") {
+ Chip("GOG", ok = false)
+ if (com.droiddeck.launcher.store.gog.GogState.signedIn) Chip("Signed in", ok = true)
+ } else {
+ Chip(stringResource(R.string.store_chip_flathub), ok = false)
+ if (StoreState.ready) Chip(stringResource(R.string.store_chip_ready), ok = true)
+ }
}
}
+ Rise(0) {
+ Row(horizontalArrangement = Arrangement.spacedBy(8.dp), modifier = Modifier.padding(top = 10.dp)) {
+ PillButton("Flathub", selected = store == "flathub") { store = "flathub" }
+ PillButton("GOG", selected = store == "gog") { store = "gog" }
+ }
+ }
+ if (store == "gog") {
+ GogStore(s)
+ return@Column
+ }
if (!s.ready) {
Rise(1) { Note(stringResource(R.string.store_needs_runtime)) }
return@Column
diff --git a/app/src/test/java/com/droiddeck/launcher/store/gog/GogTest.kt b/app/src/test/java/com/droiddeck/launcher/store/gog/GogTest.kt
new file mode 100644
index 000000000..53566a67a
--- /dev/null
+++ b/app/src/test/java/com/droiddeck/launcher/store/gog/GogTest.kt
@@ -0,0 +1,109 @@
+package com.droiddeck.launcher.store.gog
+
+import org.json.JSONObject
+import org.junit.Assert.assertEquals
+import org.junit.Assert.assertNull
+import org.junit.Assert.assertTrue
+import org.junit.Rule
+import org.junit.Test
+import org.junit.rules.TemporaryFolder
+import org.junit.runner.RunWith
+import org.robolectric.RobolectricTestRunner
+import org.robolectric.annotation.Config
+import java.io.File
+
+@RunWith(RobolectricTestRunner::class)
+@Config(sdk = [28], manifest = Config.NONE)
+class GogTest {
+ @org.junit.Test
+ fun successfulOperationReturnsNoErrorAndReleasesTheLock() {
+ org.junit.Assert.assertNull(GogManager.exclusive("setup") { null })
+ org.junit.Assert.assertNull(GogManager.busy)
+ org.junit.Assert.assertEquals("download failed", GogManager.exclusive("install") { "download failed" })
+ org.junit.Assert.assertNull(GogManager.busy)
+ }
+
+ @org.junit.Test
+ fun concurrentOperationIsRejectedAndExceptionsReleaseTheLock() {
+ org.junit.Assert.assertNull(GogManager.exclusive("install") {
+ org.junit.Assert.assertEquals("Another GOG task is running", GogManager.exclusive("setup") { null })
+ null
+ })
+ runCatching { GogManager.exclusive("setup") { error("failed") } }
+ org.junit.Assert.assertNull(GogManager.busy)
+ }
+
+ @get:Rule val tmp = TemporaryFolder()
+
+ private fun game(): File = tmp.newFolder("Example Game").apply {
+ File(this, "Bin").mkdirs()
+ File(this, "Bin/Game.EXE").writeText("x")
+ File(this, "Launcher.exe").writeText("x")
+ File(this, "Data").mkdirs()
+ }
+
+ private fun info(vararg tasks: String) =
+ JSONObject("""{"gameId":"42","rootGameId":"42","name":"Example: The Game","playTasks":[${tasks.joinToString(",")}]}""")
+
+ @Test fun primaryGameTaskWinsWithWindowsPathsMatchedCaselessly() {
+ val dir = game()
+ val parsed = GogGameInfo.parse(dir, info(
+ """{"type":"FileTask","category":"launcher","isPrimary":true,"path":"Launcher.exe"}""",
+ """{"type":"URLTask","category":"document","link":"https://example.com"}""",
+ """{"type":"FileTask","category":"game","isPrimary":true,"path":"bin\\game.exe","workingDir":"data","arguments":"-windowed -lang \"en\""}""",
+ ))!!
+ assertEquals("42", parsed.id)
+ assertEquals("Example: The Game", parsed.name)
+ assertEquals(File(dir, "Bin/Game.EXE"), parsed.task!!.exe)
+ assertEquals(File(dir, "Data"), parsed.task!!.workingDir)
+ assertEquals("-windowed -lang \"en\"", parsed.task!!.arguments)
+ }
+
+ @Test fun noWorkingDirStartsInTheGameFolder() {
+ val dir = game()
+ val parsed = GogGameInfo.parse(dir, info("""{"type":"FileTask","category":"game","path":"Bin\\Game.exe"}"""))!!
+ assertEquals(dir, parsed.task!!.workingDir)
+ assertEquals("", parsed.task!!.arguments)
+ }
+
+ @Test fun missingExeMeansNoTask() {
+ val dir = game()
+ assertNull(GogGameInfo.parse(dir, info("""{"type":"FileTask","category":"game","isPrimary":true,"path":"Missing.exe"}"""))!!.task)
+ }
+
+ @Test fun readPicksTheRootGameOverDlcInfoFiles() {
+ val dir = game()
+ File(dir, "goggame-7.info").writeText("""{"gameId":"7","rootGameId":"42","name":"DLC","playTasks":[]}""")
+ File(dir, "goggame-42.info").writeText(info("""{"type":"FileTask","category":"game","isPrimary":true,"path":"Bin\\Game.exe"}""").toString())
+ assertEquals("42", GogGameInfo.read(dir)!!.id)
+ }
+
+ @Test fun incompleteUntilTheDownloadFinishes() {
+ val dir = game()
+ File(dir, GogManager.MARKER).writeText("""{"id":"42"}""")
+ assertTrue(GogManager.incomplete(dir))
+ File(dir, "goggame-42.info").writeText(info("""{"type":"FileTask","category":"game","isPrimary":true,"path":"Bin\\Game.exe"}""").toString())
+ File(dir, ".gogdl-resume").writeText("")
+ assertTrue(GogManager.incomplete(dir))
+ File(dir, ".gogdl-resume").delete()
+ assertEquals(false, GogManager.incomplete(dir))
+ }
+
+ @Test fun libraryKeepsWindowsGamesAndPageCount() {
+ val (games, pages) = GogApi.parseLibraryPage("""{"totalPages":3,"products":[
+ {"id":1207658924,"title":"Unreal Tournament","image":"//images-1.gog-statics.com/abc","worksOn":{"Windows":true,"Mac":false,"Linux":false}},
+ {"id":2,"title":"Mac Only","image":"//images-1.gog-statics.com/def","worksOn":{"Windows":false,"Mac":true}}
+ ]}""")!!
+ assertEquals(3, pages)
+ assertEquals(listOf("1207658924"), games.map { it.id })
+ assertEquals("https://images-1.gog-statics.com/abc_392.jpg", games[0].image)
+ }
+
+ @Test fun artFillsGamesDbUrlTemplates() {
+ val art = GogApi.parseArt("""{"game":{"vertical_cover":{"url_format":"https://images.gog.com/v{formatter}.{ext}"},
+ "logo":{"url_format":"https://images.gog.com/l{formatter}.{ext}"}}}""")!!
+ assertEquals("https://images.gog.com/v.jpg", art.cover)
+ assertEquals("https://images.gog.com/l.png", art.logo)
+ assertNull(art.hero)
+ }
+}
diff --git a/tools/linuxfs/overlay/usr/local/bin/droiddeck-gog b/tools/linuxfs/overlay/usr/local/bin/droiddeck-gog
new file mode 100755
index 000000000..8c181e29d
--- /dev/null
+++ b/tools/linuxfs/overlay/usr/local/bin/droiddeck-gog
@@ -0,0 +1,195 @@
+#!/usr/bin/python3
+"""The app's GOG store talks to Heroic's gogdl through this: one JSON object per line on stdout,
+so progress reaches the front end as numbers rather than log lines. The app puts gogdl in the
+runtime (GOGDL below) and keeps the sign-in in its own files (the auth path every command takes).
+Tokens never leave on stdout except from "token", whose output the app does not log.
+
+ droiddeck-gog login trades the sign-in page's code for tokens
+ droiddeck-gog token a current access token (refreshed when due)
+ droiddeck-gog info folder name and sizes of the Windows build
+ droiddeck-gog install downloads into /
+ droiddeck-gog update brings an installed game to the latest build
+
+Lines: {"e":"info",...}, {"e":"progress","percent":45,"written":..,"total":..},
+{"e":"error","message":"..."} and {"e":"done",...} at the end. Exit status 0 on success.
+"""
+import json
+import os
+import re
+import subprocess
+import sys
+
+GOGDL = "/opt/droiddeck/gogdl/gogdl"
+PLATFORM = "windows"
+MARKER = ".droiddeck-gog.json"
+PROGRESS = re.compile(r"= Progress: ([\d.]+) (\d+)/(\d+)")
+# gogdl's own log lines: "[NAME] LEVEL: message".
+LOG = re.compile(r"^\[([^\]]+)\] (\w+): (.*)$")
+
+
+def emit(**kw):
+ sys.stdout.write(json.dumps(kw) + "\n")
+ sys.stdout.flush()
+
+
+def gogdl(auth, *args):
+ return [sys.executable or "/usr/bin/python3", GOGDL, "--auth-config-path", auth] + list(args)
+
+
+def last_json(text):
+ """gogdl prints its answer as the last JSON line of stdout, after anything else it says."""
+ for line in reversed(text.splitlines()):
+ line = line.strip()
+ if line.startswith("{") or line == "null":
+ try:
+ return json.loads(line)
+ except ValueError:
+ continue
+ return None
+
+
+def quiet(auth, *args):
+ """Runs a gogdl command that answers in JSON; its log (stderr) is dropped, never echoed."""
+ run = subprocess.run(gogdl(auth, *args), stdout=subprocess.PIPE, stderr=subprocess.PIPE, text=True)
+ return run.returncode, last_json(run.stdout), run.stderr
+
+
+def login(auth, code):
+ os.makedirs(os.path.dirname(auth) or ".", exist_ok=True)
+ status, data, _ = quiet(auth, "auth", "--code", code)
+ if status != 0 or not isinstance(data, dict) or data.get("error") or not data.get("access_token"):
+ emit(e="error", message="GOG did not accept the sign-in")
+ return 1
+ emit(e="done", user_id=str(data.get("user_id", "")))
+ return 0
+
+
+def token(auth):
+ if not os.path.isfile(auth):
+ emit(e="error", message="signed out")
+ return 1
+ status, data, _ = quiet(auth, "auth")
+ if status != 0 or not isinstance(data, dict) or not data.get("access_token"):
+ emit(e="error", message="signed out")
+ return 1
+ expires = float(data.get("loginTime", 0)) + float(data.get("expires_in", 0))
+ emit(e="token", access_token=data["access_token"], user_id=str(data.get("user_id", "")), expires=int(expires))
+ return 0
+
+
+def sizes(size, language):
+ """Bytes to download and on disk: the depots every language needs ("*") plus one language's."""
+ pick = [k for k in ("*", language) if k in size]
+ if language not in size:
+ pick += [k for k in size if k != "*"][:1]
+ return (sum(int(size[k].get("download_size") or 0) for k in pick),
+ sum(int(size[k].get("disk_size") or 0) for k in pick))
+
+
+def language_of(data):
+ langs = data.get("languages") or []
+ for want in ("en-US", "en"):
+ if want in langs:
+ return want
+ return langs[0] if langs else "en-US"
+
+
+def info(auth, game_id, out=True):
+ status, data, err = quiet(auth, "info", game_id, "--platform", PLATFORM, "--skip-dlcs")
+ if status != 0 or not isinstance(data, dict) or not data.get("folder_name"):
+ reason = failure(err) or "GOG has no Windows build of this game to download"
+ if out:
+ emit(e="error", message=reason)
+ return None, reason
+ language = language_of(data)
+ download, disk = sizes(data.get("size") or {}, language)
+ result = dict(folder=data["folder_name"], language=language, download_size=download, disk_size=disk,
+ build=str(data.get("buildId", "")))
+ if out:
+ emit(e="info", **result)
+ return result, None
+
+
+def failure(text):
+ """The last error gogdl logged, for the user; None when it logged none."""
+ errors = [m.group(3) for m in (LOG.match(line) for line in (text or "").splitlines())
+ if m and m.group(2) in ("ERROR", "CRITICAL")]
+ return errors[-1] if errors else None
+
+
+def follow(argv):
+ """Runs a download, turning its progress lines into ours. Returns (status, last error)."""
+ proc = subprocess.Popen(argv, stdout=subprocess.PIPE, stderr=subprocess.STDOUT, text=True, bufsize=1)
+ error, last = None, -1
+ for line in proc.stdout:
+ line = line.rstrip("\n")
+ m = PROGRESS.search(line)
+ if m:
+ percent = int(float(m.group(1)))
+ if percent != last:
+ last = percent
+ emit(e="progress", percent=percent, written=int(m.group(2)), total=int(m.group(3)))
+ continue
+ log = LOG.match(line)
+ if log and log.group(2) in ("ERROR", "CRITICAL"):
+ error = log.group(3)
+ # Everything else goes to the app's log of this command as it is.
+ sys.stdout.write(line + "\n")
+ sys.stdout.flush()
+ return proc.wait(), error
+
+
+def install(auth, game_id, base):
+ data, _ = info(auth, game_id)
+ if data is None:
+ return 1
+ target = os.path.join(base, data["folder"])
+ os.makedirs(target, exist_ok=True)
+ # Which game a folder is, from the first byte: the app lists a stopped download by it.
+ with open(os.path.join(target, MARKER), "w", encoding="utf-8") as f:
+ json.dump(dict(id=game_id, build=data["build"], language=data["language"]), f)
+ status, error = follow(gogdl(auth, "download", game_id, "--platform", PLATFORM, "--path", base,
+ "--skip-dlcs", "--lang", data["language"]))
+ if status != 0 or not os.path.isfile(os.path.join(target, "goggame-%s.info" % game_id)):
+ emit(e="error", message=error or "the download stopped (gogdl exit %d)" % status)
+ return 1
+ emit(e="done", dir=target)
+ return 0
+
+
+def update(auth, game_id, target):
+ data, _ = info(auth, game_id, out=False)
+ language = data["language"] if data else "en-US"
+ status, error = follow(gogdl(auth, "update", game_id, "--platform", PLATFORM, "--path", target,
+ "--skip-dlcs", "--lang", language))
+ if status != 0:
+ emit(e="error", message=error or "the update stopped (gogdl exit %d)" % status)
+ return 1
+ emit(e="done", dir=target)
+ return 0
+
+
+def main(argv):
+ if len(argv) < 3:
+ print(__doc__)
+ return 2
+ verb, auth, rest = argv[1], argv[2], argv[3:]
+ if not os.path.isfile(GOGDL):
+ emit(e="error", message="gogdl is not set up")
+ return 1
+ if verb == "login" and len(rest) == 1:
+ return login(auth, rest[0])
+ if verb == "token" and not rest:
+ return token(auth)
+ if verb == "info" and len(rest) == 1:
+ return 0 if info(auth, rest[0])[0] else 1
+ if verb == "install" and len(rest) == 2:
+ return install(auth, rest[0], rest[1])
+ if verb == "update" and len(rest) == 2:
+ return update(auth, rest[0], rest[1])
+ print(__doc__)
+ return 2
+
+
+if __name__ == "__main__":
+ sys.exit(main(sys.argv))
diff --git a/tools/linuxfs/overlay/usr/local/bin/droiddeck-steam-games b/tools/linuxfs/overlay/usr/local/bin/droiddeck-steam-games
index 50802187c..5ddac6f83 100755
--- a/tools/linuxfs/overlay/usr/local/bin/droiddeck-steam-games
+++ b/tools/linuxfs/overlay/usr/local/bin/droiddeck-steam-games
@@ -178,7 +178,7 @@ def unimported(steam_root, snapshot, candidates):
def watch(steam_root, listing):
- games = read_json(listing, [])
+ games = [game for game in read_json(listing, []) if isinstance(game, dict) and not game.get("store")]
if os.environ.get("BL_STEAM_OFFLINE") == "1" or not games:
return
candidates, sources = {}, {}
@@ -344,6 +344,10 @@ def route(steam_root, acct, games):
owned, candidates, sources = {}, {}, {}
shortcuts, routes = [], {}
for game in games:
+ # A store's copy (GOG) is that store's game, never the Steam title of the same name.
+ if game.get("store"):
+ shortcuts.append(game)
+ continue
folder = game.get("folder", "")
appid = candidates.get(folder, candidates.get(legacy_path(folder)))
stamp = source_stamp(game)
diff --git a/tools/linuxfs/overlay/usr/local/bin/droiddeck-steam-shortcuts b/tools/linuxfs/overlay/usr/local/bin/droiddeck-steam-shortcuts
index a08ccb7be..20e5ab415 100755
--- a/tools/linuxfs/overlay/usr/local/bin/droiddeck-steam-shortcuts
+++ b/tools/linuxfs/overlay/usr/local/bin/droiddeck-steam-shortcuts
@@ -219,9 +219,10 @@ def main():
for game in games:
if not game.get("exe") or not game.get("name"):
continue
- entry = shortcut(game)
+ # A store's own launch arguments (a GOG play task's) unless the user has set some in Steam.
+ entry = shortcut(game, game.get("args") or "")
previous = managed.get(entry["appid"])
- if previous and isinstance(previous.get("LaunchOptions"), str):
+ if previous and isinstance(previous.get("LaunchOptions"), str) and previous["LaunchOptions"]:
entry["LaunchOptions"] = previous["LaunchOptions"]
ours.append(entry)
entries = {str(i): v for i, v in enumerate(kept + ours)}
diff --git a/tools/tests/test_gog.py b/tools/tests/test_gog.py
new file mode 100644
index 000000000..742041af8
--- /dev/null
+++ b/tools/tests/test_gog.py
@@ -0,0 +1,145 @@
+import io
+import json
+from pathlib import Path
+import subprocess
+import tempfile
+import unittest
+from contextlib import redirect_stdout
+from unittest.mock import patch
+
+from test_steam_games import BIN, imports, load, shortcuts
+
+gog = load('droiddeck-gog')
+
+# Stands in for gogdl: answers info/auth like it, and "downloads" by writing the game's info file.
+FAKE_GOGDL = r'''
+import json, os, sys
+args = sys.argv[1:]
+auth = args[args.index("--auth-config-path") + 1]
+args = args[args.index("--auth-config-path") + 2:]
+cmd = args[0]
+if cmd == "auth":
+ if "--code" in args:
+ print(json.dumps({"error": True}) if args[-1] == "bad" else json.dumps({"access_token": "A", "refresh_token": "R", "user_id": "7", "expires_in": 3600, "loginTime": 1000}))
+ elif os.path.exists(auth):
+ print(json.dumps({"access_token": "A", "refresh_token": "R", "user_id": "7", "expires_in": 3600, "loginTime": 1000}))
+ else:
+ print("null")
+elif cmd == "info":
+ print("[MAIN] INFO: working", file=sys.stderr)
+ print(json.dumps({"folder_name": "Example", "languages": ["de-DE", "en-US"], "buildId": "b1",
+ "size": {"*": {"download_size": 100, "disk_size": 200}, "en-US": {"download_size": 10, "disk_size": 20},
+ "de-DE": {"download_size": 5, "disk_size": 6}}}))
+elif cmd == "download":
+ base = args[args.index("--path") + 1]
+ if os.environ.get("FAIL"):
+ print("[DOWNLOAD] ERROR: disk full", file=sys.stderr)
+ sys.exit(1)
+ for p in ("0.00 0/100", "50.10 50/100", "50.90 51/100", "100.00 100/100"):
+ print("[PROGRESS] INFO: = Progress: %s, Running for: 00:00:01, ETA: 00:00:01" % p, file=sys.stderr)
+ target = os.path.join(base, "Example")
+ os.makedirs(target, exist_ok=True)
+ open(os.path.join(target, "goggame-%s.info" % args[1]), "w").write("{}")
+'''
+
+
+class GogHelperTest(unittest.TestCase):
+ def setUp(self):
+ temp = tempfile.TemporaryDirectory()
+ self.addCleanup(temp.cleanup)
+ self.root = Path(temp.name)
+ fake = self.root / 'gogdl'
+ fake.write_text(FAKE_GOGDL)
+ patcher = patch.object(gog, 'GOGDL', str(fake))
+ patcher.start()
+ self.addCleanup(patcher.stop)
+ self.auth = str(self.root / 'gog/auth.json')
+
+ def run_helper(self, *args):
+ out = io.StringIO()
+ with redirect_stdout(out):
+ status = gog.main(['droiddeck-gog'] + list(args))
+ lines = [json.loads(line) for line in out.getvalue().splitlines() if line.startswith('{')]
+ return status, lines, out.getvalue()
+
+ def test_token_output_never_carries_the_refresh_token(self):
+ Path(self.auth).parent.mkdir()
+ Path(self.auth).write_text('{}')
+ status, lines, raw = self.run_helper('token', self.auth)
+ self.assertEqual(0, status)
+ self.assertEqual({'e': 'token', 'access_token': 'A', 'user_id': '7', 'expires': 4600}, lines[0])
+ self.assertNotIn('"R"', raw)
+
+ def test_signed_out_without_the_auth_file(self):
+ status, lines, _ = self.run_helper('token', self.auth)
+ self.assertEqual(1, status)
+ self.assertEqual('error', lines[0]['e'])
+
+ def test_login_reports_success_without_tokens(self):
+ status, lines, raw = self.run_helper('login', self.auth, 'good')
+ self.assertEqual((0, [{'e': 'done', 'user_id': '7'}]), (status, lines))
+ self.assertNotIn('access_token', raw)
+ status, lines, _ = self.run_helper('login', self.auth, 'bad')
+ self.assertEqual(1, status)
+
+ def test_sizes_are_shared_depots_plus_one_language(self):
+ status, lines, _ = self.run_helper('info', self.auth, '42')
+ self.assertEqual(0, status)
+ self.assertEqual(dict(e='info', folder='Example', language='en-US', download_size=110, disk_size=220, build='b1'), lines[0])
+
+ def test_install_reports_progress_once_per_percent_and_marks_the_folder(self):
+ base = self.root / 'GOG'
+ status, lines, _ = self.run_helper('install', self.auth, '42', str(base))
+ self.assertEqual(0, status)
+ self.assertEqual([0, 50, 100], [line['percent'] for line in lines if line['e'] == 'progress'])
+ self.assertEqual({'e': 'done', 'dir': str(base / 'Example')}, lines[-1])
+ self.assertEqual('42', json.loads((base / 'Example' / gog.MARKER).read_text())['id'])
+
+ def test_failed_download_passes_on_gogdls_error(self):
+ with patch.dict('os.environ', {'FAIL': '1'}):
+ status, lines, _ = self.run_helper('install', self.auth, '42', str(self.root / 'GOG'))
+ self.assertEqual(1, status)
+ self.assertEqual({'e': 'error', 'message': 'disk full'}, lines[-1])
+
+
+class StoreShortcutsTest(unittest.TestCase):
+ def setUp(self):
+ temp = tempfile.TemporaryDirectory()
+ self.addCleanup(temp.cleanup)
+ self.root = Path(temp.name)
+ self.steam = self.root / 'Steam'
+ self.acct = self.steam / 'userdata/123'
+ (self.acct / 'config').mkdir(parents=True)
+ folder = self.root / 'GOG/Example'
+ folder.mkdir(parents=True)
+ (folder / 'Example.exe').write_bytes(b'game')
+ self.game = dict(name='Example', exe=str(folder / 'Example.exe'), folder=str(folder), dir=str(folder),
+ appid=0x87654321, args='-windowed', store='gog')
+
+ def write(self, *games):
+ listing = self.root / 'games.json'
+ listing.write_text(json.dumps(list(games)))
+ subprocess.run(['python3', str(BIN / 'droiddeck-steam-shortcuts'), str(self.steam), str(listing)], check=True, capture_output=True)
+ return list(shortcuts.parse((self.acct / 'config/shortcuts.vdf').read_bytes())['shortcuts'].values())
+
+ def test_store_arguments_become_launch_options(self):
+ self.assertEqual('-windowed', self.write(self.game)[0]['LaunchOptions'])
+
+ def test_launch_options_set_in_steam_are_kept(self):
+ self.write(self.game)
+ path = self.acct / 'config/shortcuts.vdf'
+ entries = shortcuts.parse(path.read_bytes())['shortcuts']
+ entries['0']['LaunchOptions'] = '-dx11'
+ path.write_bytes(shortcuts.write({'shortcuts': entries}))
+ self.assertEqual('-dx11', self.write(self.game)[0]['LaunchOptions'])
+
+ def test_store_games_are_never_routed_to_an_owned_steam_title(self):
+ imports.save_json(self.acct / 'config' / imports.STATE,
+ dict(account='123', owned={'42': True}, candidates={self.game['folder']: 42},
+ sources={self.game['folder']: imports.source_stamp(self.game)}))
+ games, routes = imports.route(self.steam, self.acct, [self.game])
+ self.assertEqual(([self.game], {}), (games, routes))
+
+
+if __name__ == '__main__':
+ unittest.main()