mirror of
https://github.com/Droid-Deck/DroidDeck.git
synced 2026-10-14 19:27:09 -04:00
Compare commits
1
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
05cb4de6ba |
No files matched your search
@@ -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
|
||||
|
||||
@@ -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
@@ -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)}
|
||||
|
||||
@@ -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()
|
||||
Reference in new issue
Block a user