mirror of
https://github.com/Droid-Deck/DroidDeck.git
synced 2026-11-11 11:01:07 +00:00
Stores: the section's skeleton, store sidecar on added games, source badges, Setup gate
A new Stores rail item (GOG, Epic Games, Amazon Games), off by default behind Setup > Stores. The page is full width: four chips and a cog whose popup holds the section's settings (which tab a store opens on, add installs to Steam, speed tier); a signed-out store shows its sign-in card. A store install is an added game: its folder sits under the Games storage with a .droiddeck-store.json sidecar that AddedGames.scanGame reads for the exe, the title and the source, so the existing listing -> shortcuts writer path registers it in Steam; a running client gets it live over its DevTools port. Games that need arguments or environment get a launcher .bat the shortcut runs. Every Games row and hero now carries a source chip. The download queue, its foreground service and the Downloads page are in place for the store clients that follow.
This commit is contained in:
1 parent
0091b751f4
commit
9f71b427bf
35 files changed
+2911
-22
No files matched your search
@@ -163,6 +163,11 @@
|
||||
<service
|
||||
android:name=".core.WirelessAdbPairingService"
|
||||
android:exported="false" />
|
||||
<!-- Keeps the process alive while a store (GOG, Epic, Amazon) game downloads. -->
|
||||
<service
|
||||
android:name=".stores.download.StoreDownloadService"
|
||||
android:exported="false"
|
||||
android:foregroundServiceType="dataSync" />
|
||||
<!-- Hands a zipped session folder to the share sheet; nothing else is exposed. -->
|
||||
<provider
|
||||
android:name="androidx.core.content.FileProvider"
|
||||
|
||||
@@ -164,6 +164,10 @@ class MainActivity : ComponentActivity() {
|
||||
private var launcherFullscreen by mutableStateOf(true)
|
||||
private var animationsEnabled by mutableStateOf(true)
|
||||
private var storeEnabled by mutableStateOf(false)
|
||||
private var gameStoresEnabled by mutableStateOf(false)
|
||||
private var gameStoresAddToSteam by mutableStateOf(true)
|
||||
private var gameStoresSpeedTier by mutableStateOf("fast")
|
||||
private var storesOpenTab by mutableStateOf(SessionPrefs.STORES_OPEN_LIBRARY)
|
||||
private var mic by mutableStateOf(false)
|
||||
private var wifiDiscovery by mutableStateOf(false)
|
||||
private var wifiDiscoveryPermission by mutableStateOf(false)
|
||||
@@ -523,6 +527,10 @@ class MainActivity : ComponentActivity() {
|
||||
launcherFullscreen = SessionPrefs.launcherFullscreen(this)
|
||||
animationsEnabled = SessionPrefs.animationsEnabled(this)
|
||||
storeEnabled = SessionPrefs.storeEnabled(this)
|
||||
gameStoresEnabled = SessionPrefs.gameStoresEnabled(this)
|
||||
gameStoresAddToSteam = SessionPrefs.gameStoresAddToSteam(this)
|
||||
gameStoresSpeedTier = SessionPrefs.gameStoresSpeedTier(this)
|
||||
storesOpenTab = SessionPrefs.storesOpenTab(this)
|
||||
applyLauncherFullscreen()
|
||||
updates.start()
|
||||
setContent {
|
||||
@@ -576,6 +584,11 @@ class MainActivity : ComponentActivity() {
|
||||
launcherFullscreen = launcherFullscreen,
|
||||
animationsEnabled = animationsEnabled,
|
||||
storeEnabled = storeEnabled,
|
||||
gameStoresEnabled = gameStoresEnabled,
|
||||
gameStoresAddToSteam = gameStoresAddToSteam,
|
||||
gameStoresSpeedTier = gameStoresSpeedTier,
|
||||
storesOpenTab = storesOpenTab,
|
||||
storeDownloadsActive = com.droiddeck.launcher.stores.StoresState.activeDownloads,
|
||||
),
|
||||
FrontEndActions(
|
||||
onPlay = { startSteamSession() },
|
||||
@@ -586,7 +599,8 @@ class MainActivity : ComponentActivity() {
|
||||
.putExtra(SessionService.EXTRA_MODE, SessionService.MODE_DESKTOP)
|
||||
.putExtra(SessionService.EXTRA_STEAM_UI, "desktop"), steamSession = true)
|
||||
},
|
||||
onSteamGame = { g -> if (shortcutPicker) chooseGameShortcut(g) else launchGame(g) },
|
||||
// A store game may need something done first (an Epic game its sign-in code).
|
||||
onSteamGame = { g -> if (shortcutPicker) chooseGameShortcut(g) else com.droiddeck.launcher.stores.StoreLaunch.prepare(this, g) { launchGame(g) } },
|
||||
onGameShortcut = { g -> com.droiddeck.launcher.frontend.GameShortcuts.pin(this, g) },
|
||||
onExportGameFile = { g -> pickGameExport(g) },
|
||||
onSyncGameFiles = { pickGameExport(null) },
|
||||
@@ -702,6 +716,11 @@ class MainActivity : ComponentActivity() {
|
||||
applyLauncherFullscreen()
|
||||
},
|
||||
onStoreEnabled = { on -> SessionPrefs.setStoreEnabled(this, on); storeEnabled = on },
|
||||
onGameStoresEnabled = { on -> SessionPrefs.setGameStoresEnabled(this, on); gameStoresEnabled = on; if (on) com.droiddeck.launcher.stores.StoresState.refresh(this) },
|
||||
onGameStoresAddToSteam = { on -> SessionPrefs.setGameStoresAddToSteam(this, on); gameStoresAddToSteam = on },
|
||||
onGameStoresSpeedTier = { tier -> SessionPrefs.setGameStoresSpeedTier(this, tier); gameStoresSpeedTier = tier },
|
||||
onStoresOpenTab = { tab -> SessionPrefs.setStoresOpenTab(this, tab); storesOpenTab = tab },
|
||||
onLibraryChanged = { refreshAddedGames(); refresh() },
|
||||
onAnimationsEnabled = { on ->
|
||||
SessionPrefs.setAnimationsEnabled(this, on)
|
||||
animationsEnabled = on
|
||||
@@ -903,6 +922,8 @@ class MainActivity : ComponentActivity() {
|
||||
override fun onResume() {
|
||||
super.onResume()
|
||||
refreshWifiDiscovery()
|
||||
// Back from a store's sign-in page: the Stores chips show the account at once.
|
||||
if (gameStoresEnabled) com.droiddeck.launcher.stores.StoresState.refresh(this)
|
||||
com.droiddeck.launcher.ui.Motion.refresh(this)
|
||||
// Back from a session stopped behind a flood: open on its blue, before the first frame.
|
||||
com.droiddeck.launcher.ui.QuitFlood.take()?.let { c ->
|
||||
|
||||
@@ -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.stores.StoreGameSidecar
|
||||
import com.droiddeck.launcher.stores.StoreInstallRoot
|
||||
import java.io.File
|
||||
import java.util.zip.CRC32
|
||||
|
||||
@@ -16,13 +18,21 @@ object AddedGames {
|
||||
private const val LEGACY_LIBRARY = "/mnt/bannerlator-sd"
|
||||
|
||||
class Game(
|
||||
val folder: File, val name: String, val exe: File, val guestExe: String, val guestDir: String,
|
||||
val folder: File, val name: String,
|
||||
/** The game's own .exe: what the icon is read from and what the settings page lists. */
|
||||
val exe: File,
|
||||
/** The guest path the shortcut runs: [exe], or a store install's launcher .bat in front of it. */
|
||||
val guestExe: String, val guestDir: String,
|
||||
/** The client's 32-bit appid for this shortcut, as an unsigned value. */
|
||||
val appId: Long,
|
||||
/** What steam://rungameid/ takes for a shortcut. */
|
||||
val gameId: Long,
|
||||
val candidates: List<File>,
|
||||
val steamAppId: Int? = null,
|
||||
/** Where the game came from: a store's id, or [Library.ADDED] for a folder the user added. */
|
||||
val source: String = Library.ADDED,
|
||||
/** The store's own id for the game, from its sidecar; null for a plain added folder. */
|
||||
val storeId: String? = null,
|
||||
) {
|
||||
fun folderName(): String = folder.name
|
||||
}
|
||||
@@ -69,6 +79,10 @@ object AddedGames {
|
||||
GameStorage.effective(context)?.let { lib ->
|
||||
if (path.startsWith("${lib.path}/")) return "$LIBRARY/" + path.removePrefix("${lib.path}/")
|
||||
}
|
||||
// Inside the runtime's own tree (store installs on internal storage live at
|
||||
// root/Games/Stores): the tree is the session's / and needs no bind.
|
||||
val runtime = LinuxRuntime.rootDir(context).absolutePath
|
||||
if (path.startsWith("$runtime/")) return "/" + path.removePrefix("$runtime/")
|
||||
return null
|
||||
}
|
||||
|
||||
@@ -99,9 +113,13 @@ object AddedGames {
|
||||
val steamInstalls = steamInstallDirs(dir)
|
||||
for (folder in dir.listFiles { f -> f.isDirectory }?.sortedBy { it.name.lowercase() } ?: emptyList()) {
|
||||
if (folder.name.lowercase() in steamInstalls || folder.name.equals("steamapps", ignoreCase = true)) continue
|
||||
// The store install root sits inside the Games storage; its games are scanned below, per store.
|
||||
if (folder.name == "Games" && StoreInstallRoot.roots(context).any { it.absolutePath == folder.absolutePath }) continue
|
||||
scanGame(context, folder, out)
|
||||
}
|
||||
}
|
||||
// Games the Stores section installed: one folder each under <root>/<Store>/, with a sidecar.
|
||||
for (folder in StoreInstallRoot.gameFolders(context)) scanGame(context, folder, out)
|
||||
return out.distinctBy { it.folder.canonicalPath }
|
||||
}
|
||||
|
||||
@@ -125,13 +143,20 @@ object AddedGames {
|
||||
|
||||
private fun scanGame(context: Context, folder: File, out: MutableList<Game>) {
|
||||
run {
|
||||
val sidecar = StoreGameSidecar.read(folder)
|
||||
// A store install the user keeps out of Steam: not a shortcut, so not listed here.
|
||||
if (sidecar != null && !sidecar.addToSteam) return
|
||||
val candidates = candidates(folder)
|
||||
val picked = SessionPrefs.addedGameExe(context, folder.path)
|
||||
val chosen = picked.takeIf { it.isNotEmpty() }?.let { File(it) }?.takeIf { it.isFile }
|
||||
val found = chosen ?: candidates.firstOrNull() ?: return
|
||||
val foundGuest = guestPath(context, found)
|
||||
if (foundGuest == null) { Log.w(TAG, "${folder.name}: the session cannot see ${found.path}"); return }
|
||||
val name = folder.name
|
||||
// A store install says which exe it is; the folder's guess only when that file is gone.
|
||||
val found = chosen ?: sidecar?.exeFile(folder)?.takeIf { it.isFile } ?: candidates.firstOrNull() ?: return
|
||||
// The store's launcher .bat stands in front of the exe when the game needs arguments or
|
||||
// environment; it is what the shortcut runs. Dropped when the user picked another exe.
|
||||
val launch = if (chosen == null) sidecar?.launcherFile(folder)?.takeIf { it.isFile } ?: found else found
|
||||
val foundGuest = guestPath(context, launch)
|
||||
if (foundGuest == null) { Log.w(TAG, "${folder.name}: the session cannot see ${launch.path}"); return }
|
||||
val name = sidecar?.title ?: 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(("\"${foundGuest.replaceFirst(Regex("^$LIBRARY/"), "$LEGACY_LIBRARY/")}\"" + name).toByteArray()) }.value
|
||||
val appId = SessionPrefs.addedGameAppId(context, folder.path, crc or 0x80000000L)
|
||||
@@ -143,10 +168,11 @@ object AddedGames {
|
||||
?.takeIf { SessionPrefs.adoptAddedGameExe(context, folder.path, picked, it.path, rev) }
|
||||
val exe = adopted ?: found
|
||||
val guestExe = if (adopted != null) guestPath(context, adopted) ?: return else foundGuest
|
||||
val guestDir = guestPath(context, exe.parentFile ?: folder) ?: return
|
||||
val guestDir = guestPath(context, (if (adopted != null) adopted else launch).parentFile ?: folder) ?: return
|
||||
val steamId = config?.let { steamRoute(it, 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,
|
||||
source = sidecar?.store?.id ?: Library.ADDED, storeId = sidecar?.id))
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -24,12 +24,18 @@ object Library {
|
||||
val hero: File? = null, val lastPlayed: Long = 0L,
|
||||
val gameFiles: File? = null, val protonPrefix: File? = null,
|
||||
val icon: File? = null,
|
||||
/** Where the game came from: [SOURCE_STEAM], a store's id (gog, epic, amazon) or [ADDED] for a folder the user added. */
|
||||
val source: String = SOURCE_STEAM,
|
||||
/** The store's own id for a store install; null otherwise. */
|
||||
val storeId: String? = null,
|
||||
) {
|
||||
/** Decimal form used by Steam links and Android shortcuts, including unsigned shortcut ids. */
|
||||
val gameIdString: String get() = java.lang.Long.toUnsignedString(gameId)
|
||||
}
|
||||
/** The [SteamGame.library] of a game added to the library rather than installed by Steam. */
|
||||
/** The [SteamGame.library] of a game added to the library rather than installed by Steam, and the [SteamGame.source] of a plain added folder. */
|
||||
const val ADDED = "added"
|
||||
/** The [SteamGame.source] of a title Steam itself installed. */
|
||||
const val SOURCE_STEAM = "steam"
|
||||
|
||||
class Rom(val name: String, val hostPath: File, val guestPath: String, val emulatorId: String, val art: File? = null)
|
||||
class Emulator(val id: String, val name: String, val system: String, val program: String, val installed: Boolean, val games: List<Rom>) {
|
||||
@@ -155,6 +161,7 @@ object Library {
|
||||
hero = art.hero ?: art.header, gameFiles = g.folder,
|
||||
protonPrefix = protonPrefix(context, g.steamAppId?.toLong() ?: g.appId, libraries = roots),
|
||||
icon = art.icon?.takeIf { it.extension.lowercase() != "ico" },
|
||||
source = g.source, storeId = g.storeId,
|
||||
)
|
||||
}
|
||||
}).distinctBy { it.gameId }
|
||||
|
||||
@@ -26,6 +26,8 @@ object LibraryCache {
|
||||
library = o.getString("library"), gameId = o.getLong("gameId"), hero = o.file("hero"),
|
||||
lastPlayed = o.optLong("lastPlayed"), gameFiles = o.file("gameFiles"), protonPrefix = o.file("protonPrefix"),
|
||||
icon = o.file("icon"),
|
||||
source = o.optString("source").ifEmpty { if (o.getString("library") == Library.ADDED) Library.ADDED else Library.SOURCE_STEAM },
|
||||
storeId = o.optString("storeId").ifEmpty { null },
|
||||
)
|
||||
}
|
||||
}.getOrElse { e -> Log.w(TAG, "unreadable, ignored: ${e.message}"); emptyList() }
|
||||
@@ -36,7 +38,8 @@ object LibraryCache {
|
||||
for (g in games) array.put(
|
||||
JSONObject().put("appId", g.appId).put("name", g.name).put("library", g.library).put("gameId", g.gameId)
|
||||
.put("lastPlayed", g.lastPlayed).putFile("art", g.art).putFile("hero", g.hero)
|
||||
.putFile("gameFiles", g.gameFiles).putFile("protonPrefix", g.protonPrefix).putFile("icon", g.icon),
|
||||
.putFile("gameFiles", g.gameFiles).putFile("protonPrefix", g.protonPrefix).putFile("icon", g.icon)
|
||||
.put("source", g.source).apply { g.storeId?.let { put("storeId", it) } },
|
||||
)
|
||||
val f = file(context)
|
||||
val tmp = File(f.path + ".tmp")
|
||||
|
||||
@@ -53,6 +53,45 @@ object SessionPrefs {
|
||||
prefs(context).edit().putBoolean("storeEnabled", on).apply()
|
||||
}
|
||||
|
||||
/** The Stores section (GOG, Epic Games, Amazon Games): its rail item. Off by default. */
|
||||
fun gameStoresEnabled(context: Context): Boolean = prefs(context).getBoolean("gameStoresEnabled", false)
|
||||
|
||||
fun setGameStoresEnabled(context: Context, on: Boolean) {
|
||||
prefs(context).edit().putBoolean("gameStoresEnabled", on).apply()
|
||||
}
|
||||
|
||||
/** Whether a game a store installs is added to the Steam client's library as a shortcut. On by default. */
|
||||
fun gameStoresAddToSteam(context: Context): Boolean = prefs(context).getBoolean("gameStoresAddToSteam", true)
|
||||
|
||||
fun setGameStoresAddToSteam(context: Context, on: Boolean) {
|
||||
prefs(context).edit().putBoolean("gameStoresAddToSteam", on).apply()
|
||||
}
|
||||
|
||||
/** The store download engine's speed tier (stores/download/StoreDownloadTier ids); Fast unless chosen otherwise. */
|
||||
fun gameStoresSpeedTier(context: Context): String = prefs(context).getString("gameStoresSpeedTier", "fast") ?: "fast"
|
||||
|
||||
fun setGameStoresSpeedTier(context: Context, tier: String) {
|
||||
prefs(context).edit().putString("gameStoresSpeedTier", tier).apply()
|
||||
}
|
||||
|
||||
/** Which tab a signed-in store opens on when its chip is picked: [STORES_OPEN_LIBRARY] or [STORES_OPEN_STORE]. */
|
||||
fun storesOpenTab(context: Context): String =
|
||||
prefs(context).getString("storesOpenTab", STORES_OPEN_LIBRARY)?.takeIf { it == STORES_OPEN_STORE } ?: STORES_OPEN_LIBRARY
|
||||
|
||||
fun setStoresOpenTab(context: Context, tab: String) {
|
||||
prefs(context).edit().putString("storesOpenTab", if (tab == STORES_OPEN_STORE) STORES_OPEN_STORE else STORES_OPEN_LIBRARY).apply()
|
||||
}
|
||||
|
||||
const val STORES_OPEN_LIBRARY = "library"
|
||||
const val STORES_OPEN_STORE = "store"
|
||||
|
||||
/** How many store downloads run at once, 1..3; one by default (a single download already fills the link). */
|
||||
fun gameStoresParallel(context: Context): Int = prefs(context).getInt("gameStoresParallel", 1).coerceIn(1, 3)
|
||||
|
||||
fun setGameStoresParallel(context: Context, count: Int) {
|
||||
prefs(context).edit().putInt("gameStoresParallel", count.coerceIn(1, 3)).apply()
|
||||
}
|
||||
|
||||
/**
|
||||
* The session's performance HUD (the fps box). A Deck-mode Steam session with the performance
|
||||
* overlay has Steam's own (mangoapp, from the QAM), so there the HUD is off unless turned on
|
||||
|
||||
@@ -0,0 +1,187 @@
|
||||
package com.droiddeck.launcher.stores
|
||||
|
||||
import android.content.Context
|
||||
import android.util.Base64
|
||||
import android.util.Log
|
||||
import com.droiddeck.launcher.frontend.AddedGames
|
||||
import com.droiddeck.launcher.frontend.Library
|
||||
import com.droiddeck.launcher.runtime.LinuxRuntime
|
||||
import com.droiddeck.launcher.session.SessionService
|
||||
import com.droiddeck.launcher.session.SessionState
|
||||
import org.json.JSONArray
|
||||
import org.json.JSONObject
|
||||
import java.io.ByteArrayOutputStream
|
||||
import java.io.DataInputStream
|
||||
import java.io.File
|
||||
import java.io.InputStream
|
||||
import java.io.OutputStream
|
||||
import java.net.HttpURLConnection
|
||||
import java.net.Socket
|
||||
import java.net.URL
|
||||
import java.security.SecureRandom
|
||||
|
||||
/**
|
||||
* Adds and removes a store game's shortcut in a Steam client that is running right now, so it
|
||||
* shows without waiting for the next client start. The runtime starts the client with its
|
||||
* DevTools port open (droiddeck-session writes it to session/agent/cdp-port) and the client's own
|
||||
* JavaScript API has `SteamClient.Apps.AddShortcut`; this speaks the small part of the DevTools
|
||||
* protocol that one `Runtime.evaluate` needs, over a plain socket, nothing else.
|
||||
*
|
||||
* Best-effort throughout: no session, no port, no SharedJSContext, or an API that moved and the
|
||||
* shortcut simply arrives with the shortcuts writer at the next start. The appid Steam derives
|
||||
* (CRC of the quoted exe plus the name) is the one the writer computes, so the two never disagree.
|
||||
*/
|
||||
object SteamLiveShortcuts {
|
||||
private const val TAG = "SteamLive"
|
||||
private const val TAG_NAME = "droiddeck-app"
|
||||
private const val COMPAT_TOOL = "droiddeck-proton-arm64"
|
||||
|
||||
/** Adds every store game in [games] the client does not list yet. Blocking; worker thread. */
|
||||
fun sync(context: Context, games: List<AddedGames.Game>) {
|
||||
val wanted = games.filter { it.source != Library.ADDED }
|
||||
if (wanted.isEmpty() || !clientRunning()) return
|
||||
val list = JSONArray()
|
||||
for (g in wanted) list.put(JSONObject().put("name", g.name).put("exe", g.guestExe).put("dir", g.guestDir))
|
||||
val js = """
|
||||
(async () => {
|
||||
const want = ${list};
|
||||
const have = await SteamClient.Apps.GetAllShortcuts();
|
||||
const strip = s => (s || "").replace(/^"|"$/g, "");
|
||||
let added = 0;
|
||||
for (const w of want) {
|
||||
if (have.some(a => a.data && strip(a.data.strExePath) === w.exe && a.data.strAppName === w.name)) continue;
|
||||
const id = await SteamClient.Apps.AddShortcut(w.name, w.exe, w.dir, "");
|
||||
if (!id) continue;
|
||||
try { SteamClient.Apps.AddUserTagToApps([id], ${JSONObject.quote(TAG_NAME)}); } catch (e) {}
|
||||
try { SteamClient.Apps.SpecifyCompatTool(id, ${JSONObject.quote(COMPAT_TOOL)}); } catch (e) {}
|
||||
added++;
|
||||
}
|
||||
return added;
|
||||
})()
|
||||
""".trimIndent()
|
||||
val result = evaluate(context, js) ?: return
|
||||
Log.i(TAG, "live shortcuts: $result added")
|
||||
if (result != "0") StoresState.logLine("Steam: $result shortcut(s) added to the running client")
|
||||
}
|
||||
|
||||
/** Removes the shortcut with [appId] from the running client. Blocking; worker thread. */
|
||||
fun remove(context: Context, appId: Long) {
|
||||
if (!clientRunning()) return
|
||||
val result = evaluate(context, "(async () => { await SteamClient.Apps.RemoveShortcut(${appId and 0xFFFFFFFFL}); return 1; })()")
|
||||
if (result != null) Log.i(TAG, "live shortcuts: removed $appId")
|
||||
}
|
||||
|
||||
private fun clientRunning(): Boolean = SessionState.running && SessionState.mode == SessionService.MODE_STEAM && !SessionState.stopRequested
|
||||
|
||||
/** `Runtime.evaluate` of [expression] in the client's SharedJSContext; the value as text, or null. */
|
||||
private fun evaluate(context: Context, expression: String): String? {
|
||||
val portFile = File(LinuxRuntime.sessionRoot(context), "agent/cdp-port")
|
||||
val port = runCatching { portFile.readText().trim().toInt() }.getOrNull() ?: return null
|
||||
val targets = runCatching { JSONArray(get("http://127.0.0.1:$port/json")) }.getOrNull() ?: return null
|
||||
var wsUrl: String? = null
|
||||
for (i in 0 until targets.length()) {
|
||||
val t = targets.optJSONObject(i) ?: continue
|
||||
if (t.optString("title").contains("SharedJSContext") || t.optString("url").contains("SharedJSContext")) { wsUrl = t.optString("webSocketDebuggerUrl").ifEmpty { null }; break }
|
||||
}
|
||||
if (wsUrl == null) { Log.w(TAG, "no SharedJSContext target"); return null }
|
||||
return try {
|
||||
WebSocket(wsUrl, 8000).use { ws ->
|
||||
ws.sendText(JSONObject().put("id", 1).put("method", "Runtime.evaluate").put("params", JSONObject()
|
||||
.put("expression", expression).put("returnByValue", true).put("awaitPromise", true).put("userGesture", true)).toString())
|
||||
val deadline = System.currentTimeMillis() + 15_000
|
||||
while (System.currentTimeMillis() < deadline) {
|
||||
val message = JSONObject(ws.receiveText() ?: return null)
|
||||
if (message.optInt("id") != 1) continue
|
||||
if (message.has("error")) { Log.w(TAG, "cdp: ${message.optJSONObject("error")}"); return null }
|
||||
val result = message.optJSONObject("result") ?: return null
|
||||
result.optJSONObject("exceptionDetails")?.let { Log.w(TAG, "js: ${it.optJSONObject("exception")?.optString("description") ?: it.optString("text")}"); return null }
|
||||
return result.optJSONObject("result")?.opt("value")?.toString()
|
||||
}
|
||||
null
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
Log.w(TAG, "cdp: ${e.javaClass.simpleName}: ${e.message}")
|
||||
null
|
||||
}
|
||||
}
|
||||
|
||||
private fun get(url: String): String {
|
||||
val c = URL(url).openConnection() as HttpURLConnection
|
||||
c.connectTimeout = 3000; c.readTimeout = 3000
|
||||
try { return c.inputStream.bufferedReader().readText() } finally { c.disconnect() }
|
||||
}
|
||||
|
||||
/** The least of RFC 6455 a localhost DevTools talk needs: upgrade, masked text out, text in. */
|
||||
private class WebSocket(url: String, timeoutMs: Int) : AutoCloseable {
|
||||
private val socket: Socket
|
||||
private val out: OutputStream
|
||||
private val input: DataInputStream
|
||||
|
||||
init {
|
||||
val u = URL(url.replaceFirst("ws://", "http://"))
|
||||
socket = Socket(u.host, if (u.port > 0) u.port else 80).apply { soTimeout = timeoutMs }
|
||||
out = socket.getOutputStream()
|
||||
input = DataInputStream(socket.getInputStream())
|
||||
val key = ByteArray(16).also { SecureRandom().nextBytes(it) }
|
||||
val path = u.path.ifEmpty { "/" } + (u.query?.let { "?$it" } ?: "")
|
||||
out.write(("GET $path HTTP/1.1\r\nHost: ${u.host}:${u.port}\r\nUpgrade: websocket\r\nConnection: Upgrade\r\n" +
|
||||
"Sec-WebSocket-Key: ${Base64.encodeToString(key, Base64.NO_WRAP)}\r\nSec-WebSocket-Version: 13\r\n\r\n").toByteArray())
|
||||
out.flush()
|
||||
val status = readLine(input) ?: throw java.io.IOException("no upgrade response")
|
||||
if (!status.contains(" 101 ")) throw java.io.IOException("upgrade refused: $status")
|
||||
while (true) { val line = readLine(input) ?: break; if (line.isEmpty()) break }
|
||||
}
|
||||
|
||||
private fun readLine(s: InputStream): String? {
|
||||
val buf = ByteArrayOutputStream()
|
||||
while (true) {
|
||||
val b = s.read()
|
||||
if (b < 0) return if (buf.size() == 0) null else buf.toString("UTF-8")
|
||||
if (b == '\n'.code) return buf.toString("UTF-8").trimEnd('\r')
|
||||
buf.write(b)
|
||||
}
|
||||
}
|
||||
|
||||
fun sendText(text: String) {
|
||||
val payload = text.toByteArray()
|
||||
val mask = ByteArray(4).also { SecureRandom().nextBytes(it) }
|
||||
val frame = ByteArrayOutputStream()
|
||||
frame.write(0x81)
|
||||
when {
|
||||
payload.size < 126 -> frame.write(0x80 or payload.size)
|
||||
payload.size < 65536 -> { frame.write(0x80 or 126); frame.write(payload.size ushr 8); frame.write(payload.size and 0xFF) }
|
||||
else -> { frame.write(0x80 or 127); for (i in 7 downTo 0) frame.write(((payload.size.toLong() ushr (8 * i)) and 0xFF).toInt()) }
|
||||
}
|
||||
frame.write(mask)
|
||||
for (i in payload.indices) frame.write((payload[i].toInt() xor mask[i % 4].toInt()) and 0xFF)
|
||||
out.write(frame.toByteArray()); out.flush()
|
||||
}
|
||||
|
||||
/** The next text message (fragments joined); null on close. */
|
||||
fun receiveText(): String? {
|
||||
val message = ByteArrayOutputStream()
|
||||
while (true) {
|
||||
val b0 = input.read(); if (b0 < 0) return null
|
||||
val b1 = input.read(); if (b1 < 0) return null
|
||||
val fin = b0 and 0x80 != 0
|
||||
val opcode = b0 and 0x0F
|
||||
var len = (b1 and 0x7F).toLong()
|
||||
if (len == 126L) len = input.readUnsignedShort().toLong()
|
||||
else if (len == 127L) len = input.readLong()
|
||||
val masked = b1 and 0x80 != 0
|
||||
val mask = if (masked) ByteArray(4).also { input.readFully(it) } else null
|
||||
if (len > 16L * 1024 * 1024) throw java.io.IOException("frame too large")
|
||||
val data = ByteArray(len.toInt()).also { input.readFully(it) }
|
||||
if (mask != null) for (i in data.indices) data[i] = (data[i].toInt() xor mask[i % 4].toInt()).toByte()
|
||||
when (opcode) {
|
||||
0x8 -> return null
|
||||
0x9 -> { out.write(byteArrayOf(0x8A.toByte(), 0x80.toByte(), 0, 0, 0, 0)); out.flush() }
|
||||
0x1, 0x0 -> { message.write(data); if (fin) return message.toString("UTF-8") }
|
||||
else -> {}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
override fun close() { runCatching { out.write(byteArrayOf(0x88.toByte(), 0x80.toByte(), 0, 0, 0, 0)); out.flush() }; runCatching { socket.close() } }
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,15 @@
|
||||
package com.droiddeck.launcher.stores
|
||||
|
||||
/**
|
||||
* The three storefronts the Stores section talks to. [id] is what the sidecar, the prefs file
|
||||
* names and the Games badge carry; it never changes once a game is installed under it.
|
||||
*/
|
||||
enum class Store(val id: String, val label: String, val shortLabel: String) {
|
||||
GOG("gog", "GOG", "GOG"),
|
||||
EPIC("epic", "Epic Games", "Epic"),
|
||||
AMAZON("amazon", "Amazon Games", "Amazon");
|
||||
|
||||
companion object {
|
||||
fun byId(id: String?): Store? = entries.firstOrNull { it.id == id }
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,48 @@
|
||||
package com.droiddeck.launcher.stores
|
||||
|
||||
import android.content.Context
|
||||
import android.util.Log
|
||||
import org.json.JSONObject
|
||||
import java.io.File
|
||||
|
||||
/**
|
||||
* Each store's sign-in, kept in the app's private files: `filesDir/stores/<store>/credentials.json`.
|
||||
* Tokens never go to shared storage and never into a log; the file holds whatever the store's auth
|
||||
* flow handed back (access and refresh tokens, expiry, the account's display name, and for Amazon
|
||||
* the device registration), as one JSON object the store's own client reads and writes.
|
||||
*/
|
||||
object StoreAccounts {
|
||||
private const val TAG = "StoreAccounts"
|
||||
private const val FILE = "credentials.json"
|
||||
|
||||
/** The store's private folder: credentials, caches, the engine's scratch. */
|
||||
fun dir(context: Context, store: Store): File = File(File(context.filesDir, "stores"), store.id).apply { mkdirs() }
|
||||
|
||||
fun credentialsFile(context: Context, store: Store): File = File(dir(context, store), FILE)
|
||||
|
||||
/** The stored credentials, or null when signed out or unreadable (never logged: the text is the secret). */
|
||||
fun read(context: Context, store: Store): JSONObject? {
|
||||
val f = credentialsFile(context, store)
|
||||
if (!f.isFile) return null
|
||||
return try { JSONObject(f.readText()) } catch (e: Exception) { Log.w(TAG, "${store.id}: credentials unreadable (${e.javaClass.simpleName})"); null }
|
||||
}
|
||||
|
||||
fun write(context: Context, store: Store, json: JSONObject) {
|
||||
val f = credentialsFile(context, store)
|
||||
val tmp = File(f.path + ".tmp")
|
||||
tmp.writeText(json.toString())
|
||||
tmp.setReadable(false, false); tmp.setReadable(true, true)
|
||||
if (!tmp.renameTo(f)) { f.delete(); tmp.renameTo(f) }
|
||||
}
|
||||
|
||||
fun clear(context: Context, store: Store) {
|
||||
credentialsFile(context, store).delete()
|
||||
}
|
||||
|
||||
/** The account's display name when signed in (the store's own name for it, or a stand-in), else null. */
|
||||
fun signedInAs(context: Context, store: Store): String? {
|
||||
val json = read(context, store) ?: return null
|
||||
if (json.optString("access_token", "").isEmpty()) return null
|
||||
return json.optString("display_name", "").ifEmpty { json.optString("username", "") }.ifEmpty { store.label }
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,67 @@
|
||||
package com.droiddeck.launcher.stores
|
||||
|
||||
/**
|
||||
* One title as the Stores page draws it, whichever store produced it: a card on a shelf, a tile
|
||||
* in a grid, the hero of a game page. Library games and storefront results both project into
|
||||
* this, so one card composable serves every tab.
|
||||
*/
|
||||
data class CatalogItem(
|
||||
val store: Store,
|
||||
/** Store-native id: GOG product id, Epic catalog item id (offer id for storefront results), Amazon product id. */
|
||||
val id: String,
|
||||
val title: String,
|
||||
/** Wide (16:9-ish) art for cards and heroes. */
|
||||
val imageUrl: String?,
|
||||
/** Tall (2:3) box art when the store publishes one; falls back to [imageUrl]. */
|
||||
val tallImageUrl: String? = null,
|
||||
/** Comma-joined genre / tag line. */
|
||||
val tags: String = "",
|
||||
val isFree: Boolean = false,
|
||||
/** False when the endpoint gave no price at all: the price row then draws nothing. */
|
||||
val hasPrice: Boolean = false,
|
||||
/** Pre-formatted by the store ("$9.99"). */
|
||||
val finalPrice: String = "",
|
||||
val originalPrice: String = "",
|
||||
val discountPercent: Int = 0,
|
||||
/** The web product page: where "Get for free" and "View on <store>" land. */
|
||||
val storeUrl: String = "",
|
||||
val developer: String = "",
|
||||
val releaseDate: String = "",
|
||||
val description: String = "",
|
||||
/** True for a title the signed-in account owns (a library entry). */
|
||||
val owned: Boolean = false,
|
||||
/** The install size in bytes when known, else 0. */
|
||||
val sizeBytes: Long = 0L,
|
||||
/** Per-store identifiers the install needs (Epic namespace / catalog id / app name, Amazon entitlement / sku). */
|
||||
val extra: Map<String, String> = emptyMap(),
|
||||
) {
|
||||
val isDiscounted: Boolean get() = discountPercent > 0 && originalPrice.isNotBlank()
|
||||
/** Stable key across stores ("gog:1207658924"). */
|
||||
val key: String get() = "${store.id}:$id"
|
||||
}
|
||||
|
||||
/** A store's storefront shelves: what the Store tab shows above the account's own library. */
|
||||
class StoreShelves(
|
||||
val whatsNew: List<CatalogItem> = emptyList(),
|
||||
val deals: List<CatalogItem> = emptyList(),
|
||||
val free: List<CatalogItem> = emptyList(),
|
||||
val trending: List<CatalogItem> = emptyList(),
|
||||
) {
|
||||
val isEmpty: Boolean get() = whatsNew.isEmpty() && deals.isEmpty() && free.isEmpty() && trending.isEmpty()
|
||||
val all: List<CatalogItem> get() = (whatsNew + deals + free + trending).distinctBy { it.id }
|
||||
}
|
||||
|
||||
/** A game a store installed, as found on disk through its sidecar. */
|
||||
class InstalledStoreGame(val sidecar: StoreGameSidecar, val folder: java.io.File) {
|
||||
val key: String get() = "${sidecar.store.id}:${sidecar.id}"
|
||||
}
|
||||
|
||||
/** Human sizes the way the Downloads page and the cards print them ("810.2 MB", "3.9 GB"). */
|
||||
fun formatBytes(bytes: Long): String = when {
|
||||
bytes >= 1_073_741_824L -> "%.1f GB".format(bytes / 1_073_741_824.0)
|
||||
bytes >= 1_048_576L -> "%.1f MB".format(bytes / 1_048_576.0)
|
||||
bytes > 0L -> "%.0f KB".format(bytes / 1024.0)
|
||||
else -> ""
|
||||
}
|
||||
|
||||
fun formatSpeed(bytesPerSec: Long): String = if (bytesPerSec <= 0L) "" else "${formatBytes(bytesPerSec)}/s"
|
||||
@@ -0,0 +1,115 @@
|
||||
package com.droiddeck.launcher.stores
|
||||
|
||||
import org.json.JSONArray
|
||||
import org.json.JSONObject
|
||||
import java.io.File
|
||||
|
||||
/**
|
||||
* What a store install leaves beside the game so the rest of the app can treat the folder like
|
||||
* any added game: `.droiddeck-store.json` in the game's folder.
|
||||
*
|
||||
* [AddedGames.scanGame] reads it to pick the exe (instead of guessing from the folder), to name
|
||||
* the shortcut after the store's title and to tag the game with its [store]. [launcher] is the
|
||||
* small .bat the install writes when the game needs arguments or environment at launch (Epic's
|
||||
* identity arguments, Amazon's FuelPump variables): a Steam shortcut can only carry a fixed Exe,
|
||||
* so the launcher is what the shortcut runs and [exe] is what it starts. [addToSteam] off keeps
|
||||
* the game out of the client's library (Setup › Stores, or the game page's "Add to Steam").
|
||||
*
|
||||
* Paths are relative to the game folder with forward slashes, so the folder can move between
|
||||
* storage volumes and stay valid.
|
||||
*/
|
||||
class StoreGameSidecar(
|
||||
val store: Store,
|
||||
val id: String,
|
||||
val title: String,
|
||||
val exe: String,
|
||||
val launcher: String? = null,
|
||||
val args: List<String> = emptyList(),
|
||||
val env: Map<String, String> = emptyMap(),
|
||||
val installVersion: String = "",
|
||||
val installedAt: Long = 0L,
|
||||
val addToSteam: Boolean = true,
|
||||
val cover: String? = null,
|
||||
val hero: String? = null,
|
||||
/** Per-store identifiers the launcher needs again (Epic namespace / catalog id, Amazon entitlement). */
|
||||
val extra: Map<String, String> = emptyMap(),
|
||||
) {
|
||||
fun exeFile(folder: File): File = File(folder, exe)
|
||||
fun launcherFile(folder: File): File? = launcher?.takeIf { it.isNotEmpty() }?.let { File(folder, it) }
|
||||
|
||||
fun copy(
|
||||
exe: String = this.exe, launcher: String? = this.launcher, args: List<String> = this.args,
|
||||
env: Map<String, String> = this.env, installVersion: String = this.installVersion,
|
||||
installedAt: Long = this.installedAt, addToSteam: Boolean = this.addToSteam,
|
||||
cover: String? = this.cover, hero: String? = this.hero, extra: Map<String, String> = this.extra,
|
||||
) = StoreGameSidecar(store, id, title, exe, launcher, args, env, installVersion, installedAt, addToSteam, cover, hero, extra)
|
||||
|
||||
fun toJson(): JSONObject = JSONObject().apply {
|
||||
put("version", VERSION)
|
||||
put("store", store.id)
|
||||
put("id", id)
|
||||
put("title", title)
|
||||
put("exe", exe)
|
||||
if (!launcher.isNullOrEmpty()) put("launcher", launcher)
|
||||
if (args.isNotEmpty()) put("args", JSONArray(args))
|
||||
if (env.isNotEmpty()) put("env", JSONObject(env as Map<*, *>))
|
||||
if (installVersion.isNotEmpty()) put("installVersion", installVersion)
|
||||
if (installedAt > 0L) put("installedAt", installedAt)
|
||||
put("addToSteam", addToSteam)
|
||||
if (!cover.isNullOrEmpty()) put("cover", cover)
|
||||
if (!hero.isNullOrEmpty()) put("hero", hero)
|
||||
if (extra.isNotEmpty()) put("extra", JSONObject(extra as Map<*, *>))
|
||||
}
|
||||
|
||||
fun write(folder: File) {
|
||||
folder.mkdirs()
|
||||
val file = File(folder, FILE_NAME)
|
||||
val tmp = File(folder, "$FILE_NAME.tmp")
|
||||
tmp.writeText(toJson().toString(2))
|
||||
if (!tmp.renameTo(file)) { file.delete(); tmp.renameTo(file) }
|
||||
}
|
||||
|
||||
companion object {
|
||||
const val FILE_NAME = ".droiddeck-store.json"
|
||||
const val VERSION = 1
|
||||
|
||||
fun file(folder: File): File = File(folder, FILE_NAME)
|
||||
|
||||
/** The sidecar in [folder], or null when there is none or it cannot be read. */
|
||||
fun read(folder: File): StoreGameSidecar? {
|
||||
val f = file(folder)
|
||||
if (!f.isFile) return null
|
||||
return runCatching { parse(f.readText()) }.getOrNull()
|
||||
}
|
||||
|
||||
/** Pure parse; null for anything that is not a usable sidecar (so a scan falls back to guessing). */
|
||||
fun parse(text: String): StoreGameSidecar? {
|
||||
val o = runCatching { JSONObject(text) }.getOrNull() ?: return null
|
||||
val store = Store.byId(o.optString("store")) ?: return null
|
||||
val id = o.optString("id", "")
|
||||
val title = o.optString("title", "")
|
||||
val exe = o.optString("exe", "").replace('\\', '/').trimStart('/')
|
||||
if (id.isEmpty() || title.isEmpty() || exe.isEmpty()) return null
|
||||
// A relative path only: a sidecar that points outside its folder is not trusted.
|
||||
if (!relativeInside(exe)) return null
|
||||
val launcher = o.optString("launcher", "").replace('\\', '/').trimStart('/').takeIf { it.isNotEmpty() && relativeInside(it) }
|
||||
val args = o.optJSONArray("args")?.let { a -> List(a.length()) { a.optString(it) }.filter { it.isNotEmpty() } } ?: emptyList()
|
||||
val env = LinkedHashMap<String, String>()
|
||||
o.optJSONObject("env")?.let { e -> e.keys().forEach { k -> env[k] = e.optString(k) } }
|
||||
val extra = LinkedHashMap<String, String>()
|
||||
o.optJSONObject("extra")?.let { e -> e.keys().forEach { k -> extra[k] = e.optString(k) } }
|
||||
return StoreGameSidecar(
|
||||
store, id, title, exe, launcher, args, env,
|
||||
installVersion = o.optString("installVersion", ""),
|
||||
installedAt = o.optLong("installedAt", 0L),
|
||||
addToSteam = o.optBoolean("addToSteam", true),
|
||||
cover = o.optString("cover", "").ifEmpty { null },
|
||||
hero = o.optString("hero", "").ifEmpty { null },
|
||||
extra = extra,
|
||||
)
|
||||
}
|
||||
|
||||
private fun relativeInside(path: String): Boolean =
|
||||
path.isNotEmpty() && !path.startsWith("/") && !path.contains(":") && path.split('/').none { it == ".." }
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,63 @@
|
||||
package com.droiddeck.launcher.stores
|
||||
|
||||
import android.content.Context
|
||||
import com.droiddeck.launcher.runtime.LinuxRuntime
|
||||
import com.droiddeck.launcher.session.GameStorage
|
||||
import java.io.File
|
||||
|
||||
/**
|
||||
* Where store games are installed: `<Games storage>/Games/<Store>/<title>`.
|
||||
*
|
||||
* The Games storage is the second Steam library when one is chosen (an SD card's app folder,
|
||||
* bound into the session at /mnt/droiddeck-sd), else the runtime's own tree, where the folder
|
||||
* sits at /root/Games/Stores - beside the user's added folders, which are bound under /root/Games
|
||||
* one by one. Both roots are always scanned, so a game installed on the card before the setting
|
||||
* changed stays in the library; new installs go to [installRoot].
|
||||
*/
|
||||
object StoreInstallRoot {
|
||||
private const val INTERNAL = "root/Games/Stores"
|
||||
private const val ON_LIBRARY = "Games"
|
||||
|
||||
/** The folder a new install goes under (before the store's own folder). */
|
||||
fun installRoot(context: Context): File =
|
||||
GameStorage.effective(context)?.let { File(it.path, ON_LIBRARY) } ?: File(LinuxRuntime.rootDir(context), INTERNAL)
|
||||
|
||||
/** Every root that may hold store games, the current install root first. */
|
||||
fun roots(context: Context): List<File> {
|
||||
val internal = File(LinuxRuntime.rootDir(context), INTERNAL)
|
||||
val library = GameStorage.effective(context)?.let { File(it.path, ON_LIBRARY) }
|
||||
return listOfNotNull(library, internal).distinctBy { it.absolutePath }
|
||||
}
|
||||
|
||||
fun storeDir(root: File, store: Store): File = File(root, store.shortLabel)
|
||||
|
||||
/** The game folders under every root, by store: what AddedGames scans in addition to the user's folders. */
|
||||
fun gameFolders(context: Context): List<File> = roots(context).flatMap { root ->
|
||||
Store.entries.flatMap { store ->
|
||||
storeDir(root, store).listFiles { f -> f.isDirectory }?.sortedBy { it.name.lowercase() } ?: emptyList()
|
||||
}
|
||||
}
|
||||
|
||||
/** The folder a game installs into; an existing folder with this game's sidecar wins over a fresh name. */
|
||||
fun folderFor(context: Context, store: Store, id: String, title: String): File {
|
||||
for (root in roots(context)) {
|
||||
val dir = storeDir(root, store)
|
||||
dir.listFiles { f -> f.isDirectory }?.forEach { folder ->
|
||||
val sidecar = StoreGameSidecar.read(folder)
|
||||
if (sidecar != null && sidecar.store == store && sidecar.id == id) return folder
|
||||
}
|
||||
}
|
||||
return File(storeDir(installRoot(context), store), folderName(title, id))
|
||||
}
|
||||
|
||||
/**
|
||||
* A folder name from a store title: letters, digits and a few safe marks, single spaces, at most
|
||||
* 60 characters; the store id when nothing printable is left. The name is the shortcut's folder
|
||||
* for ever, so it is chosen once at install and read back from the sidecar afterwards.
|
||||
*/
|
||||
fun folderName(title: String, id: String): String {
|
||||
val cleaned = title.replace(Regex("[^A-Za-z0-9 ._'()\\-]"), " ").replace(Regex("\\s+"), " ").trim().trimEnd('.')
|
||||
val cut = if (cleaned.length > 60) cleaned.take(60).trimEnd().trimEnd('.') else cleaned
|
||||
return cut.ifEmpty { id.replace(Regex("[^A-Za-z0-9._-]"), "_").ifEmpty { "game" } }
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,77 @@
|
||||
package com.droiddeck.launcher.stores
|
||||
|
||||
import android.content.Context
|
||||
import android.util.Log
|
||||
import com.droiddeck.launcher.frontend.AddedGames
|
||||
import com.droiddeck.launcher.frontend.LibraryCache
|
||||
import com.droiddeck.launcher.frontend.Library
|
||||
import com.droiddeck.launcher.session.SessionPrefs
|
||||
import java.io.File
|
||||
|
||||
/**
|
||||
* What happens around a store install so the rest of the app sees the game: the sidecar is
|
||||
* written, the launcher .bat beside it when the game needs one, and Steam is told.
|
||||
*
|
||||
* Telling Steam is the same path an added folder takes: the game is one more folder the
|
||||
* [AddedGames] scan finds, the scan's listing (`session/added-games.json`) is what the runtime's
|
||||
* shortcuts writer reads at the client's next start, and here the listing is rewritten at once so
|
||||
* a Steam client that restarts inside the running session already has it. When the client is up,
|
||||
* [SteamLiveShortcuts] adds the shortcut over the client's DevTools port as well, so the game shows
|
||||
* without a restart; that part is best-effort.
|
||||
*/
|
||||
object StoreInstalls {
|
||||
private const val TAG = "StoreInstalls"
|
||||
|
||||
/**
|
||||
* Finishes an install: writes the sidecar (and the launcher), then registers the game. Runs on
|
||||
* the caller's worker thread.
|
||||
*/
|
||||
fun complete(context: Context, folder: File, sidecar: StoreGameSidecar) {
|
||||
val app = context.applicationContext
|
||||
val withLauncher = StoreLaunch.writeLauncher(folder, sidecar)
|
||||
withLauncher.copy(addToSteam = sidecar.addToSteam && SessionPrefs.gameStoresAddToSteam(app)).write(folder)
|
||||
register(app)
|
||||
}
|
||||
|
||||
/** Rewrites the session's listing from a fresh scan and, with a client running, adds live. */
|
||||
fun register(context: Context) {
|
||||
val app = context.applicationContext
|
||||
try {
|
||||
val games = AddedGames.scan(app)
|
||||
AddedGames.writeListing(app, games)
|
||||
LibraryCache.save(app, Library.launchableGames(app))
|
||||
SteamLiveShortcuts.sync(app, games)
|
||||
} catch (e: Exception) {
|
||||
Log.w(TAG, "registration: ${e.message}")
|
||||
}
|
||||
}
|
||||
|
||||
/** Flips whether the game is a Steam shortcut; the listing follows. Off the main thread. */
|
||||
fun setAddToSteam(context: Context, game: InstalledStoreGame, on: Boolean, onDone: () -> Unit) {
|
||||
val app = context.applicationContext
|
||||
Thread({
|
||||
game.sidecar.copy(addToSteam = on).write(game.folder)
|
||||
register(app)
|
||||
StoresState.logLine("${if (on) "added" else "removed"} Steam shortcut for \"${game.sidecar.title}\"")
|
||||
StoresState.post(onDone)
|
||||
}, "stores-steam-toggle").start()
|
||||
}
|
||||
|
||||
/**
|
||||
* Removes a store game: its folder with the sidecar, then the shortcut (the listing without
|
||||
* it makes the writer drop the entry; the live client is asked too). Blocking.
|
||||
*/
|
||||
fun uninstall(context: Context, game: InstalledStoreGame) {
|
||||
val app = context.applicationContext
|
||||
val appId = AddedGames.scan(app).firstOrNull { it.folder.absolutePath == game.folder.absolutePath }?.appId
|
||||
deleteTree(game.folder)
|
||||
register(app)
|
||||
if (appId != null) SteamLiveShortcuts.remove(app, appId)
|
||||
StoresState.logLine("uninstalled \"${game.sidecar.title}\"")
|
||||
}
|
||||
|
||||
fun deleteTree(dir: File) {
|
||||
dir.listFiles()?.forEach { if (it.isDirectory) deleteTree(it) else it.delete() }
|
||||
dir.delete()
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,104 @@
|
||||
package com.droiddeck.launcher.stores
|
||||
|
||||
import android.content.Context
|
||||
import android.util.Log
|
||||
import com.droiddeck.launcher.frontend.Library
|
||||
import java.io.File
|
||||
|
||||
/**
|
||||
* How a store game is started inside the session. The shortcut Steam gets can only name an exe,
|
||||
* so when a game needs arguments or environment the install writes a small `.droiddeck-launch.bat`
|
||||
* beside it and the shortcut runs that: Proton's steam.exe shim hands a .bat to Wine's cmd, the
|
||||
* script sets the variables, steps into the exe's folder and starts it, and waits for it as a
|
||||
* batch file does, so Steam sees the game running.
|
||||
*
|
||||
* Epic's online sign-in is a fresh exchange code per launch (it expires in minutes), which no fixed
|
||||
* command line can carry: [prepare] mints one right before a launch and leaves it in
|
||||
* `.droiddeck-epic-code`; the script reads the file, deletes it and passes the AUTH arguments. With
|
||||
* no file the game starts in its offline identity mode, which most EOS titles accept.
|
||||
*/
|
||||
object StoreLaunch {
|
||||
private const val TAG = "StoreLaunch"
|
||||
const val LAUNCHER = ".droiddeck-launch.bat"
|
||||
const val EPIC_CODE = ".droiddeck-epic-code"
|
||||
|
||||
/**
|
||||
* Writes the launcher for [sidecar] when it needs one (arguments, environment, or an Epic
|
||||
* sign-in), removes a stale one otherwise, and returns the sidecar with [StoreGameSidecar.launcher] set.
|
||||
*/
|
||||
fun writeLauncher(folder: File, sidecar: StoreGameSidecar): StoreGameSidecar {
|
||||
val needs = sidecar.args.isNotEmpty() || sidecar.env.isNotEmpty() || sidecar.store == Store.EPIC
|
||||
val file = File(folder, LAUNCHER)
|
||||
if (!needs) { file.delete(); return sidecar.copy(launcher = null) }
|
||||
file.writeText(launcherText(sidecar))
|
||||
return sidecar.copy(launcher = LAUNCHER)
|
||||
}
|
||||
|
||||
/** The batch file's text; pure, so it can be checked without a folder. CRLF line ends, as cmd expects. */
|
||||
fun launcherText(sidecar: StoreGameSidecar): String {
|
||||
val exe = sidecar.exe.replace('/', '\\')
|
||||
val dir = exe.substringBeforeLast('\\', "")
|
||||
val lines = ArrayList<String>()
|
||||
lines += "@echo off"
|
||||
lines += "setlocal"
|
||||
lines += "rem Written by DroidDeck for ${sidecar.store.label}: ${sidecar.title}. Steam's shortcut runs this file."
|
||||
lines += "cd /d \"%~dp0$dir\""
|
||||
for ((k, v) in sidecar.env) if (ENV_NAME.matches(k)) lines += "set \"$k=${v.replace("\"", "")}\""
|
||||
lines += "set \"DD_AUTH=\""
|
||||
if (sidecar.store == Store.EPIC) {
|
||||
lines += "if not exist \"%~dp0$EPIC_CODE\" goto run"
|
||||
lines += "set /p DD_CODE=<\"%~dp0$EPIC_CODE\""
|
||||
lines += "del \"%~dp0$EPIC_CODE\""
|
||||
lines += "set \"DD_AUTH=-AUTH_LOGIN=unused -AUTH_PASSWORD=%DD_CODE% -AUTH_TYPE=exchangecode\""
|
||||
lines += ":run"
|
||||
}
|
||||
val args = sidecar.args.joinToString(" ") { quoteArg(it) }
|
||||
lines += "\"%~dp0$exe\"" + (if (args.isNotEmpty()) " $args" else "") + " %DD_AUTH%"
|
||||
return lines.joinToString("\r\n") + "\r\n"
|
||||
}
|
||||
|
||||
private val ENV_NAME = Regex("[A-Za-z_][A-Za-z0-9_]*")
|
||||
|
||||
/** An argument on cmd's line: quoted when it has a space and is not already quoted; control characters dropped. */
|
||||
fun quoteArg(arg: String): String {
|
||||
val clean = arg.filter { it >= ' ' && it != '\u007f' }
|
||||
return if (' ' in clean && !clean.contains('"')) "\"$clean\"" else clean
|
||||
}
|
||||
|
||||
/**
|
||||
* Everything a launch needs done first, then [onReady] on the main thread: for an Epic game a
|
||||
* fresh exchange code written beside its launcher (a few seconds at most; the launch goes
|
||||
* ahead without one when the store cannot be reached). Other stores need nothing.
|
||||
*/
|
||||
fun prepare(context: Context, store: Store, id: String, onReady: () -> Unit) {
|
||||
if (store != Store.EPIC) { onReady(); return }
|
||||
val app = context.applicationContext
|
||||
Thread({
|
||||
try {
|
||||
val game = StoreInstallRoot.gameFolders(app).firstOrNull { folder -> StoreGameSidecar.read(folder)?.let { it.store == store && it.id == id } == true }
|
||||
if (game != null) {
|
||||
val code = StoresState.backend(Store.EPIC)?.let { (it as? EpicLaunchSupport)?.exchangeCode(app) }
|
||||
val file = File(game, EPIC_CODE)
|
||||
if (code != null) { file.writeText(code); Log.i(TAG, "epic: exchange code ready for $id") }
|
||||
else { file.delete(); Log.w(TAG, "epic: no exchange code; launching offline") }
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
Log.w(TAG, "epic prepare: ${e.message}")
|
||||
}
|
||||
StoresState.post(onReady)
|
||||
}, "stores-launch-prep").start()
|
||||
}
|
||||
|
||||
/** The same, for a game the Games tab launches: a store's game by its [Library.SteamGame.source]. */
|
||||
fun prepare(context: Context, game: Library.SteamGame, onReady: () -> Unit) {
|
||||
val store = Store.byId(game.source)
|
||||
val id = game.storeId
|
||||
if (store == null || id == null) { onReady(); return }
|
||||
prepare(context, store, id, onReady)
|
||||
}
|
||||
}
|
||||
|
||||
/** What the Epic backend adds for launches: a short-lived exchange code for the signed-in account. */
|
||||
interface EpicLaunchSupport {
|
||||
fun exchangeCode(context: Context): String?
|
||||
}
|
||||
@@ -0,0 +1,53 @@
|
||||
package com.droiddeck.launcher.stores
|
||||
|
||||
import android.util.Log
|
||||
|
||||
/**
|
||||
* The native download engine for the three stores: `libdroiddeckstores.so`, loaded the first time
|
||||
* a store needs it, never at process start. The app has to run without it (a build without the
|
||||
* Rust side, or an ABI the library was not built for), so [available] is a plain false then and
|
||||
* Setup says "Stores engine not built"; the store managers fall back to their Java fetch loops.
|
||||
*/
|
||||
object StoresNative {
|
||||
private const val TAG = "StoresNative"
|
||||
private const val LIBRARY = "droiddeckstores"
|
||||
|
||||
@Volatile private var loaded: Boolean? = null
|
||||
@Volatile private var versionText: String? = null
|
||||
@Volatile private var failure: String? = null
|
||||
|
||||
/** True once the library is loaded and its version symbol binds; false when it cannot be. Cached. */
|
||||
val available: Boolean get() = ensureLoaded()
|
||||
|
||||
/** The engine's own version string, or null when the library is not there. */
|
||||
val version: String? get() { ensureLoaded(); return versionText }
|
||||
|
||||
/** Why the library did not load, for the Setup row; null when it did. */
|
||||
val problem: String? get() { ensureLoaded(); return failure }
|
||||
|
||||
@Synchronized
|
||||
fun ensureLoaded(): Boolean {
|
||||
loaded?.let { return it }
|
||||
val ok = try {
|
||||
System.loadLibrary(LIBRARY)
|
||||
versionText = nativeVersion()
|
||||
Log.i(TAG, "engine ${versionText ?: "?"} loaded")
|
||||
true
|
||||
} catch (t: Throwable) {
|
||||
// UnsatisfiedLinkError when the .so is missing or a symbol is not there; anything else is
|
||||
// a surprise worth the same treatment: the Java paths carry on.
|
||||
failure = t.javaClass.simpleName + (t.message?.let { ": $it" } ?: "")
|
||||
Log.w(TAG, "engine unavailable: $failure")
|
||||
false
|
||||
}
|
||||
loaded = ok
|
||||
return ok
|
||||
}
|
||||
|
||||
/** Resets the cached answer, so a probe after an update tries the library again. */
|
||||
@Synchronized
|
||||
fun forget() { loaded = null }
|
||||
|
||||
@JvmStatic
|
||||
private external fun nativeVersion(): String
|
||||
}
|
||||
@@ -0,0 +1,157 @@
|
||||
package com.droiddeck.launcher.stores
|
||||
|
||||
import android.content.Context
|
||||
import android.os.Handler
|
||||
import android.os.Looper
|
||||
import android.util.Log
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableStateListOf
|
||||
import androidx.compose.runtime.mutableStateMapOf
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.setValue
|
||||
import com.droiddeck.launcher.stores.download.DownloadEntry
|
||||
import java.text.SimpleDateFormat
|
||||
import java.util.Date
|
||||
import java.util.Locale
|
||||
|
||||
/**
|
||||
* The Stores section's state for the life of the process (like the Flathub store's StoreState):
|
||||
* who is signed in where, each store's library and shelves, what is installed on disk and what is
|
||||
* being downloaded, so a download carries on - and its progress shows - while the user moves
|
||||
* around the launcher or plays something meanwhile.
|
||||
*
|
||||
* Everything here is read on the main thread by Compose; the workers post their results back.
|
||||
* The stores themselves plug in as [StoreBackend]s; a store with no backend in this build shows
|
||||
* its sign-in card and nothing else.
|
||||
*/
|
||||
object StoresState {
|
||||
private const val TAG = "Stores"
|
||||
private val main = Handler(Looper.getMainLooper())
|
||||
|
||||
/** The signed-in account name per store; absent = signed out. */
|
||||
val accounts = mutableStateMapOf<Store, String>()
|
||||
|
||||
/** Each store's owned games, once synced; absent until then. */
|
||||
val library = mutableStateMapOf<Store, List<CatalogItem>>()
|
||||
|
||||
/** Each store's storefront shelves, once fetched. */
|
||||
val shelves = mutableStateMapOf<Store, StoreShelves>()
|
||||
|
||||
/** What a store is doing for the user right now ("Syncing 12 games…"); absent when idle. */
|
||||
val status = mutableStateMapOf<Store, String>()
|
||||
|
||||
/** Why the last sync or shelf fetch failed, per store; cleared by the next success. */
|
||||
val problems = mutableStateMapOf<Store, String>()
|
||||
|
||||
/** Games the stores installed, as their sidecars say; rebuilt by [refresh]. */
|
||||
var installed by mutableStateOf<List<InstalledStoreGame>>(emptyList())
|
||||
private set
|
||||
|
||||
/** The download queue as the Downloads page lists it, newest first. */
|
||||
var downloads by mutableStateOf<List<DownloadEntry>>(emptyList())
|
||||
internal set
|
||||
|
||||
/** The engine log the Downloads page shows: the last lines, newest last. */
|
||||
val log = mutableStateListOf<String>()
|
||||
|
||||
/** The native engine's version once probed, "" when the probe said it is missing, null before. */
|
||||
var engine by mutableStateOf<String?>(null)
|
||||
private set
|
||||
|
||||
/** Downloads queued, running or paused across the stores (the rail badge). */
|
||||
val activeDownloads: Int get() = downloads.count { it.isActive }
|
||||
|
||||
private val backends = HashMap<Store, StoreBackend>()
|
||||
|
||||
/** A store's implementation, registered once as the app starts; null when this build lacks it. */
|
||||
fun backend(store: Store): StoreBackend? = backends[store]
|
||||
|
||||
fun register(backend: StoreBackend) { backends[backend.store] = backend }
|
||||
|
||||
/** Reads what is on disk for every store: sign-ins, installed games, the engine probe. Off the main thread. */
|
||||
fun refresh(context: Context) {
|
||||
val app = context.applicationContext
|
||||
Thread({
|
||||
val found = Store.entries.mapNotNull { store -> StoreAccounts.signedInAs(app, store)?.let { store to it } }
|
||||
val onDisk = StoreInstallRoot.gameFolders(app).mapNotNull { folder ->
|
||||
StoreGameSidecar.read(folder)?.let { InstalledStoreGame(it, folder) }
|
||||
}
|
||||
val version = if (StoresNative.available) StoresNative.version ?: "" else ""
|
||||
main.post {
|
||||
accounts.clear()
|
||||
found.forEach { (store, name) -> accounts[store] = name }
|
||||
installed = onDisk
|
||||
engine = version
|
||||
}
|
||||
}, "stores-refresh").start()
|
||||
}
|
||||
|
||||
fun isSignedIn(store: Store): Boolean = accounts.containsKey(store)
|
||||
|
||||
fun installedGame(store: Store, id: String): InstalledStoreGame? = installed.firstOrNull { it.sidecar.store == store && it.sidecar.id == id }
|
||||
|
||||
/** The user's library and the shelves for [store], from the cache first and the network when stale. */
|
||||
fun open(context: Context, store: Store, force: Boolean = false) {
|
||||
val backend = backends[store] ?: return
|
||||
val app = context.applicationContext
|
||||
if (!isSignedIn(store)) return
|
||||
if (force || !library.containsKey(store)) backend.syncLibrary(app, force)
|
||||
if (force || !shelves.containsKey(store)) backend.loadShelves(app, force)
|
||||
}
|
||||
|
||||
fun signIn(context: Context, store: Store) {
|
||||
backends[store]?.signIn(context) ?: logLine("${store.label}: this build has no sign-in for it yet")
|
||||
}
|
||||
|
||||
fun signOut(context: Context, store: Store) {
|
||||
val app = context.applicationContext
|
||||
Thread({
|
||||
backends[store]?.signOut(app)
|
||||
StoreAccounts.clear(app, store)
|
||||
main.post {
|
||||
accounts.remove(store); library.remove(store); status.remove(store); problems.remove(store)
|
||||
logLine("${store.label}: signed out")
|
||||
}
|
||||
}, "stores-signout").start()
|
||||
}
|
||||
|
||||
fun install(context: Context, item: CatalogItem) {
|
||||
backends[item.store]?.install(context.applicationContext, item) ?: logLine("${item.store.label}: this build cannot install yet")
|
||||
}
|
||||
|
||||
/** Removes the game's folder and its shortcut, then [onDone] on the main thread. */
|
||||
fun uninstall(context: Context, game: InstalledStoreGame, onDone: () -> Unit = {}) {
|
||||
val app = context.applicationContext
|
||||
Thread({
|
||||
runCatching { backends[game.sidecar.store]?.uninstall(app, game) }
|
||||
StoreInstalls.uninstall(app, game)
|
||||
main.post(onDone)
|
||||
}, "stores-uninstall").start()
|
||||
}
|
||||
|
||||
/** Appends to the engine log with a clock, keeping the last 80 lines. Any thread. */
|
||||
fun logLine(text: String) {
|
||||
val stamped = "${CLOCK.format(Date())} $text"
|
||||
Log.i(TAG, text)
|
||||
main.post {
|
||||
log.add(stamped)
|
||||
while (log.size > 80) log.removeAt(0)
|
||||
}
|
||||
}
|
||||
|
||||
internal fun post(block: () -> Unit) = main.post(block)
|
||||
|
||||
private val CLOCK = SimpleDateFormat("HH:mm:ss", Locale.US)
|
||||
}
|
||||
|
||||
/** What one store contributes: sign-in, its library, its shelves, installs and removals. */
|
||||
interface StoreBackend {
|
||||
val store: Store
|
||||
fun signIn(context: Context)
|
||||
fun signOut(context: Context)
|
||||
fun syncLibrary(context: Context, force: Boolean)
|
||||
fun loadShelves(context: Context, force: Boolean)
|
||||
fun install(context: Context, item: CatalogItem)
|
||||
/** The store's own records of the install, before the folder goes (the queue row, cached ids). */
|
||||
fun uninstall(context: Context, game: InstalledStoreGame)
|
||||
}
|
||||
@@ -0,0 +1,55 @@
|
||||
package com.droiddeck.launcher.stores.download
|
||||
|
||||
import com.droiddeck.launcher.stores.Store
|
||||
|
||||
/**
|
||||
* The store-agnostic shape every store's download is projected into, so one queue and one
|
||||
* Downloads page serve all three. Ported from Bannerlator's download registry, trimmed to what
|
||||
* this app shows: a stage, one byte pair, speed and ETA, and the handles that drive it.
|
||||
*/
|
||||
|
||||
/** Where a download is. The names are what the Downloads page prints over its stage strip. */
|
||||
enum class DownloadStage { MANIFEST, DOWNLOAD, VERIFY, INSTALL, DONE }
|
||||
|
||||
/**
|
||||
* QUEUED / RUNNING / PAUSED are the active states (in memory only). INSTALLED is the terminal
|
||||
* success; FAILED and CANCELLED stay listed for the session so the page can offer a retry or a
|
||||
* dismissal.
|
||||
*/
|
||||
enum class DownloadState { QUEUED, RUNNING, PAUSED, INSTALLED, FAILED, CANCELLED }
|
||||
|
||||
data class DownloadEntry(
|
||||
val store: Store,
|
||||
val id: String,
|
||||
val name: String,
|
||||
val cover: String? = null,
|
||||
val state: DownloadState = DownloadState.QUEUED,
|
||||
val stage: DownloadStage = DownloadStage.MANIFEST,
|
||||
/** Bytes landed and the total, as the engine reports them (install size). */
|
||||
val bytesDone: Long = 0L,
|
||||
val bytesTotal: Long = 0L,
|
||||
/** Smoothed speed in bytes/s (0 = unknown) and seconds left (-1 = unknown). */
|
||||
val speedBps: Long = 0L,
|
||||
val etaSeconds: Long = -1L,
|
||||
/** The engine's current line ("Downloading: data.pak"), shown under the bar. */
|
||||
val detail: String = "",
|
||||
/** Where it was installed (INSTALLED) or why it failed (FAILED). */
|
||||
val installPath: String? = null,
|
||||
val error: String? = null,
|
||||
/** 1-based place in the queue while QUEUED, else 0. */
|
||||
val queuePosition: Int = 0,
|
||||
/** When the entry reached a terminal state, for ordering and ageing out. */
|
||||
val finishedAt: Long = 0L,
|
||||
val startedAt: Long = 0L,
|
||||
) {
|
||||
val key: String get() = "${store.id}:$id"
|
||||
val isActive: Boolean get() = state == DownloadState.QUEUED || state == DownloadState.RUNNING || state == DownloadState.PAUSED
|
||||
/** 0..1 for the bar: the byte fraction while downloading, full once past it. */
|
||||
val fraction: Float get() = when {
|
||||
state == DownloadState.INSTALLED -> 1f
|
||||
stage == DownloadStage.VERIFY || stage == DownloadStage.INSTALL || stage == DownloadStage.DONE -> 1f
|
||||
bytesTotal > 0L -> (bytesDone.toDouble() / bytesTotal).toFloat().coerceIn(0f, 1f)
|
||||
else -> 0f
|
||||
}
|
||||
val percent: Int get() = (fraction * 100).toInt()
|
||||
}
|
||||
@@ -0,0 +1,214 @@
|
||||
package com.droiddeck.launcher.stores.download
|
||||
|
||||
import android.content.Context
|
||||
import android.util.Log
|
||||
import com.droiddeck.launcher.session.SessionPrefs
|
||||
import com.droiddeck.launcher.stores.StoresState
|
||||
import java.util.concurrent.atomic.AtomicBoolean
|
||||
|
||||
/**
|
||||
* One queue for every store's downloads, [parallel] of them running at a time (1..3, Setup and
|
||||
* the Downloads page), the rest waiting as data until a slot frees. A store hands in a
|
||||
* [DownloadJob] - its whole install: manifest, files, verify, post-install - and reports back
|
||||
* through the job's [JobHandle]; the queue owns the states, the registry row the UI draws and the
|
||||
* foreground service that keeps the process alive while anything runs.
|
||||
*
|
||||
* Pause for these engines is "stop now, keep the files": every store's install skips files that
|
||||
* are already complete and verified, so resuming is simply running the job again. Cancel removes
|
||||
* what was downloaded (the job's own cleanup) and the row.
|
||||
*
|
||||
* Thread-safe: one [lock] guards the table; jobs run on their own threads, outside it.
|
||||
*/
|
||||
object DownloadQueue {
|
||||
private const val TAG = "StoreDownloads"
|
||||
|
||||
/** The work for one download. [run] blocks until it is over; [cancel] asks a running job to stop. */
|
||||
interface DownloadJob {
|
||||
/** Runs the install; returns the install folder's path on success, throws or returns null on failure. */
|
||||
fun run(handle: JobHandle): String?
|
||||
/** Stops a running job; [deleteFiles] false keeps what was fetched for a later resume. */
|
||||
fun cancel(deleteFiles: Boolean)
|
||||
}
|
||||
|
||||
/** What a running job reports with. */
|
||||
class JobHandle internal constructor(val key: String, internal val cancelled: AtomicBoolean) {
|
||||
val isCancelled: Boolean get() = cancelled.get()
|
||||
fun stage(stage: DownloadStage, detail: String = "") = update(key) { it.copy(stage = stage, detail = detail.ifEmpty { it.detail }, state = DownloadState.RUNNING) }
|
||||
fun progress(bytesDone: Long, bytesTotal: Long, detail: String? = null, speedBps: Long = -1L) = update(key) {
|
||||
val speed = if (speedBps >= 0) speedBps else it.speedBps
|
||||
val eta = if (speed > 0 && bytesTotal > bytesDone) (bytesTotal - bytesDone) / speed else -1L
|
||||
it.copy(state = DownloadState.RUNNING, stage = DownloadStage.DOWNLOAD, bytesDone = bytesDone, bytesTotal = if (bytesTotal > 0) bytesTotal else it.bytesTotal, detail = detail ?: it.detail, speedBps = speed, etaSeconds = eta)
|
||||
}
|
||||
fun log(line: String) = StoresState.logLine(line)
|
||||
}
|
||||
|
||||
private class Item(var entry: DownloadEntry, val factory: () -> DownloadJob, var job: DownloadJob? = null, val cancelled: AtomicBoolean = AtomicBoolean(false), var pauseRequested: Boolean = false)
|
||||
|
||||
private val lock = Any()
|
||||
private val items = LinkedHashMap<String, Item>()
|
||||
private var running = 0
|
||||
|
||||
fun parallel(context: Context): Int = SessionPrefs.gameStoresParallel(context)
|
||||
|
||||
fun setParallel(context: Context, count: Int) {
|
||||
SessionPrefs.setGameStoresParallel(context, count)
|
||||
advance(context.applicationContext)
|
||||
}
|
||||
|
||||
fun entry(key: String): DownloadEntry? = synchronized(lock) { items[key]?.entry }
|
||||
|
||||
fun isActive(key: String): Boolean = entry(key)?.isActive == true
|
||||
|
||||
/**
|
||||
* Queues a download, or starts it when a slot is free. A key already active is left alone; a
|
||||
* finished or failed row with the same key is replaced.
|
||||
*/
|
||||
fun enqueue(context: Context, entry: DownloadEntry, factory: () -> DownloadJob) {
|
||||
val app = context.applicationContext
|
||||
synchronized(lock) {
|
||||
items[entry.key]?.let { if (it.entry.isActive) { Log.i(TAG, "${entry.key}: already queued"); return } }
|
||||
items.remove(entry.key)
|
||||
items[entry.key] = Item(entry.copy(state = DownloadState.QUEUED, stage = DownloadStage.MANIFEST, startedAt = System.currentTimeMillis()), factory)
|
||||
renumber()
|
||||
}
|
||||
StoresState.logLine("${entry.store.label}: queued \"${entry.name}\"" + if (entry.bytesTotal > 0) " (${com.droiddeck.launcher.stores.formatBytes(entry.bytesTotal)})" else "")
|
||||
publish(app)
|
||||
advance(app)
|
||||
}
|
||||
|
||||
fun pause(key: String) {
|
||||
val item = synchronized(lock) { items[key] } ?: return
|
||||
synchronized(lock) {
|
||||
if (item.entry.state == DownloadState.QUEUED) { item.entry = item.entry.copy(state = DownloadState.PAUSED, queuePosition = 0); renumber(); publishLocked(); return }
|
||||
if (item.entry.state != DownloadState.RUNNING) return
|
||||
item.pauseRequested = true
|
||||
}
|
||||
item.cancelled.set(true)
|
||||
item.job?.cancel(deleteFiles = false)
|
||||
StoresState.logLine("paused \"${item.entry.name}\"")
|
||||
}
|
||||
|
||||
fun resume(context: Context, key: String) {
|
||||
val app = context.applicationContext
|
||||
synchronized(lock) {
|
||||
val item = items[key] ?: return
|
||||
if (item.entry.state != DownloadState.PAUSED && item.entry.state != DownloadState.FAILED) return
|
||||
items.remove(key)
|
||||
items[key] = Item(item.entry.copy(state = DownloadState.QUEUED, speedBps = 0, etaSeconds = -1, error = null, startedAt = System.currentTimeMillis()), item.factory)
|
||||
renumber()
|
||||
publishLocked()
|
||||
}
|
||||
StoresState.logLine("resumed \"${entry(key)?.name}\"")
|
||||
advance(app)
|
||||
}
|
||||
|
||||
fun retry(context: Context, key: String) = resume(context, key)
|
||||
|
||||
fun cancel(context: Context, key: String) {
|
||||
val app = context.applicationContext
|
||||
val item: Item
|
||||
val wasRunning: Boolean
|
||||
synchronized(lock) {
|
||||
item = items[key] ?: return
|
||||
wasRunning = item.entry.state == DownloadState.RUNNING
|
||||
if (!wasRunning) {
|
||||
item.entry = item.entry.copy(state = DownloadState.CANCELLED, queuePosition = 0, finishedAt = System.currentTimeMillis())
|
||||
renumber()
|
||||
publishLocked()
|
||||
}
|
||||
}
|
||||
if (wasRunning) {
|
||||
item.cancelled.set(true)
|
||||
item.job?.cancel(deleteFiles = true)
|
||||
} else {
|
||||
// Never started: nothing is on disk, but the job knows where it would have gone.
|
||||
Thread({ runCatching { item.factory().cancel(deleteFiles = true) } }, "store-dl-cancel").start()
|
||||
}
|
||||
StoresState.logLine("cancelled \"${item.entry.name}\"")
|
||||
StoreDownloadService.finish(app, key)
|
||||
advance(app)
|
||||
}
|
||||
|
||||
/** Drops a finished, failed or cancelled row from the list. */
|
||||
fun dismiss(key: String) {
|
||||
synchronized(lock) {
|
||||
val item = items[key] ?: return
|
||||
if (item.entry.isActive) return
|
||||
items.remove(key)
|
||||
publishLocked()
|
||||
}
|
||||
}
|
||||
|
||||
private fun renumber() {
|
||||
var pos = 1
|
||||
for (item in items.values) if (item.entry.state == DownloadState.QUEUED) item.entry = item.entry.copy(queuePosition = pos++)
|
||||
}
|
||||
|
||||
private fun update(key: String, transform: (DownloadEntry) -> DownloadEntry) {
|
||||
synchronized(lock) {
|
||||
val item = items[key] ?: return
|
||||
item.entry = transform(item.entry)
|
||||
publishLocked()
|
||||
}
|
||||
}
|
||||
|
||||
private fun publish(context: Context) { synchronized(lock) { publishLocked() }; StoreDownloadService.sync(context) }
|
||||
|
||||
/** Newest first: what was started last sits on top, as the preview lists them. Caller holds [lock]. */
|
||||
private fun publishLocked() {
|
||||
val list = items.values.map { it.entry }.sortedByDescending { it.startedAt }
|
||||
StoresState.post { StoresState.downloads = list }
|
||||
}
|
||||
|
||||
/** Starts queued items while slots are free. */
|
||||
private fun advance(context: Context) {
|
||||
while (true) {
|
||||
val next: Item
|
||||
synchronized(lock) {
|
||||
if (running >= parallel(context)) return
|
||||
next = items.values.firstOrNull { it.entry.state == DownloadState.QUEUED } ?: return
|
||||
running++
|
||||
next.entry = next.entry.copy(state = DownloadState.RUNNING, stage = DownloadStage.MANIFEST, queuePosition = 0)
|
||||
renumber()
|
||||
publishLocked()
|
||||
}
|
||||
StoreDownloadService.start(context)
|
||||
Thread({ runItem(context, next) }, "store-dl-${next.entry.key}").start()
|
||||
}
|
||||
}
|
||||
|
||||
private fun runItem(context: Context, item: Item) {
|
||||
val handle = JobHandle(item.entry.key, item.cancelled)
|
||||
var result: String? = null
|
||||
var error: String? = null
|
||||
try {
|
||||
val job = item.factory()
|
||||
synchronized(lock) { item.job = job }
|
||||
StoresState.logLine("${item.entry.store.label}: fetching manifest for \"${item.entry.name}\"")
|
||||
result = job.run(handle)
|
||||
if (result == null && !item.cancelled.get()) error = "install failed"
|
||||
} catch (t: Throwable) {
|
||||
error = t.message ?: t.javaClass.simpleName
|
||||
Log.w(TAG, "${item.entry.key} failed", t)
|
||||
}
|
||||
synchronized(lock) {
|
||||
running--
|
||||
val now = System.currentTimeMillis()
|
||||
item.entry = when {
|
||||
item.cancelled.get() && item.pauseRequested -> item.entry.copy(state = DownloadState.PAUSED, speedBps = 0, etaSeconds = -1)
|
||||
item.cancelled.get() -> item.entry.copy(state = DownloadState.CANCELLED, finishedAt = now, speedBps = 0, etaSeconds = -1)
|
||||
result != null -> item.entry.copy(state = DownloadState.INSTALLED, stage = DownloadStage.DONE, installPath = result, bytesDone = item.entry.bytesTotal, speedBps = 0, etaSeconds = -1, finishedAt = now)
|
||||
else -> item.entry.copy(state = DownloadState.FAILED, error = error, speedBps = 0, etaSeconds = -1, finishedAt = now)
|
||||
}
|
||||
item.job = null
|
||||
publishLocked()
|
||||
}
|
||||
when (item.entry.state) {
|
||||
DownloadState.INSTALLED -> StoresState.logLine("installed \"${item.entry.name}\" → ${result}")
|
||||
DownloadState.FAILED -> StoresState.logLine("${item.entry.store.label}: \"${item.entry.name}\" failed: $error")
|
||||
else -> {}
|
||||
}
|
||||
StoreDownloadService.finish(context, item.entry.key)
|
||||
advance(context)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,114 @@
|
||||
package com.droiddeck.launcher.stores.download
|
||||
|
||||
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 android.util.Log
|
||||
import com.droiddeck.launcher.MainActivity
|
||||
import com.droiddeck.launcher.R
|
||||
import com.droiddeck.launcher.stores.StoresState
|
||||
import com.droiddeck.launcher.stores.formatBytes
|
||||
import java.util.concurrent.ConcurrentHashMap
|
||||
|
||||
/**
|
||||
* Keeps the process alive while a store download runs, with one quiet notification for the queue.
|
||||
* Without it Android demotes the app as soon as the launcher leaves the screen and a multi-gigabyte
|
||||
* install dies half way; the session has its own service for the same reason.
|
||||
*
|
||||
* [start] is called when a download begins and [finish] when one ends; the notification is rebuilt
|
||||
* from the queue's live rows, and the service stops itself when nothing is active.
|
||||
*/
|
||||
class StoreDownloadService : Service() {
|
||||
override fun onCreate() {
|
||||
super.onCreate()
|
||||
instance = this
|
||||
val nm = getSystemService(NOTIFICATION_SERVICE) as NotificationManager
|
||||
if (nm.getNotificationChannel(CHANNEL) == null) {
|
||||
nm.createNotificationChannel(NotificationChannel(CHANNEL, getString(R.string.stores_notification_channel), NotificationManager.IMPORTANCE_LOW).apply {
|
||||
description = getString(R.string.stores_notification_channel_hint)
|
||||
setShowBadge(false)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int {
|
||||
instance = this
|
||||
startForegroundCompat(build())
|
||||
if (active.isEmpty()) stopNow()
|
||||
return START_NOT_STICKY
|
||||
}
|
||||
|
||||
override fun onDestroy() {
|
||||
if (instance === this) instance = null
|
||||
super.onDestroy()
|
||||
}
|
||||
|
||||
override fun onBind(intent: Intent?): IBinder? = null
|
||||
|
||||
private fun build(): Notification {
|
||||
val entries = StoresState.downloads.filter { it.state == DownloadState.RUNNING }
|
||||
val first = entries.firstOrNull()
|
||||
val title = if (active.size > 1) getString(R.string.stores_notification_many, active.size) else getString(R.string.stores_notification_one)
|
||||
val text = when {
|
||||
first == null -> getString(R.string.stores_notification_waiting)
|
||||
first.bytesTotal > 0 -> "${first.name} · ${first.percent}% (${formatBytes(first.bytesDone)} / ${formatBytes(first.bytesTotal)})"
|
||||
else -> first.name
|
||||
}
|
||||
val tap = PendingIntent.getActivity(this, 0, Intent(this, MainActivity::class.java), PendingIntent.FLAG_IMMUTABLE)
|
||||
return Notification.Builder(this, CHANNEL)
|
||||
.setSmallIcon(android.R.drawable.stat_sys_download)
|
||||
.setContentTitle(title).setContentText(text).setOngoing(true).setContentIntent(tap).build()
|
||||
}
|
||||
|
||||
private fun refresh() {
|
||||
runCatching { (getSystemService(NOTIFICATION_SERVICE) as NotificationManager).notify(ID, build()) }
|
||||
}
|
||||
|
||||
private fun startForegroundCompat(n: Notification) {
|
||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) startForeground(ID, n, android.content.pm.ServiceInfo.FOREGROUND_SERVICE_TYPE_DATA_SYNC)
|
||||
else startForeground(ID, n)
|
||||
}
|
||||
|
||||
private fun stopNow() {
|
||||
stopForeground(STOP_FOREGROUND_REMOVE)
|
||||
stopSelf()
|
||||
}
|
||||
|
||||
companion object {
|
||||
private const val TAG = "StoreDownloadService"
|
||||
private const val CHANNEL = "store_downloads"
|
||||
private const val ID = 9102
|
||||
private val active = ConcurrentHashMap.newKeySet<String>()
|
||||
@Volatile private var instance: StoreDownloadService? = null
|
||||
|
||||
/** A download started: the service is up (idempotent). */
|
||||
fun start(context: Context) {
|
||||
StoresState.downloads.filter { it.state == DownloadState.RUNNING }.forEach { active.add(it.key) }
|
||||
try {
|
||||
val app = context.applicationContext
|
||||
val intent = Intent(app, StoreDownloadService::class.java)
|
||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) app.startForegroundService(intent) else app.startService(intent)
|
||||
} catch (e: Exception) {
|
||||
Log.w(TAG, "could not start: ${e.message}")
|
||||
}
|
||||
}
|
||||
|
||||
/** The queue changed: redraw the notification line. */
|
||||
fun sync(context: Context) {
|
||||
instance?.refresh()
|
||||
}
|
||||
|
||||
/** A download ended: drop it; the service stops with the last one. */
|
||||
fun finish(context: Context, key: String) {
|
||||
active.remove(key)
|
||||
val inst = instance ?: return
|
||||
if (active.isEmpty() && StoresState.downloads.none { it.state == DownloadState.RUNNING }) inst.stopNow() else inst.refresh()
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,29 @@
|
||||
package com.droiddeck.launcher.stores.download
|
||||
|
||||
import android.content.Context
|
||||
import androidx.annotation.StringRes
|
||||
import com.droiddeck.launcher.R
|
||||
import com.droiddeck.launcher.session.SessionPrefs
|
||||
|
||||
/**
|
||||
* How hard the native download engine may pull: the ceiling of chunk requests it keeps in flight,
|
||||
* and the inflate / hash / write threads behind them. Three tiers, one app-wide setting (Setup ›
|
||||
* Stores and the Downloads page), read when a download starts so a change applies to the next one.
|
||||
*
|
||||
* The windows are the ones the engine was tuned with (16 / 32 / 96 requests): it ramps towards the
|
||||
* ceiling only while throughput keeps rising, so a slow link settles well below any of them.
|
||||
*/
|
||||
enum class StoreDownloadTier(val id: String, @StringRes val label: Int, val networkWindow: Int, val decompressPerCore: Double) {
|
||||
BALANCED("balanced", R.string.stores_tier_balanced, 16, 0.4),
|
||||
FAST("fast", R.string.stores_tier_fast, 32, 0.5),
|
||||
MAX("max", R.string.stores_tier_max, 96, 0.8);
|
||||
|
||||
/** Inflate / hash / write workers for this device, at least two so a fetch never waits on a single writer. */
|
||||
val processWorkers: Int get() = (Runtime.getRuntime().availableProcessors() * decompressPerCore).toInt().coerceIn(2, 32)
|
||||
|
||||
companion object {
|
||||
val ALL: List<StoreDownloadTier> = entries
|
||||
fun byId(id: String?): StoreDownloadTier = entries.firstOrNull { it.id == id } ?: FAST
|
||||
fun current(context: Context): StoreDownloadTier = byId(SessionPrefs.gameStoresSpeedTier(context))
|
||||
}
|
||||
}
|
||||
@@ -230,6 +230,11 @@ private fun Content(
|
||||
StorePage(s, a, modifier)
|
||||
return
|
||||
}
|
||||
// Stores (GOG, Epic, Amazon) takes the whole pane: chips, shelves and a game's page inside it.
|
||||
if (selected == "stores" && s.gameStoresEnabled) {
|
||||
StoresPage(s, a, modifier)
|
||||
return
|
||||
}
|
||||
// The Games tab lays out its own list and detail.
|
||||
if (selected == "games" || selected.startsWith("app:")) {
|
||||
GamesPage(s, a, selected, onSelect, modifier)
|
||||
|
||||
@@ -220,11 +220,14 @@ private fun ManageSaves(g: Library.SteamGame, prefix: java.io.File, a: FrontEndA
|
||||
/** When it was last played (or where it is, if never) over its name, then room for Launch. */
|
||||
@Composable
|
||||
private fun ColumnScope.GameHeroCopy(g: Library.SteamGame, titleSize: androidx.compose.ui.unit.TextUnit) {
|
||||
Text(
|
||||
(lastPlayedText(g.lastPlayed) ?: libraryLabel(g.library)).uppercase(),
|
||||
fontSize = 12.sp, fontWeight = FontWeight.SemiBold, letterSpacing = 1.2.sp, color = LocalPalette.current.signal,
|
||||
maxLines = 1, overflow = TextOverflow.Ellipsis,
|
||||
)
|
||||
Row(verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(8.dp)) {
|
||||
SourceChip(g.source)
|
||||
Text(
|
||||
(lastPlayedText(g.lastPlayed) ?: libraryLabel(g.library)).uppercase(),
|
||||
fontSize = 12.sp, fontWeight = FontWeight.SemiBold, letterSpacing = 1.2.sp, color = LocalPalette.current.signal,
|
||||
maxLines = 1, overflow = TextOverflow.Ellipsis,
|
||||
)
|
||||
}
|
||||
Text(
|
||||
g.name, fontSize = titleSize, lineHeight = titleSize * 1.15f, fontWeight = FontWeight.Bold,
|
||||
color = MaterialTheme.colorScheme.onBackground, maxLines = 2, overflow = TextOverflow.Ellipsis,
|
||||
@@ -277,10 +280,14 @@ private fun GameRow(g: Library.SteamGame, selected: Boolean, onSelect: () -> Uni
|
||||
}
|
||||
Column(modifier = Modifier.weight(1f)) {
|
||||
Text(g.name, fontSize = 14.sp, fontWeight = FontWeight.SemiBold, color = colors.onBackground, maxLines = 1, overflow = TextOverflow.Ellipsis)
|
||||
Text(
|
||||
playedSpan(g.lastPlayed)?.replaceFirstChar { it.uppercase() } ?: stringResource(R.string.games_never_played),
|
||||
fontSize = 12.sp, color = colors.onSurfaceVariant, maxLines = 1, overflow = TextOverflow.Ellipsis,
|
||||
)
|
||||
// Where it came from beside when it was played: a store's games sit among Steam's own.
|
||||
Row(verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(6.dp)) {
|
||||
SourceChip(g.source, small = true)
|
||||
Text(
|
||||
playedSpan(g.lastPlayed)?.replaceFirstChar { it.uppercase() } ?: stringResource(R.string.games_never_played),
|
||||
fontSize = 12.sp, color = colors.onSurfaceVariant, maxLines = 1, overflow = TextOverflow.Ellipsis,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -44,6 +44,7 @@ import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.outlined.Apps
|
||||
import androidx.compose.material.icons.outlined.DesktopWindows
|
||||
import androidx.compose.material.icons.outlined.Layers
|
||||
import androidx.compose.material.icons.outlined.LocalMall
|
||||
import androidx.compose.material.icons.outlined.SportsEsports
|
||||
import androidx.compose.material.icons.outlined.Storefront
|
||||
import androidx.compose.material.icons.outlined.SystemUpdate
|
||||
@@ -57,6 +58,7 @@ import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.LaunchedEffect
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.foundation.shape.CircleShape
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.setValue
|
||||
@@ -101,7 +103,7 @@ internal fun SideRail(
|
||||
modifier = modifier.width(if (iconOnly) 64.dp else 92.dp).background(colors.surface).padding(vertical = if (compact) 8.dp else 12.dp),
|
||||
) {
|
||||
// Every section on screen at once: items shrink (to 44dp, still a touch target) before the rail scrolls.
|
||||
val count = 6 + (if (s.storeEnabled) 1 else 0) + (if (s.isHomeApp) 1 else 0)
|
||||
val count = 6 + (if (s.storeEnabled) 1 else 0) + (if (s.gameStoresEnabled) 1 else 0) + (if (s.isHomeApp) 1 else 0)
|
||||
BoxWithConstraints(Modifier.weight(1f).fillMaxWidth()) {
|
||||
val fit = (maxHeight / count - 4.dp).coerceIn(44.dp, if (iconOnly) 48.dp else if (compact) 52.dp else 60.dp)
|
||||
Column(
|
||||
@@ -112,6 +114,9 @@ internal fun SideRail(
|
||||
RailItem(stringResource(R.string.rail_steam), Icons.Outlined.SportsEsports, "steam", selected == "steam", compact, iconOnly, fit, onFocus = { onFocusSelect("steam") }) { onSelect("steam") }
|
||||
// Always there, so the items below it never move; an empty library says how to fill it.
|
||||
RailItem(stringResource(R.string.rail_games), Icons.Outlined.VideoLibrary, "games", selected == "games", compact, iconOnly, fit, onFocus = { onFocusSelect("games") }) { onSelect("games") }
|
||||
// The game stores, right under Games: what they install lands there. Its badge counts
|
||||
// the downloads in flight, so a download started here is never out of sight.
|
||||
if (s.gameStoresEnabled) RailItem(stringResource(R.string.rail_stores), Icons.Outlined.LocalMall, "stores", selected == "stores", compact, iconOnly, fit, count = s.storeDownloadsActive, onFocus = { onFocusSelect("stores") }) { onSelect("stores") }
|
||||
RailItem(stringResource(R.string.rail_desktop), Icons.Outlined.DesktopWindows, "desktop", selected == "desktop", compact, iconOnly, fit, onFocus = { onFocusSelect("desktop") }) { onSelect("desktop") }
|
||||
if (s.storeEnabled) RailItem(stringResource(R.string.rail_store), Icons.Outlined.Storefront, "store", selected == "store", compact, iconOnly, fit, onFocus = { onFocusSelect("store") }) { onSelect("store") }
|
||||
RailItem(stringResource(R.string.rail_components), Icons.Outlined.Layers, "components", selected == "components", compact, iconOnly, fit, onFocus = { onFocusSelect("components") }) { onSelect("components") }
|
||||
@@ -134,7 +139,10 @@ internal fun SideRail(
|
||||
@Composable
|
||||
private fun RailItem(
|
||||
label: String, icon: ImageVector, key: String, current: Boolean, compact: Boolean, iconOnly: Boolean, height: Dp,
|
||||
badge: Boolean = false, onFocus: () -> Unit = {}, onClick: () -> Unit,
|
||||
badge: Boolean = false,
|
||||
/** A number on the item (downloads in flight); 0 shows nothing. */
|
||||
count: Int = 0,
|
||||
onFocus: () -> Unit = {}, onClick: () -> Unit,
|
||||
) {
|
||||
val colors = MaterialTheme.colorScheme
|
||||
val pal = LocalPalette.current
|
||||
@@ -182,6 +190,12 @@ private fun RailItem(
|
||||
modifier = Modifier.align(Alignment.TopEnd).padding(top = if (iconOnly) 7.dp else 8.dp, end = if (iconOnly) 9.dp else 18.dp)
|
||||
.size(8.dp).clip(CircleShape).background(AttentionAmber),
|
||||
)
|
||||
if (count > 0) Text(
|
||||
if (count > 9) "9+" else count.toString(),
|
||||
fontSize = 10.sp, fontWeight = FontWeight.Bold, color = pal.onSignal, maxLines = 1,
|
||||
modifier = Modifier.align(Alignment.TopEnd).padding(top = if (iconOnly) 4.dp else 6.dp, end = if (iconOnly) 5.dp else 12.dp)
|
||||
.clip(RoundedCornerShape(8.dp)).background(pal.signal).padding(horizontal = 5.dp, vertical = 1.dp),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -129,6 +129,16 @@ class FrontEndState(
|
||||
val animationsEnabled: Boolean = true,
|
||||
/** The Flathub Store, a beta the user turns on in Setup. */
|
||||
val storeEnabled: Boolean = false,
|
||||
/** The Stores section (GOG, Epic Games, Amazon Games), turned on in Setup. */
|
||||
val gameStoresEnabled: Boolean = false,
|
||||
/** Setup › Stores: a game a store installs is added to Steam's library as a shortcut. */
|
||||
val gameStoresAddToSteam: Boolean = true,
|
||||
/** Setup › Stores: the download engine's speed tier (stores/download/StoreDownloadTier ids). */
|
||||
val gameStoresSpeedTier: String = "fast",
|
||||
/** Setup › Stores: the tab a signed-in store opens on (SessionPrefs.STORES_OPEN_LIBRARY / STORE). */
|
||||
val storesOpenTab: String = SessionPrefs.STORES_OPEN_LIBRARY,
|
||||
/** Downloads queued, running or paused across the three stores; the rail item's badge. */
|
||||
val storeDownloadsActive: Int = 0,
|
||||
/** The Updates page: DroidDeck's own builds and the channel followed. */
|
||||
val updates: UpdatesState = UpdatesState(),
|
||||
)
|
||||
@@ -179,6 +189,12 @@ class FrontEndActions(
|
||||
val onLauncherFullscreen: (Boolean) -> Unit = {},
|
||||
val onAnimationsEnabled: (Boolean) -> Unit = {},
|
||||
val onStoreEnabled: (Boolean) -> Unit = {},
|
||||
val onGameStoresEnabled: (Boolean) -> Unit = {},
|
||||
val onGameStoresAddToSteam: (Boolean) -> Unit = {},
|
||||
val onGameStoresSpeedTier: (String) -> Unit = {},
|
||||
val onStoresOpenTab: (String) -> Unit = {},
|
||||
/** The Games list rebuilt now: a store install or removal changed what is on disk. */
|
||||
val onLibraryChanged: () -> Unit = {},
|
||||
val onHomeApp: () -> Unit = {},
|
||||
val onHomeScreen: (Boolean) -> Unit = {},
|
||||
val onAndroidApp: (HomeApp.LaunchableApp, Int?) -> Unit = { _, _ -> },
|
||||
@@ -391,8 +407,9 @@ private fun FrontEndScreenBody(s: FrontEndState, a: FrontEndActions, page: (@Com
|
||||
selected = if (selected.startsWith("rom:")) "emu:" + selected.removePrefix("rom:").substringBefore(':') else "desktop"
|
||||
}
|
||||
LaunchedEffect(s.isHomeApp) { if (!s.isHomeApp && selected == "android-apps") selected = "steam" }
|
||||
// The Store turned off in Setup takes its page with it.
|
||||
// The Store turned off in Setup takes its page with it; so does Stores.
|
||||
LaunchedEffect(s.storeEnabled) { if (!s.storeEnabled && selected == "store") selected = "steam" }
|
||||
LaunchedEffect(s.gameStoresEnabled) { if (!s.gameStoresEnabled && selected == "stores") selected = "games" }
|
||||
// The last game uninstalled leaves the Games tab on its empty state.
|
||||
LaunchedEffect(s.steamGames.isEmpty()) {
|
||||
if (s.steamGames.isEmpty() && selected.startsWith("app:")) selected = "games"
|
||||
|
||||
@@ -76,6 +76,7 @@ import com.droiddeck.launcher.core.DeviceSupport
|
||||
import com.droiddeck.launcher.core.PhantomProcessLimit
|
||||
import com.droiddeck.launcher.core.PhantomProcessStatus
|
||||
import com.droiddeck.launcher.session.SessionPrefs
|
||||
import com.droiddeck.launcher.stores.download.StoreDownloadTier
|
||||
import com.droiddeck.launcher.R
|
||||
|
||||
// The Setup page: runtime and device checks, tools, frame generation and launch settings.
|
||||
@@ -367,6 +368,27 @@ internal fun SetupPanel(
|
||||
s.storeEnabled,
|
||||
) { a.onStoreEnabled(it) }
|
||||
}
|
||||
SettingsGroup(stringResource(R.string.setup_stores)) {
|
||||
ToggleRow(
|
||||
host, "stores-enabled", stringResource(R.string.setup_stores_show),
|
||||
stringResource(R.string.setup_stores_show_hint), s.gameStoresEnabled,
|
||||
) { a.onGameStoresEnabled(it) }
|
||||
ToggleRow(
|
||||
host, "stores-add-to-steam", stringResource(R.string.setup_stores_add_to_steam),
|
||||
stringResource(R.string.setup_stores_add_to_steam_hint), s.gameStoresAddToSteam,
|
||||
) { a.onGameStoresAddToSteam(it) }
|
||||
// The same three rows as the Stores page's own cog, for whoever looks here first.
|
||||
SettingsRow(stringResource(R.string.setup_stores_open_on), stringResource(R.string.setup_stores_open_on_hint)) {
|
||||
SegmentedTabs(
|
||||
listOf(SessionPrefs.STORES_OPEN_LIBRARY to stringResource(R.string.stores_tab_library), SessionPrefs.STORES_OPEN_STORE to stringResource(R.string.stores_tab_store)),
|
||||
s.storesOpenTab,
|
||||
) { a.onStoresOpenTab(it) }
|
||||
}
|
||||
SettingsRow(stringResource(R.string.setup_stores_speed), stringResource(R.string.setup_stores_speed_hint)) {
|
||||
SegmentedTabs(StoreDownloadTier.ALL.map { it.id to stringResource(it.label) }, s.gameStoresSpeedTier) { a.onGameStoresSpeedTier(it) }
|
||||
}
|
||||
StoresEngineRow()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,71 @@
|
||||
package com.droiddeck.launcher.ui
|
||||
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.draw.clip
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.ui.unit.sp
|
||||
import com.droiddeck.launcher.R
|
||||
import com.droiddeck.launcher.frontend.Library
|
||||
import com.droiddeck.launcher.stores.Store
|
||||
|
||||
// Where a game came from, as a small coloured chip: Steam, one of the stores, or an added folder.
|
||||
|
||||
/** The chip's colours per source: a tinted ground and a readable ink on it, one pair per store. */
|
||||
internal class SourceColours(val fill: Color, val ink: Color, val dot: Color)
|
||||
|
||||
internal fun sourceColours(source: String): SourceColours = when (source) {
|
||||
Store.GOG.id -> SourceColours(Color(0xFF3A1A3C), Color(0xFFE6A3EA), Color(0xFFC25BC8))
|
||||
Store.EPIC.id -> SourceColours(Color(0xFF2A2A2A), Color(0xFFDDDDDD), Color(0xFFBDBDBD))
|
||||
Store.AMAZON.id -> SourceColours(Color(0xFF3A2A0A), Color(0xFFFFC266), Color(0xFFFF9900))
|
||||
Library.SOURCE_STEAM -> SourceColours(Color(0xFF16293D), Color(0xFF7CC4FF), Color(0xFF1A9FFF))
|
||||
else -> SourceColours(Color(0xFF1A1D22), Color(0xFF9AA3AF), Color(0xFF9AA3AF))
|
||||
}
|
||||
|
||||
@Composable
|
||||
internal fun sourceLabel(source: String): String = when (source) {
|
||||
Library.SOURCE_STEAM -> stringResource(R.string.source_steam)
|
||||
Store.GOG.id -> Store.GOG.label
|
||||
Store.EPIC.id -> Store.EPIC.label
|
||||
Store.AMAZON.id -> Store.AMAZON.label
|
||||
else -> stringResource(R.string.source_added)
|
||||
}
|
||||
|
||||
/** The source chip itself; [small] for a list row, the regular size for a hero or a card. */
|
||||
@Composable
|
||||
internal fun SourceChip(source: String, small: Boolean = false, modifier: Modifier = Modifier) {
|
||||
val c = sourceColours(source)
|
||||
Text(
|
||||
sourceLabel(source), fontSize = if (small) 10.sp else 11.sp, fontWeight = FontWeight.Bold, letterSpacing = 0.4.sp,
|
||||
color = c.ink, maxLines = 1,
|
||||
modifier = modifier.clip(RoundedCornerShape(6.dp)).background(c.fill)
|
||||
.padding(horizontal = if (small) 6.dp else 8.dp, vertical = if (small) 1.dp else 2.dp),
|
||||
)
|
||||
}
|
||||
|
||||
/** A status chip in the stores' colours: green for done ("Installed"), blue for busy, amber for a warning. */
|
||||
@Composable
|
||||
internal fun StateChip(text: String, tone: ChipTone, small: Boolean = false) {
|
||||
val pal = LocalPalette.current
|
||||
val (fill, ink) = when (tone) {
|
||||
ChipTone.OK -> Color(0xFF16321F) to pal.good
|
||||
ChipTone.BUSY -> pal.signal.copy(alpha = 0.18f) to pal.signal
|
||||
ChipTone.WARN -> Color(0xFF3A2E10) to Color(0xFFFFC24D)
|
||||
ChipTone.DEAL -> Color(0xFF1E3A1A) to Color(0xFF9BE27A)
|
||||
ChipTone.PLAIN -> MaterialTheme.colorScheme.surfaceVariant to MaterialTheme.colorScheme.onSurfaceVariant
|
||||
}
|
||||
Text(
|
||||
text, fontSize = if (small) 9.sp else 11.sp, fontWeight = FontWeight.Bold, letterSpacing = 0.3.sp, color = ink, maxLines = 1,
|
||||
modifier = Modifier.clip(RoundedCornerShape(6.dp)).background(fill).padding(horizontal = if (small) 5.dp else 8.dp, vertical = if (small) 1.dp else 2.dp),
|
||||
)
|
||||
}
|
||||
|
||||
internal enum class ChipTone { OK, BUSY, WARN, DEAL, PLAIN }
|
||||
@@ -0,0 +1,179 @@
|
||||
package com.droiddeck.launcher.ui
|
||||
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.border
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.BoxWithConstraints
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.Spacer
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.height
|
||||
import androidx.compose.foundation.layout.heightIn
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.width
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.filled.PlayArrow
|
||||
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.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
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.graphics.Brush
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.layout.ContentScale
|
||||
import androidx.compose.ui.platform.LocalContext
|
||||
import androidx.compose.ui.res.stringResource
|
||||
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 coil.compose.AsyncImage
|
||||
import com.droiddeck.launcher.R
|
||||
import com.droiddeck.launcher.stores.CatalogItem
|
||||
import com.droiddeck.launcher.stores.Store
|
||||
import com.droiddeck.launcher.stores.StoreInstalls
|
||||
import com.droiddeck.launcher.stores.StoresState
|
||||
import com.droiddeck.launcher.stores.download.DownloadState
|
||||
import com.droiddeck.launcher.stores.formatBytes
|
||||
|
||||
// A store game's own page: hero, the one main action, how it is launched and where it lives.
|
||||
|
||||
@Composable
|
||||
internal fun StoreGameDetail(store: Store, key: String, s: FrontEndState, a: FrontEndActions, onBack: () -> Unit) {
|
||||
val ctx = LocalContext.current
|
||||
val colors = MaterialTheme.colorScheme
|
||||
val pal = LocalPalette.current
|
||||
val narrow = LocalNarrowPane.current
|
||||
val id = key.substringAfter(':')
|
||||
val item = (StoresState.library[store].orEmpty() + StoresState.shelves[store]?.all.orEmpty()).firstOrNull { it.id == id }
|
||||
val installed = StoresState.installedGame(store, id)
|
||||
val download = StoresState.downloads.firstOrNull { it.store == store && it.id == id && it.isActive }
|
||||
// Removal asks twice: one stray press of A should not cost a download.
|
||||
var confirmRemove by remember(key) { mutableStateOf(false) }
|
||||
LaunchedEffect(confirmRemove) { if (confirmRemove) { kotlinx.coroutines.delay(4000); confirmRemove = false } }
|
||||
val title = item?.title ?: installed?.sidecar?.title ?: id
|
||||
Rise(0) {
|
||||
Row(verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(12.dp), modifier = Modifier.padding(bottom = 12.dp)) {
|
||||
BackLink(store.label, compact = true, onClick = onBack)
|
||||
SourceChip(store.id)
|
||||
}
|
||||
}
|
||||
Rise(1) {
|
||||
Box(Modifier.fillMaxWidth().heightIn(min = if (narrow) 150.dp else 180.dp).clip(Shape16).background(artBrush(hueOf(title)))) {
|
||||
val art = item?.imageUrl ?: installed?.sidecar?.hero ?: installed?.sidecar?.cover
|
||||
if (art != null) AsyncImage(model = art, contentDescription = null, contentScale = ContentScale.Crop, modifier = Modifier.matchParentSize())
|
||||
Spacer(Modifier.matchParentSize().background(Brush.horizontalGradient(0f to colors.background.copy(alpha = 0.94f), 0.5f to colors.background.copy(alpha = 0.55f), 1f to Color.Transparent)))
|
||||
Column(verticalArrangement = Arrangement.spacedBy(8.dp), modifier = Modifier.align(Alignment.BottomStart).padding(horizontal = 22.dp, vertical = 18.dp)) {
|
||||
val state = when {
|
||||
installed != null -> stringResource(R.string.stores_eyebrow_installed)
|
||||
item?.owned == true -> stringResource(R.string.stores_eyebrow_library)
|
||||
else -> stringResource(R.string.stores_eyebrow_store)
|
||||
}
|
||||
val size = (item?.sizeBytes ?: 0L).takeIf { it > 0 }?.let { " · " + formatBytes(it) } ?: ""
|
||||
Text((state + size).uppercase(), fontSize = 12.sp, fontWeight = FontWeight.SemiBold, letterSpacing = 1.2.sp, color = pal.signal)
|
||||
Text(title, fontSize = if (narrow) 24.sp else 30.sp, lineHeight = if (narrow) 27.sp else 33.sp, fontWeight = FontWeight.Black, color = colors.onBackground, maxLines = 2, overflow = TextOverflow.Ellipsis)
|
||||
Actions {
|
||||
when {
|
||||
installed != null -> {
|
||||
PrimaryButton(stringResource(R.string.games_launch), main = true, icon = Icons.Filled.PlayArrow, enabled = !s.busy) { launchStoreGame(ctx, store, id, s, a) }
|
||||
val inSteam = installed.sidecar.addToSteam
|
||||
SecondaryButton(if (inSteam) stringResource(R.string.stores_in_steam) else stringResource(R.string.stores_add_to_steam)) {
|
||||
StoreInstalls.setAddToSteam(ctx, installed, !inSteam) { StoresState.refresh(ctx); a.onLibraryChanged() }
|
||||
}
|
||||
s.steamGames.firstOrNull { it.source == store.id && it.storeId == id }?.protonPrefix?.takeIf { it.isDirectory }?.let { dir ->
|
||||
SecondaryButton(stringResource(R.string.games_prefix)) { a.onBrowseFiles(dir) }
|
||||
}
|
||||
SecondaryButton(stringResource(R.string.games_files)) { a.onBrowseFiles(installed.folder) }
|
||||
SecondaryButton(if (confirmRemove) stringResource(R.string.stores_uninstall_confirm) else stringResource(R.string.stores_uninstall), enabled = download == null) {
|
||||
if (!confirmRemove) confirmRemove = true
|
||||
else { confirmRemove = false; StoresState.uninstall(ctx, installed) { StoresState.refresh(ctx); a.onLibraryChanged() }; onBack() }
|
||||
}
|
||||
}
|
||||
download != null -> {
|
||||
ActionChip(downloadLabel(download.state, download.stage, download.percent), ok = false)
|
||||
if (download.state != DownloadState.PAUSED) SecondaryButton(stringResource(R.string.stores_dl_pause)) { com.droiddeck.launcher.stores.download.DownloadQueue.pause(download.key) }
|
||||
else PrimaryButton(stringResource(R.string.stores_dl_resume), main = true) { com.droiddeck.launcher.stores.download.DownloadQueue.resume(ctx, download.key) }
|
||||
SecondaryButton(stringResource(R.string.stores_dl_cancel)) { com.droiddeck.launcher.stores.download.DownloadQueue.cancel(ctx, download.key) }
|
||||
}
|
||||
item?.owned == true -> PrimaryButton(
|
||||
if (item.sizeBytes > 0) stringResource(R.string.stores_install_size, formatBytes(item.sizeBytes)) else stringResource(R.string.stores_install), main = true,
|
||||
) { StoresState.install(ctx, item) }
|
||||
item != null && item.isFree -> PrimaryButton(stringResource(R.string.stores_get_free), main = true) { openStoreUrl(ctx, item) }
|
||||
item != null -> PrimaryButton(if (item.hasPrice && item.finalPrice.isNotBlank()) stringResource(R.string.stores_buy_price, item.finalPrice) else stringResource(R.string.stores_view_on, store.shortLabel), main = true) { openStoreUrl(ctx, item) }
|
||||
}
|
||||
BusyChip(s)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
if (item != null && item.description.isNotBlank()) Rise(2) {
|
||||
Text(item.description, fontSize = 13.sp, lineHeight = 19.sp, color = colors.onSurfaceVariant, maxLines = 6, overflow = TextOverflow.Ellipsis, modifier = Modifier.padding(top = 12.dp))
|
||||
}
|
||||
Rise(3) {
|
||||
BoxWithConstraints(Modifier.fillMaxWidth().padding(top = 16.dp)) {
|
||||
val wide = maxWidth >= 640.dp
|
||||
val launchSettings: @Composable () -> Unit = {
|
||||
Column {
|
||||
SectionTitle(stringResource(R.string.games_launch_settings), null)
|
||||
Row(horizontalArrangement = Arrangement.spacedBy(10.dp), modifier = Modifier.fillMaxWidth()) {
|
||||
InfoCard(stringResource(R.string.stores_card_proton), stringResource(R.string.stores_card_proton_value), Modifier.weight(1f))
|
||||
InfoCard(stringResource(R.string.stores_card_audio), stringResource(R.string.stores_card_audio_value), Modifier.weight(1f))
|
||||
}
|
||||
Spacer(Modifier.height(10.dp))
|
||||
Row(horizontalArrangement = Arrangement.spacedBy(10.dp), modifier = Modifier.fillMaxWidth()) {
|
||||
InfoCard(stringResource(R.string.stores_card_components), stringResource(R.string.stores_card_components_value), Modifier.weight(1f))
|
||||
InfoCard(stringResource(R.string.stores_card_engine), if (StoresState.engine.isNullOrEmpty()) stringResource(R.string.stores_card_engine_java, store.shortLabel) else stringResource(R.string.stores_card_engine_value, store.shortLabel), Modifier.weight(1f))
|
||||
}
|
||||
}
|
||||
}
|
||||
val howItRuns: @Composable () -> Unit = {
|
||||
Column {
|
||||
SectionTitle(stringResource(R.string.stores_how_it_runs), null)
|
||||
KeyValue(stringResource(R.string.stores_kv_fetched), stringResource(R.string.stores_kv_fetched_value, store.label))
|
||||
KeyValue(stringResource(R.string.stores_kv_installed_to), installed?.folder?.path ?: stringResource(R.string.stores_kv_installed_to_value, store.shortLabel, com.droiddeck.launcher.stores.StoreInstallRoot.folderName(title, id)))
|
||||
KeyValue(stringResource(R.string.stores_kv_launched_by), when (store) {
|
||||
Store.EPIC -> stringResource(R.string.stores_kv_launched_by_epic)
|
||||
Store.AMAZON -> stringResource(R.string.stores_kv_launched_by_amazon)
|
||||
Store.GOG -> stringResource(R.string.stores_kv_launched_by_value)
|
||||
})
|
||||
KeyValue(stringResource(R.string.stores_kv_steam), when {
|
||||
installed != null && installed.sidecar.addToSteam -> stringResource(R.string.stores_kv_steam_on)
|
||||
installed != null -> stringResource(R.string.stores_kv_steam_off)
|
||||
s.gameStoresAddToSteam -> stringResource(R.string.stores_kv_steam_on)
|
||||
else -> stringResource(R.string.stores_kv_steam_off)
|
||||
})
|
||||
}
|
||||
}
|
||||
if (wide) Row(horizontalArrangement = Arrangement.spacedBy(24.dp)) {
|
||||
Box(Modifier.weight(2f)) { launchSettings() }
|
||||
Box(Modifier.weight(1.2f)) { howItRuns() }
|
||||
} else Column { launchSettings(); howItRuns() }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun InfoCard(label: String, value: String, modifier: Modifier) {
|
||||
val colors = MaterialTheme.colorScheme
|
||||
Column(modifier = modifier.clip(Shape12).background(colors.surface).border(1.dp, LocalPalette.current.line, Shape12).padding(horizontal = 14.dp, vertical = 10.dp)) {
|
||||
Text(label, fontSize = 12.sp, color = colors.onSurfaceVariant, maxLines = 1)
|
||||
Text(value, fontSize = 15.sp, fontWeight = FontWeight.Bold, color = colors.onBackground, maxLines = 1, overflow = TextOverflow.Ellipsis, modifier = Modifier.padding(top = 2.dp))
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun KeyValue(k: String, v: String) {
|
||||
val colors = MaterialTheme.colorScheme
|
||||
Row(horizontalArrangement = Arrangement.spacedBy(12.dp), modifier = Modifier.fillMaxWidth().padding(vertical = 3.dp)) {
|
||||
Text(k, fontSize = 13.sp, color = colors.onSurfaceVariant, modifier = Modifier.width(110.dp))
|
||||
Text(v, fontSize = 13.sp, color = colors.onBackground, modifier = Modifier.weight(1f))
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,228 @@
|
||||
package com.droiddeck.launcher.ui
|
||||
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.border
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.BoxWithConstraints
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.heightIn
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.filled.PlayArrow
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.key
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
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.graphics.Color
|
||||
import androidx.compose.ui.platform.LocalContext
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import androidx.compose.ui.text.font.FontFamily
|
||||
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 com.droiddeck.launcher.R
|
||||
import com.droiddeck.launcher.session.SessionPrefs
|
||||
import com.droiddeck.launcher.stores.StoresState
|
||||
import com.droiddeck.launcher.stores.download.DownloadEntry
|
||||
import com.droiddeck.launcher.stores.download.DownloadQueue
|
||||
import com.droiddeck.launcher.stores.download.DownloadStage
|
||||
import com.droiddeck.launcher.stores.download.DownloadState
|
||||
import com.droiddeck.launcher.stores.download.StoreDownloadTier
|
||||
import com.droiddeck.launcher.stores.formatBytes
|
||||
import com.droiddeck.launcher.stores.formatSpeed
|
||||
|
||||
// The Downloads chip: one queue for the three stores, its settings and the engine log.
|
||||
|
||||
@Composable
|
||||
internal fun StoresDownloadsPane(s: FrontEndState, a: FrontEndActions) {
|
||||
BoxWithConstraints(Modifier.fillMaxWidth()) {
|
||||
val wide = maxWidth >= 700.dp
|
||||
val list: @Composable () -> Unit = { DownloadList(s, a) }
|
||||
val side: @Composable () -> Unit = { DownloadSettings(s, a) }
|
||||
if (wide) Row(horizontalArrangement = Arrangement.spacedBy(24.dp)) {
|
||||
Box(Modifier.weight(3f)) { list() }
|
||||
Box(Modifier.weight(2f)) { side() }
|
||||
} else Column { list(); side() }
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun DownloadList(s: FrontEndState, a: FrontEndActions) {
|
||||
val colors = MaterialTheme.colorScheme
|
||||
val entries = StoresState.downloads
|
||||
if (entries.isEmpty()) {
|
||||
Box(Modifier.fillMaxWidth().padding(vertical = 40.dp), contentAlignment = Alignment.Center) {
|
||||
Text(stringResource(R.string.stores_downloads_empty), fontSize = 13.sp, color = colors.onSurfaceVariant)
|
||||
}
|
||||
return
|
||||
}
|
||||
Column(verticalArrangement = Arrangement.spacedBy(10.dp)) {
|
||||
entries.forEachIndexed { i, d -> key(d.key) { Rise(1 + i.coerceAtMost(5)) { DownloadCard(d, s, a) } } }
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun DownloadCard(d: DownloadEntry, s: FrontEndState, a: FrontEndActions) {
|
||||
val ctx = LocalContext.current
|
||||
val colors = MaterialTheme.colorScheme
|
||||
val pal = LocalPalette.current
|
||||
Column(
|
||||
verticalArrangement = Arrangement.spacedBy(7.dp),
|
||||
modifier = Modifier.fillMaxWidth().clip(Shape12).background(colors.surface).border(1.dp, pal.line, Shape12).padding(horizontal = 14.dp, vertical = 10.dp),
|
||||
) {
|
||||
Row(verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(12.dp), modifier = Modifier.fillMaxWidth()) {
|
||||
Column(modifier = Modifier.weight(1f)) {
|
||||
Text(d.name, fontSize = 14.sp, fontWeight = FontWeight.Bold, color = colors.onBackground, maxLines = 1, overflow = TextOverflow.Ellipsis)
|
||||
SourceChip(d.store.id, small = true, modifier = Modifier.padding(top = 2.dp))
|
||||
}
|
||||
Stages(d)
|
||||
}
|
||||
ProgressBarThin(
|
||||
if (d.state == DownloadState.INSTALLED) 1f else d.fraction, paused = d.state == DownloadState.PAUSED,
|
||||
verify = d.stage == DownloadStage.VERIFY, height = 8.dp, done = d.state == DownloadState.INSTALLED,
|
||||
)
|
||||
Row(horizontalArrangement = Arrangement.spacedBy(16.dp), modifier = Modifier.fillMaxWidth()) {
|
||||
val meta = when (d.state) {
|
||||
DownloadState.INSTALLED -> stringResource(R.string.stores_installed_chip)
|
||||
DownloadState.CANCELLED -> stringResource(R.string.stores_dl_cancelled)
|
||||
DownloadState.FAILED -> d.error?.let { stringResource(R.string.stores_dl_failed_reason, it) } ?: stringResource(R.string.stores_dl_failed)
|
||||
DownloadState.PAUSED -> stringResource(R.string.stores_dl_paused)
|
||||
DownloadState.QUEUED -> if (d.queuePosition > 0) stringResource(R.string.stores_dl_queued_at, d.queuePosition) else stringResource(R.string.stores_dl_queued)
|
||||
DownloadState.RUNNING -> when {
|
||||
d.stage == DownloadStage.DOWNLOAD && d.bytesTotal > 0 -> stringResource(R.string.stores_dl_bytes, formatBytes(d.bytesDone), formatBytes(d.bytesTotal), d.percent)
|
||||
else -> stageLabel(d.stage)
|
||||
}
|
||||
}
|
||||
Text(meta, fontSize = 12.sp, color = colors.onSurfaceVariant, maxLines = 2, overflow = TextOverflow.Ellipsis, modifier = Modifier.weight(1f, fill = false))
|
||||
if (d.state == DownloadState.RUNNING && d.speedBps > 0) {
|
||||
Text(formatSpeed(d.speedBps), fontSize = 12.sp, color = colors.onSurfaceVariant, maxLines = 1)
|
||||
if (d.etaSeconds >= 0) Text(eta(d.etaSeconds), fontSize = 12.sp, color = colors.onSurfaceVariant, maxLines = 1)
|
||||
}
|
||||
}
|
||||
if (d.detail.isNotBlank() && d.state == DownloadState.RUNNING) Text(d.detail, fontSize = 11.sp, color = colors.onSurfaceVariant, maxLines = 1, overflow = TextOverflow.Ellipsis)
|
||||
Actions {
|
||||
when (d.state) {
|
||||
DownloadState.RUNNING, DownloadState.QUEUED -> {
|
||||
SecondaryButton(stringResource(R.string.stores_dl_pause), compact = true) { DownloadQueue.pause(d.key) }
|
||||
SecondaryButton(stringResource(R.string.stores_dl_cancel), compact = true) { DownloadQueue.cancel(ctx, d.key) }
|
||||
}
|
||||
DownloadState.PAUSED -> {
|
||||
PrimaryButton(stringResource(R.string.stores_dl_resume), compact = true) { DownloadQueue.resume(ctx, d.key) }
|
||||
SecondaryButton(stringResource(R.string.stores_dl_cancel), compact = true) { DownloadQueue.cancel(ctx, d.key) }
|
||||
}
|
||||
DownloadState.FAILED -> {
|
||||
PrimaryButton(stringResource(R.string.stores_dl_retry), compact = true) { DownloadQueue.retry(ctx, d.key) }
|
||||
SecondaryButton(stringResource(R.string.stores_dl_clear), compact = true) { DownloadQueue.dismiss(d.key) }
|
||||
}
|
||||
DownloadState.INSTALLED -> {
|
||||
PrimaryButton(stringResource(R.string.stores_play), compact = true, icon = Icons.Filled.PlayArrow) { launchStoreGame(ctx, d.store, d.id, s, a) }
|
||||
SecondaryButton(stringResource(R.string.stores_dl_clear), compact = true) { DownloadQueue.dismiss(d.key) }
|
||||
}
|
||||
DownloadState.CANCELLED -> SecondaryButton(stringResource(R.string.stores_dl_clear), compact = true) { DownloadQueue.dismiss(d.key) }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** Manifest → Download → Verify → Install → Done, the one under way lit, the ones behind it green. */
|
||||
@Composable
|
||||
private fun Stages(d: DownloadEntry) {
|
||||
val colors = MaterialTheme.colorScheme
|
||||
val pal = LocalPalette.current
|
||||
Row(horizontalArrangement = Arrangement.spacedBy(4.dp)) {
|
||||
for (stage in DownloadStage.entries) {
|
||||
val passed = d.state == DownloadState.INSTALLED || stage.ordinal < d.stage.ordinal
|
||||
val on = d.state == DownloadState.RUNNING && stage == d.stage
|
||||
Text(
|
||||
stageLabel(stage), fontSize = 10.sp, fontWeight = FontWeight.SemiBold, maxLines = 1,
|
||||
color = when { on -> pal.signal; passed -> pal.good; else -> colors.onSurfaceVariant },
|
||||
modifier = Modifier.clip(RoundedCornerShape(6.dp)).background(if (on) pal.signal.copy(alpha = 0.18f) else colors.surfaceVariant).padding(horizontal = 6.dp, vertical = 2.dp),
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun eta(seconds: Long): String = when {
|
||||
seconds < 60 -> "<1 min"
|
||||
seconds < 3600 -> "~${seconds / 60} min"
|
||||
else -> "~${seconds / 3600} h ${(seconds % 3600) / 60} m"
|
||||
}
|
||||
|
||||
/** The queue's settings beside it: speed tier, downloads at a time, and the engine log. */
|
||||
@Composable
|
||||
private fun DownloadSettings(s: FrontEndState, a: FrontEndActions) {
|
||||
val ctx = LocalContext.current
|
||||
val colors = MaterialTheme.colorScheme
|
||||
val pal = LocalPalette.current
|
||||
SectionTitle(stringResource(R.string.stores_download_manager), null)
|
||||
Column(verticalArrangement = Arrangement.spacedBy(10.dp)) {
|
||||
SettingCard(stringResource(R.string.setup_stores_speed), stringResource(R.string.stores_tier_note)) {
|
||||
SegmentedTabs(StoreDownloadTier.ALL.map { it.id to stringResource(it.label) }, s.gameStoresSpeedTier) { a.onGameStoresSpeedTier(it) }
|
||||
}
|
||||
SettingCard(stringResource(R.string.stores_parallel), stringResource(R.string.stores_parallel_note)) {
|
||||
var parallel by remember { mutableStateOf(DownloadQueue.parallel(ctx)) }
|
||||
SegmentedTabs(listOf(1 to "1", 2 to "2", 3 to "3"), parallel) { parallel = it; DownloadQueue.setParallel(ctx, it) }
|
||||
}
|
||||
}
|
||||
SectionTitle(stringResource(R.string.stores_engine_log), null)
|
||||
Box(Modifier.fillMaxWidth().heightIn(min = 60.dp, max = 220.dp).clip(Shape12).background(Color.Black).border(1.dp, pal.line, Shape12).padding(12.dp)) {
|
||||
val lines = StoresState.log
|
||||
Text(
|
||||
if (lines.isEmpty()) stringResource(R.string.stores_log_idle) else lines.asReversed().joinToString("\n"),
|
||||
fontSize = 11.sp, lineHeight = 16.sp, fontFamily = FontFamily.Monospace, color = Color(0xFF9BE27A),
|
||||
)
|
||||
}
|
||||
Text(stringResource(R.string.stores_downloads_footnote), fontSize = 12.sp, color = colors.onSurfaceVariant, modifier = Modifier.padding(top = 12.dp))
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun SettingCard(label: String, note: String, control: @Composable () -> Unit) {
|
||||
val colors = MaterialTheme.colorScheme
|
||||
Column(verticalArrangement = Arrangement.spacedBy(8.dp), modifier = Modifier.fillMaxWidth().clip(Shape12).background(colors.surface).border(1.dp, LocalPalette.current.line, Shape12).padding(horizontal = 14.dp, vertical = 10.dp)) {
|
||||
Text(label, fontSize = 12.sp, color = colors.onSurfaceVariant)
|
||||
control()
|
||||
Text(note, fontSize = 12.sp, color = colors.onSurfaceVariant)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* The chip row's cog: the section's own settings in a small card - which tab a store opens on,
|
||||
* whether installs go to Steam, the speed tier. Setup › Stores repeats them under its gate.
|
||||
*/
|
||||
@Composable
|
||||
internal fun StoresSettingsDialog(s: FrontEndState, a: FrontEndActions, onDismiss: () -> Unit) {
|
||||
val shown = rememberShown(onDismiss)
|
||||
val close = { shown.targetState = false }
|
||||
AppDialog(shown, close, "storesSettings", wide = false) {
|
||||
DialogHeader(stringResource(R.string.stores_settings_eyebrow), stringResource(R.string.stores_title))
|
||||
Rise(1) {
|
||||
Column {
|
||||
SettingsRow(stringResource(R.string.setup_stores_open_on), stringResource(R.string.setup_stores_open_on_hint)) {
|
||||
SegmentedTabs(
|
||||
listOf(SessionPrefs.STORES_OPEN_LIBRARY to stringResource(R.string.stores_tab_library), SessionPrefs.STORES_OPEN_STORE to stringResource(R.string.stores_tab_store)),
|
||||
s.storesOpenTab,
|
||||
) { a.onStoresOpenTab(it) }
|
||||
}
|
||||
SettingsRow(stringResource(R.string.setup_stores_add_to_steam), stringResource(R.string.setup_stores_add_to_steam_hint)) {
|
||||
ToggleSwitch(s.gameStoresAddToSteam, label = stringResource(R.string.setup_stores_add_to_steam)) { a.onGameStoresAddToSteam(it) }
|
||||
}
|
||||
SettingsRow(stringResource(R.string.setup_stores_speed), stringResource(R.string.setup_stores_speed_hint)) {
|
||||
SegmentedTabs(StoreDownloadTier.ALL.map { it.id to stringResource(it.label) }, s.gameStoresSpeedTier) { a.onGameStoresSpeedTier(it) }
|
||||
}
|
||||
}
|
||||
}
|
||||
Rise(2) { Actions { SecondaryButton(stringResource(R.string.common_ok), onClick = close) } }
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,532 @@
|
||||
package com.droiddeck.launcher.ui
|
||||
|
||||
import androidx.activity.compose.BackHandler
|
||||
import androidx.compose.animation.core.Spring
|
||||
import androidx.compose.animation.core.animateFloatAsState
|
||||
import androidx.compose.foundation.LocalIndication
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.border
|
||||
import androidx.compose.foundation.clickable
|
||||
import androidx.compose.foundation.horizontalScroll
|
||||
import androidx.compose.foundation.hoverable
|
||||
import androidx.compose.foundation.interaction.MutableInteractionSource
|
||||
import androidx.compose.foundation.interaction.collectIsPressedAsState
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.BoxWithConstraints
|
||||
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.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.height
|
||||
import androidx.compose.foundation.layout.heightIn
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.size
|
||||
import androidx.compose.foundation.layout.width
|
||||
import androidx.compose.foundation.rememberScrollState
|
||||
import androidx.compose.foundation.shape.CircleShape
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.foundation.verticalScroll
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.filled.PlayArrow
|
||||
import androidx.compose.material.icons.filled.Settings
|
||||
import androidx.compose.material.icons.outlined.Download
|
||||
import androidx.compose.material3.Icon
|
||||
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.alpha
|
||||
import androidx.compose.ui.draw.clip
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.graphics.graphicsLayer
|
||||
import androidx.compose.ui.layout.ContentScale
|
||||
import androidx.compose.ui.platform.LocalContext
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import androidx.compose.ui.semantics.Role
|
||||
import androidx.compose.ui.semantics.contentDescription
|
||||
import androidx.compose.ui.semantics.semantics
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.text.input.ImeAction
|
||||
import androidx.compose.ui.text.input.KeyboardType
|
||||
import androidx.compose.ui.text.style.TextDecoration
|
||||
import androidx.compose.ui.text.style.TextOverflow
|
||||
import androidx.compose.ui.unit.Dp
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.ui.unit.sp
|
||||
import coil.compose.AsyncImage
|
||||
import com.droiddeck.launcher.R
|
||||
import com.droiddeck.launcher.frontend.Library
|
||||
import com.droiddeck.launcher.session.SessionPrefs
|
||||
import com.droiddeck.launcher.stores.CatalogItem
|
||||
import com.droiddeck.launcher.stores.Store
|
||||
import com.droiddeck.launcher.stores.StoresState
|
||||
import com.droiddeck.launcher.stores.download.DownloadStage
|
||||
import com.droiddeck.launcher.stores.download.DownloadState
|
||||
import com.droiddeck.launcher.stores.formatBytes
|
||||
|
||||
// The Stores section: GOG, Epic Games and Amazon Games libraries and storefronts, and one
|
||||
// download queue for the three, in a single full-width pane.
|
||||
|
||||
/** The chip row's fourth entry, beside the three stores. */
|
||||
private const val DOWNLOADS = "downloads"
|
||||
|
||||
private val TABS = listOf("store", "installed", "library", "all")
|
||||
|
||||
@Composable
|
||||
internal fun StoresPage(s: FrontEndState, a: FrontEndActions, modifier: Modifier) {
|
||||
val ctx = LocalContext.current
|
||||
val narrow = LocalNarrowPane.current
|
||||
val padH = if (narrow) 14.dp else 22.dp
|
||||
var chip by rememberSaveable { mutableStateOf(Store.GOG.id) }
|
||||
var tab by rememberSaveable { mutableStateOf(if (SessionPrefs.storesOpenTab(ctx) == SessionPrefs.STORES_OPEN_STORE) "store" else "library") }
|
||||
var query by rememberSaveable { mutableStateOf("") }
|
||||
var openGame by rememberSaveable { mutableStateOf<String?>(null) }
|
||||
var settings by rememberSaveable { mutableStateOf(false) }
|
||||
val store = Store.byId(chip)
|
||||
LaunchedEffect(Unit) { StoresState.refresh(ctx) }
|
||||
// A store the account is signed into fills itself when its chip is on screen.
|
||||
LaunchedEffect(chip, StoresState.accounts[store]) { if (store != null && StoresState.isSignedIn(store)) StoresState.open(ctx, store) }
|
||||
// Picking a chip opens the tab Setup says (Library or Store); a tab the user switches to
|
||||
// afterwards stays until another chip is picked.
|
||||
val pickChip: (String) -> Unit = { id ->
|
||||
chip = id; openGame = null; query = ""
|
||||
if (id != DOWNLOADS) tab = if (s.storesOpenTab == SessionPrefs.STORES_OPEN_STORE) "store" else "library"
|
||||
}
|
||||
BackHandler(enabled = openGame != null) { openGame = null }
|
||||
val scroll = rememberScrollState()
|
||||
LaunchedEffect(chip, tab, openGame) { scroll.scrollTo(0) }
|
||||
Column(
|
||||
modifier = modifier.padding(horizontal = padH, vertical = if (narrow) 10.dp else 14.dp)
|
||||
.bumpers(
|
||||
onPrevious = { if (openGame == null && chip != DOWNLOADS) tab = TABS[(TABS.indexOf(tab) + TABS.size - 1) % TABS.size] },
|
||||
onNext = { if (openGame == null && chip != DOWNLOADS) tab = TABS[(TABS.indexOf(tab) + 1) % TABS.size] },
|
||||
),
|
||||
) {
|
||||
Rise(0) { StoreChips(chip, s.storeDownloadsActive, onPick = pickChip, onSettings = { settings = true }) }
|
||||
Column(modifier = Modifier.fillMaxWidth().verticalScroll(scroll).padding(top = 10.dp, bottom = 16.dp)) {
|
||||
when {
|
||||
chip == DOWNLOADS -> StoresDownloadsPane(s, a)
|
||||
store == null -> {}
|
||||
!StoresState.isSignedIn(store) -> Rise(1) { SignInCard(store) }
|
||||
openGame != null -> StoreGameDetail(store, openGame!!, s, a, onBack = { openGame = null })
|
||||
else -> Storefront(store, tab, query, s, a, onTab = { tab = it }, onQuery = { query = it }, onOpen = { openGame = it })
|
||||
}
|
||||
}
|
||||
}
|
||||
if (settings) StoresSettingsDialog(s, a) { settings = false }
|
||||
}
|
||||
|
||||
// ---- the chip row -------------------------------------------------------------------------------
|
||||
|
||||
/** Four equal chips - the stores with a signed-in dot, Downloads with its count - and the cog. */
|
||||
@Composable
|
||||
private fun StoreChips(selected: String, active: Int, onPick: (String) -> Unit, onSettings: () -> Unit) {
|
||||
Row(horizontalArrangement = Arrangement.spacedBy(8.dp), verticalAlignment = Alignment.CenterVertically, modifier = Modifier.fillMaxWidth()) {
|
||||
for (store in Store.entries) {
|
||||
StoreChip(
|
||||
label = store.shortLabel, on = selected == store.id, key = store.id, modifier = Modifier.weight(1f),
|
||||
lead = {
|
||||
val c = sourceColours(store.id)
|
||||
Box(Modifier.size(12.dp).clip(CircleShape).background(c.dot).alpha(if (StoresState.isSignedIn(store)) 1f else 0.35f))
|
||||
},
|
||||
description = store.label + if (StoresState.isSignedIn(store)) "" else " " + stringResource(R.string.stores_chip_signed_out),
|
||||
) { onPick(store.id) }
|
||||
}
|
||||
StoreChip(
|
||||
label = stringResource(R.string.stores_tab_downloads), on = selected == DOWNLOADS, key = DOWNLOADS, modifier = Modifier.weight(1f),
|
||||
lead = { Icon(Icons.Outlined.Download, null, modifier = Modifier.size(18.dp)) },
|
||||
trail = if (active > 0) { { CountPill(active) } } else null,
|
||||
) { onPick(DOWNLOADS) }
|
||||
IconChip(Icons.Filled.Settings, stringResource(R.string.stores_settings), onSettings)
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun StoreChip(
|
||||
label: String, on: Boolean, key: String, modifier: Modifier, lead: @Composable () -> Unit,
|
||||
trail: (@Composable () -> Unit)? = null, description: String? = null, onClick: () -> Unit,
|
||||
) {
|
||||
val colors = MaterialTheme.colorScheme
|
||||
val pal = LocalPalette.current
|
||||
val src = remember { MutableInteractionSource() }
|
||||
val hot = rememberHot(src)
|
||||
val shape = RoundedCornerShape(99.dp)
|
||||
val ink = if (on) colors.onBackground else if (hot) colors.onBackground else colors.onSurfaceVariant
|
||||
Row(
|
||||
verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(8.dp, Alignment.CenterHorizontally),
|
||||
modifier = modifier.paneItem("storechip:$key").heightIn(min = 44.dp).clip(shape)
|
||||
.background(if (on) pal.signal.copy(alpha = 0.16f) else colors.surface)
|
||||
.glideBorder(hot, shape, pal.signal, if (on) pal.signal.copy(alpha = 0.7f) else pal.line)
|
||||
.hoverable(src).clickable(interactionSource = src, indication = LocalIndication.current, role = Role.Tab, onClick = onClick)
|
||||
.controllerConfirm(onClick = onClick)
|
||||
.then(if (description != null) Modifier.semantics { contentDescription = description } else Modifier)
|
||||
.padding(horizontal = 12.dp, vertical = 8.dp),
|
||||
) {
|
||||
androidx.compose.runtime.CompositionLocalProvider(androidx.compose.material3.LocalContentColor provides ink) { lead() }
|
||||
Text(label, fontSize = 13.sp, fontWeight = FontWeight.Bold, color = ink, maxLines = 1, overflow = TextOverflow.Ellipsis)
|
||||
trail?.invoke()
|
||||
}
|
||||
}
|
||||
|
||||
/** The chip row's cog: icon only, the chips' height; opens the section's settings. */
|
||||
@Composable
|
||||
private fun IconChip(icon: androidx.compose.ui.graphics.vector.ImageVector, description: String, onClick: () -> Unit) {
|
||||
val colors = MaterialTheme.colorScheme
|
||||
val pal = LocalPalette.current
|
||||
val src = remember { MutableInteractionSource() }
|
||||
val hot = rememberHot(src)
|
||||
Box(
|
||||
contentAlignment = Alignment.Center,
|
||||
modifier = Modifier.paneItem("storechip:settings").size(44.dp).clip(CircleShape).background(colors.surface)
|
||||
.glideBorder(hot, CircleShape, pal.signal, pal.line)
|
||||
.hoverable(src).clickable(interactionSource = src, indication = LocalIndication.current, role = Role.Button, onClick = onClick)
|
||||
.controllerConfirm(onClick = onClick),
|
||||
) { Icon(icon, description, tint = if (hot) pal.signal else colors.onBackground, modifier = Modifier.size(20.dp)) }
|
||||
}
|
||||
|
||||
@Composable
|
||||
internal fun CountPill(count: Int) {
|
||||
val pal = LocalPalette.current
|
||||
Text(
|
||||
count.toString(), fontSize = 11.sp, fontWeight = FontWeight.Bold, color = pal.onSignal, maxLines = 1,
|
||||
modifier = Modifier.clip(RoundedCornerShape(8.dp)).background(pal.signal).padding(horizontal = 6.dp, vertical = 1.dp),
|
||||
)
|
||||
}
|
||||
|
||||
// ---- sign-in -----------------------------------------------------------------------------------
|
||||
|
||||
/** No account for this store yet: who it signs in as, and the button that opens its login page. */
|
||||
@Composable
|
||||
private fun SignInCard(store: Store) {
|
||||
val ctx = LocalContext.current
|
||||
val colors = MaterialTheme.colorScheme
|
||||
val pal = LocalPalette.current
|
||||
val narrow = LocalNarrowPane.current
|
||||
val hint = when (store) {
|
||||
Store.GOG -> stringResource(R.string.stores_signin_hint_gog)
|
||||
Store.EPIC -> stringResource(R.string.stores_signin_hint_epic)
|
||||
Store.AMAZON -> stringResource(R.string.stores_signin_hint_amazon)
|
||||
}
|
||||
Column(verticalArrangement = Arrangement.spacedBy(12.dp)) {
|
||||
Row(
|
||||
verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(18.dp),
|
||||
modifier = Modifier.fillMaxWidth().clip(Shape16).background(colors.surface).border(1.dp, pal.line, Shape16).padding(18.dp),
|
||||
) {
|
||||
StoreLogo(store, 56.dp)
|
||||
Column(modifier = Modifier.weight(1f)) {
|
||||
Text(stringResource(R.string.stores_signin_title, store.label), fontSize = 18.sp, fontWeight = FontWeight.SemiBold, color = colors.onBackground)
|
||||
Text(hint, fontSize = 13.sp, color = colors.onSurfaceVariant, modifier = Modifier.padding(top = 2.dp))
|
||||
}
|
||||
if (!narrow) PrimaryButton(stringResource(R.string.stores_signin), main = true) { StoresState.signIn(ctx, store) }
|
||||
}
|
||||
if (narrow) Actions { PrimaryButton(stringResource(R.string.stores_signin), main = true) { StoresState.signIn(ctx, store) } }
|
||||
Text(stringResource(R.string.stores_signin_footnote), fontSize = 12.sp, color = colors.onSurfaceVariant)
|
||||
if (StoresState.engine == "") Note(stringResource(R.string.stores_engine_missing_note))
|
||||
}
|
||||
}
|
||||
|
||||
/** A square in the store's colour with its name on it: the stores ship no logo this app may bundle. */
|
||||
@Composable
|
||||
internal fun StoreLogo(store: Store, size: Dp) {
|
||||
val c = sourceColours(store.id)
|
||||
Box(contentAlignment = Alignment.Center, modifier = Modifier.size(size).clip(Shape14).background(c.fill).border(1.dp, c.dot.copy(alpha = 0.5f), Shape14)) {
|
||||
Text(store.shortLabel, fontSize = (size.value / 4).sp, fontWeight = FontWeight.Black, color = c.ink, maxLines = 1)
|
||||
}
|
||||
}
|
||||
|
||||
// ---- storefront --------------------------------------------------------------------------------
|
||||
|
||||
@Composable
|
||||
private fun Storefront(
|
||||
store: Store, tab: String, query: String, s: FrontEndState, a: FrontEndActions,
|
||||
onTab: (String) -> Unit, onQuery: (String) -> Unit, onOpen: (String) -> Unit,
|
||||
) {
|
||||
val ctx = LocalContext.current
|
||||
val colors = MaterialTheme.colorScheme
|
||||
val library = StoresState.library[store] ?: emptyList()
|
||||
val shelves = StoresState.shelves[store]
|
||||
val installedKeys = remember(StoresState.installed) { StoresState.installed.filter { it.sidecar.store == store }.map { it.sidecar.id }.toSet() }
|
||||
val q = query.trim().lowercase()
|
||||
fun matches(i: CatalogItem) = q.isEmpty() || i.title.lowercase().contains(q)
|
||||
// Everything this store knows about: the library plus whatever the shelves brought, each title once.
|
||||
val everything = remember(library, shelves) { (library + (shelves?.all ?: emptyList())).distinctBy { it.id } }
|
||||
val installedItems = library.filter { it.id in installedKeys }
|
||||
Rise(1) {
|
||||
SubTabs(
|
||||
listOf(
|
||||
"store" to stringResource(R.string.stores_tab_store),
|
||||
"installed" to stringResource(R.string.stores_tab_count, stringResource(R.string.stores_tab_installed), installedKeys.size),
|
||||
"library" to stringResource(R.string.stores_tab_count, stringResource(R.string.stores_tab_library), library.size),
|
||||
"all" to stringResource(R.string.stores_tab_count, stringResource(R.string.stores_tab_all), everything.size),
|
||||
),
|
||||
tab, onTab,
|
||||
trailing = {
|
||||
val name = StoresState.accounts[store] ?: ""
|
||||
SmallTextButton(stringResource(R.string.stores_signout_named, name)) { StoresState.signOut(ctx, store) }
|
||||
},
|
||||
)
|
||||
}
|
||||
Rise(2) {
|
||||
SearchField(
|
||||
query, onQuery,
|
||||
placeholder = when (tab) {
|
||||
"store" -> stringResource(R.string.stores_search_catalog, store.label)
|
||||
"installed" -> stringResource(R.string.stores_search_installed)
|
||||
"library" -> stringResource(R.string.stores_search_library)
|
||||
else -> stringResource(R.string.stores_search_all, store.label)
|
||||
},
|
||||
)
|
||||
}
|
||||
StoresState.status[store]?.let { Rise(3) { Text(it, fontSize = 12.sp, color = colors.onSurfaceVariant, modifier = Modifier.padding(bottom = 6.dp)) } }
|
||||
StoresState.problems[store]?.let { Rise(3) { Box(Modifier.padding(bottom = 8.dp)) { Note(it) } } }
|
||||
val card: @Composable (CatalogItem) -> Unit = { item -> GameCard(item, store, s, a, installedKeys, onOpen) }
|
||||
when (tab) {
|
||||
"installed" -> Grid(installedItems.filter(::matches), stringResource(R.string.stores_installed_empty, store.label), card)
|
||||
"library" -> Grid(library.filter(::matches), if (library.isEmpty() && StoresState.status[store] != null) stringResource(R.string.stores_library_loading) else stringResource(R.string.stores_nothing_matches), card)
|
||||
"all" -> Grid(everything.filter(::matches), stringResource(R.string.stores_nothing_matches), card)
|
||||
else -> {
|
||||
if (q.isNotEmpty()) Grid(everything.filter(::matches), stringResource(R.string.stores_nothing_matches), card)
|
||||
else {
|
||||
Shelf(stringResource(R.string.stores_shelf_new), stringResource(R.string.stores_shelf_new_hint), shelves?.whatsNew, card)
|
||||
Shelf(stringResource(R.string.stores_shelf_deals), stringResource(R.string.stores_shelf_deals_hint), shelves?.deals, card)
|
||||
Shelf(stringResource(R.string.stores_shelf_free), stringResource(R.string.stores_shelf_free_hint), shelves?.free, card)
|
||||
Shelf(stringResource(R.string.stores_shelf_trending), stringResource(R.string.stores_shelf_trending_hint), shelves?.trending, card)
|
||||
Shelf(stringResource(R.string.stores_shelf_library), stringResource(R.string.stores_shelf_library_hint, store.label), library, card)
|
||||
if (shelves == null && library.isEmpty()) Rise(4) { Note(if (StoresState.status[store] != null) stringResource(R.string.stores_library_loading) else stringResource(R.string.stores_shelves_loading)) }
|
||||
else if (shelves != null && shelves.isEmpty && store == Store.AMAZON) Rise(4) { Note(stringResource(R.string.stores_amazon_no_catalog)) }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** Underlined tabs as the preview draws them, with [trailing] (the account) at the right. */
|
||||
@Composable
|
||||
private fun SubTabs(tabs: List<Pair<String, String>>, selected: String, onPick: (String) -> Unit, trailing: @Composable () -> Unit) {
|
||||
val colors = MaterialTheme.colorScheme
|
||||
val pal = LocalPalette.current
|
||||
Column(Modifier.fillMaxWidth()) {
|
||||
Row(verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(6.dp), modifier = Modifier.fillMaxWidth()) {
|
||||
Row(horizontalArrangement = Arrangement.spacedBy(6.dp), modifier = Modifier.weight(1f).horizontalScroll(rememberScrollState())) {
|
||||
for ((key, label) in tabs) {
|
||||
val on = key == selected
|
||||
val src = remember { MutableInteractionSource() }
|
||||
val hot = rememberHot(src)
|
||||
val pick = { onPick(key) }
|
||||
Column(
|
||||
modifier = Modifier.paneItem("storetab:$key").clip(RoundedCornerShape(8.dp))
|
||||
.background(if (hot) pal.signal.copy(alpha = 0.12f) else Color.Transparent)
|
||||
.glideBorder(hot, RoundedCornerShape(8.dp), pal.signal)
|
||||
.hoverable(src).clickable(interactionSource = src, indication = LocalIndication.current, role = Role.Tab, onClick = pick)
|
||||
.controllerConfirm(onClick = pick)
|
||||
.padding(horizontal = 8.dp, vertical = 6.dp),
|
||||
) {
|
||||
Text(label, fontSize = 13.sp, fontWeight = FontWeight.SemiBold, color = if (on) colors.onBackground else colors.onSurfaceVariant, maxLines = 1)
|
||||
Box(Modifier.padding(top = 4.dp).fillMaxWidth().height(2.dp).background(if (on) pal.signal else Color.Transparent))
|
||||
}
|
||||
}
|
||||
}
|
||||
trailing()
|
||||
}
|
||||
Box(Modifier.fillMaxWidth().height(1.dp).background(pal.line))
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun SmallTextButton(text: String, onClick: () -> Unit) {
|
||||
val colors = MaterialTheme.colorScheme
|
||||
val pal = LocalPalette.current
|
||||
val src = remember { MutableInteractionSource() }
|
||||
val hot = rememberHot(src)
|
||||
Text(
|
||||
text, fontSize = 12.sp, fontWeight = FontWeight.SemiBold, color = if (hot) colors.onBackground else colors.onSurfaceVariant, maxLines = 1, overflow = TextOverflow.Ellipsis,
|
||||
modifier = Modifier.paneItem("btn:$text").clip(RoundedCornerShape(8.dp)).glideBorder(hot, RoundedCornerShape(8.dp), pal.signal)
|
||||
.hoverable(src).clickable(interactionSource = src, indication = LocalIndication.current, role = Role.Button, onClick = onClick)
|
||||
.controllerConfirm(onClick = onClick).padding(horizontal = 8.dp, vertical = 6.dp),
|
||||
)
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun SearchField(query: String, onQuery: (String) -> Unit, placeholder: String) {
|
||||
AdbTextField(
|
||||
value = query, onValueChange = onQuery, label = "", keyboardType = KeyboardType.Text, imeAction = ImeAction.Search,
|
||||
placeholder = placeholder, compact = true, modifier = Modifier.padding(top = 8.dp, bottom = 4.dp),
|
||||
)
|
||||
}
|
||||
|
||||
/** A horizontal row of cards under a title; nothing at all when the list is empty or not here yet. */
|
||||
@Composable
|
||||
private fun Shelf(title: String, hint: String, items: List<CatalogItem>?, card: @Composable (CatalogItem) -> Unit) {
|
||||
if (items.isNullOrEmpty()) return
|
||||
val colors = MaterialTheme.colorScheme
|
||||
Row(verticalAlignment = Alignment.Bottom, horizontalArrangement = Arrangement.spacedBy(10.dp), modifier = Modifier.fillMaxWidth().padding(top = 14.dp, bottom = 6.dp)) {
|
||||
Text(title, fontSize = 16.sp, fontWeight = FontWeight.Bold, color = colors.onBackground)
|
||||
Text(hint, fontSize = 12.sp, color = colors.onSurfaceVariant, modifier = Modifier.padding(bottom = 1.dp))
|
||||
}
|
||||
Row(horizontalArrangement = Arrangement.spacedBy(10.dp), modifier = Modifier.fillMaxWidth().horizontalScroll(rememberScrollState()).padding(vertical = 2.dp)) {
|
||||
for (item in items.take(24)) key(item.key) { Box(Modifier.width(CardWidth)) { card(item) } }
|
||||
}
|
||||
}
|
||||
|
||||
/** Cards in rows that fill the width; [empty] when there are none. */
|
||||
@Composable
|
||||
private fun Grid(items: List<CatalogItem>, empty: String, card: @Composable (CatalogItem) -> Unit) {
|
||||
if (items.isEmpty()) { Box(Modifier.padding(vertical = 32.dp).fillMaxWidth(), contentAlignment = Alignment.Center) { Text(empty, fontSize = 13.sp, color = MaterialTheme.colorScheme.onSurfaceVariant) }; return }
|
||||
BoxWithConstraints(Modifier.fillMaxWidth().padding(top = 10.dp)) {
|
||||
val gap = 10.dp
|
||||
val cols = ((maxWidth + gap) / (CardWidth + gap)).toInt().coerceAtLeast(1)
|
||||
val w = (maxWidth - gap * (cols - 1)) / cols
|
||||
Column(verticalArrangement = Arrangement.spacedBy(gap)) {
|
||||
for (row in items.chunked(cols)) Row(horizontalArrangement = Arrangement.spacedBy(gap), modifier = Modifier.fillMaxWidth()) {
|
||||
for (item in row) key(item.key) { Box(Modifier.width(w)) { card(item) } }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private val CardWidth = 150.dp
|
||||
|
||||
/**
|
||||
* One title: its wide art, the title, a price or status line and one button that says the one
|
||||
* thing to do next - Install with its size, Play, the download's progress, Get for free, or View
|
||||
* on the store. Tapping the card itself opens the game's page.
|
||||
*/
|
||||
@Composable
|
||||
private fun GameCard(item: CatalogItem, store: Store, s: FrontEndState, a: FrontEndActions, installedKeys: Set<String>, onOpen: (String) -> Unit) {
|
||||
val ctx = LocalContext.current
|
||||
val colors = MaterialTheme.colorScheme
|
||||
val pal = LocalPalette.current
|
||||
val src = remember { MutableInteractionSource() }
|
||||
val hot = rememberHot(src)
|
||||
val pressed by src.collectIsPressedAsState()
|
||||
val scale by animateFloatAsState(if (pressed) 0.985f else 1f, Motion.sp(0.5f, Spring.StiffnessMedium), label = "cardScale")
|
||||
val installed = item.id in installedKeys
|
||||
val download = StoresState.downloads.firstOrNull { it.store == store && it.id == item.id && it.isActive }
|
||||
val open = { onOpen(item.key) }
|
||||
Column(
|
||||
modifier = Modifier.fillMaxWidth().paneItem("card:${item.key}").graphicsLayer { scaleX = scale; scaleY = scale }
|
||||
.clip(RoundedCornerShape(10.dp)).background(colors.surface).glideBorder(hot, RoundedCornerShape(10.dp), pal.signal, pal.line)
|
||||
.hoverable(src).clickable(interactionSource = src, indication = LocalIndication.current, onClick = open).controllerConfirm(onClick = open),
|
||||
) {
|
||||
CardArt(item, Modifier.fillMaxWidth().aspectRatio(16f / 9f))
|
||||
Column(verticalArrangement = Arrangement.spacedBy(5.dp), modifier = Modifier.padding(start = 8.dp, end = 8.dp, top = 6.dp, bottom = 8.dp)) {
|
||||
Text(item.title, fontSize = 11.sp, lineHeight = 13.sp, fontWeight = FontWeight.Bold, color = colors.onBackground, maxLines = 2, minLines = 2, overflow = TextOverflow.Ellipsis)
|
||||
PriceLine(item, installed)
|
||||
when {
|
||||
installed -> CardButton(stringResource(R.string.stores_play), primary = true, icon = Icons.Filled.PlayArrow) { launchStoreGame(ctx, store, item.id, s, a) }
|
||||
download != null -> {
|
||||
ProgressBarThin(download.fraction, paused = download.state == DownloadState.PAUSED, verify = download.stage == DownloadStage.VERIFY)
|
||||
CardButton(downloadLabel(download.state, download.stage, download.percent), primary = false) { }
|
||||
}
|
||||
item.owned -> CardButton(if (item.sizeBytes > 0) stringResource(R.string.stores_install_size, formatBytes(item.sizeBytes)) else stringResource(R.string.stores_install), primary = true) { StoresState.install(ctx, item) }
|
||||
item.isFree -> CardButton(stringResource(R.string.stores_get_free), primary = true) { openStoreUrl(ctx, item) }
|
||||
else -> CardButton(stringResource(R.string.stores_view_on, store.shortLabel), primary = false) { openStoreUrl(ctx, item) }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
internal fun CardArt(item: CatalogItem, modifier: Modifier) {
|
||||
val url = item.imageUrl ?: item.tallImageUrl
|
||||
Box(modifier.background(artBrush(hueOf(item.title)))) {
|
||||
if (url != null) AsyncImage(model = url, contentDescription = null, contentScale = ContentScale.Crop, modifier = Modifier.matchParentSize())
|
||||
else Text(item.title, fontSize = 10.sp, fontWeight = FontWeight.Black, color = Color.White, maxLines = 3, overflow = TextOverflow.Ellipsis, modifier = Modifier.align(Alignment.BottomStart).padding(6.dp))
|
||||
}
|
||||
}
|
||||
|
||||
/** "In library" / "Installed", a free label, or the deal with its old price struck through. */
|
||||
@Composable
|
||||
internal fun PriceLine(item: CatalogItem, installed: Boolean, small: Boolean = true) {
|
||||
val colors = MaterialTheme.colorScheme
|
||||
val pal = LocalPalette.current
|
||||
Row(verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(6.dp)) {
|
||||
when {
|
||||
installed -> StateChip(stringResource(R.string.stores_installed_chip), ChipTone.OK, small)
|
||||
item.owned -> StateChip(stringResource(R.string.stores_in_library), ChipTone.OK, small)
|
||||
item.isFree -> Text(stringResource(R.string.stores_free), fontSize = if (small) 11.sp else 13.sp, fontWeight = FontWeight.Bold, color = pal.good)
|
||||
item.isDiscounted -> {
|
||||
StateChip("-${item.discountPercent}%", ChipTone.DEAL, small)
|
||||
Text(item.originalPrice, fontSize = if (small) 10.sp else 12.sp, color = colors.onSurfaceVariant, textDecoration = TextDecoration.LineThrough, maxLines = 1)
|
||||
Text(item.finalPrice, fontSize = if (small) 11.sp else 13.sp, fontWeight = FontWeight.Bold, color = colors.onBackground, maxLines = 1)
|
||||
}
|
||||
item.hasPrice -> Text(item.finalPrice, fontSize = if (small) 11.sp else 13.sp, fontWeight = FontWeight.Bold, color = colors.onBackground, maxLines = 1)
|
||||
else -> Spacer(Modifier.height(if (small) 14.dp else 18.dp))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** The card's one button: full width, small type. A press does not also open the card. */
|
||||
@Composable
|
||||
private fun CardButton(text: String, primary: Boolean, icon: androidx.compose.ui.graphics.vector.ImageVector? = null, onClick: () -> Unit) {
|
||||
val colors = MaterialTheme.colorScheme
|
||||
val pal = LocalPalette.current
|
||||
val src = remember { MutableInteractionSource() }
|
||||
val hot = rememberHot(src)
|
||||
val shape = RoundedCornerShape(6.dp)
|
||||
Row(
|
||||
verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(4.dp, Alignment.CenterHorizontally),
|
||||
modifier = Modifier.fillMaxWidth().heightIn(min = 30.dp).clip(shape)
|
||||
.background(if (primary) pal.signal else colors.surfaceVariant)
|
||||
.glideBorder(hot, shape, if (primary) colors.onBackground else pal.signal, if (primary) Color.Transparent else pal.line)
|
||||
.hoverable(src).clickable(interactionSource = src, indication = LocalIndication.current, role = Role.Button, onClick = onClick)
|
||||
.controllerConfirm(onClick = onClick).padding(horizontal = 6.dp, vertical = 5.dp),
|
||||
) {
|
||||
if (icon != null) Icon(icon, null, tint = if (primary) pal.onSignal else colors.onBackground, modifier = Modifier.size(12.dp))
|
||||
Text(text, fontSize = 10.sp, fontWeight = FontWeight.Bold, color = if (primary) pal.onSignal else colors.onBackground, maxLines = 1, overflow = TextOverflow.Ellipsis)
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
internal fun ProgressBarThin(fraction: Float, paused: Boolean = false, verify: Boolean = false, height: Dp = 4.dp, done: Boolean = false) {
|
||||
val pal = LocalPalette.current
|
||||
val colors = MaterialTheme.colorScheme
|
||||
val fill = when { done -> pal.good; paused -> Color(0xFFFFC24D); verify -> Color(0xFF9B6DFF); else -> pal.signal }
|
||||
Box(Modifier.fillMaxWidth().height(height).clip(RoundedCornerShape(height / 2)).background(colors.surfaceVariant)) {
|
||||
Box(Modifier.fillMaxWidth(fraction.coerceIn(0f, 1f)).height(height).background(fill))
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
internal fun downloadLabel(state: DownloadState, stage: DownloadStage, percent: Int): String = when (state) {
|
||||
DownloadState.PAUSED -> stringResource(R.string.stores_dl_paused)
|
||||
DownloadState.QUEUED -> stringResource(R.string.stores_dl_queued)
|
||||
else -> stringResource(R.string.stores_dl_stage_percent, stageLabel(stage), percent)
|
||||
}
|
||||
|
||||
@Composable
|
||||
internal fun stageLabel(stage: DownloadStage): String = when (stage) {
|
||||
DownloadStage.MANIFEST -> stringResource(R.string.stores_stage_manifest)
|
||||
DownloadStage.DOWNLOAD -> stringResource(R.string.stores_stage_download)
|
||||
DownloadStage.VERIFY -> stringResource(R.string.stores_stage_verify)
|
||||
DownloadStage.INSTALL -> stringResource(R.string.stores_stage_install)
|
||||
DownloadStage.DONE -> stringResource(R.string.stores_stage_done)
|
||||
}
|
||||
|
||||
/** The game's web page, in the device's browser: this app buys nothing and claims nothing itself. */
|
||||
internal fun openStoreUrl(ctx: android.content.Context, item: CatalogItem) {
|
||||
val url = item.storeUrl.ifBlank { return }
|
||||
runCatching { ctx.startActivity(android.content.Intent(android.content.Intent.ACTION_VIEW, android.net.Uri.parse(url)).addFlags(android.content.Intent.FLAG_ACTIVITY_NEW_TASK)) }
|
||||
}
|
||||
|
||||
/**
|
||||
* Launches an installed store game the way the Games tab does: through its Steam shortcut. The
|
||||
* shortcut exists once the client has (re)started since the install; until then the user is told.
|
||||
*/
|
||||
internal fun launchStoreGame(ctx: android.content.Context, store: Store, id: String, s: FrontEndState, a: FrontEndActions) {
|
||||
val game = s.steamGames.firstOrNull { it.source == store.id && it.storeId == id }
|
||||
if (game == null) {
|
||||
android.widget.Toast.makeText(ctx, R.string.stores_launch_not_registered, android.widget.Toast.LENGTH_LONG).show()
|
||||
return
|
||||
}
|
||||
com.droiddeck.launcher.stores.StoreLaunch.prepare(ctx, store, id) { a.onSteamGame(game) }
|
||||
}
|
||||
@@ -0,0 +1,21 @@
|
||||
package com.droiddeck.launcher.ui
|
||||
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import com.droiddeck.launcher.R
|
||||
import com.droiddeck.launcher.stores.StoresNative
|
||||
|
||||
// Controls the Stores section and its Setup group share.
|
||||
|
||||
/** Whether the native download engine is in this build, said where the stores are turned on. */
|
||||
@Composable
|
||||
internal fun StoresEngineRow() {
|
||||
val version = StoresNative.version
|
||||
SettingsRow(
|
||||
stringResource(R.string.setup_stores_engine),
|
||||
if (version != null) stringResource(R.string.setup_stores_engine_ready, version)
|
||||
else stringResource(R.string.setup_stores_engine_missing),
|
||||
) {
|
||||
Chip(if (version != null) stringResource(R.string.setup_stores_engine_chip_ready) else stringResource(R.string.setup_stores_engine_chip_missing), ok = version != null)
|
||||
}
|
||||
}
|
||||
@@ -1957,4 +1957,132 @@
|
||||
<string name="device_info_copied">Copied.</string>
|
||||
<string name="force_ssbs_title">Force SSBS</string>
|
||||
<string name="force_ssbs_hint">Keeps a CPU speed setting on that Wine keeps turning off. Can make Windows games faster on Snapdragon 8 Elite; little change seen on older chips. Applies on the next game launch.</string>
|
||||
|
||||
<!-- Stores: GOG, Epic Games and Amazon Games -->
|
||||
<string name="rail_stores">Stores</string>
|
||||
<string name="source_steam">Steam</string>
|
||||
<string name="source_added">Added</string>
|
||||
<string name="setup_stores">Stores</string>
|
||||
<string name="setup_stores_show">Show Stores in the rail</string>
|
||||
<string name="setup_stores_show_hint">GOG, Epic Games and Amazon Games libraries, with the download manager.</string>
|
||||
<string name="setup_stores_add_to_steam">Add installed store games to Steam</string>
|
||||
<string name="setup_stores_add_to_steam_hint">Creates a non-Steam shortcut so the game shows in Steam\'s Deck UI with Steam Input.</string>
|
||||
<string name="setup_stores_open_on">Open a store on</string>
|
||||
<string name="setup_stores_open_on_hint">Which tab a signed-in store opens when its chip is picked.</string>
|
||||
<string name="setup_stores_speed">Download speed tier</string>
|
||||
<string name="setup_stores_speed_hint">Also in Stores › Downloads.</string>
|
||||
<string name="setup_stores_engine">Stores engine</string>
|
||||
<string name="setup_stores_engine_ready">Native download engine %1$s.</string>
|
||||
<string name="setup_stores_engine_missing">Stores engine not built: downloads run on the slower built-in fetcher.</string>
|
||||
<string name="setup_stores_engine_chip_ready">● Engine ready</string>
|
||||
<string name="setup_stores_engine_chip_missing">Not built</string>
|
||||
<string name="stores_tier_balanced">Balanced</string>
|
||||
<string name="stores_tier_fast">Fast</string>
|
||||
<string name="stores_tier_max">Max</string>
|
||||
<string name="stores_title">Stores</string>
|
||||
<string name="stores_settings">Stores settings</string>
|
||||
<string name="stores_settings_eyebrow">Settings</string>
|
||||
<string name="stores_chip_signed_out">(not signed in)</string>
|
||||
<string name="stores_tab_store">Store</string>
|
||||
<string name="stores_tab_installed">Installed</string>
|
||||
<string name="stores_tab_library">Library</string>
|
||||
<string name="stores_tab_all">All</string>
|
||||
<string name="stores_tab_downloads">Downloads</string>
|
||||
<string name="stores_tab_count">%1$s (%2$d)</string>
|
||||
<string name="stores_signin_title">Sign in to %1$s</string>
|
||||
<string name="stores_signin_hint_gog">GOG account (same login as GOG Galaxy). Opens GOG\'s own sign-in page.</string>
|
||||
<string name="stores_signin_hint_epic">Epic account (web sign-in). Opens Epic\'s own sign-in page.</string>
|
||||
<string name="stores_signin_hint_amazon">Amazon account (device sign-in). Opens Amazon\'s own sign-in page.</string>
|
||||
<string name="stores_signin">Sign in</string>
|
||||
<string name="stores_signin_footnote">Tokens stay in DroidDeck\'s private storage and are refreshed by the engine; nothing goes through a third party.</string>
|
||||
<string name="stores_engine_missing_note">This build has no native download engine: installs still work, on the slower built-in fetcher.</string>
|
||||
<string name="stores_signout_named">%1$s · sign out</string>
|
||||
<string name="stores_search_catalog">Search the %1$s catalog…</string>
|
||||
<string name="stores_search_installed">Search installed games…</string>
|
||||
<string name="stores_search_library">Search your library…</string>
|
||||
<string name="stores_search_all">Search everything on %1$s…</string>
|
||||
<string name="stores_installed_empty">Nothing installed from %1$s yet. Install from Library.</string>
|
||||
<string name="stores_library_loading">Fetching your library…</string>
|
||||
<string name="stores_nothing_matches">Nothing matches.</string>
|
||||
<string name="stores_shelves_loading">Loading the store…</string>
|
||||
<string name="stores_amazon_no_catalog">Amazon has no browsable catalog: claim games on Prime Gaming in a browser; they show up in Library after a sync.</string>
|
||||
<string name="stores_shelf_new">What\'s new</string>
|
||||
<string name="stores_shelf_new_hint">Just landed</string>
|
||||
<string name="stores_shelf_deals">Deals</string>
|
||||
<string name="stores_shelf_deals_hint">On sale this week</string>
|
||||
<string name="stores_shelf_free">Free games</string>
|
||||
<string name="stores_shelf_free_hint">Claim and keep</string>
|
||||
<string name="stores_shelf_trending">Trending</string>
|
||||
<string name="stores_shelf_trending_hint">Popular right now</string>
|
||||
<string name="stores_shelf_library">Your library</string>
|
||||
<string name="stores_shelf_library_hint">Owned on %1$s</string>
|
||||
<string name="stores_play">Play</string>
|
||||
<string name="stores_install">Install</string>
|
||||
<string name="stores_install_size">Install · %1$s</string>
|
||||
<string name="stores_get_free">Get for free</string>
|
||||
<string name="stores_view_on">View on %1$s</string>
|
||||
<string name="stores_buy_price">Buy · %1$s</string>
|
||||
<string name="stores_installed_chip">Installed</string>
|
||||
<string name="stores_in_library">In library</string>
|
||||
<string name="stores_free">Free</string>
|
||||
<string name="stores_eyebrow_installed">installed</string>
|
||||
<string name="stores_eyebrow_library">in your library</string>
|
||||
<string name="stores_eyebrow_store">store</string>
|
||||
<string name="stores_in_steam">✓ In Steam</string>
|
||||
<string name="stores_add_to_steam">Add to Steam</string>
|
||||
<string name="stores_uninstall">Uninstall</string>
|
||||
<string name="stores_uninstall_confirm">Press again to uninstall</string>
|
||||
<string name="stores_launch_not_registered">Steam picks the game up when it next starts; launch it from there, or start Steam now.</string>
|
||||
<string name="stores_card_proton">Proton</string>
|
||||
<string name="stores_card_proton_value">Steam\'s choice per game</string>
|
||||
<string name="stores_card_audio">Audio</string>
|
||||
<string name="stores_card_audio_value">DirectAudio</string>
|
||||
<string name="stores_card_components">Components</string>
|
||||
<string name="stores_card_components_value">FEX, DXVK, VKD3D</string>
|
||||
<string name="stores_card_engine">Engine</string>
|
||||
<string name="stores_card_engine_value">%1$s · native</string>
|
||||
<string name="stores_card_engine_java">%1$s · built-in</string>
|
||||
<string name="stores_how_it_runs">How it runs</string>
|
||||
<string name="stores_kv_fetched">Fetched from</string>
|
||||
<string name="stores_kv_fetched_value">%1$s, with your account</string>
|
||||
<string name="stores_kv_installed_to">Installed to</string>
|
||||
<string name="stores_kv_installed_to_value">Games storage (Games/%1$s/%2$s)</string>
|
||||
<string name="stores_kv_launched_by">Launched by</string>
|
||||
<string name="stores_kv_launched_by_value">Steam and Proton, the same path as Steam games</string>
|
||||
<string name="stores_kv_launched_by_epic">Steam and Proton · Epic sign-in per launch, EOS overlay off</string>
|
||||
<string name="stores_kv_launched_by_amazon">Steam and Proton · Amazon Games Services variables set</string>
|
||||
<string name="stores_kv_steam">Steam</string>
|
||||
<string name="stores_kv_steam_on">shortcut added on install (Deck UI + Steam Input)</string>
|
||||
<string name="stores_kv_steam_off">no shortcut (Stores settings)</string>
|
||||
<string name="stores_downloads_empty">No downloads. Install something from a library.</string>
|
||||
<string name="stores_dl_paused">Paused</string>
|
||||
<string name="stores_dl_queued">Queued</string>
|
||||
<string name="stores_dl_queued_at">Queued · #%1$d</string>
|
||||
<string name="stores_dl_cancelled">Cancelled</string>
|
||||
<string name="stores_dl_failed">Failed</string>
|
||||
<string name="stores_dl_failed_reason">Failed · %1$s</string>
|
||||
<string name="stores_dl_stage_percent">%1$s · %2$d%%</string>
|
||||
<string name="stores_dl_bytes">%1$s / %2$s · %3$d%%</string>
|
||||
<string name="stores_dl_pause">Pause</string>
|
||||
<string name="stores_dl_resume">Resume</string>
|
||||
<string name="stores_dl_cancel">Cancel</string>
|
||||
<string name="stores_dl_retry">Retry</string>
|
||||
<string name="stores_dl_clear">Clear</string>
|
||||
<string name="stores_stage_manifest">Manifest</string>
|
||||
<string name="stores_stage_download">Download</string>
|
||||
<string name="stores_stage_verify">Verify</string>
|
||||
<string name="stores_stage_install">Install</string>
|
||||
<string name="stores_stage_done">Done</string>
|
||||
<string name="stores_download_manager">Download manager</string>
|
||||
<string name="stores_tier_note">Caps how many chunks the engine fetches at once: Balanced 16 · Fast 32 · Max 96 in flight.</string>
|
||||
<string name="stores_parallel">Downloads at a time</string>
|
||||
<string name="stores_parallel_note">One manager for all three stores; Steam\'s own downloads are unaffected.</string>
|
||||
<string name="stores_engine_log">Engine log</string>
|
||||
<string name="stores_log_idle">idle</string>
|
||||
<string name="stores_downloads_footnote">Stages mirror the engine: manifest → chunks → verify → install. A paused download keeps its files and carries on where it stopped.</string>
|
||||
<string name="stores_notification_channel">Store downloads</string>
|
||||
<string name="stores_notification_channel_hint">Shows game download progress and keeps downloads running in the background</string>
|
||||
<string name="stores_notification_one">Downloading</string>
|
||||
<string name="stores_notification_many">%1$d downloads</string>
|
||||
<string name="stores_notification_waiting">Preparing…</string>
|
||||
</resources>
|
||||
@@ -0,0 +1,66 @@
|
||||
package com.droiddeck.launcher.frontend
|
||||
|
||||
import com.droiddeck.launcher.session.SessionPrefs
|
||||
import com.droiddeck.launcher.stores.Store
|
||||
import com.droiddeck.launcher.stores.StoreGameSidecar
|
||||
import com.droiddeck.launcher.stores.StoreInstallRoot
|
||||
import com.droiddeck.launcher.stores.StoreLaunch
|
||||
import java.io.File
|
||||
import org.junit.Assert.*
|
||||
import org.junit.Test
|
||||
import org.junit.runner.RunWith
|
||||
import org.robolectric.RobolectricTestRunner
|
||||
import org.robolectric.RuntimeEnvironment
|
||||
import org.robolectric.annotation.Config
|
||||
|
||||
/** A store install is one more folder the scan finds, named and aimed by its sidecar. */
|
||||
@RunWith(RobolectricTestRunner::class)
|
||||
@Config(sdk = [33])
|
||||
class AddedGamesStoreScanTest {
|
||||
private val app get() = RuntimeEnvironment.getApplication()
|
||||
|
||||
private fun install(store: Store, id: String, title: String, exe: String, addToSteam: Boolean = true, launcher: Boolean = false): File {
|
||||
SessionPrefs.setGameStorage(app, SessionPrefs.GAME_STORAGE_OFF, "")
|
||||
val folder = File(StoreInstallRoot.storeDir(StoreInstallRoot.installRoot(app), store), StoreInstallRoot.folderName(title, id))
|
||||
File(folder, exe).apply { parentFile!!.mkdirs(); writeText("game") }
|
||||
File(folder, "unins000.exe").writeText("uninstaller")
|
||||
var sidecar = StoreGameSidecar(store, id, title, exe, addToSteam = addToSteam, args = if (launcher) listOf("-EpicPortal") else emptyList())
|
||||
if (launcher) sidecar = StoreLaunch.writeLauncher(folder, sidecar)
|
||||
sidecar.write(folder)
|
||||
return folder
|
||||
}
|
||||
|
||||
@Test fun aStoreGameIsFoundUnderTheInternalRootWithItsSource() {
|
||||
val folder = install(Store.GOG, "1207658924", "Heroes of Might and Magic 3: Complete", "HOMM3.exe")
|
||||
val games = AddedGames.scan(app)
|
||||
val game = games.single { it.folder.absolutePath == folder.absolutePath }
|
||||
assertEquals("Heroes of Might and Magic 3: Complete", game.name)
|
||||
assertEquals("HOMM3.exe", game.exe.name)
|
||||
assertEquals(Store.GOG.id, game.source)
|
||||
assertEquals("1207658924", game.storeId)
|
||||
// Inside the runtime's tree the folder needs no bind: its guest path is its path from the tree's root.
|
||||
assertEquals("/root/Games/Stores/GOG/Heroes of Might and Magic 3 Complete/HOMM3.exe", game.guestExe)
|
||||
assertEquals("/root/Games/Stores/GOG/Heroes of Might and Magic 3 Complete", game.guestDir)
|
||||
}
|
||||
|
||||
@Test fun theLauncherIsWhatTheShortcutRunsAndTheExeIsWhatItShows() {
|
||||
val folder = install(Store.EPIC, "abc", "Celeste", "Celeste.exe", launcher = true)
|
||||
val game = AddedGames.scan(app).single { it.folder.absolutePath == folder.absolutePath }
|
||||
assertEquals("Celeste.exe", game.exe.name)
|
||||
assertTrue(game.guestExe.endsWith("/" + StoreLaunch.LAUNCHER))
|
||||
assertEquals(Store.EPIC.id, game.source)
|
||||
}
|
||||
|
||||
@Test fun aStoreGameKeptOutOfSteamIsNotListed() {
|
||||
val folder = install(Store.AMAZON, "amzn1.adg.product.x", "Yakuza 0", "Yakuza0.exe", addToSteam = false)
|
||||
assertTrue(AddedGames.scan(app).none { it.folder.absolutePath == folder.absolutePath })
|
||||
}
|
||||
|
||||
@Test fun theListingCarriesTheLauncherAndTheTitle() {
|
||||
val folder = install(Store.EPIC, "abc", "Celeste", "Celeste.exe", launcher = true)
|
||||
val games = AddedGames.scan(app).filter { it.folder.absolutePath == folder.absolutePath }
|
||||
val listing = AddedGames.writeListing(app, games).readText()
|
||||
assertTrue(listing.contains("\"name\":\"Celeste\""))
|
||||
assertTrue(listing.contains(StoreLaunch.LAUNCHER))
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,76 @@
|
||||
package com.droiddeck.launcher.stores
|
||||
|
||||
import org.junit.Assert.assertEquals
|
||||
import org.junit.Assert.assertFalse
|
||||
import org.junit.Assert.assertNull
|
||||
import org.junit.Assert.assertTrue
|
||||
import org.junit.Test
|
||||
|
||||
class StoreGameSidecarTest {
|
||||
private val sample = StoreGameSidecar(
|
||||
Store.EPIC, "abc123", "Celeste", "Celeste.exe", launcher = ".droiddeck-launch.bat",
|
||||
args = listOf("-EpicPortal", "-epicusername=\"Some One\""), env = mapOf("FOO" to "bar"),
|
||||
installVersion = "1.4.0", installedAt = 1700000000L, addToSteam = false, cover = "https://x/c.jpg",
|
||||
extra = mapOf("namespace" to "ns"),
|
||||
)
|
||||
|
||||
@Test fun roundTripsThroughJson() {
|
||||
val back = StoreGameSidecar.parse(sample.toJson().toString())!!
|
||||
assertEquals(Store.EPIC, back.store)
|
||||
assertEquals("abc123", back.id)
|
||||
assertEquals("Celeste", back.title)
|
||||
assertEquals("Celeste.exe", back.exe)
|
||||
assertEquals(".droiddeck-launch.bat", back.launcher)
|
||||
assertEquals(sample.args, back.args)
|
||||
assertEquals(sample.env, back.env)
|
||||
assertEquals("1.4.0", back.installVersion)
|
||||
assertEquals(1700000000L, back.installedAt)
|
||||
assertFalse(back.addToSteam)
|
||||
assertEquals("https://x/c.jpg", back.cover)
|
||||
assertEquals("ns", back.extra["namespace"])
|
||||
}
|
||||
|
||||
@Test fun defaultsWhenFieldsAreAbsent() {
|
||||
val back = StoreGameSidecar.parse("""{"store":"gog","id":"1","title":"T","exe":"bin\\game.exe"}""")!!
|
||||
assertTrue(back.addToSteam)
|
||||
assertEquals("bin/game.exe", back.exe)
|
||||
assertNull(back.launcher)
|
||||
assertTrue(back.args.isEmpty())
|
||||
}
|
||||
|
||||
@Test fun refusesWhatIsNotASidecar() {
|
||||
assertNull(StoreGameSidecar.parse("not json"))
|
||||
assertNull(StoreGameSidecar.parse("""{"store":"steam","id":"1","title":"T","exe":"a.exe"}"""))
|
||||
assertNull(StoreGameSidecar.parse("""{"store":"gog","id":"","title":"T","exe":"a.exe"}"""))
|
||||
// An exe outside the folder is not trusted: a sidecar must not point the shortcut anywhere.
|
||||
assertNull(StoreGameSidecar.parse("""{"store":"gog","id":"1","title":"T","exe":"../other/a.exe"}"""))
|
||||
assertNull(StoreGameSidecar.parse("""{"store":"gog","id":"1","title":"T","exe":"/abs/a.exe"}"""))
|
||||
assertNull(StoreGameSidecar.parse("""{"store":"gog","id":"1","title":"T","exe":"C:\\a.exe"}""")!!.let { null })
|
||||
}
|
||||
|
||||
@Test fun folderNamesAreSafeAndStable() {
|
||||
assertEquals("Heroes of Might and Magic 3 Complete", StoreInstallRoot.folderName("Heroes of Might and Magic 3: Complete", "1"))
|
||||
assertEquals("What Remains of Edith Finch", StoreInstallRoot.folderName("What Remains of Edith Finch...", "1"))
|
||||
assertEquals("1207658924", StoreInstallRoot.folderName("日本語のみ", "1207658924"))
|
||||
assertTrue(StoreInstallRoot.folderName("x".repeat(200), "1").length <= 60)
|
||||
}
|
||||
|
||||
@Test fun launcherStartsTheExeFromItsFolderWithArgsAndEnv() {
|
||||
val text = StoreLaunch.launcherText(sample.copy(exe = "Binaries/Win64/Celeste.exe"))
|
||||
assertTrue(text.contains("cd /d \"%~dp0Binaries\\Win64\"\r\n"))
|
||||
assertTrue(text.contains("set \"FOO=bar\"\r\n"))
|
||||
assertTrue(text.contains("\"%~dp0Binaries\\Win64\\Celeste.exe\" -EpicPortal -epicusername=\"Some One\" %DD_AUTH%\r\n"))
|
||||
// Epic reads the one-shot exchange code the app leaves beside the launcher, then deletes it.
|
||||
assertTrue(text.contains("set /p DD_CODE=<\"%~dp0.droiddeck-epic-code\""))
|
||||
assertTrue(text.contains("-AUTH_TYPE=exchangecode"))
|
||||
}
|
||||
|
||||
@Test fun gogLauncherHasNoEpicBlockAndIsOnlyWrittenWhenNeeded() {
|
||||
val plain = StoreGameSidecar(Store.GOG, "1", "T", "game.exe")
|
||||
val text = StoreLaunch.launcherText(plain.copy(args = listOf("-windowed")))
|
||||
assertFalse(text.contains("exchangecode"))
|
||||
assertTrue(text.contains("\"%~dp0game.exe\" -windowed %DD_AUTH%"))
|
||||
assertEquals("\"two words\"", StoreLaunch.quoteArg("two words"))
|
||||
assertEquals("-x=\"a b\"", StoreLaunch.quoteArg("-x=\"a b\""))
|
||||
}
|
||||
}
|
||||
@@ -7,6 +7,56 @@ the timeline, then lessons and backlog. Companion to the README (what the app *d
|
||||
|
||||
---
|
||||
|
||||
## 2026-10-08 - `feat/stores`: GOG, Epic Games and Amazon Games in the launcher (in progress)
|
||||
|
||||
A new **Stores** section - the three storefronts' libraries and public catalogs, one download
|
||||
queue for them, and every install registered in the Steam client exactly like an added folder.
|
||||
Ported from Bannerlator's store clients (auth, API, library sync, download managers); its
|
||||
Activities and XML are not ported, the UI is Compose, after the approved preview. The native
|
||||
download engine is the Rust side's work (`libdroiddeckstores.so`, loaded lazily; the app runs
|
||||
without it and Setup says "Stores engine not built").
|
||||
|
||||
- **Where it lives.** Rail item `stores` under Games, gated by Setup › Stores › "Show Stores in
|
||||
the rail" (`SessionPrefs.gameStoresEnabled`, off by default); a count badge while anything is
|
||||
downloading. The page is full width: four chips (GOG · Epic · Amazon · Downloads, a signed-in
|
||||
dot per store) and a cog at the row's end whose popup holds the section's own settings - which
|
||||
tab a store opens on (`storesOpenTab`, Library by default; applied on chip tap, never while the
|
||||
user is switching tabs by hand), "Add installed store games to Steam" (`gameStoresAddToSteam`,
|
||||
on), and the speed tier (`gameStoresSpeedTier`, Balanced / Fast / Max = 16 / 32 / 96 requests in
|
||||
flight). Setup repeats the rows under the gate. A signed-out store shows its sign-in card first.
|
||||
- **A store game is an added game.** The install lands at `<Games storage>/Games/<Store>/<title>`
|
||||
(the SD library's folder when one is chosen, else the runtime's own `/root/Games/Stores`) with a
|
||||
`.droiddeck-store.json` sidecar (store, id, title, exe, launcher, args, env, version,
|
||||
addToSteam, art). `AddedGames.scan` now walks those folders too and `scanGame` reads the sidecar
|
||||
for the exe and the name, so the game goes down the existing path: `session/added-games.json`
|
||||
→ `droiddeck-steam-shortcuts` at the client's next start. The listing is rewritten at install
|
||||
time, and with a client running the shortcut is added live over the client's DevTools port
|
||||
(`SteamClient.Apps.AddShortcut` + our tag + the compat tool; `SteamLiveShortcuts`), best-effort.
|
||||
Uninstall removes the folder, the sidecar and the shortcut.
|
||||
- **Launch.** A shortcut can only name an exe and the listing carries no launch options, so a game
|
||||
that needs arguments or environment gets a `.droiddeck-launch.bat` beside it (Proton's steam.exe
|
||||
shim hands it to Wine's cmd): Epic's identity arguments, Amazon's FuelPump variables. Epic's
|
||||
per-launch exchange code is minted by the app right before a launch into `.droiddeck-epic-code`,
|
||||
which the script reads once and deletes; with no code the game starts in its offline identity
|
||||
mode. The EOS overlay is never provisioned.
|
||||
- **Games tab badges.** `Library.SteamGame.source` (steam / gog / epic / amazon / added, from the
|
||||
sidecar) with a coloured chip on every row and in the hero eyebrow; `LibraryCache` keeps it.
|
||||
- **Downloads.** `DownloadQueue` runs 1-3 at a time, stages Manifest → Download → Verify →
|
||||
Install, pause = stop and keep the files (every engine skips verified files on the rerun),
|
||||
cancel deletes; `StoreDownloadService` keeps the process alive meanwhile; the page shows speed,
|
||||
ETA, the tier, downloads-at-a-time and the engine log.
|
||||
- Tests: `StoreGameSidecarTest`, `AddedGamesStoreScanTest` (JVM), `tools/tests/test_store_shortcuts.py`
|
||||
(the writer with a launcher .bat as Exe, the appid the app computes, removal).
|
||||
- Files: `stores/*` (Store, StoreGameSidecar, StoreInstallRoot, StoreInstalls, StoreLaunch,
|
||||
StoreAccounts, StoresState, StoresNative, SteamLiveShortcuts, download/*), `ui/StoresPage.kt`,
|
||||
`ui/StoresDetail.kt`, `ui/StoresDownloads.kt`, `ui/StoreChips.kt`, `ui/StoresWidgets.kt`;
|
||||
`frontend/AddedGames.kt`, `frontend/Library.kt`, `frontend/LibraryCache.kt`,
|
||||
`ui/FrontEndRail.kt`, `ui/FrontEndContent.kt`, `ui/FrontEndSetup.kt`, `ui/FrontEndGames.kt`,
|
||||
`ui/FrontEndScreen.kt`, `MainActivity.kt`, `session/SessionPrefs.kt`, `AndroidManifest.xml`
|
||||
(the download service), `res/values/strings.xml`.
|
||||
- Status: milestone (a) - model, sidecar, badges, Setup, the page's skeleton - compiles on CI;
|
||||
the store clients (b: GOG, c: Epic, d: Amazon) follow in this branch.
|
||||
|
||||
## 2026-10-08 - `feat/directaudio-from-release`: DirectAudio from its own release, picked by interface, and the client gets the real engine
|
||||
|
||||
Three things were wrong with audio at once, and one tidy-up @xXJSONDeruloXx asked for.
|
||||
|
||||
@@ -0,0 +1,74 @@
|
||||
"""The shortcuts writer and a store install: the app registers a game GOG, Epic or Amazon
|
||||
installed through the same listing as an added folder, pointing the shortcut at the game's
|
||||
launcher .bat when it has one. These pin the parts of the writer that contract relies on."""
|
||||
import importlib.machinery
|
||||
import importlib.util
|
||||
import json
|
||||
from pathlib import Path
|
||||
import subprocess
|
||||
import tempfile
|
||||
import unittest
|
||||
import zlib
|
||||
|
||||
BIN = Path(__file__).resolve().parents[1] / 'linuxfs/overlay/usr/local/bin'
|
||||
|
||||
|
||||
def load(name):
|
||||
loader = importlib.machinery.SourceFileLoader(name, str(BIN / name))
|
||||
spec = importlib.util.spec_from_loader(name, loader)
|
||||
module = importlib.util.module_from_spec(spec)
|
||||
loader.exec_module(module)
|
||||
return module
|
||||
|
||||
|
||||
shortcuts = load('droiddeck-steam-shortcuts')
|
||||
|
||||
FOLDER = '/root/Games/Stores/Epic/Celeste'
|
||||
LAUNCHER = FOLDER + '/.droiddeck-launch.bat'
|
||||
|
||||
|
||||
def app_id(exe, name):
|
||||
"""The appid the app computes for a shortcut (AddedGames.scanGame): CRC32 of the quoted exe
|
||||
and the name, high bit set - what Steam itself derives for a shortcut it is given."""
|
||||
return (zlib.crc32(('"%s"' % exe + name).encode()) | 0x80000000) & 0xFFFFFFFF
|
||||
|
||||
|
||||
class StoreShortcutsTest(unittest.TestCase):
|
||||
def setUp(self):
|
||||
self.temp = tempfile.TemporaryDirectory()
|
||||
self.addCleanup(self.temp.cleanup)
|
||||
self.root = Path(self.temp.name)
|
||||
self.steam = self.root / 'Steam'
|
||||
self.config = self.steam / 'userdata/123/config'
|
||||
self.config.mkdir(parents=True)
|
||||
self.listing = self.root / 'added-games.json'
|
||||
|
||||
def write(self, *games):
|
||||
self.listing.write_text(json.dumps(list(games)))
|
||||
subprocess.run(['python3', str(BIN / 'droiddeck-steam-shortcuts'), str(self.steam), str(self.listing)],
|
||||
check=True, capture_output=True)
|
||||
return shortcuts.parse((self.config / 'shortcuts.vdf').read_bytes())['shortcuts']
|
||||
|
||||
def test_a_launcher_bat_is_the_shortcuts_exe_and_the_folder_its_start_dir(self):
|
||||
game = dict(name='Celeste', exe=LAUNCHER, folder=FOLDER, dir=FOLDER, appid=app_id(LAUNCHER, 'Celeste'), seen=0)
|
||||
entries = self.write(game)
|
||||
entry = next(iter(entries.values()))
|
||||
self.assertEqual('"%s"' % LAUNCHER, entry['Exe'])
|
||||
self.assertEqual('"%s"' % FOLDER, entry['StartDir'])
|
||||
self.assertEqual('Celeste', entry['AppName'])
|
||||
self.assertIn('droiddeck-app', entry['tags'].values())
|
||||
|
||||
def test_the_appid_the_app_computes_is_the_one_written(self):
|
||||
appid = app_id(LAUNCHER, 'Celeste')
|
||||
entries = self.write(dict(name='Celeste', exe=LAUNCHER, folder=FOLDER, dir=FOLDER, appid=appid, seen=0))
|
||||
entry = next(iter(entries.values()))
|
||||
self.assertEqual(appid, entry['appid'] & 0xFFFFFFFF)
|
||||
|
||||
def test_an_uninstalled_game_leaves_the_listing_and_the_shortcut_goes_with_it(self):
|
||||
game = dict(name='Celeste', exe=LAUNCHER, folder=FOLDER, dir=FOLDER, appid=app_id(LAUNCHER, 'Celeste'), seen=0)
|
||||
self.assertEqual(1, len(self.write(game)))
|
||||
self.assertEqual(0, len(self.write()))
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
unittest.main()
|
||||
Reference in new issue
Block a user