Compare commits

...
Author SHA1 Message Date
xXJsonDeruloXx 05cb4de6ba feat: install GOG games from the Store into Steam via gogdl 2026-10-04 13:25:43 -04:00
17 changed files with 1526 additions and 16 deletions

No files matched your search

+1
View File
@@ -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
+4
View File
@@ -148,6 +148,10 @@
<service
android:name=".runtime.RuntimeInstallService"
android:exported="false" />
<!-- Holds the process in the foreground while a GOG game downloads. -->
<service
android:name=".store.gog.GogInstallService"
android:exported="false" />
<service
android:name=".core.WirelessAdbPairingService"
android:exported="false" />
@@ -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<File>,
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<Game> {
val out = ArrayList<Game>()
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<Game>) {
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)
@@ -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/<logName>.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<String>, fakeRoot: Boolean = false, logName: String? = null,
linkDir: File? = null, onLine: (String) -> Unit): Int {
linkDir: File? = null, extraBinds: List<String> = 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<String>, fakeRoot: Boolean, linkDir: File?, onLine: (String) -> Unit): Int {
private fun runLogged(context: Context, argv: List<String>, fakeRoot: Boolean, linkDir: File?, extraBinds: List<String>,
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()
@@ -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",
@@ -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<Game>? {
val games = ArrayList<Game>()
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<List<Game>, 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()
}
}
}
@@ -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-<id>.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
}
}
@@ -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"))
}
}
}
@@ -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<String>)
/** 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<Base> {
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<Installed> = 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<String>, logName: String?, binds: List<String> = 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<String>, 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)
}
}
@@ -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<List<GogApi.Game>?>(null)
private set
var libraryFailed by mutableStateOf(false)
private set
var loadingLibrary by mutableStateOf(false)
private set
var installed by mutableStateOf<List<GogManager.Installed>>(emptyList())
private set
/** What is running: "setup", "login", or a game id; null when idle. */
var busy by mutableStateOf<String?>(null)
private set
var stage by mutableStateOf<String?>(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<String, GogManager.Sizes?>()
@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()
}
}
}
@@ -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<String?>(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<Triple<String, String, String?>>, 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() },
)
}
}
@@ -78,6 +78,8 @@ internal fun StorePage(s: FrontEndState, a: FrontEndActions, modifier: Modifier)
var openApp by rememberSaveable { mutableStateOf<String?>(null) }
var query by rememberSaveable { mutableStateOf("") }
var category by rememberSaveable { mutableStateOf<String?>(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
@@ -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)
}
}
+195
View File
@@ -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 <auth> <code> trades the sign-in page's code for tokens
droiddeck-gog token <auth> a current access token (refreshed when due)
droiddeck-gog info <auth> <id> folder name and sizes of the Windows build
droiddeck-gog install <auth> <id> <base> downloads into <base>/<folder name>
droiddeck-gog update <auth> <id> <dir> 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))
@@ -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)
@@ -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)}
+145
View File
@@ -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()