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()