mirror of
https://github.com/Droid-Deck/DroidDeck.git
synced 2026-10-19 05:34:53 -04:00
Compare commits
2
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
b262d390cb | ||
|
|
ebc7f89fe3 |
No files matched your search
@@ -35,5 +35,6 @@ class App : Application() {
|
||||
com.droiddeck.launcher.stores.StoresState.register(com.droiddeck.launcher.stores.gog.GogBackend)
|
||||
com.droiddeck.launcher.stores.StoresState.register(com.droiddeck.launcher.stores.epic.EpicBackend)
|
||||
com.droiddeck.launcher.stores.StoresState.register(com.droiddeck.launcher.stores.amazon.AmazonBackend)
|
||||
com.droiddeck.launcher.stores.StoresState.register(com.droiddeck.launcher.stores.itch.ItchBackend)
|
||||
}
|
||||
}
|
||||
@@ -11,7 +11,7 @@ import java.io.File
|
||||
|
||||
object GuestCommand {
|
||||
fun run(context: Context, argv: List<String>, fakeRoot: Boolean = false, logName: String? = null,
|
||||
linkDir: File? = null, onLine: (String) -> Unit): Int {
|
||||
linkDir: File? = null, extraBinds: List<String>? = null, fastPath: Boolean = false, onLine: (String) -> Unit): Int {
|
||||
val log = logName?.let {
|
||||
try {
|
||||
File(File(LinuxRuntime.logDir(context), SessionPaths.TOOLS_DIR).apply { mkdirs() }, "$it.log").printWriter()
|
||||
@@ -19,13 +19,14 @@ object GuestCommand {
|
||||
}
|
||||
log?.println("== ${java.util.Date()} ${argv.joinToString(" ")}")
|
||||
try {
|
||||
return runLogged(context, argv, fakeRoot, linkDir) { line -> log?.println(line); log?.flush(); onLine(line) }
|
||||
return runLogged(context, argv, fakeRoot, linkDir, extraBinds, fastPath) { line -> log?.println(line); log?.flush(); onLine(line) }
|
||||
} finally {
|
||||
log?.close()
|
||||
}
|
||||
}
|
||||
|
||||
private fun runLogged(context: Context, argv: List<String>, fakeRoot: Boolean, linkDir: File?, onLine: (String) -> Unit): Int {
|
||||
private fun runLogged(context: Context, argv: List<String>, fakeRoot: Boolean, linkDir: File?, extraBinds: List<String>?,
|
||||
fastPath: Boolean, onLine: (String) -> Unit): Int {
|
||||
val root = LinuxRuntime.rootDir(context)
|
||||
LinuxRuntime.writeAccounts(context)
|
||||
SessionFiles.stage(context, root)
|
||||
@@ -33,8 +34,10 @@ object GuestCommand {
|
||||
val runtimeDir = File(context.filesDir, ".flatpak-rt").apply { mkdirs() }
|
||||
val cmd = LinuxRuntime.prootPrefix(context, root, "/root", fakeRoot)
|
||||
if (linkDir != null) cmd.add(1, "--link2symlink")
|
||||
LinuxRuntime.binds(context, null, runtimeDir, Environment.getExternalStorageDirectory(), null)
|
||||
.forEach { cmd.add("-b"); cmd.add(it) }
|
||||
val binds = LinuxRuntime.binds(context, null, runtimeDir, Environment.getExternalStorageDirectory(), extraBinds)
|
||||
binds.forEach { cmd.add("-b"); cmd.add(it) }
|
||||
// Fake root and link2symlink are emulated by proot, so their syscalls must stay on the traced path.
|
||||
val fastPathKey = if (fastPath && !fakeRoot && linkDir == null && ProotFastPath.enabled(context)) ProotFastPath.key(root, binds) else null
|
||||
cmd += listOf(
|
||||
"/usr/bin/env", "-i", "HOME=/root", "USER=root", "LANG=C.UTF-8",
|
||||
"PATH=/usr/local/bin:/usr/bin:/bin", "XDG_RUNTIME_DIR=${LinuxRuntime.GUEST_RUNTIME_DIR}",
|
||||
@@ -42,6 +45,7 @@ object GuestCommand {
|
||||
"DROIDDECK_BWRAP_SOCKET=${BwrapSpawner.socket(context)}",
|
||||
"XDG_DATA_DIRS=/root/.local/share/flatpak/exports/share:/usr/local/share:/usr/share",
|
||||
)
|
||||
fastPathKey?.let { cmd += ProotFastPath.guestEnv(root, binds, it) }
|
||||
cmd += argv
|
||||
val builder = ProcessBuilder(cmd).directory(root).redirectErrorStream(true)
|
||||
builder.environment().apply {
|
||||
@@ -49,6 +53,7 @@ object GuestCommand {
|
||||
put("PROOT_TMP_DIR", LinuxRuntime.prootTempDir(context).path)
|
||||
if (linkDir != null) put("PROOT_L2S_DIR", linkDir.path)
|
||||
if (SessionPrefs.prootNoSeccomp(context)) put("PROOT_NO_SECCOMP", "1")
|
||||
fastPathKey?.let { ProotFastPath.hostEnv(it).let { (k, v) -> put(k, v) } }
|
||||
LinuxRuntime.prootLibraryPath(context).takeIf { it.isNotEmpty() }?.let { put("LD_LIBRARY_PATH", it) }
|
||||
}
|
||||
val process = builder.start()
|
||||
|
||||
@@ -57,7 +57,7 @@ object CloudSaves {
|
||||
val item = sidecar.extra["catalogItemId"].orEmpty()
|
||||
if (ns.isEmpty() || item.isEmpty()) "" else EpicApiClient.getCustomAttribute(token, ns, item, "CloudSaveFolder")
|
||||
}
|
||||
Store.AMAZON -> ""
|
||||
Store.AMAZON, Store.ITCH -> ""
|
||||
} ?: return null
|
||||
p.edit().putString("cloud_template_$id", fetched).apply()
|
||||
Log.i(TAG, "cloud ${store.id} $id template ${if (fetched.isEmpty()) "none" else "found"}")
|
||||
@@ -95,7 +95,7 @@ object CloudSaves {
|
||||
val dir = when (store) {
|
||||
Store.GOG -> CloudSavePaths.gog(template, pfx, folder)
|
||||
Store.EPIC -> CloudSavePaths.epic(template, pfx, folder, EpicCredentialStore.load(context)?.accountId.orEmpty(), id)
|
||||
Store.AMAZON -> null
|
||||
Store.AMAZON, Store.ITCH -> null
|
||||
}
|
||||
return dir to (if (dir == null) "unresolved" else "ok")
|
||||
}
|
||||
@@ -120,7 +120,7 @@ object CloudSaves {
|
||||
val account = EpicCredentialStore.load(context)?.accountId.orEmpty()
|
||||
if (token == null || account.isEmpty()) null else EpicCloud(account, id, token)
|
||||
}
|
||||
Store.AMAZON -> null
|
||||
Store.AMAZON, Store.ITCH -> null
|
||||
}
|
||||
|
||||
private fun gogScopedToken(context: Context, id: String, clientId: String): String? {
|
||||
|
||||
@@ -12,6 +12,7 @@ internal object LoginFlows {
|
||||
Store.GOG -> GogFlow(state)
|
||||
Store.EPIC -> epicFlow()
|
||||
Store.AMAZON -> amazonFlow()
|
||||
Store.ITCH -> null
|
||||
}
|
||||
|
||||
@Volatile var epicFlow: () -> StoreLoginActivity.Flow? = { null }
|
||||
|
||||
@@ -3,7 +3,8 @@ package com.droiddeck.launcher.stores
|
||||
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");
|
||||
AMAZON("amazon", "Amazon Games", "Amazon"),
|
||||
ITCH("itch", "itch.io", "itch");
|
||||
|
||||
companion object {
|
||||
fun byId(id: String?): Store? = entries.firstOrNull { it.id == id }
|
||||
|
||||
@@ -54,6 +54,7 @@ object StoreSizes {
|
||||
when (item.store) {
|
||||
Store.EPIC -> EpicPrefs.get(app).edit().putLong("size_${item.id}", it).apply()
|
||||
Store.AMAZON -> AmazonPrefs.get(app).edit().putLong("size_${item.id}", it).apply()
|
||||
Store.ITCH -> {}
|
||||
Store.GOG -> {}
|
||||
}
|
||||
}
|
||||
@@ -108,5 +109,6 @@ object StoreSizes {
|
||||
AmazonManifest.parse(bytes).totalInstallSize
|
||||
}
|
||||
Store.GOG -> 0L
|
||||
Store.ITCH -> com.droiddeck.launcher.stores.itch.ItchLibrary.size(context, item)
|
||||
} }
|
||||
}
|
||||
@@ -16,6 +16,7 @@ import java.util.Locale
|
||||
|
||||
object StoresState {
|
||||
private const val TAG = "Stores"
|
||||
private const val SEARCH_DELAY_MS = 400L
|
||||
private val main = Handler(Looper.getMainLooper())
|
||||
|
||||
val accounts = mutableStateMapOf<Store, String>()
|
||||
@@ -24,6 +25,8 @@ object StoresState {
|
||||
|
||||
val shelves = mutableStateMapOf<Store, StoreShelves>()
|
||||
|
||||
val found = mutableStateMapOf<Store, List<CatalogItem>>()
|
||||
|
||||
val status = mutableStateMapOf<Store, String>()
|
||||
|
||||
val problems = mutableStateMapOf<Store, String>()
|
||||
@@ -64,6 +67,8 @@ object StoresState {
|
||||
|
||||
private val backends = HashMap<Store, StoreBackend>()
|
||||
|
||||
private val searchSeq = java.util.concurrent.ConcurrentHashMap<Store, Int>()
|
||||
|
||||
fun backend(store: Store): StoreBackend? = backends[store]
|
||||
|
||||
fun register(backend: StoreBackend) { backends[backend.store] = backend }
|
||||
@@ -129,6 +134,26 @@ object StoresState {
|
||||
if (!shelves.containsKey(store)) backends[store]?.loadShelves(context.applicationContext, false)
|
||||
}
|
||||
|
||||
fun search(context: Context, store: Store, query: String) {
|
||||
val backend = backends[store] ?: return
|
||||
val text = query.trim()
|
||||
if (text.isEmpty()) {
|
||||
searchSeq.remove(store)
|
||||
found.remove(store)
|
||||
return
|
||||
}
|
||||
val seq = (searchSeq[store] ?: 0) + 1
|
||||
searchSeq[store] = seq
|
||||
val app = context.applicationContext
|
||||
Thread({
|
||||
Thread.sleep(SEARCH_DELAY_MS)
|
||||
if (searchSeq[store] != seq) return@Thread
|
||||
val items = runCatching { backend.search(app, text) }.getOrDefault(emptyList())
|
||||
if (searchSeq[store] != seq) return@Thread
|
||||
main.post { found[store] = items }
|
||||
}, "stores-search").start()
|
||||
}
|
||||
|
||||
fun signIn(context: Context, store: Store) {
|
||||
expired.remove(store)
|
||||
backends[store]?.signIn(context) ?: logLine("${store.label}: this build has no sign-in for it yet")
|
||||
@@ -265,6 +290,7 @@ interface StoreBackend {
|
||||
fun signOut(context: Context)
|
||||
fun syncLibrary(context: Context, force: Boolean)
|
||||
fun loadShelves(context: Context, force: Boolean)
|
||||
fun search(context: Context, query: String): List<CatalogItem> = emptyList()
|
||||
fun install(context: Context, item: CatalogItem, root: java.io.File)
|
||||
fun uninstall(context: Context, game: InstalledStoreGame)
|
||||
}
|
||||
@@ -335,7 +335,7 @@ object DownloadQueue {
|
||||
StoresState.logLine("installed \"${item.entry.name}\" → ${result}")
|
||||
StoresState.notifyLibraryChanged(context)
|
||||
val games = runCatching {
|
||||
com.droiddeck.launcher.frontend.Library.launchableGames(context).firstOrNull { it.gameFiles?.absolutePath == result }?.let { "app:${it.appId}" }
|
||||
com.droiddeck.launcher.frontend.LibraryCache.load(context).firstOrNull { it.gameFiles?.absolutePath == result }?.let { "app:${it.appId}" }
|
||||
}.getOrNull()
|
||||
StoreDownloadService.ended(context, item.entry, installed = true, gamesKey = games)
|
||||
}
|
||||
|
||||
@@ -0,0 +1,34 @@
|
||||
package com.droiddeck.launcher.stores.itch
|
||||
|
||||
import com.droiddeck.launcher.stores.StoreNet
|
||||
import org.json.JSONArray
|
||||
import org.json.JSONObject
|
||||
|
||||
internal object ItchApi {
|
||||
const val KEYS_PAGE = "https://itch.io/user/settings/api-keys"
|
||||
|
||||
private const val BASE = "https://api.itch.io"
|
||||
private const val WEB = "https://itch.io"
|
||||
|
||||
fun get(path: String, key: String): JSONObject? {
|
||||
val body = StoreNet.get(BASE + path, bearer = key, timeoutMs = 25_000) ?: return null
|
||||
return runCatching { JSONObject(body) }.getOrNull()
|
||||
}
|
||||
|
||||
fun profile(key: String): JSONObject? = get("/profile", key)?.optJSONObject("user")
|
||||
|
||||
fun ownedKeys(key: String, page: Int): JSONObject? = get("/profile/owned-keys?page=$page&per_page=500", key)
|
||||
|
||||
fun uploads(key: String, gameId: String, downloadKeyId: String): JSONArray? =
|
||||
get("/games/$gameId/uploads?download_key_id=$downloadKeyId", key)?.optJSONArray("uploads")
|
||||
|
||||
fun search(key: String, query: String, page: Int): JSONArray? {
|
||||
val q = java.net.URLEncoder.encode(query, "UTF-8")
|
||||
return get("/search/games?query=$q&page=$page", key)?.optJSONArray("games")
|
||||
}
|
||||
|
||||
fun downloadUrl(uploadId: String, downloadKeyId: String): String =
|
||||
"$BASE/uploads/$uploadId/download?download_key_id=$downloadKeyId"
|
||||
|
||||
fun pageUrl(slug: String): String = "$WEB/$slug"
|
||||
}
|
||||
@@ -0,0 +1,36 @@
|
||||
package com.droiddeck.launcher.stores.itch
|
||||
|
||||
import android.content.Context
|
||||
import com.droiddeck.launcher.stores.Store
|
||||
import com.droiddeck.launcher.stores.StoreAccounts
|
||||
import org.json.JSONObject
|
||||
|
||||
internal object ItchAuth {
|
||||
private val TOKEN = Regex("[A-Za-z0-9]{40,64}")
|
||||
|
||||
fun key(context: Context): String = StoreAccounts.read(context, Store.ITCH)?.optString("access_token").orEmpty()
|
||||
|
||||
fun name(context: Context): String = StoreAccounts.signedInAs(context, Store.ITCH).orEmpty()
|
||||
|
||||
fun verify(key: String): JSONObject? = ItchApi.profile(key)
|
||||
|
||||
fun save(context: Context, key: String, user: JSONObject) {
|
||||
StoreAccounts.write(
|
||||
context,
|
||||
Store.ITCH,
|
||||
JSONObject()
|
||||
.put("access_token", key)
|
||||
.put("username", user.optString("username"))
|
||||
.put("display_name", user.optString("display_name").ifBlank { user.optString("displayName") }.ifBlank { user.optString("username") })
|
||||
.put("user_id", user.optLong("id")),
|
||||
)
|
||||
}
|
||||
|
||||
fun candidates(text: String): List<String> {
|
||||
if (text.isBlank()) return emptyList()
|
||||
val out = LinkedHashSet<String>()
|
||||
text.lineSequence().map { it.trim() }.filter { TOKEN.matches(it) }.forEach { out.add(it) }
|
||||
TOKEN.findAll(text).forEach { out.add(it.value) }
|
||||
return out.take(8)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,71 @@
|
||||
package com.droiddeck.launcher.stores.itch
|
||||
|
||||
import android.content.Context
|
||||
import android.content.Intent
|
||||
import android.net.Uri
|
||||
import com.droiddeck.launcher.R
|
||||
import com.droiddeck.launcher.stores.CatalogItem
|
||||
import com.droiddeck.launcher.stores.InstalledStoreGame
|
||||
import com.droiddeck.launcher.stores.Store
|
||||
import com.droiddeck.launcher.stores.StoreBackend
|
||||
import com.droiddeck.launcher.stores.StoreInstallRoot
|
||||
import com.droiddeck.launcher.stores.StoreSizes
|
||||
import com.droiddeck.launcher.stores.StoresState
|
||||
import com.droiddeck.launcher.stores.download.DownloadEntry
|
||||
import com.droiddeck.launcher.stores.download.DownloadQueue
|
||||
import java.io.File
|
||||
|
||||
object ItchBackend : StoreBackend {
|
||||
override val store: Store = Store.ITCH
|
||||
|
||||
override fun signIn(context: Context) {
|
||||
context.startActivity(Intent(Intent.ACTION_VIEW, Uri.parse(ItchApi.KEYS_PAGE)).addFlags(Intent.FLAG_ACTIVITY_NEW_TASK))
|
||||
}
|
||||
|
||||
override fun signOut(context: Context) {
|
||||
ItchPrefs.get(context).edit().remove("library_cache").remove("library_synced_at").apply()
|
||||
StoresState.post {
|
||||
StoresState.library.remove(store)
|
||||
StoresState.shelves.remove(store)
|
||||
StoresState.found.remove(store)
|
||||
StoresState.problems.remove(store)
|
||||
StoresState.status.remove(store)
|
||||
}
|
||||
}
|
||||
|
||||
override fun syncLibrary(context: Context, force: Boolean) {
|
||||
val app = context.applicationContext
|
||||
Thread({ ItchLibrary.sync(app, force) }, "itch-sync").start()
|
||||
}
|
||||
|
||||
override fun loadShelves(context: Context, force: Boolean) {
|
||||
val app = context.applicationContext
|
||||
ItchShelves.cached(app)?.let { cached -> StoresState.post { StoresState.shelves[store] = cached } }
|
||||
Thread({
|
||||
val shelves = ItchShelves.featured(app, force)
|
||||
if (shelves != null) {
|
||||
StoresState.post { StoresState.shelves[store] = shelves }
|
||||
} else {
|
||||
StoresState.post { StoresState.problems[store] = app.getString(R.string.stores_itch_no_catalog) }
|
||||
}
|
||||
}, "itch-shelves").start()
|
||||
}
|
||||
|
||||
override fun search(context: Context, query: String): List<CatalogItem> = ItchShelves.search(context, query)
|
||||
|
||||
override fun install(context: Context, item: CatalogItem, root: File) {
|
||||
val app = context.applicationContext
|
||||
if (item.extra["downloadKeyId"].isNullOrEmpty()) {
|
||||
StoresState.logLine("itch.io: \"${item.title}\" is not in the library")
|
||||
return
|
||||
}
|
||||
val folder = StoreInstallRoot.folderFor(app, Store.ITCH, item.id, item.title, root)
|
||||
val entry = DownloadEntry(
|
||||
Store.ITCH, item.id, item.title, cover = item.imageUrl, bytesTotal = StoreSizes.size(item),
|
||||
location = StoreInstallRoot.labelFor(app, folder),
|
||||
)
|
||||
DownloadQueue.enqueue(app, entry) { ItchInstaller(app, item, folder) }
|
||||
}
|
||||
|
||||
override fun uninstall(context: Context, game: InstalledStoreGame) {}
|
||||
}
|
||||
@@ -0,0 +1,90 @@
|
||||
package com.droiddeck.launcher.stores.itch
|
||||
|
||||
import com.droiddeck.launcher.stores.CatalogItem
|
||||
import com.droiddeck.launcher.stores.Store
|
||||
import com.droiddeck.launcher.stores.StoreShelves
|
||||
import org.json.JSONArray
|
||||
import org.json.JSONObject
|
||||
|
||||
internal object ItchCatalog {
|
||||
fun slug(url: String): String = url.substringAfter("://", url).trimEnd('/')
|
||||
|
||||
fun shelvesJson(shelves: StoreShelves): String = JSONObject()
|
||||
.put("new", array(shelves.whatsNew))
|
||||
.put("deals", array(shelves.deals))
|
||||
.put("free", array(shelves.free))
|
||||
.put("top", array(shelves.trending))
|
||||
.toString()
|
||||
|
||||
fun shelves(raw: String?): StoreShelves? {
|
||||
if (raw.isNullOrBlank()) return null
|
||||
val o = runCatching { JSONObject(raw) }.getOrNull() ?: return null
|
||||
val shelves = StoreShelves(
|
||||
whatsNew = list(o.optJSONArray("new")),
|
||||
deals = list(o.optJSONArray("deals")),
|
||||
free = list(o.optJSONArray("free")),
|
||||
trending = list(o.optJSONArray("top")),
|
||||
)
|
||||
return shelves.takeUnless { it.isEmpty }
|
||||
}
|
||||
|
||||
fun array(items: List<CatalogItem>): JSONArray {
|
||||
val out = JSONArray()
|
||||
items.forEach { out.put(json(it)) }
|
||||
return out
|
||||
}
|
||||
|
||||
fun list(raw: JSONArray?): List<CatalogItem> {
|
||||
if (raw == null) return emptyList()
|
||||
return (0 until raw.length()).mapNotNull { raw.optJSONObject(it)?.let(::item) }
|
||||
}
|
||||
|
||||
private fun json(i: CatalogItem): JSONObject = JSONObject()
|
||||
.put("id", i.id)
|
||||
.put("title", i.title)
|
||||
.put("image", i.imageUrl ?: "")
|
||||
.put("tall", i.tallImageUrl ?: "")
|
||||
.put("tags", i.tags)
|
||||
.put("free", i.isFree)
|
||||
.put("hasPrice", i.hasPrice)
|
||||
.put("finalPrice", i.finalPrice)
|
||||
.put("originalPrice", i.originalPrice)
|
||||
.put("discount", i.discountPercent)
|
||||
.put("url", i.storeUrl)
|
||||
.put("developer", i.developer)
|
||||
.put("publisher", i.publisher)
|
||||
.put("releaseDate", i.releaseDate)
|
||||
.put("description", i.description)
|
||||
.put("owned", i.owned)
|
||||
.put("size", i.sizeBytes)
|
||||
.put("extra", JSONObject(i.extra))
|
||||
.put("mature", i.mature)
|
||||
|
||||
private fun item(o: JSONObject): CatalogItem {
|
||||
val extra = o.optJSONObject("extra")
|
||||
val map = HashMap<String, String>()
|
||||
extra?.keys()?.forEach { k -> map[k] = extra.optString(k) }
|
||||
return CatalogItem(
|
||||
store = Store.ITCH,
|
||||
id = o.optString("id"),
|
||||
title = o.optString("title"),
|
||||
imageUrl = o.optString("image").ifBlank { null },
|
||||
tallImageUrl = o.optString("tall").ifBlank { null },
|
||||
tags = o.optString("tags"),
|
||||
isFree = o.optBoolean("free"),
|
||||
hasPrice = o.optBoolean("hasPrice"),
|
||||
finalPrice = o.optString("finalPrice"),
|
||||
originalPrice = o.optString("originalPrice"),
|
||||
discountPercent = o.optInt("discount"),
|
||||
storeUrl = o.optString("url"),
|
||||
developer = o.optString("developer"),
|
||||
publisher = o.optString("publisher"),
|
||||
releaseDate = o.optString("releaseDate"),
|
||||
description = o.optString("description"),
|
||||
owned = o.optBoolean("owned"),
|
||||
sizeBytes = o.optLong("size"),
|
||||
extra = map,
|
||||
mature = o.optBoolean("mature"),
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,372 @@
|
||||
package com.droiddeck.launcher.stores.itch
|
||||
|
||||
import android.content.Context
|
||||
import com.droiddeck.launcher.R
|
||||
import com.droiddeck.launcher.runtime.GuestCommand
|
||||
import com.droiddeck.launcher.runtime.LinuxRuntime
|
||||
import com.droiddeck.launcher.stores.CatalogItem
|
||||
import com.droiddeck.launcher.stores.Store
|
||||
import com.droiddeck.launcher.stores.StoreExe
|
||||
import com.droiddeck.launcher.stores.StoreGameSidecar
|
||||
import com.droiddeck.launcher.stores.StoreInstallRoot
|
||||
import com.droiddeck.launcher.stores.StoreInstalls
|
||||
import com.droiddeck.launcher.stores.StoreNet
|
||||
import com.droiddeck.launcher.stores.download.DownloadQueue
|
||||
import com.droiddeck.launcher.stores.download.DownloadStage
|
||||
import org.apache.commons.compress.archivers.sevenz.SevenZFile
|
||||
import org.apache.commons.compress.archivers.tar.TarArchiveInputStream
|
||||
import org.apache.commons.compress.archivers.zip.ZipFile
|
||||
import org.apache.commons.compress.compressors.bzip2.BZip2CompressorInputStream
|
||||
import org.apache.commons.compress.compressors.gzip.GzipCompressorInputStream
|
||||
import org.apache.commons.compress.compressors.xz.XZCompressorInputStream
|
||||
import java.io.BufferedInputStream
|
||||
import java.io.File
|
||||
import java.io.FileInputStream
|
||||
import java.io.FileOutputStream
|
||||
import java.net.HttpURLConnection
|
||||
import java.net.URL
|
||||
import java.util.concurrent.atomic.AtomicBoolean
|
||||
|
||||
internal class ItchInstaller(
|
||||
private val app: Context,
|
||||
private val item: CatalogItem,
|
||||
private val folder: File,
|
||||
) : DownloadQueue.DownloadJob {
|
||||
private val cancelled = AtomicBoolean(false)
|
||||
|
||||
override fun run(handle: DownloadQueue.JobHandle): String? {
|
||||
val key = ItchAuth.key(app)
|
||||
if (key.isBlank()) throw IllegalStateException(app.getString(R.string.stores_sync_sign_in))
|
||||
val downloadKeyId = item.extra["downloadKeyId"].orEmpty()
|
||||
if (downloadKeyId.isBlank()) throw IllegalStateException(app.getString(R.string.stores_itch_not_owned))
|
||||
val gameId = item.extra["gameId"].orEmpty().ifBlank { item.id.substringAfterLast('/') }
|
||||
StoreInstalls.begin(folder, Store.ITCH, item.id, item.title, item.imageUrl, item.imageUrl)
|
||||
handle.stage(DownloadStage.MANIFEST)
|
||||
val upload = ItchUploads.windows(ItchApi.uploads(key, gameId, downloadKeyId))
|
||||
?: throw IllegalStateException(app.getString(R.string.stores_itch_no_upload))
|
||||
val uploadId = upload.optString("id")
|
||||
val filename = ItchUploads.filename(upload)
|
||||
val size = ItchUploads.size(upload)
|
||||
val scratch = StoreInstallRoot.scratchDir(app, Store.ITCH, item.id)
|
||||
scratch.mkdirs()
|
||||
val archive = File(scratch, filename.ifBlank { "download.bin" })
|
||||
val exe = try {
|
||||
handle.stage(DownloadStage.DOWNLOAD)
|
||||
download(key, downloadKeyId, uploadId, upload.optString("updated_at", upload.optString("updatedAt")), archive, size, handle)
|
||||
if (cancelled.get()) return null
|
||||
handle.stage(DownloadStage.INSTALL)
|
||||
install(archive, filename, handle)
|
||||
} catch (t: Throwable) {
|
||||
if (!cancelled.get()) StoreInstalls.discard(app, folder, listOf(scratch))
|
||||
throw t
|
||||
}
|
||||
if (exe == null) {
|
||||
if (cancelled.get()) return null
|
||||
StoreInstalls.discard(app, folder, listOf(scratch))
|
||||
throw IllegalStateException(app.getString(R.string.stores_itch_no_exe))
|
||||
}
|
||||
archive.delete()
|
||||
StoreInstalls.deleteTree(scratch)
|
||||
val sidecar = StoreGameSidecar(
|
||||
store = Store.ITCH,
|
||||
id = item.id,
|
||||
title = item.title,
|
||||
exe = exe,
|
||||
installVersion = upload.optString("updated_at", upload.optString("updatedAt")),
|
||||
installedAt = System.currentTimeMillis(),
|
||||
cover = item.imageUrl,
|
||||
hero = item.imageUrl,
|
||||
extra = mapOf("gameId" to gameId, "downloadKeyId" to downloadKeyId, "uploadId" to uploadId),
|
||||
cloud = false,
|
||||
)
|
||||
StoreInstalls.complete(app, folder, sidecar) { done, steps -> handle.stageProgress(done.toLong(), steps.toLong()) }
|
||||
return folder.path
|
||||
}
|
||||
|
||||
override fun cancel(deleteFiles: Boolean) {
|
||||
cancelled.set(true)
|
||||
if (deleteFiles) StoreInstalls.discard(app, folder, listOf(StoreInstallRoot.scratchDir(app, Store.ITCH, item.id)))
|
||||
}
|
||||
|
||||
private fun download(key: String, downloadKeyId: String, uploadId: String, version: String, target: File, expected: Long,
|
||||
handle: DownloadQueue.JobHandle) {
|
||||
// A partial file is only resumed when it came from this exact upload build.
|
||||
val origin = File(target.path + ".from")
|
||||
val stamp = "$uploadId $version $expected"
|
||||
if (!target.isFile || runCatching { origin.readText() }.getOrNull() != stamp) {
|
||||
target.delete()
|
||||
origin.writeText(stamp)
|
||||
}
|
||||
if (expected > 0 && target.length() == expected) {
|
||||
handle.progress(expected, expected)
|
||||
return
|
||||
}
|
||||
for (attempt in 0..1) {
|
||||
val have = target.length()
|
||||
var conn: HttpURLConnection? = null
|
||||
try {
|
||||
conn = (URL(ItchApi.downloadUrl(uploadId, downloadKeyId)).openConnection() as HttpURLConnection).apply {
|
||||
connectTimeout = 30_000
|
||||
readTimeout = 60_000
|
||||
instanceFollowRedirects = true
|
||||
setRequestProperty("User-Agent", StoreNet.BROWSER_UA)
|
||||
setRequestProperty("Authorization", "Bearer $key")
|
||||
setRequestProperty("Accept", "*/*")
|
||||
if (have > 0) setRequestProperty("Range", "bytes=$have-")
|
||||
}
|
||||
val code = conn.responseCode
|
||||
if (code == 416 && attempt == 0) {
|
||||
target.delete()
|
||||
continue
|
||||
}
|
||||
if (code !in 200..299) throw IllegalStateException(app.getString(R.string.stores_itch_download_failed, code))
|
||||
val append = code == 206 && have > 0
|
||||
if (have > 0) handle.log(if (append) "itch.io: resuming at ${have} bytes" else "itch.io: server ignored the resume, starting over")
|
||||
var done = if (append) have else 0L
|
||||
val total = conn.contentLengthLong.takeIf { it > 0 }?.let { if (append) it + have else it } ?: expected
|
||||
// The first report is the queue's speed baseline, so resumed bytes don't read as instant throughput.
|
||||
handle.progress(done, total)
|
||||
val buffer = ByteArray(128 * 1024)
|
||||
conn.inputStream.use { input ->
|
||||
FileOutputStream(target, append).use { out ->
|
||||
while (true) {
|
||||
if (cancelled.get()) return
|
||||
val read = input.read(buffer)
|
||||
if (read < 0) break
|
||||
out.write(buffer, 0, read)
|
||||
done += read
|
||||
handle.progress(done, total)
|
||||
}
|
||||
}
|
||||
}
|
||||
if (total > 0 && done < total) throw IllegalStateException(app.getString(R.string.stores_itch_download_failed, code))
|
||||
return
|
||||
} finally {
|
||||
runCatching { conn?.disconnect() }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun install(archive: File, filename: String, handle: DownloadQueue.JobHandle): String? {
|
||||
folder.mkdirs()
|
||||
when (val kind = sniff(archive, filename)) {
|
||||
"zip" -> unzip(archive, handle)
|
||||
"7z" -> sevenZip(archive, handle)
|
||||
"rar" -> unrar(archive, handle)
|
||||
"exe" -> copy(archive, File(folder, filename.ifBlank { "game.exe" }))
|
||||
"tar" -> untar(archive, null, handle)
|
||||
"gz", "bz2", "xz" -> untar(archive, kind, handle) ?: single(archive, kind, handle)
|
||||
else -> copy(archive, File(folder, filename.ifBlank { "game" }))
|
||||
}
|
||||
val hint = filename.substringBeforeLast('.')
|
||||
val exe = StoreExe.pick(folder, hint.ifBlank { null }, item.title)
|
||||
if (exe.isBlank()) {
|
||||
handle.log("itch.io: no exe found in ${folder.name}")
|
||||
return null
|
||||
}
|
||||
return exe
|
||||
}
|
||||
|
||||
private fun sniff(file: File, filename: String): String {
|
||||
val extension = filename.substringAfterLast('.', "").lowercase()
|
||||
if (extension == "exe") return "exe"
|
||||
val head = runCatching { FileInputStream(file).use { input -> ByteArray(300).also { input.read(it) } } }.getOrNull()
|
||||
?: return extension
|
||||
fun at(i: Int, vararg bytes: Int) = bytes.indices.all { head.getOrNull(i + it) == bytes[it].toByte() }
|
||||
return when {
|
||||
at(0, 0x50, 0x4B) -> "zip"
|
||||
at(0, 0x37, 0x7A, 0xBC, 0xAF, 0x27, 0x1C) -> "7z"
|
||||
at(0, 0x52, 0x61, 0x72, 0x21) -> "rar"
|
||||
at(0, 0x1F, 0x8B) -> "gz"
|
||||
at(0, 0x42, 0x5A, 0x68) -> "bz2"
|
||||
at(0, 0xFD, 0x37, 0x7A, 0x58, 0x5A, 0x00) -> "xz"
|
||||
at(257, 0x75, 0x73, 0x74, 0x61, 0x72) -> "tar"
|
||||
else -> extension
|
||||
}
|
||||
}
|
||||
|
||||
private fun target(name: String): File? {
|
||||
val file = File(folder, name.replace('\\', '/'))
|
||||
val root = folder.canonicalPath
|
||||
val path = file.canonicalPath
|
||||
return if (path == root || path.startsWith(root + File.separator)) file else null
|
||||
}
|
||||
|
||||
// No JVM library reads RAR5; the runtime's bsdtar (libarchive) does.
|
||||
private fun unrar(archive: File, handle: DownloadQueue.JobHandle) {
|
||||
if (!LinuxRuntime.isInstalled(app)) throw IllegalStateException(app.getString(R.string.stores_itch_archive, "rar"))
|
||||
val binds = if (listOf(app.filesDir, app.cacheDir).any { folder.absolutePath.startsWith(it.absolutePath + "/") }) null
|
||||
else listOf(folder.absolutePath)
|
||||
fun bsdtar(args: List<String>, onLine: (String) -> Unit) =
|
||||
GuestCommand.run(app, listOf("/usr/bin/bsdtar") + args, logName = "itch-unrar", extraBinds = binds, fastPath = true) { line ->
|
||||
if (!line.startsWith("proot ")) onLine(line)
|
||||
}
|
||||
var entries = 0L
|
||||
bsdtar(listOf("-tf", archive.absolutePath)) { entries++ }
|
||||
var done = 0L
|
||||
val tail = ArrayDeque<String>()
|
||||
val code = bsdtar(listOf("-xvf", archive.absolutePath, "-C", folder.absolutePath)) { line ->
|
||||
if (line.startsWith("x ")) handle.stageProgress(++done, entries) else {
|
||||
if (tail.size == 5) tail.removeFirst()
|
||||
tail.addLast(line)
|
||||
}
|
||||
}
|
||||
if (cancelled.get()) return
|
||||
if (code != 0) {
|
||||
handle.log("itch.io: bsdtar exited $code: ${tail.joinToString(" | ")}")
|
||||
throw IllegalStateException(app.getString(R.string.stores_itch_extract_failed, code))
|
||||
}
|
||||
}
|
||||
|
||||
private fun sevenZip(archive: File, handle: DownloadQueue.JobHandle) {
|
||||
val total = archive.length()
|
||||
var written = 0L
|
||||
val buffer = ByteArray(64 * 1024)
|
||||
SevenZFile.builder().setFile(archive).get().use { seven ->
|
||||
while (true) {
|
||||
if (cancelled.get()) return
|
||||
val entry = seven.nextEntry ?: break
|
||||
val file = target(entry.name)
|
||||
if (file == null) {
|
||||
handle.log("itch.io: skipped an entry outside the game folder")
|
||||
continue
|
||||
}
|
||||
if (entry.isDirectory) {
|
||||
file.mkdirs()
|
||||
continue
|
||||
}
|
||||
file.parentFile?.mkdirs()
|
||||
FileOutputStream(file).use { out ->
|
||||
while (true) {
|
||||
if (cancelled.get()) return
|
||||
val read = seven.read(buffer, 0, buffer.size)
|
||||
if (read < 0) break
|
||||
out.write(buffer, 0, read)
|
||||
written += read
|
||||
handle.stageProgress(written, total)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun untar(archive: File, codec: String?, handle: DownloadQueue.JobHandle): Unit? {
|
||||
val total = archive.length()
|
||||
var written = 0L
|
||||
var entries = 0
|
||||
val buffer = ByteArray(64 * 1024)
|
||||
val opened = runCatching {
|
||||
FileInputStream(archive).use { raw ->
|
||||
val stream = when (codec) {
|
||||
"gz" -> GzipCompressorInputStream(BufferedInputStream(raw))
|
||||
"bz2" -> BZip2CompressorInputStream(BufferedInputStream(raw))
|
||||
"xz" -> XZCompressorInputStream(BufferedInputStream(raw))
|
||||
else -> BufferedInputStream(raw)
|
||||
}
|
||||
stream.use { input ->
|
||||
TarArchiveInputStream(input).use { tar ->
|
||||
while (true) {
|
||||
if (cancelled.get()) return null
|
||||
val entry = tar.nextEntry ?: break
|
||||
entries++
|
||||
val file = target(entry.name)
|
||||
if (file == null) {
|
||||
handle.log("itch.io: skipped an entry outside the game folder")
|
||||
continue
|
||||
}
|
||||
if (entry.isDirectory) {
|
||||
file.mkdirs()
|
||||
continue
|
||||
}
|
||||
file.parentFile?.mkdirs()
|
||||
FileOutputStream(file).use { out ->
|
||||
while (true) {
|
||||
if (cancelled.get()) return null
|
||||
val read = tar.read(buffer, 0, buffer.size)
|
||||
if (read < 0) break
|
||||
out.write(buffer, 0, read)
|
||||
written += read
|
||||
handle.stageProgress(written, total)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
true
|
||||
}
|
||||
if (opened.isSuccess && entries > 0) return Unit
|
||||
if (opened.isFailure && codec == null) throw opened.exceptionOrNull() ?: IllegalStateException("tar")
|
||||
return null
|
||||
}
|
||||
|
||||
private fun single(archive: File, codec: String, handle: DownloadQueue.JobHandle) {
|
||||
val name = archive.name.substringBeforeLast('.')
|
||||
val file = target(name.ifBlank { "game" }) ?: throw IllegalStateException("archive")
|
||||
file.parentFile?.mkdirs()
|
||||
val total = archive.length()
|
||||
var written = 0L
|
||||
val buffer = ByteArray(64 * 1024)
|
||||
FileInputStream(archive).use { raw ->
|
||||
val stream = when (codec) {
|
||||
"bz2" -> BZip2CompressorInputStream(BufferedInputStream(raw))
|
||||
"xz" -> XZCompressorInputStream(BufferedInputStream(raw))
|
||||
else -> GzipCompressorInputStream(BufferedInputStream(raw))
|
||||
}
|
||||
stream.use { input ->
|
||||
FileOutputStream(file).use { out ->
|
||||
while (true) {
|
||||
if (cancelled.get()) return
|
||||
val read = input.read(buffer)
|
||||
if (read < 0) break
|
||||
out.write(buffer, 0, read)
|
||||
written += read
|
||||
handle.stageProgress(written, total)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun copy(source: File, target: File) {
|
||||
target.parentFile?.mkdirs()
|
||||
FileInputStream(source).use { input -> FileOutputStream(target).use { out -> input.copyTo(out, 128 * 1024) } }
|
||||
}
|
||||
|
||||
// Streaming readers stop silently on zips whose data descriptors disagree with the local headers;
|
||||
// the central directory is authoritative.
|
||||
private fun unzip(archive: File, handle: DownloadQueue.JobHandle) {
|
||||
val total = archive.length()
|
||||
var written = 0L
|
||||
val buffer = ByteArray(64 * 1024)
|
||||
ZipFile.builder().setFile(archive).get().use { zip ->
|
||||
for (entry in zip.entries) {
|
||||
if (cancelled.get()) return
|
||||
val target = target(entry.name)
|
||||
if (target == null) {
|
||||
handle.log("itch.io: skipped an entry outside the game folder")
|
||||
continue
|
||||
}
|
||||
if (entry.isDirectory) {
|
||||
target.mkdirs()
|
||||
continue
|
||||
}
|
||||
target.parentFile?.mkdirs()
|
||||
zip.getInputStream(entry).use { input ->
|
||||
FileOutputStream(target).use { out ->
|
||||
while (true) {
|
||||
if (cancelled.get()) return
|
||||
val read = input.read(buffer)
|
||||
if (read < 0) break
|
||||
out.write(buffer, 0, read)
|
||||
written += read
|
||||
handle.stageProgress(written, total)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private companion object
|
||||
}
|
||||
@@ -0,0 +1,162 @@
|
||||
package com.droiddeck.launcher.stores.itch
|
||||
|
||||
import android.content.Context
|
||||
import com.droiddeck.launcher.R
|
||||
import com.droiddeck.launcher.stores.CatalogItem
|
||||
import com.droiddeck.launcher.stores.Store
|
||||
import com.droiddeck.launcher.stores.StoresState
|
||||
import com.droiddeck.launcher.stores.cleanStoreText
|
||||
import com.droiddeck.launcher.stores.mergeItems
|
||||
import org.json.JSONArray
|
||||
import org.json.JSONObject
|
||||
import java.util.concurrent.atomic.AtomicBoolean
|
||||
import kotlin.math.roundToInt
|
||||
|
||||
internal object ItchLibrary {
|
||||
private const val KEY_CACHE = "library_cache"
|
||||
private const val KEY_AT = "library_synced_at"
|
||||
private const val MAX_PAGES = 20
|
||||
private const val THROTTLE_MS = 6 * 60 * 60 * 1000L
|
||||
|
||||
private val running = AtomicBoolean(false)
|
||||
|
||||
fun cached(context: Context): List<CatalogItem> {
|
||||
val raw = ItchPrefs.get(context).getString(KEY_CACHE, null) ?: return emptyList()
|
||||
return runCatching { ItchCatalog.list(JSONArray(raw)) }.getOrDefault(emptyList())
|
||||
}
|
||||
|
||||
fun sync(context: Context, force: Boolean) {
|
||||
val app = context.applicationContext
|
||||
if (!running.compareAndSet(false, true)) return
|
||||
try {
|
||||
val key = ItchAuth.key(app)
|
||||
val cache = cached(app)
|
||||
val prefs = ItchPrefs.get(app)
|
||||
if (key.isBlank()) {
|
||||
StoresState.post {
|
||||
StoresState.status.remove(Store.ITCH)
|
||||
if (cache.isNotEmpty()) StoresState.library[Store.ITCH] = cache
|
||||
StoresState.problems[Store.ITCH] = app.getString(R.string.stores_sync_sign_in)
|
||||
}
|
||||
return
|
||||
}
|
||||
val quiet = cache.isNotEmpty() && !force
|
||||
if (quiet && System.currentTimeMillis() - prefs.getLong(KEY_AT, 0L) < THROTTLE_MS) {
|
||||
StoresState.post { StoresState.library[Store.ITCH] = cache }
|
||||
return
|
||||
}
|
||||
if (!quiet) StoresState.post { StoresState.status[Store.ITCH] = app.getString(R.string.stores_sync_game_list) }
|
||||
val items = fetch(key)
|
||||
if (items == null) {
|
||||
StoresState.post {
|
||||
StoresState.status.remove(Store.ITCH)
|
||||
if (cache.isNotEmpty()) StoresState.library[Store.ITCH] = cache
|
||||
StoresState.problems[Store.ITCH] = app.getString(R.string.stores_sync_failed)
|
||||
}
|
||||
return
|
||||
}
|
||||
prefs.edit()
|
||||
.putString(KEY_CACHE, ItchCatalog.array(items).toString())
|
||||
.putLong(KEY_AT, System.currentTimeMillis())
|
||||
.apply()
|
||||
StoresState.post {
|
||||
StoresState.status.remove(Store.ITCH)
|
||||
StoresState.problems.remove(Store.ITCH)
|
||||
StoresState.library[Store.ITCH] = mergeItems(StoresState.library[Store.ITCH] ?: cache, items)
|
||||
}
|
||||
StoresState.logLine("itch.io: ${items.size} owned games")
|
||||
} finally {
|
||||
running.set(false)
|
||||
}
|
||||
}
|
||||
|
||||
fun size(context: Context, item: CatalogItem): Long {
|
||||
val key = ItchAuth.key(context.applicationContext)
|
||||
if (key.isBlank()) return 0L
|
||||
val downloadKeyId = item.extra["downloadKeyId"].orEmpty().ifEmpty { return 0L }
|
||||
val gameId = item.extra["gameId"].orEmpty().ifEmpty { return 0L }
|
||||
val upload = ItchUploads.windows(ItchApi.uploads(key, gameId, downloadKeyId)) ?: return 0L
|
||||
return ItchUploads.size(upload)
|
||||
}
|
||||
|
||||
private fun fetch(key: String): List<CatalogItem>? {
|
||||
val out = ArrayList<CatalogItem>()
|
||||
var page = 1
|
||||
var keys = 0
|
||||
while (page <= MAX_PAGES) {
|
||||
val body = ItchApi.ownedKeys(key, page) ?: return null
|
||||
val list = body.optJSONArray("owned_keys") ?: body.optJSONArray("ownedKeys") ?: return out
|
||||
if (list.length() == 0) break
|
||||
out.addAll(items(list))
|
||||
keys += list.length()
|
||||
page++
|
||||
}
|
||||
val games = out.distinctBy { it.id }
|
||||
StoresState.logLine("itch.io: $keys download keys, ${games.size} games")
|
||||
return games
|
||||
}
|
||||
|
||||
internal fun items(keys: JSONArray): List<CatalogItem> {
|
||||
val out = ArrayList<CatalogItem>(keys.length())
|
||||
for (i in 0 until keys.length()) {
|
||||
val entry = keys.optJSONObject(i) ?: continue
|
||||
val game = entry.optJSONObject("game") ?: continue
|
||||
item(game, entry.optString("id"))?.let { out.add(it) }
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
private fun JSONObject.text(snake: String, camel: String): String =
|
||||
optString(snake).ifBlank { optString(camel) }
|
||||
|
||||
internal fun item(game: JSONObject, downloadKeyId: String): CatalogItem? {
|
||||
val gameId = game.optLong("id")
|
||||
if (gameId <= 0L) return null
|
||||
val user = game.optJSONObject("user")
|
||||
val url = game.optString("url").ifBlank {
|
||||
val owner = user?.optString("username").orEmpty()
|
||||
if (owner.isBlank()) ItchApi.pageUrl(gameId.toString()) else ItchApi.pageUrl("$owner/$gameId")
|
||||
}
|
||||
val traits = game.optJSONArray("traits")?.let { arr ->
|
||||
(0 until arr.length()).map { arr.optString(it) }
|
||||
}.orEmpty()
|
||||
val platforms = game.optJSONObject("platforms")
|
||||
val sale = game.optJSONObject("sale")
|
||||
val rate = sale?.optDouble("rate", 0.0) ?: 0.0
|
||||
val cents = game.optLong("min_price", game.optLong("minPrice"))
|
||||
val canBuy = game.optBoolean("can_be_bought", game.optBoolean("canBeBought", "can_be_bought" in traits))
|
||||
val discount = if (rate > 0.0 && rate < 1.0) (rate * 100).roundToInt() else 0
|
||||
val price = if (canBuy && cents > 0) money(cents) else ""
|
||||
val salePrice = if (discount > 0 && price.isNotEmpty()) money((cents * (1.0 - rate)).roundToInt().toLong()) else ""
|
||||
val tags = buildList {
|
||||
if ("p_windows" in traits || platforms?.optBoolean("windows") == true) add("Windows")
|
||||
if ("p_linux" in traits || platforms?.optBoolean("linux") == true) add("Linux")
|
||||
if ("p_osx" in traits || platforms?.optBoolean("osx") == true) add("macOS")
|
||||
}.joinToString(", ")
|
||||
val cover = game.text("cover_url", "coverUrl").ifBlank { game.text("still_cover_url", "stillCoverUrl") }
|
||||
val tall = game.text("still_cover_url", "stillCoverUrl")
|
||||
val author = user?.text("display_name", "displayName").orEmpty().ifBlank { user?.optString("username").orEmpty() }
|
||||
return CatalogItem(
|
||||
store = Store.ITCH,
|
||||
id = ItchCatalog.slug(url).ifBlank { gameId.toString() },
|
||||
title = game.optString("title").ifBlank { "itch.io game $gameId" },
|
||||
imageUrl = cover.ifBlank { null },
|
||||
tallImageUrl = tall.ifBlank { null },
|
||||
tags = tags,
|
||||
isFree = price.isEmpty(),
|
||||
hasPrice = price.isNotEmpty(),
|
||||
finalPrice = salePrice.ifEmpty { price },
|
||||
originalPrice = if (salePrice.isNotEmpty()) price else "",
|
||||
discountPercent = if (salePrice.isNotEmpty()) discount else 0,
|
||||
storeUrl = url,
|
||||
developer = author,
|
||||
publisher = author,
|
||||
releaseDate = game.text("published_at", "publishedAt").take(10),
|
||||
description = cleanStoreText(game.text("short_text", "shortText")),
|
||||
owned = downloadKeyId.isNotBlank(),
|
||||
extra = if (downloadKeyId.isBlank()) emptyMap() else mapOf("downloadKeyId" to downloadKeyId, "gameId" to gameId.toString()),
|
||||
)
|
||||
}
|
||||
|
||||
private fun money(cents: Long): String = "$" + (cents / 100) + "." + (cents % 100).toString().padStart(2, '0')
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
package com.droiddeck.launcher.stores.itch
|
||||
|
||||
import android.content.Context
|
||||
import android.content.SharedPreferences
|
||||
|
||||
object ItchPrefs {
|
||||
const val FILE = "stores.itch"
|
||||
|
||||
@JvmStatic
|
||||
fun get(context: Context): SharedPreferences = context.applicationContext.getSharedPreferences(FILE, Context.MODE_PRIVATE)
|
||||
}
|
||||
@@ -0,0 +1,199 @@
|
||||
package com.droiddeck.launcher.stores.itch
|
||||
|
||||
import android.content.Context
|
||||
import android.content.SharedPreferences
|
||||
import android.util.Log
|
||||
import android.util.Xml
|
||||
import com.droiddeck.launcher.stores.CatalogItem
|
||||
import com.droiddeck.launcher.stores.Store
|
||||
import com.droiddeck.launcher.stores.StoreNet
|
||||
import com.droiddeck.launcher.stores.StoreShelves
|
||||
import com.droiddeck.launcher.stores.cleanStoreText
|
||||
import org.xmlpull.v1.XmlPullParser
|
||||
import java.io.StringReader
|
||||
import java.net.HttpURLConnection
|
||||
import java.net.URL
|
||||
import java.text.SimpleDateFormat
|
||||
import java.util.Locale
|
||||
|
||||
internal object ItchShelves {
|
||||
private const val TAG = "ItchShelves"
|
||||
private const val KEY = "shelves_cache"
|
||||
private const val KEY_AT = "shelves_at"
|
||||
private const val FRESH_MS = 3 * 60 * 60 * 1000L
|
||||
private const val GAP_MS = 1_200L
|
||||
private const val RETRY_MS = 20_000L
|
||||
|
||||
private val FEEDS = listOf(
|
||||
"https://itch.io/games/newest.xml",
|
||||
"https://itch.io/games/on-sale.xml",
|
||||
"https://itch.io/games/price-free.xml",
|
||||
"https://itch.io/games/top-rated.xml",
|
||||
)
|
||||
private val PLATFORMS = setOf("windows", "linux", "osx", "mac", "android", "web", "vr", "mobile")
|
||||
|
||||
@Volatile
|
||||
private var memory: StoreShelves? = null
|
||||
|
||||
fun cached(context: Context): StoreShelves? {
|
||||
memory?.let { return it }
|
||||
val stored = stored(ItchPrefs.get(context.applicationContext))
|
||||
if (stored != null) memory = stored
|
||||
return stored
|
||||
}
|
||||
|
||||
fun featured(context: Context, force: Boolean): StoreShelves? {
|
||||
val prefs = ItchPrefs.get(context.applicationContext)
|
||||
val fresh = System.currentTimeMillis() - prefs.getLong(KEY_AT, 0L) < FRESH_MS
|
||||
if (fresh && !force) {
|
||||
cached(context)?.let { return it }
|
||||
}
|
||||
val fetched = fetch()
|
||||
if (fetched != null) {
|
||||
memory = fetched
|
||||
prefs.edit().putString(KEY, ItchCatalog.shelvesJson(fetched)).putLong(KEY_AT, System.currentTimeMillis()).apply()
|
||||
return fetched
|
||||
}
|
||||
return cached(context)
|
||||
}
|
||||
|
||||
fun search(context: Context, query: String): List<CatalogItem> {
|
||||
val key = ItchAuth.key(context.applicationContext)
|
||||
if (key.isBlank()) return emptyList()
|
||||
val out = ArrayList<CatalogItem>()
|
||||
for (page in 1..2) {
|
||||
val games = ItchApi.search(key, query, page) ?: break
|
||||
if (games.length() == 0) break
|
||||
for (i in 0 until games.length()) {
|
||||
val game = games.optJSONObject(i) ?: continue
|
||||
ItchLibrary.item(game, "")?.let { out.add(it) }
|
||||
}
|
||||
if (games.length() < 30) break
|
||||
Thread.sleep(GAP_MS)
|
||||
}
|
||||
return out.distinctBy { it.id }
|
||||
}
|
||||
|
||||
private fun stored(prefs: SharedPreferences): StoreShelves? = ItchCatalog.shelves(prefs.getString(KEY, null))
|
||||
|
||||
private fun fetch(): StoreShelves? {
|
||||
val lists = ArrayList<List<CatalogItem>>(FEEDS.size)
|
||||
var answered = false
|
||||
for ((index, url) in FEEDS.withIndex()) {
|
||||
if (index > 0) Thread.sleep(GAP_MS)
|
||||
var (code, body) = open(url)
|
||||
if (code == 429) {
|
||||
Log.w(TAG, "feed 429; backing off")
|
||||
Thread.sleep(RETRY_MS)
|
||||
val again = open(url)
|
||||
code = again.first
|
||||
body = again.second
|
||||
if (code == 429) {
|
||||
Log.w(TAG, "feed still 429; keeping the cached shelves")
|
||||
lists.add(emptyList())
|
||||
break
|
||||
}
|
||||
}
|
||||
if (code in 200..299 && body != null) {
|
||||
answered = true
|
||||
lists.add(parse(body))
|
||||
} else {
|
||||
lists.add(emptyList())
|
||||
}
|
||||
}
|
||||
if (!answered) return null
|
||||
while (lists.size < FEEDS.size) lists.add(emptyList())
|
||||
val art = (lists[0] + lists[3]).mapNotNull { it.imageUrl }.distinct().take(12)
|
||||
val shelves = StoreShelves(lists[0], lists[1], lists[2], lists[3], art)
|
||||
return shelves.takeUnless { it.isEmpty }
|
||||
}
|
||||
|
||||
private fun open(url: String): Pair<Int, String?> {
|
||||
var conn: HttpURLConnection? = null
|
||||
return try {
|
||||
conn = (URL(url).openConnection() as HttpURLConnection).apply {
|
||||
connectTimeout = 20_000
|
||||
readTimeout = 20_000
|
||||
instanceFollowRedirects = true
|
||||
setRequestProperty("User-Agent", StoreNet.BROWSER_UA)
|
||||
setRequestProperty("Accept", "application/xml, text/xml, application/rss+xml, */*")
|
||||
}
|
||||
val code = conn.responseCode
|
||||
code to if (code in 200..299) conn.inputStream.bufferedReader().use { it.readText() } else null
|
||||
} catch (e: Exception) {
|
||||
Log.w(TAG, "feed ${e.javaClass.simpleName}")
|
||||
0 to null
|
||||
} finally {
|
||||
runCatching { conn?.disconnect() }
|
||||
}
|
||||
}
|
||||
|
||||
internal fun parse(xml: String): List<CatalogItem> {
|
||||
val out = ArrayList<CatalogItem>()
|
||||
val parser = Xml.newPullParser()
|
||||
parser.setFeature(XmlPullParser.FEATURE_PROCESS_NAMESPACES, false)
|
||||
parser.setInput(StringReader(xml))
|
||||
var event = parser.eventType
|
||||
var field: String? = null
|
||||
var current: HashMap<String, String>? = null
|
||||
while (event != XmlPullParser.END_DOCUMENT) {
|
||||
when (event) {
|
||||
XmlPullParser.START_TAG -> {
|
||||
if (parser.name == "item") current = HashMap()
|
||||
field = parser.name
|
||||
}
|
||||
XmlPullParser.TEXT -> {
|
||||
val map = current
|
||||
val name = field
|
||||
if (map != null && name != null) map[name] = (map[name] ?: "") + parser.text
|
||||
}
|
||||
XmlPullParser.END_TAG -> {
|
||||
if (parser.name == "item") {
|
||||
current?.let { item(it)?.let(out::add) }
|
||||
current = null
|
||||
}
|
||||
field = null
|
||||
}
|
||||
}
|
||||
event = parser.next()
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
internal fun item(m: Map<String, String>): CatalogItem? {
|
||||
val link = m["link"]?.trim().orEmpty().ifBlank { m["guid"]?.trim().orEmpty() }
|
||||
if (link.isBlank()) return null
|
||||
val title = m["plainTitle"]?.trim().orEmpty()
|
||||
.ifBlank { m["title"].orEmpty().substringBefore(" [").trim() }
|
||||
.ifBlank { ItchCatalog.slug(link) }
|
||||
val price = m["price"]?.trim().orEmpty()
|
||||
val free = price.isBlank() || price == "$0.00" || price == "0.00" || price.equals("free", ignoreCase = true)
|
||||
val tags = Regex("\\[([^]]+)]").findAll(m["title"].orEmpty())
|
||||
.map { it.groupValues[1].trim() }
|
||||
.filterNot { it.endsWith("% Off") || it.startsWith("$") || it.equals("Free", true) || it.lowercase() in PLATFORMS }
|
||||
.joinToString(", ")
|
||||
return CatalogItem(
|
||||
store = Store.ITCH,
|
||||
id = ItchCatalog.slug(link),
|
||||
title = title,
|
||||
imageUrl = m["imageurl"]?.trim()?.ifBlank { null },
|
||||
tags = tags,
|
||||
isFree = free,
|
||||
hasPrice = !free,
|
||||
finalPrice = if (free) "" else price,
|
||||
originalPrice = if (free) "" else m["fullPrice"]?.trim().orEmpty(),
|
||||
discountPercent = m["discountpercent"]?.trim()?.toIntOrNull() ?: 0,
|
||||
storeUrl = link,
|
||||
releaseDate = date(m["pubDate"]),
|
||||
description = cleanStoreText(m["description"]),
|
||||
)
|
||||
}
|
||||
|
||||
private fun date(raw: String?): String {
|
||||
val text = raw?.trim().orEmpty()
|
||||
if (text.isBlank()) return ""
|
||||
return runCatching {
|
||||
SimpleDateFormat("EEE, dd MMM yyyy HH:mm:ss zzz", Locale.US).parse(text)
|
||||
}.getOrNull()?.let { SimpleDateFormat("yyyy-MM-dd", Locale.US).format(it) }.orEmpty()
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,36 @@
|
||||
package com.droiddeck.launcher.stores.itch
|
||||
|
||||
import org.json.JSONArray
|
||||
import org.json.JSONObject
|
||||
|
||||
internal object ItchUploads {
|
||||
private val GAME_TYPES = setOf("default", "flash", "unity", "java", "html", "")
|
||||
private val PLATFORM_TRAITS = setOf("p_windows", "p_linux", "p_osx", "p_android")
|
||||
|
||||
fun windows(uploads: JSONArray?): JSONObject? {
|
||||
if (uploads == null) return null
|
||||
val all = (0 until uploads.length()).mapNotNull { uploads.optJSONObject(it) }
|
||||
.filter { it.optString("storage") != "external" && !it.optBoolean("demo") }
|
||||
if (all.isEmpty()) return null
|
||||
val games = all.filter { it.optString("type") in GAME_TYPES }
|
||||
val preferred = games.ifEmpty { all }
|
||||
val candidates = preferred.filter { windows(it) }.ifEmpty { preferred }
|
||||
return candidates.maxWithOrNull(compareBy({ it.optString("updated_at", it.optString("updatedAt")) }, { it.optLong("id") }))
|
||||
}
|
||||
|
||||
fun windows(upload: JSONObject): Boolean {
|
||||
val platforms = upload.optJSONObject("platforms")
|
||||
if (platforms != null) {
|
||||
if (platforms.optBoolean("windows")) return true
|
||||
if (platforms.optBoolean("linux") || platforms.optBoolean("osx") || platforms.optBoolean("android")) return false
|
||||
}
|
||||
val traits = upload.optJSONArray("traits") ?: return true
|
||||
val names = (0 until traits.length()).map { traits.optString(it) }
|
||||
if (names.any { it == "p_windows" }) return true
|
||||
return names.none { it in PLATFORM_TRAITS }
|
||||
}
|
||||
|
||||
fun size(upload: JSONObject): Long = upload.optLong("size", upload.optLong("size_bytes"))
|
||||
|
||||
fun filename(upload: JSONObject): String = upload.optString("filename").substringAfterLast('/')
|
||||
}
|
||||
@@ -0,0 +1,115 @@
|
||||
package com.droiddeck.launcher.ui
|
||||
|
||||
import android.content.ClipboardManager
|
||||
import android.content.Context
|
||||
import android.content.Intent
|
||||
import android.net.Uri
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.Spacer
|
||||
import androidx.compose.foundation.layout.height
|
||||
import androidx.compose.material3.AlertDialog
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.material3.TextButton
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.DisposableEffect
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.platform.LocalContext
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import androidx.compose.ui.text.input.ImeAction
|
||||
import androidx.compose.ui.text.input.KeyboardType
|
||||
import androidx.compose.ui.unit.dp
|
||||
import com.droiddeck.launcher.R
|
||||
import com.droiddeck.launcher.stores.Store
|
||||
import com.droiddeck.launcher.stores.StoresState
|
||||
import com.droiddeck.launcher.stores.itch.ItchApi
|
||||
import com.droiddeck.launcher.stores.itch.ItchAuth
|
||||
import com.droiddeck.launcher.stores.itch.ItchLibrary
|
||||
|
||||
private fun copiedKey(ctx: Context): String? {
|
||||
val clipboard = ctx.getSystemService(ClipboardManager::class.java)
|
||||
val text = runCatching { clipboard?.primaryClip?.getItemAt(0)?.text?.toString() }.getOrNull() ?: return null
|
||||
return ItchAuth.candidates(text).firstOrNull()
|
||||
}
|
||||
|
||||
@Composable
|
||||
internal fun ItchKeyDialog(onDismiss: () -> Unit) {
|
||||
val ctx = LocalContext.current
|
||||
var key by remember { mutableStateOf(copiedKey(ctx).orEmpty()) }
|
||||
var busy by remember { mutableStateOf(false) }
|
||||
var rejected by remember { mutableStateOf(false) }
|
||||
|
||||
val lifecycle = androidx.compose.ui.platform.LocalLifecycleOwner.current.lifecycle
|
||||
DisposableEffect(lifecycle) {
|
||||
val observer = androidx.lifecycle.LifecycleEventObserver { _, event ->
|
||||
if (event == androidx.lifecycle.Lifecycle.Event.ON_RESUME && key.isBlank()) {
|
||||
copiedKey(ctx)?.let { key = it }
|
||||
}
|
||||
}
|
||||
lifecycle.addObserver(observer)
|
||||
onDispose { lifecycle.removeObserver(observer) }
|
||||
}
|
||||
|
||||
fun submit() {
|
||||
val raw = key.trim()
|
||||
if (raw.isEmpty() || busy) return
|
||||
busy = true
|
||||
rejected = false
|
||||
Thread({
|
||||
val user = ItchAuth.verify(raw)
|
||||
if (user == null) {
|
||||
StoresState.post { busy = false; rejected = true }
|
||||
return@Thread
|
||||
}
|
||||
ItchAuth.save(ctx, raw, user)
|
||||
StoresState.post { busy = false; onDismiss() }
|
||||
StoresState.refresh(ctx)
|
||||
ItchLibrary.sync(ctx, true)
|
||||
}, "itch-key").start()
|
||||
}
|
||||
|
||||
AlertDialog(
|
||||
onDismissRequest = { if (!busy) onDismiss() },
|
||||
title = { Text(Store.ITCH.label) },
|
||||
text = {
|
||||
Column {
|
||||
AdbTextField(
|
||||
value = key,
|
||||
onValueChange = { key = it; rejected = false },
|
||||
label = stringResource(R.string.stores_itch_key),
|
||||
keyboardType = KeyboardType.Ascii,
|
||||
imeAction = ImeAction.Done,
|
||||
focusOnShow = true,
|
||||
onDone = { submit() },
|
||||
)
|
||||
Row(horizontalArrangement = Arrangement.Start) {
|
||||
TextButton(onClick = {
|
||||
ctx.startActivity(Intent(Intent.ACTION_VIEW, Uri.parse(ItchApi.KEYS_PAGE)))
|
||||
}) { Text(stringResource(R.string.stores_itch_get_key)) }
|
||||
}
|
||||
if (rejected) {
|
||||
Spacer(Modifier.height(8.dp))
|
||||
Text(
|
||||
stringResource(R.string.stores_itch_key_rejected),
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = MaterialTheme.colorScheme.error,
|
||||
)
|
||||
}
|
||||
}
|
||||
},
|
||||
confirmButton = {
|
||||
TextButton(enabled = key.isNotBlank() && !busy, onClick = { submit() }) {
|
||||
Text(stringResource(R.string.game_env_save))
|
||||
}
|
||||
},
|
||||
dismissButton = {
|
||||
TextButton(enabled = !busy, onClick = onDismiss) { Text(stringResource(R.string.common_cancel)) }
|
||||
},
|
||||
)
|
||||
}
|
||||
@@ -29,6 +29,7 @@ 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))
|
||||
Store.ITCH.id -> SourceColours(Color(0xFF3A1A1E), Color(0xFFFFB3B3), Color(0xFFFF5C5C))
|
||||
Library.SOURCE_STEAM -> SourceColours(Color(0xFF16293D), Color(0xFF7CC4FF), Color(0xFF1A9FFF))
|
||||
FLATHUB -> SourceColours(Color(0xFF0B2E2B), Color(0xFF9FE3DD), FlathubTeal)
|
||||
else -> SourceColours(Color(0xFF1A1D22), Color(0xFF9AA3AF), Color(0xFF9AA3AF))
|
||||
@@ -40,6 +41,7 @@ internal fun sourceLabel(source: String): String = when (source) {
|
||||
Store.GOG.id -> Store.GOG.label
|
||||
Store.EPIC.id -> Store.EPIC.label
|
||||
Store.AMAZON.id -> Store.AMAZON.label
|
||||
Store.ITCH.id -> Store.ITCH.label
|
||||
else -> stringResource(R.string.source_added)
|
||||
}
|
||||
|
||||
|
||||
@@ -182,10 +182,7 @@ private fun DownloadList(s: FrontEndState, a: FrontEndActions) {
|
||||
private fun InterruptedRow(item: com.droiddeck.launcher.stores.CatalogItem) {
|
||||
val ctx = LocalContext.current
|
||||
ArtStrip(item.imageUrl ?: item.tallImageUrl, item.title) {
|
||||
Text(item.title, fontSize = 13.sp, fontWeight = FontWeight.Bold, color = Color.White, maxLines = 1, overflow = TextOverflow.Ellipsis, modifier = Modifier.weight(1f, fill = false))
|
||||
SourceChip(item.store.id, small = true)
|
||||
Text(stringResource(R.string.stores_dl_interrupted), fontSize = 12.sp, color = Color(0xFFFFC24D), maxLines = 1)
|
||||
Spacer(Modifier.weight(1f))
|
||||
StripLabel(item.title, item.store.id, stringResource(R.string.stores_dl_interrupted), Color(0xFFFFC24D))
|
||||
RoundAction(Icons.Filled.PlayArrow, stringResource(R.string.stores_dl_resume), primary = true) { StoresState.requestInstall(ctx, item) }
|
||||
CancelAction { StoresState.clearUnfinished(ctx, item) }
|
||||
}
|
||||
@@ -207,14 +204,20 @@ private fun ArtStrip(cover: String?, name: String, content: @Composable androidx
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun androidx.compose.foundation.layout.RowScope.StripLabel(title: String, store: String, status: String, statusColor: Color) {
|
||||
Row(verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(10.dp), modifier = Modifier.weight(1f)) {
|
||||
Text(title, fontSize = 13.sp, fontWeight = FontWeight.Bold, color = Color.White, maxLines = 1, overflow = TextOverflow.Ellipsis, modifier = Modifier.weight(1f, fill = false))
|
||||
SourceChip(store, small = true)
|
||||
Text(status, fontSize = 12.sp, color = statusColor, maxLines = 1)
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun FinishedRow(d: DownloadEntry, s: FrontEndState, a: FrontEndActions, bloom: Boolean = false) {
|
||||
val ctx = LocalContext.current
|
||||
ArtStrip(d.cover, d.name) {
|
||||
Text(d.name, fontSize = 13.sp, fontWeight = FontWeight.Bold, color = Color.White, maxLines = 1, overflow = TextOverflow.Ellipsis, modifier = Modifier.weight(1f, fill = false))
|
||||
SourceChip(d.store.id, small = true)
|
||||
Text(stringResource(if (d.state == DownloadState.INSTALLED) R.string.stores_installed_chip else R.string.stores_dl_cancelled), fontSize = 12.sp, color = Color.White.copy(alpha = 0.72f), maxLines = 1)
|
||||
Spacer(Modifier.weight(1f))
|
||||
StripLabel(d.name, d.store.id, stringResource(if (d.state == DownloadState.INSTALLED) R.string.stores_installed_chip else R.string.stores_dl_cancelled), Color.White.copy(alpha = 0.72f))
|
||||
if (d.state == DownloadState.INSTALLED) Box(Modifier.bloomIn(bloom, GATHER_HOLD_MS + 150)) {
|
||||
RoundAction(Icons.Filled.PlayArrow, stringResource(R.string.stores_play), primary = true) { if (d.key == EpicClient.DOWNLOAD_KEY) a.onEpicLauncher() else launchStoreGame(ctx, d.store, d.id, s, a) }
|
||||
}
|
||||
|
||||
@@ -576,13 +576,14 @@ private fun StoreChips(
|
||||
) { onStore(store, rootBounds[store.id]) }
|
||||
}
|
||||
StoreChip(
|
||||
label = stringResource(R.string.stores_tab_downloads), on = selected == DOWNLOADS, under = fillShown && highlighted == DOWNLOADS, key = DOWNLOADS, mark = pal.signal,
|
||||
modifier = Modifier.weight(1f).onGloballyPositioned {
|
||||
label = null, on = selected == DOWNLOADS, under = fillShown && highlighted == DOWNLOADS, key = DOWNLOADS, mark = pal.signal,
|
||||
modifier = Modifier.onGloballyPositioned {
|
||||
bounds[DOWNLOADS] = it.boundsInParent()
|
||||
StoresMotion.downloadsChip = it.boundsInRoot()
|
||||
},
|
||||
lead = { Icon(Icons.Outlined.Download, null, modifier = Modifier.size(18.dp)) },
|
||||
trail = if (active > 0) { { CountPill(active) } } else null,
|
||||
description = stringResource(R.string.stores_tab_downloads),
|
||||
onFocused = onFocusChip,
|
||||
) { onDownloads() }
|
||||
var cogAt by remember { mutableStateOf<Rect?>(null) }
|
||||
@@ -596,7 +597,7 @@ private fun StoreChips(
|
||||
|
||||
@Composable
|
||||
private fun StoreChip(
|
||||
label: String, on: Boolean, under: Boolean, key: String, mark: Color, modifier: Modifier, lead: @Composable () -> Unit,
|
||||
label: String?, on: Boolean, under: Boolean, key: String, mark: Color, modifier: Modifier, lead: @Composable () -> Unit,
|
||||
trail: (@Composable () -> Unit)? = null, description: String? = null,
|
||||
onFocused: (String?) -> Unit = {}, onClick: () -> Unit,
|
||||
) {
|
||||
@@ -634,7 +635,7 @@ private fun StoreChip(
|
||||
androidx.compose.runtime.CompositionLocalProvider(androidx.compose.material3.LocalContentColor provides ink) {
|
||||
Box(Modifier.pulseRing(picked, mark), contentAlignment = Alignment.Center) { lead() }
|
||||
}
|
||||
Text(label, fontSize = 13.sp, fontWeight = FontWeight.Bold, color = ink, maxLines = 1, overflow = TextOverflow.Ellipsis)
|
||||
if (label != null) Text(label, fontSize = 13.sp, fontWeight = FontWeight.Bold, color = ink, maxLines = 1, overflow = TextOverflow.Ellipsis)
|
||||
trail?.invoke()
|
||||
}
|
||||
}
|
||||
@@ -724,11 +725,16 @@ private fun SignInButton(store: Store, large: Boolean = false) {
|
||||
val ctx = LocalContext.current
|
||||
val colors = MaterialTheme.colorScheme
|
||||
val placed = remember { arrayOfNulls<androidx.compose.ui.layout.LayoutCoordinates>(1) }
|
||||
var askKey by remember { mutableStateOf(false) }
|
||||
if (askKey) ItchKeyDialog { askKey = false }
|
||||
Box(Modifier.onGloballyPositioned { placed[0] = it }) {
|
||||
PrimaryButton(stringResource(R.string.stores_signin), main = true, large = large, icon = if (large) Icons.AutoMirrored.Filled.Login else null) {
|
||||
val at = placed[0]?.takeIf { it.isAttached }?.boundsInRoot()
|
||||
if (at == null || Motion.scale == 0f || StoresMotion.signIn != null) StoresState.signIn(ctx, store)
|
||||
else StoresMotion.signIn = SignInFlood(store, at, colors.primary, sourceColours(store.id).dot)
|
||||
if (store == Store.ITCH) askKey = true
|
||||
else {
|
||||
val at = placed[0]?.takeIf { it.isAttached }?.boundsInRoot()
|
||||
if (at == null || Motion.scale == 0f || StoresMotion.signIn != null) StoresState.signIn(ctx, store)
|
||||
else StoresMotion.signIn = SignInFlood(store, at, colors.primary, sourceColours(store.id).dot)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -752,13 +758,16 @@ private fun Storefront(
|
||||
val colors = MaterialTheme.colorScheme
|
||||
val library = StoresState.library[store] ?: emptyList()
|
||||
val shelves = StoresState.shelves[store]
|
||||
val found = StoresState.found[store] ?: emptyList()
|
||||
val installedItems = remember(StoresState.installed, library) { com.droiddeck.launcher.stores.installedCards(store, StoresState.installed, library) }
|
||||
val installedKeys = remember(installedItems) { installedItems.map { it.id }.toSet() + StoresState.installed.filter { it.sidecar.store == store }.map { it.sidecar.id } }
|
||||
val q = query.trim().lowercase()
|
||||
fun matches(i: CatalogItem) = q.isEmpty() || i.title.lowercase().contains(q)
|
||||
val foundKeys = remember(found) { found.map { it.key }.toSet() }
|
||||
fun matches(i: CatalogItem) = q.isEmpty() || i.title.lowercase().contains(q) || i.key in foundKeys
|
||||
fun shown(i: CatalogItem) = s.storesShowMature || !i.mature || i.owned
|
||||
fun shelf(l: List<CatalogItem>?) = l?.filter(::shown)
|
||||
val everything = remember(library, shelves) { (library + (shelves?.all ?: emptyList())).distinctBy { it.id } }
|
||||
val everything = remember(library, shelves, found) { (found + library + (shelves?.all ?: emptyList())).distinctBy { it.id } }
|
||||
LaunchedEffect(store, q) { StoresState.search(ctx, store, q) }
|
||||
val installedOnly = tab == "installed"
|
||||
val loaded = StoresState.library.containsKey(store)
|
||||
val showGrid = tab != "store" || q.isNotEmpty()
|
||||
@@ -998,6 +1007,7 @@ internal fun storePalette(base: Palette, store: Store?): Palette {
|
||||
Store.GOG -> Color(0xFFC25BC8) to Color(0xFF9A3FA1)
|
||||
Store.EPIC -> Color(0xFFE6E6E6) to Color(0xFFBDBDBD)
|
||||
Store.AMAZON -> Color(0xFFFF9900) to Color(0xFFE07800)
|
||||
Store.ITCH -> Color(0xFFFF5C5C) to Color(0xFFD93B3B)
|
||||
null -> return base
|
||||
}
|
||||
val on = if (accent.luminance() > 0.18f) Color(0xFF0B0B0D) else Color.White
|
||||
|
||||
@@ -2045,6 +2045,16 @@
|
||||
<string name="stores_buy_price">شراء · %1$s</string>
|
||||
<string name="stores_installed_chip">مثبَّتة</string>
|
||||
<string name="stores_in_library">في المكتبة</string>
|
||||
<string name="stores_itch_key">مفتاح API</string>
|
||||
<string name="stores_itch_get_key">الحصول على مفتاح</string>
|
||||
<string name="stores_itch_key_rejected">تم رفض المفتاح</string>
|
||||
<string name="stores_itch_not_owned">ليس في مكتبة itch.io الخاصة بك</string>
|
||||
<string name="stores_itch_no_upload">لا يوجد تنزيل لنظام Windows لهذه اللعبة</string>
|
||||
<string name="stores_itch_no_exe">لم يتم العثور على لعبة Windows في التنزيل</string>
|
||||
<string name="stores_itch_extract_failed">تعذر استخراج التنزيل (رمز الخروج %1$d)</string>
|
||||
<string name="stores_itch_no_catalog">لم يستجب itch.io</string>
|
||||
<string name="stores_itch_archive">أرشيفات %1$s غير مدعومة</string>
|
||||
<string name="stores_itch_download_failed">استجاب itch.io بـ HTTP %1$d</string>
|
||||
<string name="stores_free">مجاني</string>
|
||||
<string name="stores_resume">استئناف</string>
|
||||
<string name="stores_refresh">تحديث</string>
|
||||
|
||||
@@ -2669,6 +2669,16 @@
|
||||
<string name="boot_movie_converting_started">Convirtiendo %1$s para Steam. Mantén DroidDeck abierto.</string>
|
||||
<string name="boot_movie_unsupported">Este dispositivo no puede decodificar ese video.</string>
|
||||
<string name="boot_movie_import_failed">No se pudo importar ese video</string>
|
||||
<string name="stores_itch_key">Clave de API</string>
|
||||
<string name="stores_itch_get_key">Obtener una clave</string>
|
||||
<string name="stores_itch_key_rejected">Clave rechazada</string>
|
||||
<string name="stores_itch_not_owned">No está en tu biblioteca de itch.io</string>
|
||||
<string name="stores_itch_no_upload">Este juego no tiene descarga para Windows</string>
|
||||
<string name="stores_itch_no_exe">No se encontró un juego de Windows en la descarga</string>
|
||||
<string name="stores_itch_extract_failed">No se pudo extraer la descarga (salida %1$d)</string>
|
||||
<string name="stores_itch_no_catalog">itch.io no respondió</string>
|
||||
<string name="stores_itch_archive">Los archivos %1$s no son compatibles</string>
|
||||
<string name="stores_itch_download_failed">itch.io respondió HTTP %1$d</string>
|
||||
<string name="glasses_experimental">Cámaras Luma Ultra — Experimental</string>
|
||||
<string name="glasses_joycons">Seguimiento de posición de los Joy-Con</string>
|
||||
<string name="glasses_joycons_hint">Usa las cámaras de las gafas inteligentes para posicionar tus mandos Joy-Con emparejados. La rotación y las pulsaciones de los botones provienen de los mandos Joy-Con. La posición se estima cuando tus muñecas están ocultas. Reemplaza los mandos operados con las manos desnudas y pausa el seguimiento de la cámara del teléfono. Apunta los mandos Joy-Con hacia adelante al conectar o restablecer el centro de seguimiento.</string>
|
||||
|
||||
@@ -1961,6 +1961,16 @@
|
||||
<string name="stores_buy_price">Купи · %1$s</string>
|
||||
<string name="stores_installed_chip">Инсталирана</string>
|
||||
<string name="stores_in_library">В библиотеката</string>
|
||||
<string name="stores_itch_key">API ключ</string>
|
||||
<string name="stores_itch_get_key">Вземи ключ</string>
|
||||
<string name="stores_itch_key_rejected">Ключът е отказан</string>
|
||||
<string name="stores_itch_not_owned">Не е в библиотеката ви в itch.io</string>
|
||||
<string name="stores_itch_no_upload">Няма изтегляне за Windows за тази игра</string>
|
||||
<string name="stores_itch_no_exe">В изтеглянето не е намерена игра за Windows</string>
|
||||
<string name="stores_itch_extract_failed">Изтеглянето не можа да бъде разархивирано (код %1$d)</string>
|
||||
<string name="stores_itch_no_catalog">itch.io не отговори</string>
|
||||
<string name="stores_itch_archive">Архивите %1$s не се поддържат</string>
|
||||
<string name="stores_itch_download_failed">itch.io отговори HTTP %1$d</string>
|
||||
<string name="stores_free">Безплатна</string>
|
||||
<string name="stores_resume">Продължи</string>
|
||||
<string name="stores_refresh">Обнови</string>
|
||||
|
||||
@@ -2003,6 +2003,16 @@
|
||||
<string name="stores_buy_price">Koupit · %1$s</string>
|
||||
<string name="stores_installed_chip">Nainstalováno</string>
|
||||
<string name="stores_in_library">V knihovně</string>
|
||||
<string name="stores_itch_key">Klíč API</string>
|
||||
<string name="stores_itch_get_key">Získat klíč</string>
|
||||
<string name="stores_itch_key_rejected">Klíč odmítnut</string>
|
||||
<string name="stores_itch_not_owned">Není ve vaší knihovně itch.io</string>
|
||||
<string name="stores_itch_no_upload">Pro tuto hru není ke stažení verze pro Windows</string>
|
||||
<string name="stores_itch_no_exe">Ve stahování nebyla nalezena hra pro Windows</string>
|
||||
<string name="stores_itch_extract_failed">Stažený soubor nelze rozbalit (kód %1$d)</string>
|
||||
<string name="stores_itch_no_catalog">itch.io neodpovědělo</string>
|
||||
<string name="stores_itch_archive">Archivy %1$s nejsou podporovány</string>
|
||||
<string name="stores_itch_download_failed">itch.io odpovědělo HTTP %1$d</string>
|
||||
<string name="stores_free">Zdarma</string>
|
||||
<string name="stores_resume">Pokračovat</string>
|
||||
<string name="stores_refresh">Obnovit</string>
|
||||
|
||||
@@ -1961,6 +1961,16 @@
|
||||
<string name="stores_buy_price">Køb · %1$s</string>
|
||||
<string name="stores_installed_chip">Installeret</string>
|
||||
<string name="stores_in_library">I biblioteket</string>
|
||||
<string name="stores_itch_key">API-nøgle</string>
|
||||
<string name="stores_itch_get_key">Hent en nøgle</string>
|
||||
<string name="stores_itch_key_rejected">Nøgle afvist</string>
|
||||
<string name="stores_itch_not_owned">Ikke i dit itch.io-bibliotek</string>
|
||||
<string name="stores_itch_no_upload">Ingen Windows-download til dette spil</string>
|
||||
<string name="stores_itch_no_exe">Der blev ikke fundet et Windows-spil i downloaden</string>
|
||||
<string name="stores_itch_extract_failed">Downloaden kunne ikke udpakkes (kode %1$d)</string>
|
||||
<string name="stores_itch_no_catalog">itch.io svarede ikke</string>
|
||||
<string name="stores_itch_archive">%1$s-arkiver understøttes ikke</string>
|
||||
<string name="stores_itch_download_failed">itch.io svarede HTTP %1$d</string>
|
||||
<string name="stores_free">Gratis</string>
|
||||
<string name="stores_resume">Genoptag</string>
|
||||
<string name="stores_refresh">Opdater</string>
|
||||
|
||||
@@ -1950,6 +1950,16 @@
|
||||
<string name="stores_buy_price">Kaufen · %1$s</string>
|
||||
<string name="stores_installed_chip">Installiert</string>
|
||||
<string name="stores_in_library">In der Bibliothek</string>
|
||||
<string name="stores_itch_key">API-Schlüssel</string>
|
||||
<string name="stores_itch_get_key">Schlüssel holen</string>
|
||||
<string name="stores_itch_key_rejected">Schlüssel abgelehnt</string>
|
||||
<string name="stores_itch_not_owned">Nicht in deiner itch.io-Bibliothek</string>
|
||||
<string name="stores_itch_no_upload">Kein Windows-Download für dieses Spiel</string>
|
||||
<string name="stores_itch_no_exe">Im Download wurde kein Windows-Spiel gefunden</string>
|
||||
<string name="stores_itch_extract_failed">Download konnte nicht entpackt werden (Code %1$d)</string>
|
||||
<string name="stores_itch_no_catalog">itch.io hat nicht geantwortet</string>
|
||||
<string name="stores_itch_archive">%1$s-Archive werden nicht unterstützt</string>
|
||||
<string name="stores_itch_download_failed">itch.io antwortete mit HTTP %1$d</string>
|
||||
<string name="stores_free">Kostenlos</string>
|
||||
<string name="stores_resume">Fortsetzen</string>
|
||||
<string name="stores_refresh">Aktualisieren</string>
|
||||
|
||||
@@ -1961,6 +1961,16 @@
|
||||
<string name="stores_buy_price">Αγορά · %1$s</string>
|
||||
<string name="stores_installed_chip">Εγκατεστημένο</string>
|
||||
<string name="stores_in_library">Στη βιβλιοθήκη</string>
|
||||
<string name="stores_itch_key">Κλειδί API</string>
|
||||
<string name="stores_itch_get_key">Λήψη κλειδιού</string>
|
||||
<string name="stores_itch_key_rejected">Το κλειδί απορρίφθηκε</string>
|
||||
<string name="stores_itch_not_owned">Δεν είναι στη βιβλιοθήκη σας στο itch.io</string>
|
||||
<string name="stores_itch_no_upload">Δεν υπάρχει λήψη για Windows για αυτό το παιχνίδι</string>
|
||||
<string name="stores_itch_no_exe">Δεν βρέθηκε παιχνίδι Windows στη λήψη</string>
|
||||
<string name="stores_itch_extract_failed">Δεν ήταν δυνατή η αποσυμπίεση της λήψης (κωδικός %1$d)</string>
|
||||
<string name="stores_itch_no_catalog">Το itch.io δεν απάντησε</string>
|
||||
<string name="stores_itch_archive">Τα αρχεία %1$s δεν υποστηρίζονται</string>
|
||||
<string name="stores_itch_download_failed">Το itch.io απάντησε HTTP %1$d</string>
|
||||
<string name="stores_free">Δωρεάν</string>
|
||||
<string name="stores_resume">Συνέχιση</string>
|
||||
<string name="stores_refresh">Ανανέωση</string>
|
||||
|
||||
@@ -1979,6 +1979,16 @@
|
||||
<string name="stores_buy_price">Comprar · %1$s</string>
|
||||
<string name="stores_installed_chip">Instalado</string>
|
||||
<string name="stores_in_library">En la biblioteca</string>
|
||||
<string name="stores_itch_key">Clave de API</string>
|
||||
<string name="stores_itch_get_key">Obtener una clave</string>
|
||||
<string name="stores_itch_key_rejected">Clave rechazada</string>
|
||||
<string name="stores_itch_not_owned">No está en tu biblioteca de itch.io</string>
|
||||
<string name="stores_itch_no_upload">No hay descarga para Windows de este juego</string>
|
||||
<string name="stores_itch_no_exe">No se ha encontrado un juego de Windows en la descarga</string>
|
||||
<string name="stores_itch_extract_failed">No se ha podido extraer la descarga (código %1$d)</string>
|
||||
<string name="stores_itch_no_catalog">itch.io no respondió</string>
|
||||
<string name="stores_itch_archive">Los archivos %1$s no son compatibles</string>
|
||||
<string name="stores_itch_download_failed">itch.io respondió HTTP %1$d</string>
|
||||
<string name="stores_free">Gratis</string>
|
||||
<string name="stores_resume">Reanudar</string>
|
||||
<string name="stores_refresh">Actualizar</string>
|
||||
|
||||
@@ -1961,6 +1961,16 @@
|
||||
<string name="stores_buy_price">Osta · %1$s</string>
|
||||
<string name="stores_installed_chip">Asennettu</string>
|
||||
<string name="stores_in_library">Kirjastossa</string>
|
||||
<string name="stores_itch_key">API-avain</string>
|
||||
<string name="stores_itch_get_key">Hae avain</string>
|
||||
<string name="stores_itch_key_rejected">Avain hylätty</string>
|
||||
<string name="stores_itch_not_owned">Ei itch.io-kirjastossasi</string>
|
||||
<string name="stores_itch_no_upload">Ei Windows-latausta tälle pelille</string>
|
||||
<string name="stores_itch_no_exe">Latauksesta ei löytynyt Windows-peliä</string>
|
||||
<string name="stores_itch_extract_failed">Latausta ei voitu purkaa (koodi %1$d)</string>
|
||||
<string name="stores_itch_no_catalog">itch.io ei vastannut</string>
|
||||
<string name="stores_itch_archive">%1$s-arkistot eivät ole tuettuja</string>
|
||||
<string name="stores_itch_download_failed">itch.io vastasi HTTP %1$d</string>
|
||||
<string name="stores_free">Ilmainen</string>
|
||||
<string name="stores_resume">Jatka</string>
|
||||
<string name="stores_refresh">Päivitä</string>
|
||||
|
||||
@@ -1979,6 +1979,16 @@
|
||||
<string name="stores_buy_price">Acheter · %1$s</string>
|
||||
<string name="stores_installed_chip">Installé</string>
|
||||
<string name="stores_in_library">Dans la bibliothèque</string>
|
||||
<string name="stores_itch_key">Clé d\'API</string>
|
||||
<string name="stores_itch_get_key">Obtenir une clé</string>
|
||||
<string name="stores_itch_key_rejected">Clé refusée</string>
|
||||
<string name="stores_itch_not_owned">Absent de votre bibliothèque itch.io</string>
|
||||
<string name="stores_itch_no_upload">Pas de téléchargement Windows pour ce jeu</string>
|
||||
<string name="stores_itch_no_exe">Aucun jeu Windows trouvé dans le téléchargement</string>
|
||||
<string name="stores_itch_extract_failed">Impossible d\'extraire le téléchargement (code %1$d)</string>
|
||||
<string name="stores_itch_no_catalog">itch.io n\'a pas répondu</string>
|
||||
<string name="stores_itch_archive">Les archives %1$s ne sont pas prises en charge</string>
|
||||
<string name="stores_itch_download_failed">itch.io a répondu HTTP %1$d</string>
|
||||
<string name="stores_free">Gratuit</string>
|
||||
<string name="stores_resume">Reprendre</string>
|
||||
<string name="stores_refresh">Actualiser</string>
|
||||
|
||||
@@ -1961,6 +1961,16 @@
|
||||
<string name="stores_buy_price">खरीदें · %1$s</string>
|
||||
<string name="stores_installed_chip">इंस्टॉल है</string>
|
||||
<string name="stores_in_library">लाइब्रेरी में</string>
|
||||
<string name="stores_itch_key">API कुंजी</string>
|
||||
<string name="stores_itch_get_key">कुंजी पाएं</string>
|
||||
<string name="stores_itch_key_rejected">कुंजी अस्वीकृत</string>
|
||||
<string name="stores_itch_not_owned">आपकी itch.io लाइब्रेरी में नहीं</string>
|
||||
<string name="stores_itch_no_upload">इस गेम के लिए Windows डाउनलोड नहीं है</string>
|
||||
<string name="stores_itch_no_exe">डाउनलोड में कोई Windows गेम नहीं मिला</string>
|
||||
<string name="stores_itch_extract_failed">डाउनलोड एक्सट्रैक्ट नहीं हो सका (कोड %1$d)</string>
|
||||
<string name="stores_itch_no_catalog">itch.io ने जवाब नहीं दिया</string>
|
||||
<string name="stores_itch_archive">%1$s आर्काइव समर्थित नहीं हैं</string>
|
||||
<string name="stores_itch_download_failed">itch.io ने HTTP %1$d लौटाया</string>
|
||||
<string name="stores_free">फ़्री</string>
|
||||
<string name="stores_resume">फिर से शुरू करें</string>
|
||||
<string name="stores_refresh">रीफ़्रेश करें</string>
|
||||
|
||||
@@ -1961,6 +1961,16 @@
|
||||
<string name="stores_buy_price">Megvétel · %1$s</string>
|
||||
<string name="stores_installed_chip">Telepítve</string>
|
||||
<string name="stores_in_library">A könyvtárban</string>
|
||||
<string name="stores_itch_key">API-kulcs</string>
|
||||
<string name="stores_itch_get_key">Kulcs beszerzése</string>
|
||||
<string name="stores_itch_key_rejected">A kulcs elutasítva</string>
|
||||
<string name="stores_itch_not_owned">Nincs az itch.io-könyvtáradban</string>
|
||||
<string name="stores_itch_no_upload">Ehhez a játékhoz nincs Windows-letöltés</string>
|
||||
<string name="stores_itch_no_exe">A letöltésben nem található Windows-játék</string>
|
||||
<string name="stores_itch_extract_failed">A letöltés nem bontható ki (kód: %1$d)</string>
|
||||
<string name="stores_itch_no_catalog">Az itch.io nem válaszolt</string>
|
||||
<string name="stores_itch_archive">A(z) %1$s archívumok nem támogatottak</string>
|
||||
<string name="stores_itch_download_failed">Az itch.io HTTP %1$d választ adott</string>
|
||||
<string name="stores_free">Ingyenes</string>
|
||||
<string name="stores_resume">Folytatás</string>
|
||||
<string name="stores_refresh">Frissítés</string>
|
||||
|
||||
@@ -1940,6 +1940,16 @@
|
||||
<string name="stores_buy_price">Beli · %1$s</string>
|
||||
<string name="stores_installed_chip">Terinstal</string>
|
||||
<string name="stores_in_library">Dalam pustaka</string>
|
||||
<string name="stores_itch_key">Kunci API</string>
|
||||
<string name="stores_itch_get_key">Dapatkan kunci</string>
|
||||
<string name="stores_itch_key_rejected">Kunci ditolak</string>
|
||||
<string name="stores_itch_not_owned">Tidak ada di pustaka itch.io Anda</string>
|
||||
<string name="stores_itch_no_upload">Tidak ada unduhan Windows untuk game ini</string>
|
||||
<string name="stores_itch_no_exe">Tidak ada game Windows di unduhan</string>
|
||||
<string name="stores_itch_extract_failed">Tidak dapat mengekstrak unduhan (kode %1$d)</string>
|
||||
<string name="stores_itch_no_catalog">itch.io tidak menjawab</string>
|
||||
<string name="stores_itch_archive">Arsip %1$s tidak didukung</string>
|
||||
<string name="stores_itch_download_failed">itch.io menjawab HTTP %1$d</string>
|
||||
<string name="stores_free">Gratis</string>
|
||||
<string name="stores_resume">Lanjutkan</string>
|
||||
<string name="stores_refresh">Segarkan</string>
|
||||
|
||||
@@ -1971,6 +1971,16 @@
|
||||
<string name="stores_buy_price">Acquista · %1$s</string>
|
||||
<string name="stores_installed_chip">Installato</string>
|
||||
<string name="stores_in_library">In libreria</string>
|
||||
<string name="stores_itch_key">Chiave API</string>
|
||||
<string name="stores_itch_get_key">Ottieni una chiave</string>
|
||||
<string name="stores_itch_key_rejected">Chiave rifiutata</string>
|
||||
<string name="stores_itch_not_owned">Non è nella tua libreria itch.io</string>
|
||||
<string name="stores_itch_no_upload">Nessun download per Windows di questo gioco</string>
|
||||
<string name="stores_itch_no_exe">Nessun gioco Windows trovato nel download</string>
|
||||
<string name="stores_itch_extract_failed">Impossibile estrarre il download (codice %1$d)</string>
|
||||
<string name="stores_itch_no_catalog">itch.io non ha risposto</string>
|
||||
<string name="stores_itch_archive">Gli archivi %1$s non sono supportati</string>
|
||||
<string name="stores_itch_download_failed">itch.io ha risposto HTTP %1$d</string>
|
||||
<string name="stores_free">Gratis</string>
|
||||
<string name="stores_resume">Riprendi</string>
|
||||
<string name="stores_refresh">Aggiorna</string>
|
||||
|
||||
@@ -1940,6 +1940,16 @@
|
||||
<string name="stores_buy_price">購入 · %1$s</string>
|
||||
<string name="stores_installed_chip">インストール済み</string>
|
||||
<string name="stores_in_library">ライブラリにあり</string>
|
||||
<string name="stores_itch_key">APIキー</string>
|
||||
<string name="stores_itch_get_key">キーを取得</string>
|
||||
<string name="stores_itch_key_rejected">キーが拒否されました</string>
|
||||
<string name="stores_itch_not_owned">itch.io ライブラリにありません</string>
|
||||
<string name="stores_itch_no_upload">このゲームには Windows 版のダウンロードがありません</string>
|
||||
<string name="stores_itch_no_exe">ダウンロードに Windows 版のゲームが見つかりません</string>
|
||||
<string name="stores_itch_extract_failed">ダウンロードを展開できませんでした (終了コード %1$d)</string>
|
||||
<string name="stores_itch_no_catalog">itch.io から応答がありません</string>
|
||||
<string name="stores_itch_archive">%1$s 形式のアーカイブには対応していません</string>
|
||||
<string name="stores_itch_download_failed">itch.io が HTTP %1$d を返しました</string>
|
||||
<string name="stores_free">無料</string>
|
||||
<string name="stores_resume">再開</string>
|
||||
<string name="stores_refresh">更新</string>
|
||||
|
||||
@@ -1940,6 +1940,16 @@
|
||||
<string name="stores_buy_price">구매 · %1$s</string>
|
||||
<string name="stores_installed_chip">설치됨</string>
|
||||
<string name="stores_in_library">라이브러리에 있음</string>
|
||||
<string name="stores_itch_key">API 키</string>
|
||||
<string name="stores_itch_get_key">키 받기</string>
|
||||
<string name="stores_itch_key_rejected">키가 거부되었습니다</string>
|
||||
<string name="stores_itch_not_owned">itch.io 라이브러리에 없습니다</string>
|
||||
<string name="stores_itch_no_upload">이 게임의 Windows 다운로드가 없습니다</string>
|
||||
<string name="stores_itch_no_exe">다운로드에서 Windows 게임을 찾을 수 없습니다</string>
|
||||
<string name="stores_itch_extract_failed">다운로드한 파일의 압축을 풀 수 없습니다(코드 %1$d)</string>
|
||||
<string name="stores_itch_no_catalog">itch.io가 응답하지 않았습니다</string>
|
||||
<string name="stores_itch_archive">%1$s 압축 파일은 지원하지 않습니다</string>
|
||||
<string name="stores_itch_download_failed">itch.io가 HTTP %1$d을(를) 반환했습니다</string>
|
||||
<string name="stores_free">무료</string>
|
||||
<string name="stores_resume">재개</string>
|
||||
<string name="stores_refresh">새로 고침</string>
|
||||
|
||||
@@ -1940,6 +1940,16 @@
|
||||
<string name="stores_buy_price">Beli · %1$s</string>
|
||||
<string name="stores_installed_chip">Dipasang</string>
|
||||
<string name="stores_in_library">Dalam pustaka</string>
|
||||
<string name="stores_itch_key">Kunci API</string>
|
||||
<string name="stores_itch_get_key">Dapatkan kunci</string>
|
||||
<string name="stores_itch_key_rejected">Kunci ditolak</string>
|
||||
<string name="stores_itch_not_owned">Tiada dalam pustaka itch.io anda</string>
|
||||
<string name="stores_itch_no_upload">Tiada muat turun Windows untuk permainan ini</string>
|
||||
<string name="stores_itch_no_exe">Tiada permainan Windows ditemui dalam muat turun</string>
|
||||
<string name="stores_itch_extract_failed">Tidak dapat mengekstrak muat turun (kod %1$d)</string>
|
||||
<string name="stores_itch_no_catalog">itch.io tidak menjawab</string>
|
||||
<string name="stores_itch_archive">Arkib %1$s tidak disokong</string>
|
||||
<string name="stores_itch_download_failed">itch.io menjawab HTTP %1$d</string>
|
||||
<string name="stores_free">Percuma</string>
|
||||
<string name="stores_resume">Sambung</string>
|
||||
<string name="stores_refresh">Segar semula</string>
|
||||
|
||||
@@ -1961,6 +1961,16 @@
|
||||
<string name="stores_buy_price">Kjøp · %1$s</string>
|
||||
<string name="stores_installed_chip">Installert</string>
|
||||
<string name="stores_in_library">I biblioteket</string>
|
||||
<string name="stores_itch_key">API-nøkkel</string>
|
||||
<string name="stores_itch_get_key">Hent en nøkkel</string>
|
||||
<string name="stores_itch_key_rejected">Nøkkelen ble avvist</string>
|
||||
<string name="stores_itch_not_owned">Ikke i itch.io-biblioteket ditt</string>
|
||||
<string name="stores_itch_no_upload">Ingen Windows-nedlasting for dette spillet</string>
|
||||
<string name="stores_itch_no_exe">Fant ikke noe Windows-spill i nedlastingen</string>
|
||||
<string name="stores_itch_extract_failed">Kunne ikke pakke ut nedlastingen (kode %1$d)</string>
|
||||
<string name="stores_itch_no_catalog">itch.io svarte ikke</string>
|
||||
<string name="stores_itch_archive">%1$s-arkiver støttes ikke</string>
|
||||
<string name="stores_itch_download_failed">itch.io svarte HTTP %1$d</string>
|
||||
<string name="stores_free">Gratis</string>
|
||||
<string name="stores_resume">Gjenoppta</string>
|
||||
<string name="stores_refresh">Oppdater</string>
|
||||
|
||||
@@ -1961,6 +1961,16 @@
|
||||
<string name="stores_buy_price">Kopen · %1$s</string>
|
||||
<string name="stores_installed_chip">Geïnstalleerd</string>
|
||||
<string name="stores_in_library">In bibliotheek</string>
|
||||
<string name="stores_itch_key">API-sleutel</string>
|
||||
<string name="stores_itch_get_key">Sleutel ophalen</string>
|
||||
<string name="stores_itch_key_rejected">Sleutel geweigerd</string>
|
||||
<string name="stores_itch_not_owned">Niet in je itch.io-bibliotheek</string>
|
||||
<string name="stores_itch_no_upload">Geen Windows-download voor dit spel</string>
|
||||
<string name="stores_itch_no_exe">Geen Windows-game gevonden in de download</string>
|
||||
<string name="stores_itch_extract_failed">Download kan niet worden uitgepakt (code %1$d)</string>
|
||||
<string name="stores_itch_no_catalog">itch.io gaf geen antwoord</string>
|
||||
<string name="stores_itch_archive">%1$s-archieven worden niet ondersteund</string>
|
||||
<string name="stores_itch_download_failed">itch.io antwoordde HTTP %1$d</string>
|
||||
<string name="stores_free">Gratis</string>
|
||||
<string name="stores_resume">Hervatten</string>
|
||||
<string name="stores_refresh">Vernieuwen</string>
|
||||
|
||||
@@ -2003,6 +2003,16 @@
|
||||
<string name="stores_buy_price">Kup · %1$s</string>
|
||||
<string name="stores_installed_chip">Zainstalowano</string>
|
||||
<string name="stores_in_library">W bibliotece</string>
|
||||
<string name="stores_itch_key">Klucz API</string>
|
||||
<string name="stores_itch_get_key">Pobierz klucz</string>
|
||||
<string name="stores_itch_key_rejected">Klucz odrzucony</string>
|
||||
<string name="stores_itch_not_owned">Nie ma go w Twojej bibliotece itch.io</string>
|
||||
<string name="stores_itch_no_upload">Brak wersji na Windows dla tej gry</string>
|
||||
<string name="stores_itch_no_exe">W pobranych plikach nie znaleziono gry na Windows</string>
|
||||
<string name="stores_itch_extract_failed">Nie można rozpakować pobranego pliku (kod %1$d)</string>
|
||||
<string name="stores_itch_no_catalog">itch.io nie odpowiedziało</string>
|
||||
<string name="stores_itch_archive">Archiwa %1$s nie są obsługiwane</string>
|
||||
<string name="stores_itch_download_failed">itch.io odpowiedziało HTTP %1$d</string>
|
||||
<string name="stores_free">Bezpłatna</string>
|
||||
<string name="stores_resume">Wznów</string>
|
||||
<string name="stores_refresh">Odśwież</string>
|
||||
|
||||
@@ -1971,6 +1971,16 @@
|
||||
<string name="stores_buy_price">Comprar · %1$s</string>
|
||||
<string name="stores_installed_chip">Instalado</string>
|
||||
<string name="stores_in_library">Na biblioteca</string>
|
||||
<string name="stores_itch_key">Chave de API</string>
|
||||
<string name="stores_itch_get_key">Obter uma chave</string>
|
||||
<string name="stores_itch_key_rejected">Chave rejeitada</string>
|
||||
<string name="stores_itch_not_owned">Não está na sua biblioteca do itch.io</string>
|
||||
<string name="stores_itch_no_upload">Sem transferência para Windows deste jogo</string>
|
||||
<string name="stores_itch_no_exe">Não foi encontrado nenhum jogo para Windows na transferência</string>
|
||||
<string name="stores_itch_extract_failed">Não foi possível extrair a transferência (código %1$d)</string>
|
||||
<string name="stores_itch_no_catalog">O itch.io não respondeu</string>
|
||||
<string name="stores_itch_archive">Ficheiros %1$s não são suportados</string>
|
||||
<string name="stores_itch_download_failed">O itch.io respondeu HTTP %1$d</string>
|
||||
<string name="stores_free">Grátis</string>
|
||||
<string name="stores_resume">Retomar</string>
|
||||
<string name="stores_refresh">Atualizar</string>
|
||||
|
||||
@@ -1971,6 +1971,16 @@
|
||||
<string name="stores_buy_price">Comprar · %1$s</string>
|
||||
<string name="stores_installed_chip">Instalado</string>
|
||||
<string name="stores_in_library">Na biblioteca</string>
|
||||
<string name="stores_itch_key">Chave de API</string>
|
||||
<string name="stores_itch_get_key">Obter uma chave</string>
|
||||
<string name="stores_itch_key_rejected">Chave rejeitada</string>
|
||||
<string name="stores_itch_not_owned">Não está na sua biblioteca do itch.io</string>
|
||||
<string name="stores_itch_no_upload">Sem download para Windows deste jogo</string>
|
||||
<string name="stores_itch_no_exe">Nenhum jogo para Windows encontrado no download</string>
|
||||
<string name="stores_itch_extract_failed">Não foi possível extrair o download (código %1$d)</string>
|
||||
<string name="stores_itch_no_catalog">O itch.io não respondeu</string>
|
||||
<string name="stores_itch_archive">Arquivos %1$s não são suportados</string>
|
||||
<string name="stores_itch_download_failed">O itch.io respondeu HTTP %1$d</string>
|
||||
<string name="stores_free">Grátis</string>
|
||||
<string name="stores_resume">Retomar</string>
|
||||
<string name="stores_refresh">Atualizar</string>
|
||||
|
||||
@@ -1982,6 +1982,16 @@
|
||||
<string name="stores_buy_price">Cumpără · %1$s</string>
|
||||
<string name="stores_installed_chip">Instalat</string>
|
||||
<string name="stores_in_library">În bibliotecă</string>
|
||||
<string name="stores_itch_key">Cheie API</string>
|
||||
<string name="stores_itch_get_key">Obține o cheie</string>
|
||||
<string name="stores_itch_key_rejected">Cheie respinsă</string>
|
||||
<string name="stores_itch_not_owned">Nu este în biblioteca ta itch.io</string>
|
||||
<string name="stores_itch_no_upload">Nu există descărcare pentru Windows la acest joc</string>
|
||||
<string name="stores_itch_no_exe">Nu s-a găsit niciun joc Windows în descărcare</string>
|
||||
<string name="stores_itch_extract_failed">Descărcarea nu a putut fi extrasă (cod %1$d)</string>
|
||||
<string name="stores_itch_no_catalog">itch.io nu a răspuns</string>
|
||||
<string name="stores_itch_archive">Arhivele %1$s nu sunt acceptate</string>
|
||||
<string name="stores_itch_download_failed">itch.io a răspuns HTTP %1$d</string>
|
||||
<string name="stores_free">Gratuit</string>
|
||||
<string name="stores_resume">Reluare</string>
|
||||
<string name="stores_refresh">Reîmprospătează</string>
|
||||
|
||||
@@ -2000,6 +2000,16 @@
|
||||
<string name="stores_buy_price">Купить · %1$s</string>
|
||||
<string name="stores_installed_chip">Установлено</string>
|
||||
<string name="stores_in_library">В библиотеке</string>
|
||||
<string name="stores_itch_key">Ключ API</string>
|
||||
<string name="stores_itch_get_key">Получить ключ</string>
|
||||
<string name="stores_itch_key_rejected">Ключ отклонён</string>
|
||||
<string name="stores_itch_not_owned">Нет в вашей библиотеке itch.io</string>
|
||||
<string name="stores_itch_no_upload">Для этой игры нет загрузки для Windows</string>
|
||||
<string name="stores_itch_no_exe">В загрузке не найдена игра для Windows</string>
|
||||
<string name="stores_itch_extract_failed">Не удалось распаковать загрузку (код %1$d)</string>
|
||||
<string name="stores_itch_no_catalog">itch.io не ответил</string>
|
||||
<string name="stores_itch_archive">Архивы %1$s не поддерживаются</string>
|
||||
<string name="stores_itch_download_failed">itch.io ответил HTTP %1$d</string>
|
||||
<string name="stores_free">Бесплатно</string>
|
||||
<string name="stores_resume">Продолжить</string>
|
||||
<string name="stores_refresh">Обновить</string>
|
||||
|
||||
@@ -1961,6 +1961,16 @@
|
||||
<string name="stores_buy_price">Köp · %1$s</string>
|
||||
<string name="stores_installed_chip">Installerat</string>
|
||||
<string name="stores_in_library">I biblioteket</string>
|
||||
<string name="stores_itch_key">API-nyckel</string>
|
||||
<string name="stores_itch_get_key">Hämta en nyckel</string>
|
||||
<string name="stores_itch_key_rejected">Nyckeln avvisades</string>
|
||||
<string name="stores_itch_not_owned">Inte i ditt itch.io-bibliotek</string>
|
||||
<string name="stores_itch_no_upload">Ingen Windows-nedladdning för det här spelet</string>
|
||||
<string name="stores_itch_no_exe">Inget Windows-spel hittades i nedladdningen</string>
|
||||
<string name="stores_itch_extract_failed">Det gick inte att packa upp nedladdningen (kod %1$d)</string>
|
||||
<string name="stores_itch_no_catalog">itch.io svarade inte</string>
|
||||
<string name="stores_itch_archive">%1$s-arkiv stöds inte</string>
|
||||
<string name="stores_itch_download_failed">itch.io svarade HTTP %1$d</string>
|
||||
<string name="stores_free">Gratis</string>
|
||||
<string name="stores_resume">Återuppta</string>
|
||||
<string name="stores_refresh">Uppdatera</string>
|
||||
|
||||
@@ -1940,6 +1940,16 @@
|
||||
<string name="stores_buy_price">ซื้อ · %1$s</string>
|
||||
<string name="stores_installed_chip">ติดตั้งแล้ว</string>
|
||||
<string name="stores_in_library">อยู่ในไลบรารี</string>
|
||||
<string name="stores_itch_key">คีย์ API</string>
|
||||
<string name="stores_itch_get_key">รับคีย์</string>
|
||||
<string name="stores_itch_key_rejected">คีย์ถูกปฏิเสธ</string>
|
||||
<string name="stores_itch_not_owned">ไม่มีในคลัง itch.io ของคุณ</string>
|
||||
<string name="stores_itch_no_upload">เกมนี้ไม่มีการดาวน์โหลดสำหรับ Windows</string>
|
||||
<string name="stores_itch_no_exe">ไม่พบเกม Windows ในไฟล์ที่ดาวน์โหลด</string>
|
||||
<string name="stores_itch_extract_failed">แตกไฟล์ที่ดาวน์โหลดไม่ได้ (รหัส %1$d)</string>
|
||||
<string name="stores_itch_no_catalog">itch.io ไม่ตอบสนอง</string>
|
||||
<string name="stores_itch_archive">ไม่รองรับไฟล์ %1$s</string>
|
||||
<string name="stores_itch_download_failed">itch.io ตอบกลับ HTTP %1$d</string>
|
||||
<string name="stores_free">ฟรี</string>
|
||||
<string name="stores_resume">ดำเนินการต่อ</string>
|
||||
<string name="stores_refresh">รีเฟรช</string>
|
||||
|
||||
@@ -1950,6 +1950,16 @@
|
||||
<string name="stores_buy_price">Satın al · %1$s</string>
|
||||
<string name="stores_installed_chip">Yüklü</string>
|
||||
<string name="stores_in_library">Kütüphanede</string>
|
||||
<string name="stores_itch_key">API anahtarı</string>
|
||||
<string name="stores_itch_get_key">Anahtar al</string>
|
||||
<string name="stores_itch_key_rejected">Anahtar reddedildi</string>
|
||||
<string name="stores_itch_not_owned">itch.io kütüphanenizde yok</string>
|
||||
<string name="stores_itch_no_upload">Bu oyunun Windows indirmesi yok</string>
|
||||
<string name="stores_itch_no_exe">İndirmede Windows oyunu bulunamadı</string>
|
||||
<string name="stores_itch_extract_failed">İndirme ayıklanamadı (kod %1$d)</string>
|
||||
<string name="stores_itch_no_catalog">itch.io yanıt vermedi</string>
|
||||
<string name="stores_itch_archive">%1$s arşivleri desteklenmiyor</string>
|
||||
<string name="stores_itch_download_failed">itch.io HTTP %1$d yanıtı verdi</string>
|
||||
<string name="stores_free">Ücretsiz</string>
|
||||
<string name="stores_resume">Sürdür</string>
|
||||
<string name="stores_refresh">Yenile</string>
|
||||
|
||||
@@ -2003,6 +2003,16 @@
|
||||
<string name="stores_buy_price">Купити · %1$s</string>
|
||||
<string name="stores_installed_chip">Встановлено</string>
|
||||
<string name="stores_in_library">У бібліотеці</string>
|
||||
<string name="stores_itch_key">Ключ API</string>
|
||||
<string name="stores_itch_get_key">Отримати ключ</string>
|
||||
<string name="stores_itch_key_rejected">Ключ відхилено</string>
|
||||
<string name="stores_itch_not_owned">Немає у вашій бібліотеці itch.io</string>
|
||||
<string name="stores_itch_no_upload">Для цієї гри немає завантаження для Windows</string>
|
||||
<string name="stores_itch_no_exe">У завантаженні не знайдено гри для Windows</string>
|
||||
<string name="stores_itch_extract_failed">Не вдалося розпакувати завантаження (код %1$d)</string>
|
||||
<string name="stores_itch_no_catalog">itch.io не відповів</string>
|
||||
<string name="stores_itch_archive">Архіви %1$s не підтримуються</string>
|
||||
<string name="stores_itch_download_failed">itch.io відповів HTTP %1$d</string>
|
||||
<string name="stores_free">Безкоштовно</string>
|
||||
<string name="stores_resume">Продовжити</string>
|
||||
<string name="stores_refresh">Оновити</string>
|
||||
|
||||
@@ -1940,6 +1940,16 @@
|
||||
<string name="stores_buy_price">Mua · %1$s</string>
|
||||
<string name="stores_installed_chip">Đã cài</string>
|
||||
<string name="stores_in_library">Trong thư viện</string>
|
||||
<string name="stores_itch_key">Khóa API</string>
|
||||
<string name="stores_itch_get_key">Lấy khóa</string>
|
||||
<string name="stores_itch_key_rejected">Khóa bị từ chối</string>
|
||||
<string name="stores_itch_not_owned">Không có trong thư viện itch.io của bạn</string>
|
||||
<string name="stores_itch_no_upload">Không có bản tải Windows cho game này</string>
|
||||
<string name="stores_itch_no_exe">Không tìm thấy trò chơi Windows trong bản tải xuống</string>
|
||||
<string name="stores_itch_extract_failed">Không thể giải nén bản tải xuống (mã %1$d)</string>
|
||||
<string name="stores_itch_no_catalog">itch.io không phản hồi</string>
|
||||
<string name="stores_itch_archive">Không hỗ trợ tệp %1$s</string>
|
||||
<string name="stores_itch_download_failed">itch.io trả về HTTP %1$d</string>
|
||||
<string name="stores_free">Miễn phí</string>
|
||||
<string name="stores_resume">Tiếp tục</string>
|
||||
<string name="stores_refresh">Làm mới</string>
|
||||
|
||||
@@ -2016,6 +2016,16 @@
|
||||
<string name="stores_buy_price">购买 · %1$s</string>
|
||||
<string name="stores_installed_chip">已安装</string>
|
||||
<string name="stores_in_library">已在库中</string>
|
||||
<string name="stores_itch_key">API 密钥</string>
|
||||
<string name="stores_itch_get_key">获取密钥</string>
|
||||
<string name="stores_itch_key_rejected">密钥被拒绝</string>
|
||||
<string name="stores_itch_not_owned">不在你的 itch.io 库中</string>
|
||||
<string name="stores_itch_no_upload">这个游戏没有 Windows 下载</string>
|
||||
<string name="stores_itch_no_exe">下载内容中未找到 Windows 游戏</string>
|
||||
<string name="stores_itch_extract_failed">无法解压下载内容(代码 %1$d)</string>
|
||||
<string name="stores_itch_no_catalog">itch.io 没有响应</string>
|
||||
<string name="stores_itch_archive">不支持 %1$s 压缩包</string>
|
||||
<string name="stores_itch_download_failed">itch.io 返回 HTTP %1$d</string>
|
||||
<string name="stores_free">免费</string>
|
||||
<string name="stores_resume">继续</string>
|
||||
<string name="stores_refresh">刷新</string>
|
||||
|
||||
@@ -1940,6 +1940,16 @@
|
||||
<string name="stores_buy_price">購買 · %1$s</string>
|
||||
<string name="stores_installed_chip">已安裝</string>
|
||||
<string name="stores_in_library">已在收藏庫中</string>
|
||||
<string name="stores_itch_key">API 金鑰</string>
|
||||
<string name="stores_itch_get_key">取得金鑰</string>
|
||||
<string name="stores_itch_key_rejected">金鑰被拒絕</string>
|
||||
<string name="stores_itch_not_owned">不在你的 itch.io 收藏庫中</string>
|
||||
<string name="stores_itch_no_upload">這個遊戲沒有 Windows 下載</string>
|
||||
<string name="stores_itch_no_exe">下載內容中找不到 Windows 遊戲</string>
|
||||
<string name="stores_itch_extract_failed">無法解壓下載內容(代碼 %1$d)</string>
|
||||
<string name="stores_itch_no_catalog">itch.io 沒有回應</string>
|
||||
<string name="stores_itch_archive">不支援 %1$s 壓縮檔</string>
|
||||
<string name="stores_itch_download_failed">itch.io 回應 HTTP %1$d</string>
|
||||
<string name="stores_free">免費</string>
|
||||
<string name="stores_resume">繼續</string>
|
||||
<string name="stores_refresh">重新整理</string>
|
||||
|
||||
@@ -1940,6 +1940,16 @@
|
||||
<string name="stores_buy_price">購買 · %1$s</string>
|
||||
<string name="stores_installed_chip">已安裝</string>
|
||||
<string name="stores_in_library">已在收藏庫中</string>
|
||||
<string name="stores_itch_key">API 金鑰</string>
|
||||
<string name="stores_itch_get_key">取得金鑰</string>
|
||||
<string name="stores_itch_key_rejected">金鑰被拒絕</string>
|
||||
<string name="stores_itch_not_owned">不在你的 itch.io 收藏庫中</string>
|
||||
<string name="stores_itch_no_upload">這個遊戲沒有 Windows 下載</string>
|
||||
<string name="stores_itch_no_exe">下載內容中找不到 Windows 遊戲</string>
|
||||
<string name="stores_itch_extract_failed">無法解壓縮下載內容(代碼 %1$d)</string>
|
||||
<string name="stores_itch_no_catalog">itch.io 沒有回應</string>
|
||||
<string name="stores_itch_archive">不支援 %1$s 壓縮檔</string>
|
||||
<string name="stores_itch_download_failed">itch.io 回應 HTTP %1$d</string>
|
||||
<string name="stores_free">免費</string>
|
||||
<string name="stores_resume">繼續</string>
|
||||
<string name="stores_refresh">重新整理</string>
|
||||
|
||||
@@ -2004,6 +2004,16 @@
|
||||
<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_itch_key">API key</string>
|
||||
<string name="stores_itch_get_key">Get a key</string>
|
||||
<string name="stores_itch_key_rejected">Key rejected</string>
|
||||
<string name="stores_itch_not_owned">Not in your itch.io library</string>
|
||||
<string name="stores_itch_no_upload">No Windows download for this game</string>
|
||||
<string name="stores_itch_no_exe">No Windows game found in the download</string>
|
||||
<string name="stores_itch_extract_failed">Could not extract the download (exit %1$d)</string>
|
||||
<string name="stores_itch_no_catalog">itch.io did not answer</string>
|
||||
<string name="stores_itch_archive">%1$s archives are not supported</string>
|
||||
<string name="stores_itch_download_failed">itch.io answered HTTP %1$d</string>
|
||||
<string name="stores_free">Free</string>
|
||||
<string name="stores_resume">Resume</string>
|
||||
<string name="stores_refresh">Refresh</string>
|
||||
|
||||
@@ -1100,7 +1100,11 @@ public:
|
||||
void UpdateXr(const DroidDeckXrHand &hand, int64_t predicted) {
|
||||
if (id_ == k_unTrackedDeviceIndexInvalid) return;
|
||||
DriverPose_t pose{};
|
||||
pose.qWorldFromDriverRotation.w = pose.qDriverFromHeadRotation.w = 1;
|
||||
pose.qWorldFromDriverRotation.w = 1;
|
||||
pose.qDriverFromHeadRotation = {.94147, .3371, 0, 0};
|
||||
pose.vecDriverFromHeadTranslation[0] = right_ ? .007 : -.007;
|
||||
pose.vecDriverFromHeadTranslation[1] = .036124;
|
||||
pose.vecDriverFromHeadTranslation[2] = -.037617;
|
||||
pose.qRotation.w = 1;
|
||||
pose.deviceIsConnected = hand.active;
|
||||
pose.poseIsValid = hand.active && XrTrackingFile::Tracked(hand.pose);
|
||||
|
||||
Reference in new issue
Block a user