Compare commits

...
55 changed files with 1539 additions and 28 deletions

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
+10
View File
@@ -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>
+10
View File
@@ -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>
+10
View File
@@ -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>
+10
View File
@@ -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>
+10
View File
@@ -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>
+10
View File
@@ -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>
+10
View File
@@ -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>
+10
View File
@@ -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>
+10
View File
@@ -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>
+10
View File
@@ -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>
+10
View File
@@ -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>
+10
View File
@@ -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>
+10
View File
@@ -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>
+10
View File
@@ -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>
+10
View File
@@ -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>
+10
View File
@@ -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>
+10
View File
@@ -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>
+10
View File
@@ -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>
+10
View File
@@ -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>
+10
View File
@@ -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>
+10
View File
@@ -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>
+10
View File
@@ -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>
+10
View File
@@ -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>
+10
View File
@@ -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>
+10
View File
@@ -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>
+10
View File
@@ -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>
+10
View File
@@ -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>
+10
View File
@@ -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>
+5 -1
View File
@@ -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);