Compare commits

...
Author SHA1 Message Date
Kurt Himebauch efd9f36985 fix: add tips from common support questions 2026-11-06 21:22:50 -05:00
Kurt Himebauch 1dabc20e92 fix: refresh the session loading tips and drop stale translations 2026-11-06 20:54:19 -05:00
MaxsTechReview 18c7d56409 fix: Auto components update only DroidDeck-Proton and delete old builds (#2193) 2026-11-06 20:43:44 -05:00
MaxsTechReview 0b4bc827e7 fix(battlenet): stop Battle.net's pages playing videos on the CPU (#2195) 2026-11-06 20:39:37 -05:00
KeegantheVeganat0randKurt Himebauch a552f1e293 fix(hud): isolate strip rendering in a software layer (#2194)
* fix(hud): skip relayout when size is unchanged and cap graph redraws

* fix(hud): limit the redraw and relayout changes to the strip

* fix(hud): draw the strip background from a cached bitmap like the panels

* fix(hud): drop the strip-only redraw cap and relayout skip

* fix(hud): the strip graph redraws at most 15 times a second

* fix(hud): isolate strip rendering in a software layer

---------

Co-authored-by: Kurt Himebauch <[email protected]>
2026-11-06 20:23:10 -05:00
MaxsTechReview 066834d17d feat: compress idle game memory before low-RAM devices run out (#2187) 2026-11-06 19:49:06 -05:00
Kurt Himebauch f33d5b9e09 fix(components): auto updates follow DroidDeck-Proton only (#2190) 2026-11-06 18:41:37 -05:00
MaxsTechReview 250f80fbb5 fix: stop switching between display modes with the same refresh rate (S26 lag and heat since 0.6.7) (#2192)
fix: stop switching between display modes with the same refresh rate
2026-11-06 17:18:24 -05:00
MaxsTechReview 542bbf89c3 fix: launch Battle.net and store games from any game page tab (#2185)
* fix(library): Keep one Play request per game and show Starting while it prepares

* Track launch preparation per game

* Keep each game preparing until its own worker finishes

* Drop store lookups that finish after the activity ends
2026-11-06 15:31:09 -05:00
Kurt Himebauch e1987ca785 chore: pin gamescope to the DroidDeck-Components p22 release (#2183) 2026-11-06 14:35:10 -05:00
MaxsTechReview a81da1e476 feat(components): Auto updates for FEX, DXVK and VKD3D-Proton (#2156)
* feat(components): Auto updates for FEX, DXVK and VKD3D-Proton

Each component gets an Auto or Manual mode on the Default Components page.
Auto installs the newest DroidDeck-Components nightly into every Proton that
ships the component, matching each Proton's ARM64EC or x64 flavour, keeps the
current and replaced builds, and removes older builds it downloaded.

A game that ran on the previous build and exits within 30 seconds twice on the
new one rolls the line back; the failed build is skipped until a newer one is
published. Existing manual choices keep that component on Manual, and DXVK
stays on Manual while a GPU rule pins its version.

* fix(components): Auto notes per line, Original label and first-update rollback

Boot history on the Original is recorded before the first update so that
update can roll back, rollback notes stay with their own component, the
Original shows by name, and new Protons get the current build even when no
newer one is published.

* fix(components): Auto owns only its own downloads and follows in-place Proton updates

Auto deletes only builds it downloaded, re-swaps a Proton whose version
changed under an existing choice, reports a missing nightly listing as a
failed check, writes its state atomically so the page can read it without
waiting on a download, and words the rollback and download messages to
match what it does.

* fix(components): Auto keeps a rollback and reads updated Protons' own files

After a rollback only a build newer than every rejected one is taken, so an
older build still listed in the nightly cannot replace the restored one.
Flavour detection treats a choice saved for an older Proton version as not
applied, so a Proton Steam updated in place is read from its own files and
re-swapped. A failed state rename falls back to a direct write and is logged.

* fix(components): Keep the last good Auto state when a save fails

* fix(components): Serialise Auto state reads and writes

* Install the Linux runtime by itself on first launch

The runtime auto-update skipped devices with no runtime, so FEX, Proton,
DXVK and VKD3D waited for the first Steam launch to install it. Claim a
first install when the device never had a runtime, on a usable network,
outside battery saver and with a supported GPU, and check Proton as
soon as the runtime is ready. A runtime the user removed stays removed.

* fix(components): Check components as soon as Proton is installed

The Auto check stamped its hourly timer even when no Proton was
installed yet, so a fresh device waited an hour after Proton arrived.
Skip the stamp until a Proton exists and run the check right after a
Proton install finishes.

* fix(components): Apply stored builds on every check and retry unfinished rollbacks

A held or failed download no longer skips applying the stored build or
stamps the hourly timer. Stored builds are applied on every check, so a
new Proton is reconciled inside the hour, and a check requested while
one runs is queued. A rollback that could not restore or swap a Proton
is retried and reported only once it finishes. Removing the runtime
marks it as seen so it is not installed again, and the failed-save test
skips where the process can still write read-only files.

* fix(components): Keep rollback restores pending until applied and save state on failure

* fix(components): Hold newer builds until a pending rollback is applied

* fix(components): Drop pending restores for removed Protons

* fix(components): Never auto-install a non-ARM64X build over a Proton's own ARM64X DXVK or VKD3D

* fix(components): Read probe archives outside the lock and block auto updates while the original build is unknown

* fix(components): Require ARM64X when any Proton on the line needs it, and before each swap
2026-11-06 13:56:34 -05:00
MaxsTechReview 116728917f fix(input): Keep the Android pointer hidden over the game after hovering session menus (#2176)
* fix(input): Keep the Android pointer hidden over the game after hovering session menus

* fix(input): Defer the overlay pointer icon from a container, since ComposeView is final
2026-11-06 13:56:30 -05:00
MaxsTechReview 5f180d909b fix(input): Click a touch stick only on a fast double tap (#2173)
* fix(input): Click a touch stick only on a fast double tap

* fix(input): Count the release position in stick tap travel

* fix(input): Treat cancelled pointer-ups as cancelled stick touches
2026-11-06 13:56:25 -05:00
Kurt Himebauch 88acdbc3ab ci: publish gamescope builds to DroidDeck-Components and never replace a tag (#2181) 2026-11-06 13:36:56 -05:00
Kurt Himebauch 42b2810822 ci: retry PR test build cleanup and sweep leftovers every six hours (#2180) 2026-11-06 13:36:52 -05:00
Kurt Himebauch 1001bc00af fix: PiP scales the full-size output instead of shrinking it (#2174)
* fix: PiP scales the full-size output instead of shrinking it

* fix: hide the performance HUD as PiP starts so it does not flash at full size
2026-11-06 13:24:37 -05:00
MaxsTechReview 69c6c14f3f fix(wow): Clear the game field selection after reading and sending chat (#2175)
* fix(wow): Clear the game field selection after reading and sending chat

* fix(wow): Skip the field cleanup when the chat request has closed
2026-11-06 13:17:13 -05:00
MaxsTechReview 6060f2288f feat(wow): voice type and keyboard chat bar for World of Warcraft (#2171)
* Add voice and keyboard chat input for World of Warcraft

Open a chat bar with an editable text field when a WoW client focuses
a text box (reported by Proton's winex11 IME context hook through
agent/wine-ime.json). First use asks for Voice type or Android keyboard
with a remember toggle; the choice is also in the side menu's Keyboard
group while a WoW client runs. Voice type uses the device's speech
recognition service. On dual-screen devices the bar and keyboard open
on the second screen. Send types the text and presses Enter; Cancel
presses Escape.

* Cover the Arm64 WoW client in the chat match test

* Give the second-screen chat bar the session's result and back owners

* Open the WoW chat bar with the game field's current text

When the bar opens, select and copy the game's text field and fill the
bar with it through the session clipboard bridge, then put the guest
clipboard back. Send selects the field first, so the bar's text
replaces what was there instead of being appended.

* Keep WoW chat consistent across screen changes and failed field reads

Close the bar when the game field closes even after the program name
is cleared, move the bar to the top screen when the second screen drops
it, and drop any pending second-screen keyboard request when the bar
opens. A field read that gets no copy of its own leaves the game text
unselected and Send then types without replacing it. Ctrl is only
pressed and released when nothing else holds it, and a pending read
ends when the clipboard bridge stops.

* fix(session): Close WoW chat for other programs and keep the game cursor on cancelled reads
2026-11-06 12:17:37 -05:00
William Allenandmaxjivi05 a1bc8467fd feat: show Steam launch options on the Launch tab (#2163)
* Show Steam launch options on the Launch tab

* fix: Launch options is one row on the Launch tab, like the shortcut fields

* fix(launch): reread Steam's launch options when the game page resumes

* fix(launch): keep launch options disabled while they reload on resume

* fix(launch): a saved launch option is not replaced by a read that started before it

* fix(launch): an unreadable Steam config keeps the shown launch options instead of clearing them

* fix(launch): an unparsable Steam config and a pending reload keep the shown launch options

* fix(launch): the resume reload follows the game the panel shows

---------

Co-authored-by: maxjivi05 <[email protected]>
2026-11-06 10:43:44 -05:00
Kurt Himebauch d6566f0dbe feat: GOG achievements on by default, drop stores engine row (#2168) 2026-11-06 10:22:42 -05:00
89 changed files with 3560 additions and 716 deletions

No files matched your search

+17 -3
View File
@@ -39,11 +39,25 @@ jobs:
path: |
gamescope.tzst
gamescope.tzst.sha256
- uses: actions/create-github-app-token@v2
id: components-token
if: github.event_name == 'workflow_dispatch' && inputs.tag != ''
with:
app-id: ${{ vars.DROIDDECK_CI_APP_ID }}
private-key: ${{ secrets.DROIDDECK_CI_APP_KEY }}
owner: Droid-Deck
repositories: DroidDeck-Components
- name: Publish
if: github.event_name == 'workflow_dispatch' && inputs.tag != ''
env:
GH_TOKEN: ${{ github.token }}
GH_TOKEN: ${{ steps.components-token.outputs.token }}
GH_REPO: Droid-Deck/DroidDeck-Components
TAG: ${{ inputs.tag }}
run: |
set -euo pipefail
gh release create "${{ inputs.tag }}" --latest=false --prerelease=false --title "${{ inputs.tag }}" --notes "gamescope 3.16.29 with tools/gamescope/patches; staged by the apk over /usr/local/bin/gamescope. sha256 $(cut -d' ' -f1 gamescope.tzst.sha256)" gamescope.tzst gamescope.tzst.sha256 || gh release upload "${{ inputs.tag }}" gamescope.tzst gamescope.tzst.sha256 --clobber
gh release edit "${{ inputs.tag }}" --latest=false --prerelease=false
if gh release view "$TAG" >/dev/null 2>&1; then
echo "::error::$TAG is already published in DroidDeck-Components, and published tags are never replaced: use a new tag"
exit 1
fi
gh release create "$TAG" --latest=false --prerelease=false --title "$TAG" --notes "gamescope 3.16.29 with tools/gamescope/patches ($GITHUB_REPOSITORY $GITHUB_SHA, run $GITHUB_RUN_ID); staged by the apk over /usr/local/bin/gamescope. sha256 $(cut -d' ' -f1 gamescope.tzst.sha256)" gamescope.tzst gamescope.tzst.sha256
gh release edit "$TAG" --latest=false --prerelease=false
+10 -2
View File
@@ -47,10 +47,18 @@ jobs:
PR_NUMBER: ${{ github.event.pull_request.number }}
run: |
set -euo pipefail
retry() {
for attempt in 1 2 3 4; do
"$@" && return 0
[ "$attempt" = 4 ] || sleep $((attempt * 20))
done
return 1
}
existing=$(retry gh api --paginate "repos/Droid-Deck/DroidDeck-CI/releases?per_page=100" --jq '.[].tag_name')
found=0
for tag in "pr-$PR_NUMBER" "pr-$PR_NUMBER.0"; do
if gh release view "$tag" -R Droid-Deck/DroidDeck-CI >/dev/null 2>&1; then
gh release delete "$tag" -R Droid-Deck/DroidDeck-CI --yes --cleanup-tag
if grep -qxF "$tag" <<<"$existing"; then
retry gh release delete "$tag" -R Droid-Deck/DroidDeck-CI --yes --cleanup-tag
echo "Deleted $tag"
found=1
fi
+57
View File
@@ -0,0 +1,57 @@
name: PR Release Sweep
run-name: sweep test builds of closed PRs
on:
schedule:
- cron: '17 */6 * * *'
workflow_dispatch:
permissions:
contents: read
pull-requests: read
concurrency:
group: pr-release-sweep
cancel-in-progress: false
jobs:
sweep:
runs-on: ubuntu-24.04
timeout-minutes: 15
steps:
- uses: actions/create-github-app-token@v2
id: ci-token
with:
app-id: ${{ vars.DROIDDECK_CI_APP_ID }}
private-key: ${{ secrets.DROIDDECK_CI_APP_KEY }}
owner: Droid-Deck
repositories: DroidDeck-CI
- name: Delete test builds whose PR is no longer open
env:
CI_TOKEN: ${{ steps.ci-token.outputs.token }}
GH_TOKEN: ${{ github.token }}
run: |
set -euo pipefail
retry() {
for attempt in 1 2 3 4; do
"$@" && return 0
[ "$attempt" = 4 ] || sleep $((attempt * 20))
done
return 1
}
tags=$(GH_TOKEN="$CI_TOKEN" retry gh api --paginate "repos/Droid-Deck/DroidDeck-CI/releases?per_page=100" --jq '.[].tag_name' |
grep -E '^pr-[0-9]+(\.0)?$' || true)
failed=0
for tag in $tags; do
number=${tag#pr-}
number=${number%.0}
state=$(retry gh api "repos/$GITHUB_REPOSITORY/pulls/$number" --jq .state) || { failed=1; continue; }
if [ "$state" != open ]; then
if GH_TOKEN="$CI_TOKEN" retry gh release delete "$tag" -R Droid-Deck/DroidDeck-CI --yes --cleanup-tag; then
echo "Deleted $tag (PR #$number is $state)"
else
failed=1
fi
fi
done
exit "$failed"
@@ -5,8 +5,11 @@ import android.util.Log
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.setValue
import com.droiddeck.launcher.session.ComponentUpdater
import com.droiddeck.launcher.session.ComponentUpdates
import com.droiddeck.launcher.session.ComponentsManager
import com.droiddeck.launcher.session.ProtonDefault
import com.droiddeck.launcher.session.SessionPrefs
internal class ComponentsMenu(private val activity: android.app.Activity, private val ui: Handler) {
var compSnapshot by mutableStateOf<ComponentsManager.Snapshot?>(null)
@@ -20,6 +23,10 @@ internal class ComponentsMenu(private val activity: android.app.Activity, privat
get() = downloads
private set(value) { downloads = value }
val compDownloadRevision: Long get() = downloadRevision
var compModes by mutableStateOf<Map<String, String>>(emptyMap())
var compAuto by mutableStateOf<Map<String, List<ComponentUpdater.Status>>>(emptyMap())
var compAutoHeld by mutableStateOf<Set<String>>(emptySet())
var compAutoChecking by mutableStateOf(false)
private var shownDefault: String? = null
fun refreshComponents(snapshotFirst: Boolean = false) {
@@ -29,7 +36,14 @@ internal class ComponentsMenu(private val activity: android.app.Activity, privat
val snap = runCatching { ComponentsManager.snapshot(activity) }.onFailure { Log.w(TAG, "components", it) }.getOrNull()
val cat = runCatching { ComponentsManager.catalog(activity, false) }.getOrNull()
val chosen = snap?.let { s -> runCatching { ProtonDefault.selectedId(activity, s.protons.map { it.proton }) }.getOrNull() }
runCatching { ComponentUpdater.settle(activity) }
val modes = ComponentsManager.COMPONENTS.associateWith { SessionPrefs.componentMode(activity, it) }
val auto = ComponentsManager.COMPONENTS.associateWith { runCatching { ComponentUpdater.status(activity, it) }.getOrDefault(emptyList()) }
val held = if (runCatching { ComponentsManager.hardwareDxvk(activity) }.getOrDefault(false)) setOf("dxvk") else emptySet()
ui.post {
compModes = modes
compAuto = auto
compAutoHeld = held
compSnapshot = snap ?: ComponentsManager.Snapshot(emptyList(), emptyList())
if (cat != null && cat.fetchedAt > 0) { compCatalog = cat.items; compCatalogAt = cat.fetchedAt }
if (chosen != null && chosen != shownDefault) {
@@ -42,6 +56,35 @@ internal class ComponentsMenu(private val activity: android.app.Activity, privat
}, "components").start()
}
fun chooseComponentMode(comp: String, mode: String) {
SessionPrefs.setComponentMode(activity, comp, mode)
compModes = compModes + (comp to mode)
if (mode != ComponentUpdates.AUTO) return
checkComponentUpdates(force = true)
}
private var checkAgain: Boolean? = null
fun checkComponentUpdates(force: Boolean) {
if (compAutoChecking) {
checkAgain = force || checkAgain == true
return
}
compAutoChecking = true
Thread({
val notes = runCatching { ComponentUpdater.ensure(activity, force) }.onFailure { Log.w(TAG, "component updates", it) }.getOrDefault(emptyList())
ui.post {
compAutoChecking = false
checkAgain?.let { again ->
checkAgain = null
checkComponentUpdates(again)
}
if (notes.isNotEmpty()) android.widget.Toast.makeText(activity, notes.joinToString("\n"), android.widget.Toast.LENGTH_LONG).show()
refreshComponents()
}
}, "component-updates").start()
}
fun chooseProton(id: String) {
val proton = compSnapshot?.protons?.firstOrNull { it.proton.id == id }?.proton ?: return
compProton = id
@@ -40,6 +40,8 @@ import com.droiddeck.launcher.ui.PackageRow
import com.droiddeck.launcher.ui.exposeTestTags
import com.droiddeck.launcher.session.OfflineMode
import com.droiddeck.launcher.session.ProtonExtras
import com.droiddeck.launcher.session.ComponentUpdater
import com.droiddeck.launcher.session.ComponentUpdates
import com.droiddeck.launcher.session.ComponentsManager
import com.droiddeck.launcher.ui.ComponentsPage
import com.droiddeck.launcher.session.GameSaves
@@ -94,7 +96,11 @@ class FrontEndActivity : ComponentActivity() {
private val components = ComponentsMenu(this, ui)
private val decky = DeckyMenu(this, ui)
private val updates = UpdatesMenu(this, ui)
private val protons = ProtonMenu(this, ui) { !busy && flood == null && compatLaunch == null && pkgStage == null && components.compBusy == null }
private val protons = ProtonMenu(
this, ui,
idle = { !busy && flood == null && compatLaunch == null && pkgStage == null && components.compBusy == null },
onInstalled = { if (!OfflineMode.enabled(this)) components.checkComponentUpdates(force = false) },
)
private var installed by mutableStateOf<String?>(null)
private var flood by mutableStateOf<PendingFlood?>(null)
@@ -201,6 +207,7 @@ class FrontEndActivity : ComponentActivity() {
private var gameStoresSpeedTier by mutableStateOf("fast")
private var navRequest by mutableStateOf<String?>(null)
private val gameLaunchRequest = java.util.concurrent.atomic.AtomicLong()
private var preparingGames by mutableStateOf(emptySet<String>())
private var storesShowMature by mutableStateOf(false)
private var storesSort by mutableStateOf(SessionPrefs.STORES_SORTS.first())
private var storesView by mutableStateOf(SessionPrefs.DEFAULT_STORES_VIEW)
@@ -1137,6 +1144,7 @@ class FrontEndActivity : ComponentActivity() {
installed = installed, ready = ready, available = available?.version, removalPending = removalPending, runtimeActionsBlocked = runtimeChangesBlocked(),
vkBridge = vkBridge, venus = venus, venusHostVulkan = venusHostVulkan, vkBridgeSafe = vkBridgeSafe, bridgeProtonDxvk = bridgeProtonDxvk, adreno = adrenoGpu,
shortcutPicker = shortcutPicker,
preparingGames = preparingGames,
shortcutLibraryScanning = shortcutLibraryScanning,
gameSyncFolder = gameSyncFolder,
busy = busy, stage = stage, percent = percent,
@@ -1215,14 +1223,26 @@ class FrontEndActivity : ComponentActivity() {
},
onSteamGame = { g ->
if (shortcutPicker) chooseGameShortcut(g)
else {
val request = gameLaunchRequest.incrementAndGet()
com.droiddeck.launcher.frontend.AutoComponents.beforeLaunch(this, g) {
if (request != gameLaunchRequest.get() || isFinishing || isDestroyed) return@beforeLaunch
com.droiddeck.launcher.stores.StoreLaunch.prepare(this, g) {
if (request == gameLaunchRequest.get() && !isFinishing && !isDestroyed) launchGame(g)
else if (g.launchKey !in preparingGames) {
preparingGames = preparingGames + g.launchKey
prepareAndLaunch(g, g.launchKey)
}
},
onStoreGame = { store, id ->
val key = "${store.id}:$id"
if (key !in preparingGames) {
preparingGames = preparingGames + key
val app = applicationContext
Thread({
val g = runCatching { com.droiddeck.launcher.stores.StoreLaunch.installedGame(app, store, id) }.getOrNull()
runOnUiThread {
if (g != null && !isFinishing && !isDestroyed) prepareAndLaunch(g, key)
else {
preparingGames = preparingGames - key
if (g == null && !isFinishing && !isDestroyed) android.widget.Toast.makeText(this, R.string.stores_launch_not_registered, android.widget.Toast.LENGTH_LONG).show()
}
}
}
}, "store-play").start()
}
},
onEpicLauncher = { openEpicLauncher() },
@@ -1880,6 +1900,13 @@ class FrontEndActivity : ComponentActivity() {
if (applied.isNotEmpty()) ui.post {
android.widget.Toast.makeText(this, getString(R.string.main_applied, applied.joinToString(", ")), android.widget.Toast.LENGTH_LONG).show()
}
if (!OfflineMode.enabled(this)) {
val updated = runCatching { ComponentUpdater.ensure(this, force = false) }
.onFailure { android.util.Log.w("ComponentUpdater", "automatic component updates", it) }.getOrDefault(emptyList())
if (updated.isNotEmpty()) ui.post {
android.widget.Toast.makeText(this, updated.joinToString("\n"), android.widget.Toast.LENGTH_LONG).show()
}
}
ui.post {
componentsQueueBusy = false
if (showComponents) components.refreshComponents()
@@ -1942,6 +1969,18 @@ class FrontEndActivity : ComponentActivity() {
if (Build.VERSION.SDK_INT >= 33) state.getParcelable(key, Intent::class.java)
else @Suppress("DEPRECATION") state.getParcelable(key)
private fun prepareAndLaunch(g: Library.SteamGame, key: String) {
val request = gameLaunchRequest.incrementAndGet()
val done = { preparingGames = preparingGames - key }
com.droiddeck.launcher.frontend.AutoComponents.beforeLaunch(this, g, onBlocked = done) {
if (request != gameLaunchRequest.get() || isFinishing || isDestroyed) { done(); return@beforeLaunch }
com.droiddeck.launcher.stores.StoreLaunch.prepare(this, g) {
done()
if (request == gameLaunchRequest.get() && !isFinishing && !isDestroyed) launchGame(g)
}
}
}
override fun onDestroy() {
gameLaunchRequest.incrementAndGet()
cancelCompatLaunch()
@@ -2127,9 +2166,11 @@ class FrontEndActivity : ComponentActivity() {
private fun importComponent(uri: Uri) {
val name = displayNameOf(uri) ?: "imported.wcp"
components.componentAction(getString(R.string.comp_busy_importing)) {
val tmp = File(cacheDir, "component-import.wcp")
contentResolver.openInputStream(uri)?.use { input -> tmp.outputStream().use { input.copyTo(it) } } ?: error(getString(R.string.comp_cannot_read))
try { getString(R.string.comp_imported, ComponentsManager.importPackage(this, tmp, name).version) } finally { tmp.delete() }
val tmp = File.createTempFile("component-import", ".wcp", cacheDir)
try {
contentResolver.openInputStream(uri)?.use { input -> tmp.outputStream().use { input.copyTo(it) } } ?: error(getString(R.string.comp_cannot_read))
getString(R.string.comp_imported, ComponentUpdater.importPackage(this, tmp, name).version)
} finally { tmp.delete() }
}
}
@@ -2153,7 +2194,15 @@ class FrontEndActivity : ComponentActivity() {
onDeletePackage = { file -> components.componentAction(getString(R.string.comp_busy_deleting)) { ComponentsManager.deletePackage(this, file) } },
onDeleteOriginal = { version -> components.compProton?.let { pid -> components.componentAction(getString(R.string.comp_busy_deleting)) { ComponentsManager.deleteOriginal(this, pid, components.compComp, version) } } },
onDownload = { components.downloadComponent(it) },
onRefresh = { components.refreshComponentCatalog() },
onRefresh = {
components.refreshComponentCatalog()
if (components.compModes.values.any { it == ComponentUpdates.AUTO }) components.checkComponentUpdates(force = true)
},
compModes = components.compModes,
compAuto = components.compAuto,
compAutoHeld = components.compAutoHeld,
compAutoChecking = components.compAutoChecking,
onCompMode = { comp, mode -> components.chooseComponentMode(comp, mode) },
onImport = { pickComponent.launch(InAppFilePicker.buildIntent(this, WCP_EXT, getString(R.string.comp_pick_wcp))) },
onBack = { if (!com.droiddeck.launcher.ui.Hops.back { showComponents = false }) showComponents = false },
games = steamGames,
@@ -2982,6 +3031,7 @@ class FrontEndActivity : ComponentActivity() {
private fun removeRuntime() {
if (runtimeChangesBlocked()) return
val removal = LinuxRuntimeInstaller.beginUninstall(this) ?: return
com.droiddeck.launcher.runtime.RuntimeAutoUpdate.seed(this)
ready = false
com.droiddeck.launcher.runtime.RuntimeInstallService.keepRemovalAlive(this)
followRuntimeOperation { removal.run(it) }
@@ -3008,6 +3058,7 @@ class FrontEndActivity : ComponentActivity() {
android.widget.Toast.makeText(this, it, android.widget.Toast.LENGTH_LONG).show()
}
refresh()
if (ok == true && LinuxRuntime.isInstalled(this)) protons.checkUpdates()
}
}, "runtime-operation").start()
}
@@ -3015,9 +3066,12 @@ class FrontEndActivity : ComponentActivity() {
private fun autoUpdateRuntime() {
val release = available?.takeIf { catalogFresh } ?: return
if (!lifecycle.currentState.isAtLeast(androidx.lifecycle.Lifecycle.State.RESUMED) || runtimeChangesBlocked() || removalPending) return
if (release.version == installed || installed == null) return
if (installed != null) com.droiddeck.launcher.runtime.RuntimeAutoUpdate.seed(this)
if (release.version == installed) return
if (installed == null && (OfflineMode.enabled(this) || !com.droiddeck.launcher.core.DeviceSupport.canDraw(this) ||
!com.droiddeck.launcher.frontend.AutoComponents.online(this) || com.droiddeck.launcher.frontend.AutoComponents.unfavourable(this))) return
if (com.droiddeck.launcher.runtime.RuntimeAutoUpdate.claim(this, installed, release.version)) {
Log.i(TAG, "runtime: updating $installed to ${release.version} by itself")
Log.i(TAG, "runtime: installing ${release.version} over ${installed ?: "nothing"} by itself")
install(release)
}
}
@@ -18,6 +18,7 @@ import com.droiddeck.launcher.runtime.LinuxRuntimeInstaller
internal class ProtonMenu(
private val activity: android.app.Activity,
private val ui: Handler,
private val onInstalled: () -> Unit = {},
private val idle: () -> Boolean = { true },
) {
private object Operation {
@@ -61,6 +62,7 @@ internal class ProtonMenu(
if (automatic) Log.w("ProtonMenu", "auto-update ${tool.id}: $problem")
else android.widget.Toast.makeText(activity, problem, android.widget.Toast.LENGTH_LONG).show()
}
if (problem == null) onInstalled()
if (automatic) ensureAutoUpdates()
}
}, "install-proton-$id").start()
@@ -4,6 +4,7 @@ import androidx.compose.foundation.background
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.imePadding
import android.hardware.input.InputManager
import android.hardware.display.DisplayManager
import android.content.Intent
@@ -233,6 +234,10 @@ class SessionActivity : ComponentActivity(), SurfaceHolder.Callback {
private var screenKeyBind: List<Int>? = null
private var screenKeyDir: Int? = null
private var screenKeyTextFocus = false
private val appTextFocus get() = screenKeyTextFocus || wowChat.request != null
private val wowChat by lazy { com.droiddeck.launcher.ui.WowChat(this, ::sendWowChat, ::cancelWowChat, ::readGameField) }
private var wowChatBottom: SecondScreenPresentation? = null
private var wowChatOnTop by mutableStateOf(false)
private var screenKeyEdit by mutableStateOf<com.droiddeck.launcher.input.ScreenKey?>(null)
private var screenLayoutsOpen by mutableStateOf(false)
private var screenKeysShown by mutableStateOf(true)
@@ -826,7 +831,10 @@ class SessionActivity : ComponentActivity(), SurfaceHolder.Callback {
window.setBackgroundDrawable(android.graphics.drawable.ColorDrawable(it))
}
sessionOverlay = createSessionOverlay()
root.addView(sessionOverlay)
root.addView(object : FrameLayout(this) {
override fun onResolvePointerIcon(event: MotionEvent, pointerIndex: Int): android.view.PointerIcon? =
if (platformPointerShown == true) super.onResolvePointerIcon(event, pointerIndex) else null
}.apply { addView(sessionOverlay) })
setContentView(root)
glassesScope.launch { Glasses.state.collect {
glasses = it
@@ -910,6 +918,8 @@ class SessionActivity : ComponentActivity(), SurfaceHolder.Callback {
}
androidx.compose.runtime.LaunchedEffect(AgentGuest.steamKeyboard) { updateOnScreenControls() }
androidx.compose.runtime.LaunchedEffect(AgentGuest.desktopTextField) { onDesktopTextField(AgentGuest.desktopTextField) }
androidx.compose.runtime.LaunchedEffect(AgentGuest.gameTextField, AgentGuest.gameProgram) { onGameTextField(AgentGuest.gameTextField) }
androidx.compose.runtime.LaunchedEffect(wowChat.request) { placeWowChat() }
androidx.compose.runtime.LaunchedEffect(pipUi, pcKeyboardOpen) { updateDesktopKeyboardButton() }
val keyboardRequestsSeen = androidx.compose.runtime.remember { intArrayOf(AgentGuest.keyboardRequests) }
androidx.compose.runtime.LaunchedEffect(AgentGuest.keyboardRequests) {
@@ -1033,6 +1043,10 @@ class SessionActivity : ComponentActivity(), SurfaceHolder.Callback {
}
}
}
if (wowChat.asking) com.droiddeck.launcher.ui.WowChatChoiceDialog(wowChat)
if (wowChatOnTop && wowChat.request != null) androidx.compose.foundation.layout.Box(
Modifier.fillMaxSize().imePadding(), contentAlignment = androidx.compose.ui.Alignment.BottomCenter,
) { com.droiddeck.launcher.ui.WowChatPanel(wowChat) }
if (gestureSettingsOpen) com.droiddeck.launcher.ui.TouchGestureDialog(
game = gestureGame,
onDismiss = { gestureSettingsOpen = false },
@@ -1310,6 +1324,9 @@ class SessionActivity : ComponentActivity(), SurfaceHolder.Callback {
},
onKeyboard = { drawerOpen = false; pcKeyboardOpen = false; keyboard?.toggle() },
onHardwareKeyboard = ::togglePcKeyboard,
wowChatShown = com.droiddeck.launcher.ui.WowChat.isWow(AgentGuest.gameProgram),
wowChat = wowChat.remembered,
onWowChat = wowChat::choose,
controller = controllerSettings,
onRumble = { on -> updateControllerPrefs { ControllerPrefs.setRumble(this@SessionActivity, on) } },
onSteamButton = { on -> updateControllerPrefs { ControllerPrefs.setSteamButton(this@SessionActivity, on) } },
@@ -2270,9 +2287,7 @@ class SessionActivity : ComponentActivity(), SurfaceHolder.Callback {
}
private fun windowOutputSize(width: Int, height: Int): Pair<Int, Int>? {
if (width <= 0 || height <= 0) return null
if (isInPictureInPictureMode) return com.droiddeck.launcher.session.PipPolicy.outputSize(
width, height, SessionState.outputSize.first, SessionState.outputSize.second)
if (width <= 0 || height <= 0 || isInPictureInPictureMode) return null
if (bottomGame != null) return when {
SessionState.followScreen ->
com.droiddeck.launcher.session.SessionDisplay.followSize(width, height, false, SessionState.followScale)
@@ -2642,7 +2657,7 @@ class SessionActivity : ComponentActivity(), SurfaceHolder.Callback {
SessionState.mode == SessionService.MODE_STEAM && SessionState.running &&
SessionState.firstFrameSeen && !SessionState.suspended && !ended &&
!pipUi && !drawerOpen && !pcKeyboardOpen && !achievementsOpen &&
!gestureSettingsOpen && !screenKeyTextFocus && window.decorView.hasWindowFocus()
!gestureSettingsOpen && !appTextFocus && window.decorView.hasWindowFocus()
if (steamVolume) {
val audio = getSystemService(android.media.AudioManager::class.java)
if (com.droiddeck.launcher.input.SteamVolumeKeys.dispatch(event, !audio.isVolumeFixed) { direction ->
@@ -2656,10 +2671,10 @@ class SessionActivity : ComponentActivity(), SurfaceHolder.Callback {
Log.i(TAG, String.format(java.util.Locale.ROOT, "back key device=%d (%s) source=0x%x flags=0x%x",
event.deviceId, event.device?.name, event.source, event.flags))
}
val mouseToGuest = !pipUi && !screenKeyTextFocus && !drawerOpen && !pcKeyboardOpen && !achievementsOpen && !gestureSettingsOpen &&
val mouseToGuest = !pipUi && !appTextFocus && !drawerOpen && !pcKeyboardOpen && !achievementsOpen && !gestureSettingsOpen &&
!SessionState.suspended && !(::loading.isInitialized && (loading.visible || loading.ended))
if (mouseBackInput.onKeyEvent(event, mouseToGuest)) return true
val backToQam = !pipUi && !screenKeyTextFocus && !drawerOpen && !pcKeyboardOpen && !achievementsOpen &&
val backToQam = !pipUi && !appTextFocus && !drawerOpen && !pcKeyboardOpen && !achievementsOpen &&
SessionState.mode == SessionService.MODE_STEAM && SessionState.running && SessionState.firstFrameSeen &&
!SessionState.suspended && padBridge?.canHoldQam() == true
if (backQamInput.onKeyEvent(event, backToQam)) {
@@ -2668,7 +2683,7 @@ class SessionActivity : ComponentActivity(), SurfaceHolder.Callback {
return true
}
if (pipUi) return true
if (screenKeyTextFocus) return super.dispatchKeyEvent(event)
if (appTextFocus) return super.dispatchKeyEvent(event)
if (physicalShortcuts.onKey(
event,
enabled = SessionState.running && !SessionState.suspended &&
@@ -3552,20 +3567,105 @@ class SessionActivity : ComponentActivity(), SurfaceHolder.Callback {
showCursor(px, py)
}
private fun openBottomKeyboard() {
if (!started) return
private fun bottomKeyboardPresentation(): SecondScreenPresentation? {
if (!started) return null
if (secondScreenPresentation?.showsKeyboard != true && dockDrawer()) {
val target = com.droiddeck.launcher.input.DualScreen.sessionDisplay(this, displayManager, shownOnDisplayId) ?: return
val target = com.droiddeck.launcher.input.DualScreen.sessionDisplay(this, displayManager, shownOnDisplayId) ?: return null
if (drawerOpen || drawerPinned) drawerOpen = false
typingReturnMode = secondScreenMode
selectedSecondScreenDisplay = target.displayId
selectSecondScreenMode(SecondScreenMode.KEYBOARD_TRACKPAD)
}
val presentation = secondScreenPresentation ?: return
if (!presentation.showsKeyboard) return
val display = presentation.display?.displayId ?: return
return secondScreenPresentation?.takeIf { it.showsKeyboard && it.display != null }
}
private fun openBottomKeyboard() {
val presentation = bottomKeyboardPresentation() ?: return
presentation.requestKeyboard(forField = true)
com.droiddeck.launcher.ui.SessionTyping.focusDisplay(this, display)
com.droiddeck.launcher.ui.SessionTyping.focusDisplay(this, presentation.display.displayId)
}
private fun onGameTextField(focused: Boolean) {
if (!focused || !com.droiddeck.launcher.ui.WowChat.isWow(AgentGuest.gameProgram)) wowChat.chatClosed()
else if (resumed && !pipUi && !pcKeyboardOpen &&
resources.configuration.hardKeyboardHidden != android.content.res.Configuration.HARDKEYBOARDHIDDEN_NO) {
keyboard?.takeIf { it.shown }?.hide()
wowChat.chatOpened()
}
}
private fun placeWowChat() {
val open = wowChat.request != null
val shown = wowChatBottom
wowChatOnTop = false
if (open && shown == null) {
val presentation = bottomKeyboardPresentation()
if (presentation == null) {
wowChatOnTop = true
return
}
wowChatBottom = presentation
presentation.showChat(onLost = {
if (wowChatBottom === presentation) {
wowChatBottom = null
wowChatOnTop = wowChat.request != null
}
}) {
androidx.compose.runtime.CompositionLocalProvider(
androidx.activity.compose.LocalActivityResultRegistryOwner provides this,
androidx.activity.compose.LocalOnBackPressedDispatcherOwner provides this,
) { DroidDeckTheme { com.droiddeck.launcher.ui.WowChatPanel(wowChat) } }
}
com.droiddeck.launcher.ui.SessionTyping.focusDisplay(this, presentation.display.displayId)
} else if (!open && shown != null) {
wowChatBottom = null
shown.hideChat()
if (secondScreenPresentation === shown) bottomKeyboardClosed()
}
}
private fun sendWowChat(text: String, replace: Boolean) {
val host = keyboard ?: return
if (replace) {
selectGameField()
if (text.isEmpty()) host.tap(KeyEvent.KEYCODE_DEL)
} else {
endGameField()
}
host.type(text)
host.tap(KeyEvent.KEYCODE_ENTER)
uiHandler.postDelayed({
if (AgentGuest.gameTextField && com.droiddeck.launcher.ui.WowChat.isWow(AgentGuest.gameProgram)) endGameField()
}, FIELD_SETTLE_MS)
}
private fun endGameField() {
WaylandCompositor.nativeSendKey(KEY_END, 1)
WaylandCompositor.nativeSendKey(KEY_END, 0)
}
private fun selectGameField(vararg then: Int) {
val held = KEY_LEFTCTRL in guestKeysDown || KEY_LEFTCTRL in gestureKeysDown
if (!held) WaylandCompositor.nativeSendKey(KEY_LEFTCTRL, 1)
for (key in intArrayOf(KEY_A) + then) {
WaylandCompositor.nativeSendKey(key, 1)
WaylandCompositor.nativeSendKey(key, 0)
}
if (!held) WaylandCompositor.nativeSendKey(KEY_LEFTCTRL, 0)
}
private fun readGameField(done: (String?) -> Unit) {
val opened = wowChat.request
val stillOpen = { wowChat.request === opened && AgentGuest.gameTextField }
sessionClipboard.captureGuest(FIELD_READ_MS, onTimeout = { if (stillOpen()) endGameField() }) { text ->
if (text != null && stillOpen()) endGameField()
done(text)
}
selectGameField(KEY_C)
}
private fun cancelWowChat() {
keyboard?.tap(KeyEvent.KEYCODE_ESCAPE)
}
private fun bottomKeyboardClosed() {
@@ -3990,7 +4090,7 @@ class SessionActivity : ComponentActivity(), SurfaceHolder.Callback {
}
private fun appUiOwnsPointer(): Boolean =
drawerOpen || pcKeyboardOpen || achievementsOpen || pipUi || glassesOutputActive || bottomOutputActive || touchEditorOpen
drawerOpen || pcKeyboardOpen || achievementsOpen || pipUi || glassesOutputActive || bottomOutputActive || touchEditorOpen || wowChatOnTop
private fun applyPlatformPointer(shown: Boolean) {
if (!::sessionRoot.isInitialized || !::surfaceView.isInitialized) return
@@ -4285,6 +4385,7 @@ class SessionActivity : ComponentActivity(), SurfaceHolder.Callback {
private fun showPipUi(show: Boolean) {
pipUi = show
if (show) {
if (::hud.isInitialized) hud.setHidden(true)
drawerOpen = false
pcKeyboardOpen = false
achievementsOpen = false
@@ -5199,6 +5300,11 @@ class SessionActivity : ComponentActivity(), SurfaceHolder.Callback {
private const val AXIS_UNITS_PER_PIXEL = 3f
private const val KEY_LEFTCTRL = 29
private const val KEY_RIGHTCTRL = 97
private const val KEY_A = 30
private const val KEY_C = 46
private const val KEY_END = 107
private const val FIELD_READ_MS = 600L
private const val FIELD_SETTLE_MS = 200L
private const val BTN_SIDE = 0x113
private const val BTN_EXTRA = 0x114
private const val DESKTOP_GRAB_TAP_MS = 500L
@@ -26,6 +26,10 @@ object AgentGuest {
private set
var keyboardRequests by mutableIntStateOf(0)
private set
var gameTextField by mutableStateOf(false)
private set
var gameProgram by mutableStateOf<String?>(null)
private set
var keyboardDismissals by mutableIntStateOf(0)
private set
@Volatile private var keyboardRoute = false
@@ -43,7 +47,7 @@ object AgentGuest {
observer?.stopWatching()
observer = null
lastFocusKey = null
main.post { steamKeyboard = false; desktopTextField = false }
main.post { steamKeyboard = false; desktopTextField = false; gameTextField = false; gameProgram = null }
val dir = dir(context)
dir.deleteRecursively()
dir.mkdirs()
@@ -59,6 +63,7 @@ object AgentGuest {
"focus.json" -> readJson(File(dir, path))?.let(::recordFocus)
"keyboard.json" -> readJson(File(dir, path))?.let { recordKeyboard(this, it) }
"ime.json" -> readJson(File(dir, path))?.let { recordTextField(this, it) }
"wine-ime.json" -> readJson(File(dir, path))?.let { recordGameTextField(this, it) }
"keyboard-request.json" -> readJson(File(dir, path))?.let { recordKeyboardRequest(this) }
"keyboard-dismiss.json" -> readJson(File(dir, path))?.let { recordKeyboardDismiss(this) }
}
@@ -71,7 +76,7 @@ object AgentGuest {
synchronized(lock) {
observer?.stopWatching()
observer = null
main.post { steamKeyboard = false; desktopTextField = false }
main.post { steamKeyboard = false; desktopTextField = false; gameTextField = false; gameProgram = null }
}
}
@@ -129,6 +134,16 @@ object AgentGuest {
SessionEvents.record("ime.changed", mapOf("textField" to focused, "seq" to snapshot.optInt("seq")))
}
private fun recordGameTextField(source: FileObserver, snapshot: JSONObject) {
val focused = snapshot.optBoolean("textField")
val program = snapshot.optString("program").takeIf { it.isNotEmpty() }
synchronized(lock) {
if (source !== observer) return
main.post { gameProgram = program; gameTextField = focused }
}
SessionEvents.record("game.ime.changed", mapOf("textField" to focused, "program" to program, "seq" to snapshot.optInt("seq")))
}
private fun recordFocus(snapshot: JSONObject) {
val fields = linkedMapOf<String, Any?>(
"focusedApp" to snapshot.opt("focusedApp"),
@@ -475,15 +475,15 @@ object AutoComponents {
return true
}
fun beforeLaunch(context: Context, game: Library.SteamGame, onReady: () -> Unit) {
fun beforeLaunch(context: Context, game: Library.SteamGame, onBlocked: () -> Unit = {}, onReady: () -> Unit) {
val app = context.applicationContext
Thread({
val ready = runCatching { prepareLaunch(app, game) }.getOrElse {
Log.w(TAG, "${game.name}: launch repair discovery failed: ${it.javaClass.simpleName}: ${it.message}")
false
}
Handler(Looper.getMainLooper()).post(if (ready) onReady else onBlocked)
queueGame(app, game, asked = false)
if (ready) Handler(Looper.getMainLooper()).post(onReady)
}, "auto-components-launch").apply { isDaemon = true; start() }
}
@@ -23,6 +23,7 @@ object Library {
SteamGame(appId, name, art, library, gameId, hero, lastPlayed, gameFiles, protonPrefix, icon, source, storeId, logo)
val gameIdString: String get() = java.lang.Long.toUnsignedString(gameId)
val launchKey: String get() = if (storeId != null) "$source:$storeId" else gameIdString
val profileKey: String get() = when {
source == com.droiddeck.launcher.stores.Store.BATTLENET.id -> com.droiddeck.launcher.runtime.BattleNet.APP_ID.toString()
library == ADDED -> java.lang.Integer.toUnsignedString(appId)
@@ -14,8 +14,8 @@ object SteamLaunchOptions {
private const val TAG = "SteamLaunchOptions"
private val STEAM = listOf("UserLocalConfigStore", "Software", "Valve", "Steam")
fun current(context: Context, appId: Int): String =
SteamLiveShortcuts.launchOptions(context, appId.toLong()) ?: steam(SessionPrefs.activeSteamRoot(context), appId).orEmpty()
fun current(context: Context, appId: Int): String? =
SteamLiveShortcuts.launchOptions(context, appId.toLong()) ?: steam(SessionPrefs.activeSteamRoot(context), appId)
enum class Saved { YES, NO, DESKTOP }
@@ -35,7 +35,8 @@ object SteamLaunchOptions {
internal fun steam(steamHome: File, appId: Int): String? {
val config = AddedGames.accountConfig(steamHome) ?: return null
val text = runCatching { File(config, "localconfig.vdf").readText() }.getOrNull() ?: return null
return launchOptions(text, appId)
if (section(text, STEAM) == null) return null
return launchOptions(text, appId).orEmpty()
}
internal fun write(steamHome: File, appId: Int, options: String): Boolean {
@@ -141,6 +141,8 @@ class DroidDeckHudView(context: Context) : View(context) {
private var dDividerTop = -1f
private var dFrame: Bitmap? = null
private var dFrameKey = ""
private var sFrame: Bitmap? = null
private var sFrameKey = ""
private val glowPaint = Paint(Paint.ANTI_ALIAS_FLAG)
private val colGpu = 0xFF5EE08A.toInt()
@@ -274,9 +276,17 @@ class DroidDeckHudView(context: Context) : View(context) {
seenFpsUpdates = updates
periodDone.trySend(Unit)
}
if (graphRect != null) invalidate()
if (graphRect == null) return
if (stripRect != null) {
val now = SystemClock.uptimeMillis()
if (now - lastGraphDrawMs < STRIP_GRAPH_REDRAW_MS) return
lastGraphDrawMs = now
}
invalidate()
}
private var lastGraphDrawMs = 0L
private fun cycleSize() {
size = size.next()
onSizeCycledListener?.accept(size.token)
@@ -524,6 +534,9 @@ class DroidDeckHudView(context: Context) : View(context) {
private val inkRect = android.graphics.Rect()
private fun rebuild() {
val renderingLayer = if (size == DroidDeckHudSize.STRIP && dock == DroidDeckHudDock.NONE)
LAYER_TYPE_SOFTWARE else LAYER_TYPE_NONE
if (layerType != renderingLayer) setLayerType(renderingLayer, null)
glyphs.clear(); tileRects.clear(); graphRect = null
headerMark = null; headerWordmark = null; chipRect = null; dPanel = null; dDividerTop = -1f; tabRect = null; stripRect = null
val s = snap
@@ -1369,17 +1382,30 @@ class DroidDeckHudView(context: Context) : View(context) {
}
private fun drawStrip(canvas: Canvas, rect: RectF) {
headerPaint.shader = null
headerPaint.color = panelColor()
canvas.drawRect(rect, headerPaint)
val stroke = outlineWidth()
if (stroke <= 0f) return
val line = max(stroke / 2f, density)
val p = parent as? View
val atBottom = p != null && p.height > 0 && y + height / 2f > p.height / 2f
headerPaint.color = withAlpha(outlineColor(), (110 + 145 * outlineIntensity).roundToInt().coerceAtMost(255))
if (atBottom) canvas.drawRect(rect.left, rect.top, rect.right, rect.top + line, headerPaint)
else canvas.drawRect(rect.left, rect.bottom - line, rect.right, rect.bottom, headerPaint)
stripFrame(rect, atBottom)?.let { canvas.drawBitmap(it, 0f, 0f, null) }
}
private fun stripFrame(rect: RectF, atBottom: Boolean): Bitmap? {
val w = rect.width().roundToInt(); val h = rect.height().roundToInt()
if (w <= 0 || h <= 0) return null
val stroke = outlineWidth()
val key = "$w,$h,$stroke,$atBottom,$surfaceRgb,$bgOpacity,$outlineIntensity,${outlineColor()}"
if (key == sFrameKey) sFrame?.let { return it }
val bmp = Bitmap.createBitmap(w, h, Bitmap.Config.ARGB_8888)
val c = Canvas(bmp)
headerPaint.shader = null
headerPaint.color = panelColor()
c.drawRect(rect, headerPaint)
if (stroke > 0f) {
val line = max(stroke / 2f, density)
headerPaint.color = withAlpha(outlineColor(), (110 + 145 * outlineIntensity).roundToInt().coerceAtMost(255))
if (atBottom) c.drawRect(rect.left, rect.top, rect.right, rect.top + line, headerPaint)
else c.drawRect(rect.left, rect.bottom - line, rect.right, rect.bottom, headerPaint)
}
sFrame = bmp; sFrameKey = key
return bmp
}
private fun outlineWidth(): Float = if (outlineIntensity > 0f) outlineIntensity * sp(3.5f) else 0f
@@ -1499,6 +1525,7 @@ class DroidDeckHudView(context: Context) : View(context) {
const val TAB_PAD_X_SP = 7f
const val TAB_PAD_Y_SP = 5.5f
const val REFRESH_MS = 500L
const val STRIP_GRAPH_REDRAW_MS = 66L
private const val HUE_IDLE_MS = 500L
const val MIN_SCALE = 0.5f
const val MAX_SCALE = 2f
@@ -89,21 +89,31 @@ class KeyboardHost(
clearFocus()
}
fun type(text: CharSequence?) {
if (text.isNullOrEmpty()) return
val events = map.getEvents(text.toString().toCharArray())
if (events != null) {
for (event in events) sendKeyEvent(event)
} else {
for (ch in text) {
val t = System.currentTimeMillis()
val down = KeyEvent(t, t, KeyEvent.ACTION_DOWN, KeyEvent.KEYCODE_UNKNOWN, 0, 0,
KeyCharacterMap.VIRTUAL_KEYBOARD, 0, 0, 0)
sendKeyEvent(CharKeyEvent(down, ch))
sendKeyEvent(CharKeyEvent(KeyEvent.changeAction(down, KeyEvent.ACTION_UP), ch))
}
}
}
fun tap(keyCode: Int) {
val t = System.currentTimeMillis()
sendKeyEvent(KeyEvent(t, t, KeyEvent.ACTION_DOWN, keyCode, 0))
sendKeyEvent(KeyEvent(t, t, KeyEvent.ACTION_UP, keyCode, 0))
}
private inner class KeyConnection : BaseInputConnection(this, false) {
override fun commitText(text: CharSequence?, newCursorPosition: Int): Boolean {
if (text.isNullOrEmpty()) return true
val events = map.getEvents(text.toString().toCharArray())
if (events != null) {
for (event in events) sendKeyEvent(event)
} else {
for (ch in text) {
val t = System.currentTimeMillis()
val down = KeyEvent(t, t, KeyEvent.ACTION_DOWN, KeyEvent.KEYCODE_UNKNOWN, 0, 0,
KeyCharacterMap.VIRTUAL_KEYBOARD, 0, 0, 0)
sendKeyEvent(CharKeyEvent(down, ch))
sendKeyEvent(CharKeyEvent(KeyEvent.changeAction(down, KeyEvent.ACTION_UP), ch))
}
}
type(text)
return true
}
@@ -123,12 +133,6 @@ class KeyboardHost(
return true
}
private fun tap(keyCode: Int) {
val t = System.currentTimeMillis()
sendKeyEvent(KeyEvent(t, t, KeyEvent.ACTION_DOWN, keyCode, 0))
sendKeyEvent(KeyEvent(t, t, KeyEvent.ACTION_UP, keyCode, 0))
}
override fun getTextBeforeCursor(n: Int, flags: Int): CharSequence = ""
override fun getTextAfterCursor(n: Int, flags: Int): CharSequence = ""
override fun getSelectedText(flags: Int): CharSequence? = null
@@ -15,6 +15,7 @@ import android.view.View
import android.view.WindowInsets
import com.droiddeck.launcher.session.SessionPrefs
import kotlin.math.abs
import kotlin.math.hypot
import kotlin.math.max
import kotlin.math.min
import kotlin.math.sin
@@ -48,6 +49,10 @@ class OnScreenControls(
var dirty = false
var clicked = false
var lastUp = 0L
var downAt = 0L
var downX = 0f
var downY = 0f
var travel = 0f
val wide = id in shoulderIds
val halfW get() = if (wide) radius * SHOULDER_WIDTH else radius
val halfH get() = if (wide) radius * SHOULDER_HEIGHT else radius
@@ -61,6 +66,7 @@ class OnScreenControls(
}
fun drag(x: Float, y: Float): Boolean {
travel = max(travel, hypot(x - downX, y - downY))
var dx = x - ax
var dy = y - ay
val d = sqrt(dx * dx + dy * dy)
@@ -73,6 +79,14 @@ class OnScreenControls(
}
private val stickRedraw = ThrottledRedraw(this, STICK_REDRAW_MS)
private val tapSlop = android.view.ViewConfiguration.get(context).scaledTouchSlop.toFloat()
private fun stickTap(control: Control, event: MotionEvent): Boolean {
val i = event.actionIndex
val travel = max(control.travel, hypot(event.getX(i) - control.downX, event.getY(i) - control.downY))
return stickTap(control.downAt, event.eventTime, travel, tapSlop, control.clicked,
event.actionMasked == MotionEvent.ACTION_CANCEL || (event.flags and MotionEvent.FLAG_CANCELED) != 0)
}
private val controls = listOf(
Control("up", "dpad", -1, 26f, 0f, -52f),
@@ -545,6 +559,10 @@ class OnScreenControls(
control.pressedBy = event.getPointerId(index)
if (control.stick >= 0) {
control.clicked = settings.stickClick && control.lastUp > 0L && event.eventTime - control.lastUp < DOUBLE_TAP_MS
control.downAt = event.eventTime
control.downX = event.getX(index)
control.downY = event.getY(index)
control.travel = 0f
val adaptive = settings.adaptiveSticks
control.ax = if (adaptive) event.getX(index) else control.cx
control.ay = if (adaptive) event.getY(index) else control.cy
@@ -613,7 +631,7 @@ class OnScreenControls(
for (control in controls) {
if (control.pressedBy == pointer || event.actionMasked == MotionEvent.ACTION_CANCEL) {
if (control.pressedBy != -1) changed = true
if (control.stick >= 0 && control.pressedBy != -1) control.lastUp = if (control.clicked || event.actionMasked == MotionEvent.ACTION_CANCEL) 0L else event.eventTime
if (control.stick >= 0 && control.pressedBy != -1) control.lastUp = if (stickTap(control, event)) event.eventTime else 0L
control.pressedBy = -1
control.clicked = false
if (control.stick >= 0 && (control.kx != 0f || control.ky != 0f)) control.dirty = true
@@ -794,6 +812,10 @@ class OnScreenControls(
private companion object {
const val DOUBLE_TAP_MS = 300L
const val STICK_TAP_MS = 200L
internal fun stickTap(downAt: Long, upAt: Long, travel: Float, slop: Float, clicked: Boolean, cancelled: Boolean): Boolean =
!clicked && !cancelled && upAt - downAt <= STICK_TAP_MS && travel <= slop
const val STICK_REDRAW_MS = 33L
const val MIN_STICK_MOVE_PX = 1f
const val MIN_FACE_MM = 9.5f
@@ -19,11 +19,20 @@ class SessionClipboard(context: Context, private val hasFocus: () -> Boolean) {
private var published = false
private var shared: String? = null
private var echo: String? = null
private var capture: ((String?) -> Unit)? = null
private var captureBefore: String? = null
private var captureLate: (() -> Unit)? = null
private val androidListener = ClipboardManager.OnPrimaryClipChangedListener { refresh(changed = true) }
private val guestListener = object : WaylandCompositor.ClipboardListener {
override fun onGuestClipboardText(text: String) {
handler.post {
if (active && hasFocus() && WaylandCompositor.isClipboardListener(this)) {
val taken = capture?.takeIf { text != captureBefore }
if (taken != null && WaylandCompositor.isClipboardListener(this)) {
capture = null
handler.removeCallbacks(captureTimeout)
WaylandCompositor.setClipboardText(shared)
taken(text)
} else if (active && hasFocus() && WaylandCompositor.isClipboardListener(this)) {
val current = clipboard.primaryClip?.takeIf { it.itemCount > 0 }?.getItemAt(0)?.text
shared = text
published = true
@@ -36,6 +45,21 @@ class SessionClipboard(context: Context, private val hasFocus: () -> Boolean) {
}
}
private val captureTimeout = Runnable {
val taken = capture ?: return@Runnable
capture = null
captureLate?.invoke()
taken(null)
}
fun captureGuest(timeoutMs: Long, onTimeout: () -> Unit = {}, onText: (String?) -> Unit) {
capture?.let { handler.removeCallbacks(captureTimeout); it(null) }
capture = onText
captureLate = onTimeout
captureBefore = shared
handler.postDelayed(captureTimeout, timeoutMs)
}
fun start() {
if (active) return
active = true
@@ -47,6 +71,8 @@ class SessionClipboard(context: Context, private val hasFocus: () -> Boolean) {
fun stop() {
if (!active) return
active = false
handler.removeCallbacks(captureTimeout)
capture?.let { capture = null; it(null) }
clipboard.removePrimaryClipChangedListener(androidListener)
WaylandCompositor.clearClipboardListener(guestListener)
handler.removeCallbacksAndMessages(null)
@@ -8,8 +8,10 @@ object RuntimeAutoUpdate {
const val MAX_ATTEMPTS = 3
const val RETRY_MS = 10 * 60_000L
fun due(installed: String?, release: String?, attempts: Int, lastAt: Long = 0L, now: Long = lastAt + RETRY_MS): Boolean =
installed != null && !release.isNullOrEmpty() && release != installed && attempts < MAX_ATTEMPTS &&
private const val SEEDED = "seeded"
fun due(installed: String?, release: String?, attempts: Int, lastAt: Long = 0L, now: Long = lastAt + RETRY_MS, first: Boolean = false): Boolean =
(installed != null || first) && !release.isNullOrEmpty() && release != installed && attempts < MAX_ATTEMPTS &&
(attempts == 0 || now - lastAt !in 0 until RETRY_MS)
fun key(appVersion: Int, release: String) = "$appVersion:$release"
@@ -20,11 +22,17 @@ object RuntimeAutoUpdate {
val key = key(BuildConfig.VERSION_CODE, release)
synchronized(this) {
val attempts = prefs.getInt(key, 0)
if (!due(installed, release, attempts, prefs.getLong("$key@at", 0L), now)) return false
val first = installed == null && !prefs.getBoolean(SEEDED, false)
if (!due(installed, release, attempts, prefs.getLong("$key@at", 0L), now, first)) return false
val edit = prefs.edit()
prefs.all.keys.filterNot { it.startsWith("${BuildConfig.VERSION_CODE}:") }.forEach { edit.remove(it) }
prefs.all.keys.filterNot { it.startsWith("${BuildConfig.VERSION_CODE}:") || it == SEEDED }.forEach { edit.remove(it) }
edit.putInt(key, attempts + 1).putLong("$key@at", now).apply()
return true
}
}
fun seed(context: Context) {
val prefs = context.getSharedPreferences(PREFS, Context.MODE_PRIVATE)
if (!prefs.getBoolean(SEEDED, false)) prefs.edit().putBoolean(SEEDED, true).apply()
}
}
@@ -0,0 +1,401 @@
package com.droiddeck.launcher.session
import android.content.Context
import android.util.AtomicFile
import android.util.Log
import com.droiddeck.launcher.R
import com.droiddeck.launcher.frontend.AutoComponents
import org.json.JSONArray
import org.json.JSONObject
import java.io.File
object ComponentUpdater {
private const val TAG = "ComponentUpdater"
private const val CHECK_EVERY_MS = 3_600_000L
private const val BOOTED_KEPT = 200
private const val HANDED_BACK = "handedBack"
internal const val HAND_BACK_FILES = "handBackFiles"
private const val IMPORTED = "imported"
private val lock = Any()
private val io = Any()
data class Status(val line: String, val current: String, val previous: String, val protons: List<String>, val note: String)
private fun file(context: Context) = File(File(context.filesDir, "components").apply { mkdirs() }, "updates.json")
private fun load(context: Context): JSONObject = synchronized(io) {
runCatching { JSONObject(String(AtomicFile(file(context)).readFully(), Charsets.UTF_8)) }.getOrDefault(JSONObject())
}
private fun save(context: Context, state: JSONObject) {
synchronized(io) {
val atomic = AtomicFile(file(context))
val output = runCatching { atomic.startWrite() }.onFailure { Log.w(TAG, "could not save", it) }.getOrNull() ?: return
try {
output.write(state.toString(1).toByteArray(Charsets.UTF_8))
atomic.finishWrite(output)
} catch (error: Exception) {
atomic.failWrite(output)
Log.w(TAG, "could not save", error)
}
}
}
private fun Map<String, Map<String, ComponentsManager.Choice>>.choiceOf(p: ComponentsManager.Proton, comp: String): ComponentsManager.Choice? =
this[p.id]?.get(comp)?.takeIf { it.protonVersion == p.version }
private fun Map<String, Map<String, ComponentsManager.Choice>>.fileOf(p: ComponentsManager.Proton, comp: String): String? = choiceOf(p, comp)?.file
private class Target(val comp: String, val p: ComponentsManager.Proton, val line: ComponentUpdates.Line, val ls: JSONObject) {
val key: String get() = line.key
}
private fun JSONObject.obj(key: String): JSONObject = optJSONObject(key) ?: JSONObject().also { put(key, it) }
private fun JSONArray?.strings(): Set<String> = this?.let { a -> (0 until a.length()).map { a.optString(it) }.toSet() } ?: emptySet()
private fun JSONObject.strings(key: String): Set<String> = optJSONArray(key).strings()
private fun JSONObject?.ints(): Map<String, Int> = this?.keys()?.asSequence()?.associateWith { optInt(it) } ?: emptyMap()
private fun JSONObject?.texts(): Map<String, String> = this?.keys()?.asSequence()?.associateWith { optString(it) } ?: emptyMap()
fun settle(context: Context) {
val owned = load(context).optJSONObject("lines")?.let { lines -> lines.keys().asSequence().flatMap { lines.optJSONObject(it).strings("owned") }.toSet() } ?: emptySet()
val own = (runCatching { ComponentsManager.protons(context) }.getOrNull() ?: return).filter(ComponentUpdates::follows).mapTo(HashSet()) { it.id }
val choices = ComponentsManager.activeChoices(context).filterKeys { it in own }.values.flatMap { it.entries }
for (comp in ComponentsManager.COMPONENTS) {
if (SessionPrefs.componentModeChosen(context, comp)) continue
val manual = choices.any { (c, choice) -> c == comp && ComponentUpdates.manual(choice, null, owned) }
SessionPrefs.setComponentMode(context, comp, if (manual) ComponentUpdates.MANUAL else ComponentUpdates.AUTO)
}
}
private fun managed(context: Context, comp: String): Boolean =
SessionPrefs.componentMode(context, comp) == ComponentUpdates.AUTO && !(comp == "dxvk" && ComponentsManager.hardwareDxvk(context))
private fun logLines(context: Context): Sequence<String> {
val dir = ComponentsManager.steamLogs(context)
return sequenceOf("console_log.previous.txt", "console_log.txt").map { File(dir, it) }.filter { it.isFile }
.flatMap { f -> runCatching { f.readLines() }.getOrDefault(emptyList()).asSequence() }
}
private val STAMP = Regex("""^\[(\d{4}-\d\d-\d\d \d\d:\d\d:\d\d)]""")
private fun latestStamp(context: Context): String =
logLines(context).mapNotNull { STAMP.find(it)?.groupValues?.get(1) }.maxOrNull() ?: "0"
private fun lineOf(context: Context, state: JSONObject, p: ComponentsManager.Proton, comp: String): ComponentUpdates.Line? {
if (!ComponentUpdates.follows(p) || !ComponentsManager.supports(p, comp)) return null
if (comp == "fex") return ComponentUpdates.line("fex", false)
val key = "${p.id}|${p.version}"
val flavors = state.obj("flavors").obj(key)
val arm64ec = if (flavors.has(comp)) flavors.optBoolean(comp) else ComponentsManager.arm64ec(context, p, comp)?.also { flavors.put(comp, it) }
return arm64ec?.let { ComponentUpdates.line(comp, it) }
}
fun ensure(context: Context, force: Boolean): List<String> = synchronized(lock) {
if (SessionState.running) return emptyList()
settle(context)
val state = load(context)
val notes = ArrayList<String>()
val found = runCatching { ComponentsManager.protons(context) }.getOrNull()
val protons = found ?: emptyList()
if (found != null) handBack(context, state, found)
judge(context, state, protons, notes)
val due = force || System.currentTimeMillis() - state.optLong("checkedAt") >= CHECK_EVERY_MS
val wanted = ComponentsManager.COMPONENTS.filter { managed(context, it) }
if (wanted.isEmpty() || protons.isEmpty()) {
save(context, state)
return notes
}
try {
dropRemoved(state, protons)
val checked = due && check(context, state, protons, wanted, force, notes)
apply(context, state, protons, wanted, notes)
prune(context, state)
if (checked) state.put("checkedAt", System.currentTimeMillis())
} finally {
save(context, state)
}
notes
}
private fun check(
context: Context, state: JSONObject, protons: List<ComponentsManager.Proton>, wanted: List<String>, force: Boolean, notes: MutableList<String>,
): Boolean {
if (!AutoComponents.isOnline(context)) {
state.put("note", context.getString(R.string.comp_auto_waits_network))
return false
}
val catalog = runCatching { ComponentsManager.catalog(context, true).items }
.onFailure { Log.w(TAG, "catalog", it) }.getOrDefault(emptyList())
if (catalog.none { it.release == ComponentsManager.DD_NIGHTLY }) {
state.put("note", context.getString(R.string.comp_auto_check_failed))
return false
}
val holdBack = !force && AutoComponents.constrained(context)
val lines = state.obj("lines")
var complete = true
state.remove("note")
for (comp in wanted) for (p in protons) {
val line = lineOf(context, state, p, comp) ?: continue
val ls = lines.obj(line.key)
if (rollingBack(ls)) {
complete = false
continue
}
val current = ls.optString("current")
val missing = current.isNotEmpty() && !ComponentsManager.isStored(context, current)
val relisted = if (missing) catalog.firstOrNull { it.file == current } else null
if (relisted != null) {
if (holdBack) {
state.put("note", context.getString(R.string.comp_auto_waits_unmetered))
complete = false
continue
}
val got = runCatching { ComponentsManager.download(context, relisted) {} }
if (got.isFailure) {
Log.w(TAG, "download ${relisted.file} again", got.exceptionOrNull())
state.put("note", context.getString(R.string.comp_auto_check_failed))
complete = false
continue
}
own(state, ls, current)
}
val target = ComponentUpdates.pick(catalog, line, ls.strings("bad"))
if (target == null || current == target.file) continue
val stored = ComponentsManager.isStored(context, target.file)
if (!stored) {
if (holdBack) {
state.put("note", context.getString(R.string.comp_auto_waits_unmetered))
complete = false
continue
}
val got = runCatching { ComponentsManager.download(context, target) {} }
if (got.isFailure) {
Log.w(TAG, "download ${target.file}", got.exceptionOrNull())
state.put("note", context.getString(R.string.comp_auto_check_failed))
complete = false
continue
}
}
val stocks = protons.filter { lineOf(context, state, it, comp)?.key == line.key }.map { ComponentsManager.arm64x(context, it, comp) }
if (ComponentUpdates.needsArm64x(line, stocks) && !ComponentsManager.packageArm64x(context, target.file, comp)) {
Log.w(TAG, "${target.file} is not ARM64X like ${p.name}'s own ${line.key}; skipped")
ls.put("bad", JSONArray(ls.strings("bad") + target.file))
if (!stored) runCatching { ComponentsManager.deletePackage(context, target.file) }
continue
}
ls.put("previous", if (missing && relisted == null) ComponentUpdates.ORIGINAL else current.ifEmpty { ComponentUpdates.ORIGINAL })
.put("current", target.file).put("since", latestStamp(context)).put("failed", JSONObject())
if (!stored) own(state, ls, target.file)
ls.remove("note")
notes += context.getString(R.string.comp_auto_updated, ComponentsManager.LABEL[comp], target.file.removeSuffix(".wcp"))
}
return complete
}
private fun apply(context: Context, state: JSONObject, protons: List<ComponentsManager.Proton>, wanted: List<String>, notes: MutableList<String>) {
val lines = state.obj("lines")
val auto = placed(state)
val choices = ComponentsManager.activeChoices(context)
val targets = wanted.flatMap { comp ->
protons.mapNotNull { p -> lineOf(context, state, p, comp)?.let { line -> lines.optJSONObject(line.key)?.let { Target(comp, p, line, it) } } }
}.filterNot { t -> ComponentUpdates.manual(choices.choiceOf(t.p, t.comp), t.ls.optJSONObject("applied")?.optString(t.p.id), auto, t.ls.optString("current")) }
for (t in targets) {
val from = t.ls.optJSONObject("restore")?.optString(t.p.id).orEmpty()
val current = t.ls.optString("current")
if (from.isNotEmpty()) {
if (choices.fileOf(t.p, t.comp) == from) runCatching { ComponentsManager.restore(context, t.p.id, t.comp, ComponentsManager.safeName(t.p.version)) }
.onFailure { Log.w(TAG, "restore ${t.p.name} ${t.key}", it) }
} else if (current.isNotEmpty() && choices.fileOf(t.p, t.comp) != current) {
if (ComponentUpdates.needsArm64x(t.line, listOf(ComponentsManager.arm64x(context, t.p, t.comp))) &&
!ComponentsManager.packageArm64x(context, current, t.comp)) continue
runCatching { ComponentsManager.swap(context, t.p.id, current) }
.onFailure { Log.w(TAG, "${t.p.name} ${t.key}", it) }
}
}
val after = ComponentsManager.activeChoices(context)
val unfinished = HashSet<String>()
for (t in targets) {
val restore = t.ls.optJSONObject("restore")
val from = restore?.optString(t.p.id).orEmpty()
val current = t.ls.optString("current")
if (from.isNotEmpty()) {
if (after.fileOf(t.p, t.comp) == from) unfinished += t.key
else {
restore?.remove(t.p.id)
if (after.choiceOf(t.p, t.comp)?.file?.startsWith("original ") != false) t.ls.optJSONObject("applied")?.remove(t.p.id)
}
} else if (current.isNotEmpty()) {
if (after.fileOf(t.p, t.comp) == current) t.ls.obj("applied").put(t.p.id, current) else unfinished += t.key
}
}
for (key in lines.keys().asSequence().toList()) {
val ls = lines.optJSONObject(key) ?: continue
val pending = ls.optString("pendingNote")
if (pending.isEmpty() || key in unfinished || (ls.optJSONObject("restore")?.length() ?: 0) > 0) continue
ls.remove("pendingNote")
ls.put("note", pending)
notes += pending
}
}
private fun own(state: JSONObject, ls: JSONObject, file: String) {
ls.put("owned", JSONArray(ls.strings("owned") + file))
state.put(IMPORTED, JSONArray(state.strings(IMPORTED) - file))
}
fun importPackage(context: Context, source: File, name: String): ComponentsManager.Package = synchronized(lock) {
ComponentsManager.importPackage(context, source, name).also { disown(context, it.file) }
}
fun disown(context: Context, file: String) = synchronized(lock) {
val state = load(context)
state.optJSONObject("lines")?.let { lines ->
lines.keys().asSequence().mapNotNull { lines.optJSONObject(it) }.filter { file in it.strings("owned") }
.forEach { it.put("owned", JSONArray(it.strings("owned") - file)) }
}
state.put(IMPORTED, JSONArray(state.strings(IMPORTED) + file))
save(context, state)
}
internal fun placed(state: JSONObject): Set<String> {
val remembered = state.strings(HAND_BACK_FILES)
val lines = state.optJSONObject("lines") ?: return remembered - state.strings(IMPORTED)
return remembered + lines.keys().asSequence().flatMap { k ->
val ls = lines.optJSONObject(k) ?: return@flatMap emptySequence()
(ls.strings("owned") + ls.strings("bad") + ls.optString("current") + ls.optString("previous")).asSequence()
}.filter { it.isNotEmpty() }.toSet() - state.strings(IMPORTED)
}
private fun handBack(context: Context, state: JSONObject, protons: List<ComponentsManager.Proton>) {
if (state.optBoolean(HANDED_BACK)) return
val placed = placed(state)
state.put(HAND_BACK_FILES, JSONArray(placed.sorted()))
val own = protons.filter(ComponentUpdates::follows).mapTo(HashSet()) { it.id }
runCatching { ComponentsManager.dropQueuedPackages(context, placed) { it in own || ComponentUpdates.ownId(it) } }.onFailure { Log.w(TAG, "drop queued Auto builds", it) }.getOrNull() ?: return
val strays = ComponentUpdates.strays(protons, ComponentsManager.activeChoices(context), placed)
for ((p, comp) in strays) {
runCatching { ComponentsManager.restoreUnlessQueued(context, p.id, comp, ComponentsManager.safeName(p.version)) }
.onFailure { Log.w(TAG, "hand back ${p.name} $comp", it) }
}
val after = ComponentsManager.activeChoices(context)
if (ComponentUpdates.strays(protons, after, placed).isEmpty() && !ComponentUpdates.unseen(protons, after, placed)) {
state.put(HANDED_BACK, true)
state.remove(HAND_BACK_FILES)
}
}
internal fun dropRemoved(state: JSONObject, protons: List<ComponentsManager.Proton>) {
val installed = protons.mapTo(HashSet()) { it.id }
val lines = state.optJSONObject("lines") ?: return
for (key in lines.keys().asSequence().toList()) {
val restore = lines.optJSONObject(key)?.optJSONObject("restore") ?: continue
for (id in restore.keys().asSequence().toList()) if (id !in installed) restore.remove(id)
}
}
internal fun rollingBack(ls: JSONObject): Boolean =
(ls.optJSONObject("restore")?.length() ?: 0) > 0 || ls.optString("pendingNote").isNotEmpty()
private fun judge(context: Context, state: JSONObject, protons: List<ComponentsManager.Proton>, notes: MutableList<String>) {
val mark = state.optString("logMark")
val runs = ComponentUpdates.launches(logLines(context), mark)
if (runs.isEmpty()) return
state.put("logMark", runs.maxOf { it.start })
val byTool = protons.associateBy { it.dir.name }
val lines = state.obj("lines")
for (comp in ComponentsManager.COMPONENTS) {
val onLine = HashMap<String, MutableList<ComponentUpdates.Launch>>()
for (run in runs) {
val p = byTool[run.tool] ?: continue
if (ComponentsManager.gameComponents(context, run.app).containsKey(comp)) continue
val line = lineOf(context, state, p, comp) ?: continue
onLine.getOrPut(line.key) { ArrayList() } += run
}
for ((key, list) in onLine) {
val ls = lines.obj(key)
val verdict = ComponentUpdates.judge(ComponentUpdates.Trial(
ls.optString("current"), ls.optString("previous"), ls.optString("since"),
ls.optJSONObject("booted").texts(), ls.optJSONObject("failed").ints(),
), list)
ls.put("booted", JSONObject(verdict.booted.entries.toList().takeLast(BOOTED_KEPT).associate { it.key to it.value } as Map<*, *>))
ls.put("failed", JSONObject(verdict.failed as Map<*, *>))
if (verdict.rollBack && managed(context, comp)) rollBack(context, state, protons, comp, key, notes)
}
}
}
private fun rollBack(context: Context, state: JSONObject, protons: List<ComponentsManager.Proton>, comp: String, key: String, notes: MutableList<String>) {
val ls = state.obj("lines").obj(key)
val bad = ls.optString("current")
val onLine = protons.filter { lineOf(context, state, it, comp)?.key == key }
val line = ComponentUpdates.LINES.first { it.key == key }
val back = ls.optString("previous").takeIf { prev -> prev == ComponentUpdates.ORIGINAL || (ComponentsManager.isStored(context, prev) &&
(!ComponentUpdates.needsArm64x(line, onLine.map { ComponentsManager.arm64x(context, it, comp) }) || ComponentsManager.packageArm64x(context, prev, comp))) }
?: ComponentUpdates.ORIGINAL
val choices = ComponentsManager.activeChoices(context)
val targets = onLine.filter { choices.fileOf(it, comp) == bad }
for (p in targets) {
runCatching {
if (back == ComponentUpdates.ORIGINAL) ComponentsManager.restore(context, p.id, comp, ComponentsManager.safeName(p.version))
else ComponentsManager.swap(context, p.id, back)
}.onFailure { Log.w(TAG, "roll back ${p.name} $key", it) }
}
val after = ComponentsManager.activeChoices(context)
val unfinished = targets.filter { after.fileOf(it, comp) == bad }
for (p in targets - unfinished.toSet()) {
if (back == ComponentUpdates.ORIGINAL) ls.optJSONObject("applied")?.remove(p.id)
else if (after.fileOf(p, comp) == back) ls.obj("applied").put(p.id, back)
}
ls.put("bad", JSONArray(ls.strings("bad") + bad)).put("current", back).put("previous", ComponentUpdates.ORIGINAL)
.put("since", latestStamp(context)).put("failed", JSONObject())
if (back == ComponentUpdates.ORIGINAL) ls.obj("restore").let { restore -> unfinished.forEach { restore.put(it.id, bad) } }
val label = ComponentsManager.LABEL[comp]
val shown = back.ifEmpty { context.getString(R.string.comp_tag_original) }.removeSuffix(".wcp")
val note = context.getString(R.string.comp_auto_rolled_back, label, bad.removeSuffix(".wcp"), shown)
Log.w(TAG, note)
if (unfinished.isNotEmpty()) {
ls.put("pendingNote", note)
return
}
ls.remove("pendingNote")
ls.put("note", note)
notes += note
prune(context, state)
}
private fun prune(context: Context, state: JSONObject) {
val lines = state.obj("lines")
val selected = ComponentsManager.selectedPackages(context)
val keep = lines.keys().asSequence().flatMap { k ->
val ls = lines.optJSONObject(k) ?: return@flatMap emptySequence()
ComponentUpdates.keep(ls.optString("current"), ls.optString("previous"), selected).asSequence()
}.toSet()
val stored = ComponentsManager.storedFiles(context)
val followed = ComponentUpdates.LINES.filter { managed(context, it.comp) }
for (k in lines.keys().asSequence().toList()) {
val ls = lines.optJSONObject(k) ?: continue
val owned = ls.strings("owned")
val stale = ComponentUpdates.stale(followed.firstOrNull { it.key == k }, owned, stored, keep, ls.optString("current"), ls.strings("bad").toSet())
val gone = stale.filter { runCatching { ComponentsManager.deleteUnused(context, it) }.getOrDefault(false) }
ls.put("owned", JSONArray(owned - gone.toSet()))
}
}
fun status(context: Context, comp: String): List<Status> {
val state = load(context)
val lines = state.optJSONObject("lines") ?: JSONObject()
val protons = runCatching { ComponentsManager.protons(context) }.getOrDefault(emptyList())
val byLine = protons.groupBy { lineOf(context, state, it, comp)?.key }
return ComponentUpdates.LINES.filter { it.comp == comp }.mapNotNull { line ->
val on = byLine[line.key].orEmpty()
val ls = lines.optJSONObject(line.key)
if (on.isEmpty() && ls == null) return@mapNotNull null
val note = ls?.optString("note").orEmpty().ifEmpty { state.optString("note") }
Status(line.key, ls?.optString("current").orEmpty(), ls?.optString("previous").orEmpty(), on.map { it.name }, note)
}
}
}
@@ -0,0 +1,167 @@
package com.droiddeck.launcher.session
import java.nio.ByteBuffer
import java.nio.ByteOrder
import java.text.SimpleDateFormat
import java.util.Locale
object ComponentUpdates {
const val AUTO = "auto"
const val MANUAL = "manual"
const val ORIGINAL = ""
const val BOOT_SECONDS = 30L
const val BOOTED_SECONDS = 60L
const val FAILED_BOOTS_BEFORE_ROLLBACK = 2
data class Line(val key: String, val comp: String, val prefix: String)
val LINES = listOf(
Line("dxvk", "dxvk", "DD-dxvk-"),
Line("dxvk-arm64ec", "dxvk", "DD-dxvk-arm64ec-"),
Line("vkd3d", "vkd3d", "DD-vkd3d-proton-"),
Line("vkd3d-arm64ec", "vkd3d", "DD-vkd3d-proton-arm64ec-"),
Line("fex", "fex", "DD-FEXCore-"),
)
private val OWN_PREFIX = ProtonExtras.tools.first { it.id == ProtonExtras.OWN }.prefix
fun follows(p: ComponentsManager.Proton): Boolean = !p.valve && p.dir.name.startsWith(OWN_PREFIX)
fun ownId(protonId: String): Boolean = protonId.startsWith(ComponentsManager.safeName(OWN_PREFIX))
fun strays(
protons: List<ComponentsManager.Proton>,
choices: Map<String, Map<String, ComponentsManager.Choice>>,
placed: Set<String>,
): List<Pair<ComponentsManager.Proton, String>> =
protons.filterNot(::follows).flatMap { p ->
choices[p.id].orEmpty().filter { (_, c) -> !c.auto && c.protonVersion == p.version && c.file in placed }.keys.map { p to it }
}
fun unseen(
protons: List<ComponentsManager.Proton>,
choices: Map<String, Map<String, ComponentsManager.Choice>>,
placed: Set<String>,
): Boolean {
val found = protons.mapTo(HashSet()) { it.id }
return choices.any { (id, comps) -> id !in found && comps.values.any { !it.auto && it.file in placed } }
}
fun line(comp: String, arm64ec: Boolean): Line = LINES.first {
it.comp == comp && (comp == "fex" || it.key.endsWith("-arm64ec") == arm64ec)
}
fun pick(items: List<ComponentsManager.CatalogItem>, line: Line, bad: Set<String>): ComponentsManager.CatalogItem? {
val newestBad = bad.filter { belongs(it, line) }.maxWithOrNull { a, b -> ProtonExtras.compareVersions(a, b) }
return items.filter { it.release == ComponentsManager.DD_NIGHTLY && it.comp == line.comp && belongs(it.file, line) }
.filter { newestBad == null || ProtonExtras.compareVersions(it.file, newestBad) > 0 }
.maxWithOrNull { a, b -> ProtonExtras.compareVersions(a.file, b.file) }
}
fun belongs(file: String, line: Line): Boolean =
file.startsWith(line.prefix) && file.removePrefix(line.prefix).firstOrNull()?.isDigit() == true
fun arm64ec(pe: ByteArray): Boolean? = runCatching {
val b = ByteBuffer.wrap(pe).order(ByteOrder.LITTLE_ENDIAN)
val header = b.getInt(0x3c)
if (b.getInt(header) != 0x4550) return null
val sections = b.getShort(header + 6).toInt() and 0xffff
val optionalSize = b.getShort(header + 20).toInt() and 0xffff
val optional = header + 24
if ((b.getShort(optional).toInt() and 0xffff) != 0x20b) return false
val loadConfigRva = b.getInt(optional + 112 + 10 * 8)
if (loadConfigRva == 0) return false
val table = optional + optionalSize
var offset = -1
for (i in 0 until sections) {
val s = table + i * 40
val size = b.getInt(s + 8)
val va = b.getInt(s + 12)
if (loadConfigRva >= va && loadConfigRva < va + maxOf(size, 1)) offset = b.getInt(s + 20) + loadConfigRva - va
}
if (offset < 0) return null
b.getInt(offset) >= 0xC8 + 8 && b.getLong(offset + 0xC8) != 0L
}.getOrNull()
fun arm64x(pe: ByteArray): Boolean = runCatching {
val b = ByteBuffer.wrap(pe).order(ByteOrder.LITTLE_ENDIAN)
val header = b.getInt(0x3c)
b.getInt(header) == 0x4550 && (b.getShort(header + 4).toInt() and 0xffff) == 0xaa64 && arm64ec(pe) == true
}.getOrDefault(false)
fun allArm64x(dlls: Map<String, ByteArray>, owned: Set<String>): Boolean {
val graphics = dlls.filterKeys { it.lowercase() in owned }
return graphics.isNotEmpty() && graphics.values.all(::arm64x)
}
fun needsArm64x(line: Line, stocks: List<Boolean?>): Boolean =
line.key.endsWith("-arm64ec") && stocks.any { it != false }
data class Launch(val app: String, val tool: String, val start: String, val seconds: Long)
private val PROCESS = Regex("""^\[(\d{4}-\d\d-\d\d \d\d:\d\d:\d\d)] Game process (added|removed)\s*: AppID (\d+)(?: "(.*))?""")
private val TOOL = Regex("""compatibilitytools\.d/([^/'"]+)['"]?/|steamapps/common/([^/'"]+)['"]?/proton""")
fun launches(log: Sequence<String>, after: String): List<Launch> {
val format = SimpleDateFormat("yyyy-MM-dd HH:mm:ss", Locale.US)
val open = HashMap<String, Pair<String, String>>()
val out = ArrayList<Launch>()
for (line in log) {
val m = PROCESS.find(line) ?: continue
val (time, what, app) = m.destructured
if (time <= after) continue
if (what == "added") {
val tool = TOOL.find(m.groupValues[4])?.let { t -> t.groupValues[1].ifEmpty { t.groupValues[2] } } ?: continue
open[app] = time to tool
} else {
val (start, tool) = open.remove(app) ?: continue
val seconds = runCatching { (format.parse(time)!!.time - format.parse(start)!!.time) / 1000 }.getOrNull() ?: continue
out += Launch(app, tool, start, seconds)
}
}
return out
}
data class Trial(
val current: String,
val previous: String,
val since: String,
val booted: Map<String, String>,
val failed: Map<String, Int>,
)
data class Verdict(val booted: Map<String, String>, val failed: Map<String, Int>, val rollBack: Boolean)
fun judge(trial: Trial, runs: List<Launch>): Verdict {
val booted = trial.booted.toMutableMap()
val failed = trial.failed.toMutableMap()
var rollBack = false
for (run in runs) {
val build = if (trial.since.isNotEmpty() && run.start > trial.since) trial.current else trial.previous
if (run.seconds >= BOOTED_SECONDS) {
booted[run.app] = build
if (build == trial.current) failed.remove(run.app)
} else if (run.seconds < BOOT_SECONDS && build == trial.current && trial.current != trial.previous &&
booted[run.app] == trial.previous) {
val n = (failed[run.app] ?: 0) + 1
failed[run.app] = n
if (n >= FAILED_BOOTS_BEFORE_ROLLBACK) rollBack = true
}
}
return Verdict(booted, failed, rollBack)
}
fun keep(current: String, previous: String, selected: Set<String>): Set<String> =
(setOf(current, previous) + selected) - ORIGINAL
fun manual(choice: ComponentsManager.Choice?, applied: String?, auto: Set<String>, current: String? = null): Boolean =
choice != null && !choice.auto && !choice.file.startsWith("original ") && choice.file != current &&
(if (applied.isNullOrEmpty()) choice.file !in auto else choice.file != applied)
fun stale(line: Line?, owned: Set<String>, stored: List<String>, keep: Set<String>, current: String, bad: Set<String> = emptySet()): List<String> {
val ranked = line?.takeIf { belongs(current, it) }
fun newer(file: String) = ranked != null && belongs(file, ranked) && ProtonExtras.compareVersions(file, current) > 0
return (owned + stored.filter { ranked != null && belongs(it, ranked) })
.filter { it !in keep && ((it in bad && it in owned) || !newer(it)) }.distinct()
}
}
@@ -84,6 +84,7 @@ object ComponentsManager {
private fun root(context: Context) = LinuxRuntime.rootDir(context)
private fun steam(context: Context) = File(root(context), "root/.local/share/Steam")
fun steamLogs(context: Context) = File(steam(context), "logs")
private fun host(context: Context, guest: String) = File(root(context), guest.removePrefix("/"))
private fun dataDir(context: Context) = File(context.filesDir, "components").apply { mkdirs() }
private fun packagesDir(context: Context) = File(dataDir(context), "packages").apply { mkdirs() }
@@ -699,6 +700,12 @@ object ComponentsManager {
context.getString(R.string.cmgr_restore_done, LABEL[comp], p.name, protonVersion)
}
fun restoreUnlessQueued(context: Context, protonId: String, comp: String, protonVersion: String): Boolean = synchronized(lock) {
if (loadState(context).optJSONObject("queued")?.optJSONObject(protonId)?.has(comp) == true) return false
restore(context, protonId, comp, protonVersion)
true
}
private fun applyOriginal(context: Context, p: Proton, comp: String, protonVersion: String, wcp: File, state: JSONObject) {
val current = protonVersion == safeName(p.version)
if (!current) ensureOriginal(context, p, comp, state)
@@ -749,6 +756,25 @@ object ComponentsManager {
saveState(context, state)
}
fun dropQueuedPackages(context: Context, files: Set<String>, keep: (String) -> Boolean): Int = synchronized(lock) {
val state = loadState(context)
val queued = state.sub("queued")
var dropped = 0
for (pid in queued.keys().asSequence().toList()) {
if (keep(pid)) continue
val comps = queued.optJSONObject(pid) ?: continue
for (comp in comps.keys().asSequence().toList()) {
val entry = comps.optJSONObject(comp) ?: continue
if (entry.optString("kind") != "package" || entry.optBoolean("auto") || entry.optString("file") !in files) continue
comps.remove(comp)
dropped++
}
if (comps.length() == 0) queued.remove(pid)
}
if (dropped > 0) saveState(context, state)
dropped
}
class StoredPackage(val pkg: Package, val inUse: Boolean)
class StoredOriginal(val protonId: String, val protonName: String?, val comp: String, val protonVersion: String, val size: Long, val kept: Boolean)
class Stored(val packages: List<StoredPackage>, val originals: List<StoredOriginal>)
@@ -756,7 +782,11 @@ object ComponentsManager {
internal fun liveSelection(key: String, entry: JSONObject, proton: Proton?): Boolean =
proton != null && (key == "queued" || entry.optString("protonVersion") == proton.version)
private fun packagesInUse(state: JSONObject, installed: Map<String, Proton>): Set<String> {
internal fun liveOnAny(key: String, entry: JSONObject, installs: List<Proton>?): Boolean =
installs?.any { liveSelection(key, entry, it) } == true
internal fun packagesInUse(state: JSONObject, installed: List<Proton>): Set<String> {
val byId = installed.groupBy { it.id }
val names = HashSet<String>()
for (key in listOf("active", "queued")) {
val byProton = state.optJSONObject(key) ?: continue
@@ -764,7 +794,7 @@ object ComponentsManager {
val comps = byProton.optJSONObject(pid) ?: continue
for (comp in comps.keys()) {
val entry = comps.optJSONObject(comp) ?: continue
if (liveSelection(key, entry, installed[pid])) entry.optString("file").takeIf { it.isNotEmpty() }?.let(names::add)
if (liveOnAny(key, entry, byId[pid])) entry.optString("file").takeIf { it.isNotEmpty() }?.let(names::add)
}
}
}
@@ -786,7 +816,7 @@ object ComponentsManager {
fun stored(context: Context): Stored = synchronized(lock) {
val state = loadState(context)
val installed = protons(context).distinctBy { it.id }.associateBy { it.id }
val used = packagesInUse(state, installed)
val used = packagesInUse(state, protons(context))
val originals = originalsDir(context).listFiles()?.filter { it.isDirectory }?.flatMap { proton ->
proton.listFiles()?.filter { it.isDirectory }?.flatMap { version ->
COMPONENTS.mapNotNull { comp ->
@@ -813,15 +843,15 @@ object ComponentsManager {
}
private fun checkPackageUnused(context: Context, state: JSONObject, name: String) {
val installed = protons(context).distinctBy { it.id }.associateBy { it.id }
val installed = protons(context).groupBy { it.id }
for (key in listOf("queued", "active")) {
val byProton = state.sub(key)
for (pid in byProton.keys()) {
val comps = byProton.optJSONObject(pid) ?: continue
for (comp in comps.keys()) {
val entry = comps.optJSONObject(comp) ?: continue
check(entry.optString("file") != name || !liveSelection(key, entry, installed[pid])) {
context.getString(R.string.cmgr_package_in_use, name, installed[pid]?.name ?: pid)
check(entry.optString("file") != name || !liveOnAny(key, entry, installed[pid])) {
context.getString(R.string.cmgr_package_in_use, name, installed[pid]?.firstOrNull()?.name ?: pid)
}
}
}
@@ -911,7 +941,7 @@ object ComponentsManager {
fun deletePackage(context: Context, file: String): String = synchronized(lock) {
val name = safeName(file)
val state = loadState(context)
val installed = protons(context).distinctBy { it.id }.associateBy { it.id }
val installed = protons(context).groupBy { it.id }
var changed = false
for (key in listOf("queued", "active")) {
val byProton = state.sub(key)
@@ -920,7 +950,7 @@ object ComponentsManager {
for (comp in comps.keys().asSequence().toList()) {
val entry = comps.optJSONObject(comp) ?: continue
if (entry.optString("file") != name) continue
check(!liveSelection(key, entry, installed[pid])) { context.getString(R.string.cmgr_package_in_use, name, installed[pid]?.name ?: pid) }
check(!liveOnAny(key, entry, installed[pid])) { context.getString(R.string.cmgr_package_in_use, name, installed[pid]?.firstOrNull()?.name ?: pid) }
comps.remove(comp)
changed = true
}
@@ -967,9 +997,16 @@ object ComponentsManager {
if (!force && cache.isFile) runCatching { return parseCatalog(JSONObject(cache.readText())) }
if (!force) return Catalog(emptyList(), 0)
val items = JSONArray()
val previous = if (cache.isFile) runCatching { JSONObject(cache.readText()).optJSONArray("items") }.getOrNull() else null
var fetched = 0
for ((repo, tag) in SOURCES) {
val body = Downloader.downloadString("https://api.github.com/repos/$repo/releases/tags/$tag") ?: continue
val assets = runCatching { JSONObject(body).optJSONArray("assets") }.getOrNull() ?: continue
val body = Downloader.downloadString("https://api.github.com/repos/$repo/releases/tags/$tag")
val assets = body?.let { runCatching { JSONObject(it).optJSONArray("assets") }.getOrNull() }
if (assets == null) {
keepSource(previous, tag, items)
continue
}
fetched++
for (i in 0 until assets.length()) {
val a = assets.getJSONObject(i)
val name = a.optString("name")
@@ -980,13 +1017,18 @@ object ComponentsManager {
.put("url", a.optString("browser_download_url")).put("size", a.optLong("size")).put("digest", a.optString("digest")))
}
}
if (items.length() == 0 && cache.isFile) runCatching { return parseCatalog(JSONObject(cache.readText())) }
if (fetched == 0 && cache.isFile) runCatching { return parseCatalog(JSONObject(cache.readText())) }
val out = JSONObject().put("items", items).put("fetchedAt", System.currentTimeMillis() / 1000)
val tmp = File(cache.parentFile, cache.name + ".tmp")
tmp.writeText(out.toString()); tmp.renameTo(cache)
return parseCatalog(out)
}
internal fun keepSource(previous: JSONArray?, tag: String, into: JSONArray) {
previous ?: return
for (i in 0 until previous.length()) previous.optJSONObject(i)?.takeIf { it.optString("release") == tag }?.let(into::put)
}
private fun parseCatalog(o: JSONObject): Catalog {
val arr = o.optJSONArray("items") ?: JSONArray()
val items = (0 until arr.length()).map { arr.getJSONObject(it) }.map {
@@ -1031,6 +1073,86 @@ object ComponentsManager {
ForcedPackage(setOf(GpuInfo.Family.A6XX), "dxvk", "dxvk-2.7.1-linux.wcp", "Dxvk-Linux"),
)
private val ARM64EC_PROBE = mapOf("dxvk" to "d3d11.dll", "vkd3d" to "d3d12.dll")
fun arm64ec(context: Context, p: Proton, comp: String): Boolean? = stockProbe(context, p, comp)?.let(ComponentUpdates::arm64ec)
fun arm64x(context: Context, p: Proton, comp: String): Boolean? = stockProbe(context, p, comp)?.let(ComponentUpdates::arm64x)
fun packageArm64x(context: Context, file: String, comp: String): Boolean {
val dir = "${COMP_DIR[comp] ?: return false}/aarch64-windows/"
val f = File(packagesDir(context), safeName(file)).takeIf { it.isFile } ?: return false
val dlls = runCatching {
readWcp(f) { tar ->
val found = HashMap<String, ByteArray>()
while (true) {
val e = tar.nextTarEntry ?: break
val rel = normalize(e.name)
if (!e.isDirectory && rel.startsWith(dir) && '/' !in rel.removePrefix(dir)) found[rel.removePrefix(dir)] = tar.readBytes()
}
found
}
}.getOrNull() ?: return false
return ComponentUpdates.allArm64x(dlls, COMP_DLLS.getValue(comp))
}
private fun readWcpEntry(f: File, rel: String): ByteArray? = readWcp(f) { tar ->
var found: ByteArray? = null
while (found == null) {
val e = tar.nextTarEntry ?: break
if (!e.isDirectory && normalize(e.name) == rel) found = tar.readBytes()
}
found
}
private fun stockProbe(context: Context, p: Proton, comp: String): ByteArray? {
val rel = "${COMP_DIR[comp] ?: return null}/aarch64-windows/${ARM64EC_PROBE.getValue(comp)}"
val original = synchronized(lock) {
val swapped = loadState(context).optJSONObject("active")?.optJSONObject(p.id)?.optJSONObject(comp)
?.let { liveSelection("active", it, p) } == true
if (!swapped) return File(p.dir, rel).takeIf { it.isFile }?.let { runCatching { it.readBytes() }.getOrNull() }
originalFile(context, p, comp).takeIf { it.isFile } ?: return null
}
return runCatching { readWcpEntry(original, rel) }.getOrNull()
}
fun supports(p: Proton, comp: String): Boolean =
if (comp == "fex") File(p.dir, FEX_FILES.first()).isFile else File(p.dir, "${COMP_DIR.getValue(comp)}/aarch64-windows").isDirectory
fun hardwareDxvk(context: Context): Boolean =
runtimeManaged(context, "dxvk") || FORCED_PACKAGES.any { it.comp == "dxvk" && it.matches(GpuInfo.detect()) } ||
(com.droiddeck.launcher.core.DeviceSupport.vkBridge(context) && !SessionPrefs.bridgeProtonDxvk(context) &&
bridgeDxvk(AppVulkan.detect(context)) != null)
data class Choice(val file: String, val auto: Boolean, val protonVersion: String)
fun activeChoices(context: Context): Map<String, Map<String, Choice>> = synchronized(lock) {
val active = loadState(context).optJSONObject("active") ?: return emptyMap()
active.keys().asSequence().toList().associateWith { pid ->
val comps = active.optJSONObject(pid) ?: JSONObject()
comps.keys().asSequence().toList().mapNotNull { c ->
comps.optJSONObject(c)?.let { c to Choice(it.optString("file"), it.optBoolean("auto"), it.optString("protonVersion")) }
}.toMap()
}
}
fun selectedPackages(context: Context): Set<String> = synchronized(lock) {
packagesInUse(loadState(context), protons(context))
}
fun deleteUnused(context: Context, file: String): Boolean = synchronized(lock) {
val name = safeName(file)
val f = File(packagesDir(context), name)
if (!f.isFile || name in packagesInUse(loadState(context), protons(context))) return false
deletePackage(context, name)
true
}
fun storedFiles(context: Context): List<String> =
packagesDir(context).listFiles()?.filter { it.isFile && it.name.endsWith(".wcp") }?.map { it.name } ?: emptyList()
fun isStored(context: Context, file: String): Boolean = File(packagesDir(context), safeName(file)).isFile
internal fun forcedPackageWanted(activeFile: String?, protonVersionMatches: Boolean): Boolean =
activeFile.isNullOrEmpty() || !protonVersionMatches
@@ -21,7 +21,7 @@ object PerfMode {
val cur = display.mode
val cap = minOf(peakRefreshCap(a), refreshLimit.takeIf { it > 0 }?.toFloat() ?: Float.POSITIVE_INFINITY)
val best = selectMode(display.supportedModes, cur, fpsLimit, cap)
if (best != null && a.window.attributes.preferredDisplayModeId != best.modeId) {
if (best != null && needsChange(display.supportedModes, a.window.attributes.preferredDisplayModeId, best)) {
a.window.attributes = a.window.attributes.apply { preferredDisplayModeId = best.modeId }
"display ${cur.refreshRate.toInt()} → ${best.refreshRate.toInt()} Hz (mode ${best.modeId})"
} else "display ${cur.refreshRate.toInt()} Hz"
@@ -64,6 +64,11 @@ object PerfMode {
?: sameSize.minByOrNull { it.refreshRate }
}
internal fun needsChange(modes: Array<Display.Mode>, preferredId: Int, best: Display.Mode): Boolean {
val preferred = modes.firstOrNull { preferredId != 0 && it.modeId == preferredId } ?: return true
return Math.abs(preferred.refreshRate - best.refreshRate) > 0.5f
}
fun cadenceFits(hz: Float, fpsLimit: Int): Boolean {
if (fpsLimit <= 0) return true
val ratio = hz / fpsLimit
@@ -11,12 +11,4 @@ object PipPolicy {
height.toDouble() / width > 2.39 -> 100 to 239
else -> width to height
}
fun outputSize(width: Int, height: Int, outputWidth: Int, outputHeight: Int): Pair<Int, Int>? {
if (width <= 0 || height <= 0 || outputWidth <= 0 || outputHeight <= 0) return null
val scale = minOf(width.toFloat() / outputWidth, height.toFloat() / outputHeight)
val fittedWidth = ((outputWidth * scale).toInt() and 1.inv()).coerceAtLeast(2)
val fittedHeight = ((outputHeight * scale).toInt() and 1.inv()).coerceAtLeast(2)
return fittedWidth to fittedHeight
}
}
@@ -351,6 +351,8 @@ object SessionPrefs {
}
const val PC_KEYBOARD_SIZE_DEFAULT = 47
const val WOW_CHAT_VOICE = "voice"
const val WOW_CHAT_KEYBOARD = "keyboard"
val PC_KEYBOARD_SIZE_RANGE = 25..70
const val PC_KEYBOARD_OPACITY_DEFAULT = 100
val PC_KEYBOARD_OPACITY_RANGE = 30..100
@@ -362,6 +364,13 @@ object SessionPrefs {
prefs(context).edit().putInt("pcKeyboardSize", percent.coerceIn(PC_KEYBOARD_SIZE_RANGE)).apply()
}
fun wowChatInput(context: Context): String? =
prefs(context).getString("wowChatInput", null)?.takeIf { it == WOW_CHAT_VOICE || it == WOW_CHAT_KEYBOARD }
fun setWowChatInput(context: Context, mode: String?) {
prefs(context).edit().apply { if (mode == null) remove("wowChatInput") else putString("wowChatInput", mode) }.apply()
}
fun pcKeyboardOpacity(context: Context): Int =
prefs(context).getInt("pcKeyboardOpacity", PC_KEYBOARD_OPACITY_DEFAULT).takeIf { it in PC_KEYBOARD_OPACITY_RANGE } ?: PC_KEYBOARD_OPACITY_DEFAULT
@@ -499,6 +508,17 @@ object SessionPrefs {
prefs(context).edit().putString("gpuDriverMode", mode).apply()
}
fun componentMode(context: Context, comp: String): String =
prefs(context).getString("componentMode.$comp", ComponentUpdates.AUTO)
?.takeIf { it == ComponentUpdates.AUTO || it == ComponentUpdates.MANUAL } ?: ComponentUpdates.AUTO
fun componentModeChosen(context: Context, comp: String): Boolean = prefs(context).contains("componentMode.$comp")
fun setComponentMode(context: Context, comp: String, mode: String) {
require(mode == ComponentUpdates.AUTO || mode == ComponentUpdates.MANUAL)
prefs(context).edit().putString("componentMode.$comp", mode).commit()
}
fun settleGpuDriverMode(context: Context) {
val p = prefs(context)
if (p.contains("gpuDriverMode")) return
@@ -30,7 +30,7 @@ object SettingsReset {
)
internal val SESSION_PREFIXES = listOf(
"resolutionCap.", "displayResolution.", "customRes.", "hdr.", "fpsLimit.", "performanceMode.", "suspendPolicy.",
"resolutionCap.", "displayResolution.", "customRes.", "hdr.", "fpsLimit.", "performanceMode.", "suspendPolicy.", "componentMode.",
)
private val STEAM_TOUCH_KEYS = listOf("feedback", "autoFade", "gyro", "shake")
@@ -39,7 +39,7 @@ object GogComet {
var status by mutableStateOf("off")
private set
fun enabled(context: Context) = GogPrefs.get(context).getBoolean("achievements", false)
fun enabled(context: Context) = GogPrefs.get(context).getBoolean("achievements", true)
fun setEnabled(context: Context, on: Boolean) {
GogPrefs.get(context).edit().putBoolean("achievements", on).apply()
@@ -49,7 +49,9 @@ import androidx.compose.ui.input.InputMode
import androidx.compose.ui.layout.ContentScale
import androidx.compose.ui.platform.LocalConfiguration
import android.content.Context
import androidx.compose.runtime.DisposableEffect
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.platform.LocalLifecycleOwner
import androidx.compose.ui.res.pluralStringResource
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.semantics.Role
@@ -60,6 +62,8 @@ import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.Dp
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import androidx.lifecycle.Lifecycle
import androidx.lifecycle.LifecycleEventObserver
import coil.compose.AsyncImage
import com.droiddeck.launcher.R
import com.droiddeck.launcher.frontend.AddedGameArt
@@ -192,7 +196,7 @@ internal fun SteamGameArtEditor(
game: com.droiddeck.launcher.frontend.Library.SteamGame, onClose: () -> Unit, onChanged: () -> Unit,
) {
val target = remember(game.appId) { ArtTarget.of(game) }
ArtOnlyEditor(game.name, target, game, { SteamGameArt.describe(it, game) }, onClose, onChanged, launchAppId = game.appId)
ArtOnlyEditor(game.name, target, game, { SteamGameArt.describe(it, game) }, onClose, onChanged)
}
@Composable
@@ -208,25 +212,17 @@ internal fun StoreGameArtEditor(folder: String, name: String, onClose: () -> Uni
@Composable
private fun ArtOnlyEditor(
name: String, target: ArtTarget?, refresh: Any?, describe: (Context) -> List<AddedGameArt.SlotState>,
onClose: () -> Unit, onChanged: () -> Unit, launchAppId: Int? = null,
onClose: () -> Unit, onChanged: () -> Unit,
) {
val app = LocalContext.current.applicationContext
val colors = MaterialTheme.colorScheme
val scope = rememberCoroutineScope()
val shown = rememberShown(onClose)
val close = { shown.targetState = false }
var version by remember { mutableIntStateOf(0) }
var arts by remember { mutableStateOf<List<AddedGameArt.SlotState>>(emptyList()) }
var artSlot by remember { mutableStateOf<AddedGameArt.Slot?>(null) }
var logoOpen by remember { mutableStateOf(false) }
var launchOptions by remember { mutableStateOf<String?>(null) }
var launchVersion by remember { mutableIntStateOf(0) }
var editingLaunch by remember { mutableStateOf<String?>(null) }
var launchSaving by remember { mutableStateOf(false) }
var launchError by remember { mutableStateOf<Int?>(null) }
val position = rememberLogoPosition(target, version)
LaunchedEffect(version, refresh) { if (target != null) arts = withContext(Dispatchers.IO) { describe(app) } }
LaunchedEffect(launchAppId, launchVersion) { if (launchAppId != null) launchOptions = withContext(Dispatchers.IO) { SteamLaunchOptions.current(app, launchAppId) } }
val first = remember { FocusRequester() }
LaunchedEffect(Unit) { focusWithinFrames({ false }) { first } }
AppDialog(shown, close, "artEditor", wide = false, maxWidth = 460.dp) {
@@ -236,19 +232,10 @@ private fun ArtOnlyEditor(
verticalArrangement = Arrangement.spacedBy(6.dp),
modifier = Modifier.fillMaxWidth().heightIn(max = maxHeight).verticalScroll(rememberScrollState()),
) {
if (launchAppId != null) {
FieldRow(stringResource(R.string.edit_launch_options), focus = first, onClick = { if (!launchSaving) launchOptions?.let { editingLaunch = it } }) {
val options = launchOptions
if (options == null || launchSaving) Loading() else ValueText(options.ifEmpty { "—" }, muted = options.isEmpty())
}
val error = launchError
if (error != null) Text(stringResource(error), fontSize = 12.sp, color = colors.error, modifier = Modifier.padding(start = 4.dp))
else Text(stringResource(R.string.edit_launch_options_steam_hint), fontSize = 12.sp, color = colors.onSurfaceVariant, modifier = Modifier.padding(start = 4.dp))
}
Text(stringResource(R.string.edit_artwork), fontSize = 12.sp, color = colors.onSurfaceVariant, modifier = Modifier.padding(start = 4.dp, top = if (launchAppId != null) 8.dp else 2.dp))
Text(stringResource(R.string.edit_artwork), fontSize = 12.sp, color = MaterialTheme.colorScheme.onSurfaceVariant, modifier = Modifier.padding(start = 4.dp, top = 2.dp))
for (slot in AddedGameArt.Slot.values()) key(slot) {
val state = arts.firstOrNull { it.slot == slot }
FieldRow(slot.label(), focus = if (slot == AddedGameArt.Slot.COVER && launchAppId == null) first else null, onClick = { if (target != null) artSlot = slot }) {
FieldRow(slot.label(), focus = if (slot == AddedGameArt.Slot.COVER) first else null, onClick = { if (target != null) artSlot = slot }) {
Row(verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(10.dp)) {
SlotThumb(slot, state?.file)
if (state != null) ValueText(artSourceLabel(state.source), muted = true) else Loading()
@@ -258,27 +245,6 @@ private fun ArtOnlyEditor(
PickerRow(Icons.Outlined.Tune, stringResource(R.string.art_adjust_logo), logoPositionSummary(position?.value), enabled = target != null && position != null) { logoOpen = true }
}
}
val before = editingLaunch
if (before != null && launchAppId != null) TextEntryDialog(stringResource(R.string.edit_launch_options), before, onDismiss = { editingLaunch = null }) { value ->
if (launchSaving || value == before.trim()) return@TextEntryDialog
launchSaving = true
scope.launch {
try {
val saved = withContext(Dispatchers.IO) { SteamLaunchOptions.set(app, launchAppId, value, before) }
launchError = when (saved) {
SteamLaunchOptions.Saved.YES -> null
SteamLaunchOptions.Saved.DESKTOP -> R.string.edit_launch_options_desktop
SteamLaunchOptions.Saved.NO -> R.string.edit_launch_options_not_saved
}
if (saved == SteamLaunchOptions.Saved.YES) launchOptions = value else {
launchOptions = null
launchVersion++
}
} finally {
launchSaving = false
}
}
}
val chosen = target ?: return
artSlot?.let { slot ->
ArtChooserDialog(chosen, slot, arts.firstOrNull { it.slot == slot }, onBack = { artSlot = null }, onCloseAll = { artSlot = null; close() }) { version++; onChanged() }
@@ -287,6 +253,72 @@ private fun ArtOnlyEditor(
if (logoOpen && loaded != null) LogoPositionHost(chosen, arts, loaded, onBack = { logoOpen = false }, onCloseAll = { logoOpen = false; close() }) { version++; onChanged() }
}
@Composable
internal fun SteamLaunchOptionsPanel(appId: Int) {
val app = LocalContext.current.applicationContext
val colors = MaterialTheme.colorScheme
val scope = rememberCoroutineScope()
var options by remember(appId) { mutableStateOf<String?>(null) }
var version by remember(appId) { mutableIntStateOf(0) }
var editing by remember(appId) { mutableStateOf<String?>(null) }
var saving by remember(appId) { mutableStateOf(false) }
var loading by remember(appId) { mutableStateOf(false) }
var error by remember(appId) { mutableStateOf<Int?>(null) }
LaunchedEffect(appId, version) {
options = withContext(Dispatchers.IO) { SteamLaunchOptions.current(app, appId) } ?: options.orEmpty()
loading = false
}
val lifecycle = LocalLifecycleOwner.current.lifecycle
DisposableEffect(lifecycle, appId) {
val observer = LifecycleEventObserver { _, event ->
if (event == Lifecycle.Event.ON_RESUME && !saving) {
loading = true
version++
}
}
lifecycle.addObserver(observer)
onDispose { lifecycle.removeObserver(observer) }
}
Column(Modifier.fillMaxWidth().tabPanel(), verticalArrangement = Arrangement.spacedBy(2.dp)) {
val value = options
MenuItem(
stringResource(R.string.edit_launch_options), checked = false, enabled = value != null && !saving && !loading,
detail = if (value == null || saving || loading) "…" else value.ifEmpty { "—" },
) { if (!saving && !loading) options?.let { editing = it } }
val message = error
Text(
stringResource(message ?: R.string.edit_launch_options_steam_hint),
fontSize = 12.sp,
color = if (message == null) colors.onSurfaceVariant else colors.error,
modifier = Modifier.padding(start = 4.dp),
)
}
val before = editing
if (before != null) TextEntryDialog(stringResource(R.string.edit_launch_options), before, onDismiss = { editing = null }) { value ->
if (saving || value == before.trim()) return@TextEntryDialog
saving = true
scope.launch {
try {
val saved = withContext(Dispatchers.IO) { SteamLaunchOptions.set(app, appId, value, before) }
error = when (saved) {
SteamLaunchOptions.Saved.YES -> null
SteamLaunchOptions.Saved.DESKTOP -> R.string.edit_launch_options_desktop
SteamLaunchOptions.Saved.NO -> R.string.edit_launch_options_not_saved
}
if (saved == SteamLaunchOptions.Saved.YES) {
options = value
version++
} else {
options = null
version++
}
} finally {
saving = false
}
}
}
}
private class LoadedPosition(val value: LogoPosition.Value?)
@Composable
@@ -11,6 +11,7 @@ import androidx.compose.foundation.text.KeyboardActions
import androidx.compose.foundation.text.KeyboardOptions
import androidx.compose.material3.Button
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.OutlinedButton
import androidx.compose.material3.OutlinedTextField
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
@@ -110,7 +111,16 @@ internal class BottomScreenTextInput(private val fallback: TextInputService) : P
}
@Composable
internal fun TypingPanel(request: TypingRequest, onDone: () -> Unit) {
internal fun TypingPanel(
request: TypingRequest,
modifier: Modifier = Modifier.fillMaxSize(),
title: String = stringResource(R.string.dual_typing_title),
done: String = stringResource(R.string.dual_typing_done),
onCancel: (() -> Unit)? = null,
leading: (@Composable () -> Unit)? = null,
showKeyboard: Boolean = true,
onDone: () -> Unit,
) {
val colors = MaterialTheme.colorScheme
var field by remember(request) { mutableStateOf(request.value) }
LaunchedEffect(request.value) {
@@ -120,19 +130,22 @@ internal fun TypingPanel(request: TypingRequest, onDone: () -> Unit) {
val keyboard = LocalSoftwareKeyboardController.current
LaunchedEffect(request) {
kotlinx.coroutines.delay(120)
if (!showKeyboard) return@LaunchedEffect
runCatching { focus.requestFocus() }
keyboard?.show()
}
val options = request.imeOptions
val secret = options.keyboardType == KeyboardType.Password || options.keyboardType == KeyboardType.NumberPassword
Column(
Modifier.fillMaxSize().background(colors.background).padding(horizontal = 20.dp, vertical = 14.dp),
modifier.background(colors.background).padding(horizontal = 20.dp, vertical = 14.dp),
verticalArrangement = Arrangement.spacedBy(10.dp),
) {
Row(Modifier.fillMaxWidth(), verticalAlignment = Alignment.CenterVertically) {
Text(stringResource(R.string.dual_typing_title), fontSize = 15.sp, fontWeight = FontWeight.SemiBold,
Row(Modifier.fillMaxWidth(), verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(8.dp)) {
leading?.invoke()
Text(title, fontSize = 15.sp, fontWeight = FontWeight.SemiBold,
color = colors.onSurfaceVariant, modifier = Modifier.weight(1f))
Button(onClick = onDone, modifier = Modifier.testTag("dual-typing-done")) { Text(stringResource(R.string.dual_typing_done)) }
if (onCancel != null) OutlinedButton(onClick = onCancel, modifier = Modifier.testTag("dual-typing-cancel")) { Text(stringResource(R.string.common_cancel)) }
Button(onClick = onDone, modifier = Modifier.testTag("dual-typing-done")) { Text(done) }
}
OutlinedTextField(
value = field,
@@ -81,6 +81,8 @@ import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import com.droiddeck.launcher.R
import com.droiddeck.launcher.session.ComponentUpdater
import com.droiddeck.launcher.session.ComponentUpdates
import com.droiddeck.launcher.session.ComponentsManager
import com.droiddeck.launcher.session.ComponentsManager.CatalogItem
import com.droiddeck.launcher.session.ComponentsManager.Snapshot
@@ -163,6 +165,11 @@ fun ComponentsPage(
protons: ProtonsState = ProtonsState(),
protonActions: ProtonsActions = ProtonsActions(),
protonOptions: @Composable () -> Unit = {},
compModes: Map<String, String> = emptyMap(),
compAuto: Map<String, List<ComponentUpdater.Status>> = emptyMap(),
compAutoHeld: Set<String> = emptySet(),
compAutoChecking: Boolean = false,
onCompMode: (String, String) -> Unit = { _, _ -> },
) {
val colors = MaterialTheme.colorScheme
val pal = LocalPalette.current
@@ -521,6 +528,9 @@ fun ComponentsPage(
else -> stringResource(R.string.comp_nightlies_count, availableRows(l, catalog, snapshot).size)
}
}
val followed = view?.proton?.let(ComponentUpdates::follows) == true
val moded: (String) -> Boolean = { followed && it in compModes && it !in compAutoHeld && !ComponentsManager.runtimeManaged(it, gpu.systemPowerVr) }
val automatic: (String) -> Boolean = { moded(it) && compModes[it] == ComponentUpdates.AUTO }
val installedRows: @Composable ColumnScope.(String, Boolean) -> Unit = { l, right ->
when (l) {
PROTON_TAB -> {
@@ -535,7 +545,7 @@ fun ComponentsPage(
(if (swaps > 0) stringResource(R.string.comp_swapped, v.proton.version, swaps) else stringResource(R.string.comp_all_original, v.proton.version))
.let { if (v.inUseByGame) stringResource(R.string.comp_game_running_suffix, it) else it },
v.proton.id == view?.proton?.id, removable = removable,
modifier = (if (i == 0) Modifier.focusRequester(firstRow) else Modifier).trackRow(key, rowsAt),
modifier = (if (i == 0 && !moded(l)) Modifier.focusRequester(firstRow) else Modifier).trackRow(key, rowsAt),
onSelect = { if (v.proton.id != view?.proton?.id) { onProton(v.proton.id); flyHome(key, PROTON_TAB) } },
onDelete = {
if (extra != null) ask(key, right, ctx.getString(R.string.proton_remove_title, v.proton.name), ctx.getString(R.string.proton_remove_body), ctx.getString(R.string.store_remove), true, v.proton.name) {
@@ -555,7 +565,7 @@ fun ComponentsPage(
val key = "row:$l:${item.file ?: item.protonVersion}"
ChoiceLine(
item.name, item.detail, item.selected, item.removable, tag = item.tag, enabled = !managedDxvk,
modifier = (if (i == 0 && !managedDxvk) Modifier.focusRequester(firstRow) else Modifier).trackRow(key, rowsAt),
modifier = (if (i == 0 && !managedDxvk && !moded(l)) Modifier.focusRequester(firstRow) else Modifier).trackRow(key, rowsAt),
onSelect = {
if (managedDxvk || item.selected || p == null) return@ChoiceLine
val waits = if (view.inUseByGame) ctx.getString(R.string.comp_waits_long) else ctx.getString(R.string.comp_applies_long)
@@ -680,15 +690,31 @@ fun ComponentsPage(
label = "layerRows",
) { k -> body(k) }
}
if (single) {
slide { k -> RowGroup(installedTitle(k)) { installedRows(k, false) } }
Spacer(Modifier.height(12.dp))
slide { k -> RowGroup(availableTitle(k)) { availableRows(k) } }
} else Row(horizontalArrangement = Arrangement.spacedBy(14.dp), verticalAlignment = Alignment.Top) {
Column(Modifier.weight(1f)) { slide { k -> RowGroup(installedTitle(k)) { installedRows(k, false) } } }
Column(Modifier.weight(1f)) { slide { k -> RowGroup(availableTitle(k)) { availableRows(k) } } }
if (moded(l)) slide { k ->
if (moded(k)) Column(verticalArrangement = Arrangement.spacedBy(14.dp), modifier = Modifier.padding(bottom = 14.dp)) {
PanelHeading(stringResource(R.string.comp_mode_title))
ModeChoices(
listOf(
Triple(ComponentUpdates.AUTO, R.string.common_auto, R.string.comp_mode_auto_detail),
Triple(ComponentUpdates.MANUAL, R.string.drivers_manual, R.string.comp_mode_manual_detail),
),
if (automatic(k)) ComponentUpdates.AUTO else ComponentUpdates.MANUAL, single, firstRow,
onPick = { onCompMode(k, it) },
)
if (automatic(k)) AutoComponentBody(ComponentsManager.LABEL[k] ?: k, compAuto[k].orEmpty(), compAutoChecking)
}
}
if (!automatic(l)) {
if (single) {
slide { k -> RowGroup(installedTitle(k)) { installedRows(k, false) } }
Spacer(Modifier.height(12.dp))
slide { k -> RowGroup(availableTitle(k)) { availableRows(k) } }
} else Row(horizontalArrangement = Arrangement.spacedBy(14.dp), verticalAlignment = Alignment.Top) {
Column(Modifier.weight(1f)) { slide { k -> RowGroup(installedTitle(k)) { installedRows(k, false) } } }
Column(Modifier.weight(1f)) { slide { k -> RowGroup(availableTitle(k)) { availableRows(k) } } }
}
Box(Modifier.padding(top = 14.dp)) { slide { k -> actions(k) } }
}
Box(Modifier.padding(top = 14.dp)) { slide { k -> actions(k) } }
if (l == "fex") {
SettingsGroup(stringResource(R.string.fex_preset_title)) {
SettingsRow(stringResource(R.string.fex_preset_title), null) {
@@ -1078,6 +1104,25 @@ private fun RowGroup(title: String, content: @Composable ColumnScope.() -> Unit)
}
}
@Composable
private fun AutoComponentBody(label: String, lines: List<ComponentUpdater.Status>, checking: Boolean) {
val colors = MaterialTheme.colorScheme
Column(verticalArrangement = Arrangement.spacedBy(10.dp)) {
PanelTitle(stringResource(R.string.comp_auto_title, label), stringResource(R.string.comp_auto_hint))
if (lines.isEmpty()) EmptyLine(stringResource(R.string.comp_auto_none))
for (line in lines) {
val previous = line.previous.removeSuffix(".wcp").ifEmpty { stringResource(R.string.comp_tag_original) }
val fallback = if (line.current.isEmpty()) null else stringResource(R.string.comp_auto_fallback, previous)
PanelRow(
line.current.removeSuffix(".wcp").ifEmpty { stringResource(if (checking) R.string.comp_auto_checking else R.string.comp_tag_original) },
listOfNotNull(line.protons.joinToString(", ").ifEmpty { null }, fallback).joinToString(" · "),
selected = true, onClick = null,
)
}
lines.map { it.note }.filter { it.isNotEmpty() }.distinct().forEach { Text(it, fontSize = 13.sp, color = colors.onSurfaceVariant) }
}
}
@Composable
private fun EmptyLine(text: String) {
Text(text, fontSize = 13.sp, color = MaterialTheme.colorScheme.onSurfaceVariant, modifier = Modifier.padding(horizontal = 14.dp, vertical = 12.dp))
@@ -122,6 +122,7 @@ class FrontEndState(
val frameGen: FrameGen.Mode = FrameGen.Mode.OFF,
val lossless: Lossless.State = Lossless.State.NONE,
val shortcutPicker: Boolean = false,
val preparingGames: Set<String> = emptySet(),
val shortcutLibraryScanning: Boolean = false,
val gameSyncFolder: String? = null,
val pageKey: String? = null,
@@ -188,6 +189,7 @@ class FrontEndActions(
val onPlayDesktopUi: () -> Unit,
val onSteamUrl: (String) -> Unit = {},
val onSteamGame: (Library.SteamGame) -> Unit,
val onStoreGame: (com.droiddeck.launcher.stores.Store, String) -> Unit = { _, _ -> },
val onGameShortcut: (Library.SteamGame) -> Unit = {},
val onExportGameFile: (Library.SteamGame) -> Unit = {},
val onSyncGameFiles: () -> Unit = {},
@@ -56,6 +56,7 @@ import androidx.compose.ui.draw.alpha
import androidx.compose.ui.draw.clip
import androidx.compose.ui.draw.rotate
import androidx.compose.ui.graphics.Brush
import androidx.compose.ui.focus.focusProperties
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.graphicsLayer
import androidx.compose.ui.semantics.Role
@@ -150,6 +151,27 @@ internal fun Chip(t: String, ok: Boolean) {
@Composable
internal fun rememberHot(src: MutableInteractionSource): Boolean = src.collectIsFocusedAsState().value || src.collectIsHoveredAsState().value
@Composable
internal fun DialogToggleRow(label: String, checked: Boolean, onChange: (Boolean) -> Unit) {
val pal = LocalPalette.current
val src = remember { MutableInteractionSource() }
val hot = rememberHot(src)
val flip = { onChange(!checked) }
Row(
verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(10.dp),
modifier = Modifier.fillMaxWidth().clip(Shape12)
.background(if (hot) pal.signal.copy(alpha = 0.12f) else Color.Transparent)
.border(if (hot) 2.dp else 1.dp, if (hot) pal.signal else pal.line, Shape12)
.hoverable(src)
.clickable(interactionSource = src, indication = null, role = Role.Switch, onClick = flip)
.controllerConfirm(onClick = flip)
.padding(start = 14.dp, end = 4.dp),
) {
Text(label, fontSize = 14.sp, color = MaterialTheme.colorScheme.onBackground, modifier = Modifier.weight(1f))
ToggleSwitch(checked, label = label, modifier = Modifier.focusProperties { canFocus = false }) { onChange(it) }
}
}
@Composable
internal fun PrimaryButton(
text: String,
@@ -408,11 +408,13 @@ internal fun GameActions(
!cloud && (game != null || installed != null) -> {
val gone = storeKey?.let { StoresState.uninstalling[it] }
val xboxBusy = store == Store.XBOX && id != null && XboxContent.busy(id)
val preparing = (game?.launchKey ?: storeKey)?.let { it in s.preparingGames } == true
PrimaryButton(
stringResource(R.string.stores_play), enabled = !s.busy && gone == null && download == null && !xboxBusy && (store != Store.GOG || installed?.sidecar?.isInstalled != false),
stringResource(if (preparing) R.string.store_starting else R.string.stores_play),
enabled = !s.busy && !preparing && gone == null && download == null && !xboxBusy && (store != Store.GOG || installed?.sidecar?.isInstalled != false),
main = true, icon = Icons.Filled.PlayArrow, modifier = leadOn,
) {
if (game != null) a.onSteamGame(game) else if (store != null && id != null) launchStoreGame(ctx, store, id, s, a)
if (game != null) a.onSteamGame(game) else if (store != null && id != null) launchStoreGame(store, id, s, a)
}
if (game != null) {
if (compactMenu) GameMoreMenu(game, a) else {
@@ -808,11 +810,17 @@ private fun LaunchTab(game: Library.SteamGame, s: FrontEndState, a: FrontEndActi
val host = rememberMenuHost()
Column(verticalArrangement = Arrangement.spacedBy(12.dp), modifier = Modifier.fillMaxWidth()) {
LaunchSettings(s, a, host, game, hopRail ?: GameRef.key(game))
if (canEditSteamLaunchOptions(game, s.shortcutPicker)) {
SteamLaunchOptionsPanel(game.appId)
}
val folder = game.gameFiles
if (game.library == Library.ADDED && game.source == Library.ADDED && !s.shortcutPicker && folder != null) ShortcutFields(game, folder)
}
}
internal fun canEditSteamLaunchOptions(game: Library.SteamGame, shortcutPicker: Boolean): Boolean =
!shortcutPicker && game.source == Library.SOURCE_STEAM && game.library != Library.ADDED
@Composable
private fun ShortcutFields(game: Library.SteamGame, folder: File) {
val ctx = LocalContext.current
@@ -250,18 +250,8 @@ internal fun GpuDriversBody(
Triple(GPU_MODE_PAIR, R.string.gpu_mode_pair, R.string.gpu_mode_pair_detail),
Triple(GPU_MODE_MIX, R.string.gpu_mode_mix, R.string.gpu_mode_mix_detail),
)
val choice: @Composable (Int, Modifier) -> Unit = { i, mod ->
val (id, title, detail) = modes[i]
PanelRow(
stringResource(title), stringResource(detail), gpu.mode == id,
modifier = mod.then(if (gpu.mode == id) Modifier.focusRequester(first) else Modifier)
.then(if (i == 0) Modifier.onGloballyPositioned { placed[1] = it }.onFocusChanged { if (it.isFocused) revealCard() } else Modifier),
onClick = { if (gpu.mode != id) actions.onMode(id) },
)
}
if (single) Column(verticalArrangement = Arrangement.spacedBy(8.dp)) { modes.indices.forEach { choice(it, Modifier) } }
else Row(horizontalArrangement = Arrangement.spacedBy(10.dp), modifier = Modifier.fillMaxWidth().height(IntrinsicSize.Min)) {
modes.indices.forEach { choice(it, Modifier.weight(1f).fillMaxHeight()) }
ModeChoices(modes, gpu.mode, single, first, onPick = actions.onMode) { i ->
if (i == 0) Modifier.onGloballyPositioned { placed[1] = it }.onFocusChanged { if (it.isFocused) revealCard() } else Modifier
}
when (gpu.mode) {
GPU_MODE_AUTO -> AutoBody(gpu, actions)
@@ -439,12 +429,35 @@ internal const val SLOT_DISPLAY = "panel"
internal const val SLOT_RUNTIME = "rt"
@Composable
private fun PanelHeading(text: String) {
internal fun ModeChoices(
modes: List<Triple<String, Int, Int>>,
selected: String,
single: Boolean,
first: FocusRequester,
onPick: (String) -> Unit,
extra: (Int) -> Modifier = { Modifier },
) {
val choice: @Composable (Int, Modifier) -> Unit = { i, mod ->
val (id, title, detail) = modes[i]
PanelRow(
stringResource(title), stringResource(detail), selected == id,
modifier = mod.then(if (selected == id) Modifier.focusRequester(first) else Modifier).then(extra(i)),
onClick = { if (selected != id) onPick(id) },
)
}
if (single) Column(verticalArrangement = Arrangement.spacedBy(8.dp)) { modes.indices.forEach { choice(it, Modifier) } }
else Row(horizontalArrangement = Arrangement.spacedBy(10.dp), modifier = Modifier.fillMaxWidth().height(IntrinsicSize.Min)) {
modes.indices.forEach { choice(it, Modifier.weight(1f).fillMaxHeight()) }
}
}
@Composable
internal fun PanelHeading(text: String) {
Text(text.uppercase(), fontSize = 12.sp, fontWeight = FontWeight.SemiBold, letterSpacing = 1.5.sp, color = MaterialTheme.colorScheme.onSurfaceVariant)
}
@Composable
private fun PanelTitle(title: String, hint: String) {
internal fun PanelTitle(title: String, hint: String) {
Column(verticalArrangement = Arrangement.spacedBy(3.dp), modifier = Modifier.padding(top = 4.dp)) {
Text(title, fontSize = 18.sp, fontWeight = FontWeight.SemiBold, color = MaterialTheme.colorScheme.onBackground)
Text(hint, fontSize = 13.sp, color = MaterialTheme.colorScheme.onSurfaceVariant)
@@ -461,7 +474,7 @@ private fun Pill(text: String, fill: Color, ink: Color) {
}
@Composable
private fun PanelRow(
internal fun PanelRow(
title: String,
detail: String,
selected: Boolean,
@@ -46,6 +46,8 @@ class SecondScreenPresentation(
private val frame = FrameLayout(context)
private var menuView: androidx.compose.ui.platform.ComposeView? = null
private var dashboardView: androidx.compose.ui.platform.ComposeView? = null
private var chatView: androidx.compose.ui.platform.ComposeView? = null
private var chatLost: (() -> Unit)? = null
var menuShown = false
private set
private val root = LinearLayout(context).apply {
@@ -78,7 +80,7 @@ class SecondScreenPresentation(
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.R) {
root.setOnApplyWindowInsetsListener { view, insets ->
if (insets.isVisible(WindowInsets.Type.ime())) imeSeen = true
else if (imeSeen) keyboardClosed()
else if (imeSeen && chatView == null) keyboardClosed()
view.onApplyWindowInsets(insets)
}
}
@@ -128,6 +130,37 @@ class SecondScreenPresentation(
if (created && isShowing && currentMode == SecondScreenMode.TERMINAL && terminalPanel?.ready != true) renderMode()
}
fun showChat(onLost: () -> Unit, content: @androidx.compose.runtime.Composable () -> Unit) {
if (!showsKeyboard || chatView != null) return
keyboardHost?.takeIf { it.shown }?.hide()
keyboardShown.value = false
keyboardPending = false
fieldKeyboard = false
chatLost = onLost
ownComposeViews()
val view = androidx.compose.ui.platform.ComposeView(context).apply {
isFocusable = true
isFocusableInTouchMode = true
setContent { content() }
}
chatView = view
root.addView(view, 1.coerceAtMost(root.childCount), LinearLayout.LayoutParams(-1, -2))
}
fun hideChat() {
chatLost = null
dropChat()
}
private fun dropChat() {
val view = chatView ?: return
chatView = null
chatLost?.let { lost -> chatLost = null; sessionActivity.runOnUiThread(lost) }
imeSeen = false
root.removeView(view)
view.disposeComposition()
}
val showsKeyboard: Boolean get() = isShowing && !menuShown && currentMode == SecondScreenMode.KEYBOARD_TRACKPAD
val showsControls: Boolean get() = isShowing && currentMode != SecondScreenMode.NONE
@@ -178,6 +211,7 @@ class SecondScreenPresentation(
showMenu(false)
closeDashboard()
closeTerminal()
dropChat()
keyboardHost?.hide()
keyboardHost = null
dismiss()
@@ -212,6 +246,7 @@ class SecondScreenPresentation(
}
private fun renderMode() {
dropChat()
fieldKeyboard = false
keyboardHost?.hide()
keyboardHost = null
@@ -264,6 +264,9 @@ class DrawerActions(
val onImportLossless: () -> Unit,
val onKeyboard: () -> Unit,
val onHardwareKeyboard: () -> Unit,
val wowChatShown: Boolean = false,
val wowChat: String? = null,
val onWowChat: (String?) -> Unit = {},
val onSteamMenu: (() -> Unit)?,
val onQam: (() -> Unit)?,
val onAchievements: (() -> Unit)? = null,
@@ -698,6 +701,15 @@ internal fun SessionDrawer(open: Boolean, requestedPage: SessionDrawerPage, cont
host.open = null; a.onKeyboard()
}
}
if (a.wowChatShown) ChoiceRow(
host, "wow-chat", stringResource(R.string.wow_chat_title), null,
listOf(
null to stringResource(R.string.wow_chat_ask),
SessionPrefs.WOW_CHAT_VOICE to stringResource(R.string.wow_chat_voice),
SessionPrefs.WOW_CHAT_KEYBOARD to stringResource(R.string.pckb_android),
),
a.wowChat, chipModifier = focus.track(page, "wow-chat"), onPick = a.onWowChat,
)
val keyboardContext = LocalContext.current
remember { PcKeyboardPrefs.load(keyboardContext) }
SliderRow(
@@ -1,14 +1,8 @@
package com.droiddeck.launcher.ui
import androidx.compose.foundation.background
import androidx.compose.foundation.border
import androidx.compose.foundation.clickable
import androidx.compose.foundation.hoverable
import androidx.compose.foundation.interaction.MutableInteractionSource
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Usb
@@ -18,12 +12,7 @@ import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.remember
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.focus.focusProperties
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.semantics.Role
import androidx.compose.ui.focus.FocusRequester
import androidx.compose.ui.focus.focusRequester
import androidx.compose.ui.res.stringResource
@@ -48,23 +37,7 @@ internal fun SlowUsbDialog(drive: UsbLink.SlowDrive, never: Boolean, onNever: (B
stringResource(message, drive.name, UsbLink.generation(drive.mbps), speed, UsbLink.megabytesPerSecond(drive.mbps)),
fontSize = 13.sp, lineHeight = 18.sp, color = MaterialTheme.colorScheme.onSurfaceVariant,
)
val pal = LocalPalette.current
val src = remember { MutableInteractionSource() }
val hot = rememberHot(src)
val flip = { onNever(!never) }
Row(
verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(10.dp),
modifier = Modifier.fillMaxWidth().clip(Shape12)
.background(if (hot) pal.signal.copy(alpha = 0.12f) else Color.Transparent)
.border(if (hot) 2.dp else 1.dp, if (hot) pal.signal else pal.line, Shape12)
.hoverable(src)
.clickable(interactionSource = src, indication = null, role = Role.Switch, onClick = flip)
.controllerConfirm(onClick = flip)
.padding(start = 14.dp, end = 4.dp),
) {
Text(stringResource(R.string.slow_usb_never), fontSize = 14.sp, color = MaterialTheme.colorScheme.onBackground, modifier = Modifier.weight(1f))
ToggleSwitch(never, label = stringResource(R.string.slow_usb_never), modifier = Modifier.focusProperties { canFocus = false }) { onNever(it) }
}
DialogToggleRow(stringResource(R.string.slow_usb_never), never, onNever)
Row(horizontalArrangement = Arrangement.End, modifier = Modifier.fillMaxWidth()) {
PrimaryButton(stringResource(R.string.slow_usb_dismiss), modifier = Modifier.focusRequester(focus), onClick = close)
}
@@ -221,7 +221,7 @@ private fun FinishedRow(d: DownloadEntry, s: FrontEndState, a: FrontEndActions,
ArtStrip(d.cover, d.name) {
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 if (d.key == com.droiddeck.launcher.stores.battlenet.BattleNetClient.DOWNLOAD_KEY) a.onBattleNet() else launchStoreGame(ctx, d.store, d.id, s, a) }
RoundAction(Icons.Filled.PlayArrow, stringResource(R.string.stores_play), primary = true) { if (d.key == EpicClient.DOWNLOAD_KEY) a.onEpicLauncher() else if (d.key == com.droiddeck.launcher.stores.battlenet.BattleNetClient.DOWNLOAD_KEY) a.onBattleNet() else launchStoreGame(d.store, d.id, s, a) }
}
}
}
@@ -374,7 +374,7 @@ private fun DownloadCard(d: DownloadEntry, s: FrontEndState, a: FrontEndActions)
CancelAction { DownloadQueue.clear(ctx, d.key) }
}
DownloadState.INSTALLED -> Box(Modifier.bloomIn(true, 420)) {
RoundAction(Icons.Filled.PlayArrow, stringResource(R.string.stores_play), primary = true) { if (d.key == EpicClient.DOWNLOAD_KEY) a.onEpicLauncher() else if (d.key == com.droiddeck.launcher.stores.battlenet.BattleNetClient.DOWNLOAD_KEY) a.onBattleNet() else launchStoreGame(ctx, d.store, d.id, s, a) }
RoundAction(Icons.Filled.PlayArrow, stringResource(R.string.stores_play), primary = true) { if (d.key == EpicClient.DOWNLOAD_KEY) a.onEpicLauncher() else if (d.key == com.droiddeck.launcher.stores.battlenet.BattleNetClient.DOWNLOAD_KEY) a.onBattleNet() else launchStoreGame(d.store, d.id, s, a) }
}
DownloadState.CANCELLED -> {}
}
@@ -545,6 +545,5 @@ internal fun storesSettingsStep(at: Rect, s: androidx.compose.runtime.State<Fron
com.droiddeck.launcher.stores.gog.GogComet.setEnabled(context, it)
}
}
StoresEngineRow()
}
}
@@ -1430,18 +1430,7 @@ internal fun openUrl(ctx: android.content.Context, url: String) {
runCatching { ctx.startActivity(android.content.Intent(android.content.Intent.ACTION_VIEW, android.net.Uri.parse(url)).addFlags(android.content.Intent.FLAG_ACTIVITY_NEW_TASK)) }
}
internal fun launchStoreGame(ctx: android.content.Context, store: Store, id: String, s: FrontEndState, a: FrontEndActions) {
internal fun launchStoreGame(store: Store, id: String, s: FrontEndState, a: FrontEndActions) {
val game = s.steamGames.firstOrNull { it.source == store.id && it.storeId == id }
if (game != null) {
a.onSteamGame(game)
return
}
val app = ctx.applicationContext
Thread({
val installed = runCatching { com.droiddeck.launcher.stores.StoreLaunch.installedGame(app, store, id) }.getOrNull()
StoresState.post {
if (installed == null) android.widget.Toast.makeText(ctx, R.string.stores_launch_not_registered, android.widget.Toast.LENGTH_LONG).show()
else a.onSteamGame(installed)
}
}, "store-play").start()
if (game != null) a.onSteamGame(game) else a.onStoreGame(store, id)
}
@@ -1,18 +0,0 @@
package com.droiddeck.launcher.ui
import androidx.compose.runtime.Composable
import androidx.compose.ui.res.stringResource
import com.droiddeck.launcher.R
import com.droiddeck.launcher.stores.StoresNative
@Composable
internal fun StoresEngineRow() {
val version = StoresNative.version
SettingsRow(
stringResource(R.string.setup_stores_engine),
null,
) {
Chip(if (version != null) stringResource(R.string.setup_stores_engine_chip_ready) else stringResource(R.string.setup_stores_engine_chip_missing), ok = version != null)
}
}
@@ -0,0 +1,230 @@
package com.droiddeck.launcher.ui
import android.Manifest
import android.content.Context
import android.content.Intent
import android.content.pm.PackageManager
import android.os.Bundle
import android.speech.RecognitionListener
import android.speech.RecognizerIntent
import android.speech.SpeechRecognizer
import android.widget.Toast
import androidx.activity.compose.BackHandler
import androidx.activity.compose.rememberLauncherForActivityResult
import androidx.activity.result.contract.ActivityResultContracts
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Mic
import androidx.compose.material3.FilledIconToggleButton
import androidx.compose.material3.Icon
import androidx.compose.runtime.Composable
import androidx.compose.runtime.DisposableEffect
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Modifier
import androidx.compose.ui.focus.FocusRequester
import androidx.compose.ui.focus.focusRequester
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.platform.testTag
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.TextRange
import androidx.compose.ui.text.input.ImeAction
import androidx.compose.ui.text.input.ImeOptions
import androidx.compose.ui.text.input.TextFieldValue
import androidx.compose.ui.unit.dp
import com.droiddeck.launcher.R
import com.droiddeck.launcher.session.SessionPrefs
class WowChat(
private val context: Context,
private val onSend: (String, Boolean) -> Unit,
private val onCancel: () -> Unit,
private val readField: ((String?) -> Unit) -> Unit = {},
) {
var request by mutableStateOf<TypingRequest?>(null)
private set
var asking by mutableStateOf(false)
private set
var listening by mutableStateOf(false)
private set
var voice by mutableStateOf(false)
private set
var remembered by mutableStateOf(SessionPrefs.wowChatInput(context))
private set
private var recognizer: SpeechRecognizer? = null
private var spokenBase = ""
private var fieldRead = false
val open: Boolean get() = request != null || asking
fun chatOpened() {
if (open) return
when (val mode = remembered) {
null -> asking = true
else -> start(mode)
}
}
fun chatClosed() {
asking = false
finish()
}
fun pick(mode: String, remember: Boolean) {
asking = false
if (remember) choose(mode)
start(mode)
}
fun choose(mode: String?) {
SessionPrefs.setWowChatInput(context, mode)
remembered = SessionPrefs.wowChatInput(context)
}
fun dismissChoice() {
asking = false
onCancel()
}
fun send() {
val text = request?.value?.text.orEmpty()
val replace = fieldRead
finish()
onSend(text, replace)
}
fun cancel() {
finish()
onCancel()
}
private fun start(mode: String) {
voice = mode == SessionPrefs.WOW_CHAT_VOICE && voiceAvailable(context)
if (mode == SessionPrefs.WOW_CHAT_VOICE && !voice) Toast.makeText(context, R.string.wow_chat_voice_unavailable, Toast.LENGTH_SHORT).show()
val opened = TypingRequest(TextFieldValue(""), ImeOptions(singleLine = true, imeAction = ImeAction.Send), {}, {})
fieldRead = false
request = opened
readField { text ->
if (request === opened && !text.isNullOrEmpty() && opened.value.text.isEmpty()) {
fieldRead = true
opened.value = TextFieldValue(text, TextRange(text.length))
if (recognizer != null) spokenBase = "${text.trimEnd()} "
}
}
}
private fun finish() {
stopListening()
request = null
voice = false
}
fun listen() {
val active = request ?: return
stopListening()
val speech = runCatching { SpeechRecognizer.createSpeechRecognizer(context) }.getOrNull() ?: return
recognizer = speech
spokenBase = active.value.text.trimEnd().let { if (it.isEmpty()) it else "$it " }
speech.setRecognitionListener(object : RecognitionListener {
override fun onReadyForSpeech(params: Bundle?) { listening = true }
override fun onBeginningOfSpeech() {}
override fun onRmsChanged(rmsdB: Float) {}
override fun onBufferReceived(buffer: ByteArray?) {}
override fun onEndOfSpeech() {}
override fun onError(error: Int) { if (recognizer === speech) stopListening() }
override fun onResults(results: Bundle?) {
if (recognizer !== speech) return
heard(results)
stopListening()
}
override fun onPartialResults(partialResults: Bundle?) { if (recognizer === speech) heard(partialResults) }
override fun onEvent(eventType: Int, params: Bundle?) {}
})
val intent = Intent(RecognizerIntent.ACTION_RECOGNIZE_SPEECH)
.putExtra(RecognizerIntent.EXTRA_LANGUAGE_MODEL, RecognizerIntent.LANGUAGE_MODEL_FREE_FORM)
.putExtra(RecognizerIntent.EXTRA_PARTIAL_RESULTS, true)
.putExtra(RecognizerIntent.EXTRA_CALLING_PACKAGE, context.packageName)
runCatching { speech.startListening(intent) }.onFailure { stopListening() }
}
private fun heard(results: Bundle?) {
val spoken = results?.getStringArrayList(SpeechRecognizer.RESULTS_RECOGNITION)?.firstOrNull()?.takeIf { it.isNotBlank() } ?: return
val text = spokenBase + spoken
request?.value = TextFieldValue(text, TextRange(text.length))
}
fun stopListening() {
listening = false
val speech = recognizer ?: return
recognizer = null
runCatching { speech.cancel() }
runCatching { speech.destroy() }
}
companion object {
private val PROGRAM = Regex("wow[\\w-]*\\.exe", RegexOption.IGNORE_CASE)
fun isWow(program: String?): Boolean = program != null && PROGRAM.matches(program)
fun voiceAvailable(context: Context): Boolean = SpeechRecognizer.isRecognitionAvailable(context)
}
}
@Composable
internal fun WowChatPanel(chat: WowChat, modifier: Modifier = Modifier.fillMaxWidth()) {
val request = chat.request ?: return
val context = LocalContext.current
val mic = rememberLauncherForActivityResult(ActivityResultContracts.RequestPermission()) { granted ->
if (granted) chat.listen() else Toast.makeText(context, R.string.wow_chat_mic_denied, Toast.LENGTH_SHORT).show()
}
val listen = {
if (context.checkSelfPermission(Manifest.permission.RECORD_AUDIO) == PackageManager.PERMISSION_GRANTED) chat.listen()
else mic.launch(Manifest.permission.RECORD_AUDIO)
}
LaunchedEffect(request) { if (chat.voice) listen() }
DisposableEffect(request) { onDispose { chat.stopListening() } }
BackHandler(onBack = chat::cancel)
TypingPanel(
request, modifier,
title = stringResource(if (chat.listening) R.string.wow_chat_listening else R.string.wow_chat_title),
done = stringResource(R.string.wow_chat_send),
onCancel = chat::cancel,
showKeyboard = !chat.voice,
leading = if (WowChat.voiceAvailable(context)) ({
FilledIconToggleButton(
checked = chat.listening,
onCheckedChange = { on -> if (on) listen() else chat.stopListening() },
modifier = Modifier.testTag("wow-chat-mic"),
) { Icon(Icons.Filled.Mic, stringResource(R.string.wow_chat_voice)) }
}) else null,
onDone = chat::send,
)
}
@Composable
internal fun WowChatChoiceDialog(chat: WowChat) {
var remember by remember { mutableStateOf(true) }
var picked by remember { mutableStateOf<String?>(null) }
val shown = rememberShown {
val mode = picked
if (mode != null) chat.pick(mode, remember) else chat.dismissChoice()
}
val close = { shown.targetState = false }
val choose = { mode: String -> picked = mode; close() }
val focus = remember { FocusRequester() }
LaunchedEffect(Unit) { runCatching { focus.requestFocus() } }
AppDialog(shown, close, "wowChat", wide = false, maxWidth = 520.dp) {
DialogTitleRow(stringResource(R.string.wow_chat_title), close)
DialogToggleRow(stringResource(R.string.wow_chat_remember), remember) { remember = it }
Row(horizontalArrangement = Arrangement.spacedBy(10.dp, androidx.compose.ui.Alignment.End), modifier = Modifier.fillMaxWidth()) {
SecondaryButton(stringResource(R.string.pckb_android), modifier = Modifier.testTag("wow-chat-keyboard")) { choose(SessionPrefs.WOW_CHAT_KEYBOARD) }
PrimaryButton(stringResource(R.string.wow_chat_voice), icon = Icons.Filled.Mic,
modifier = Modifier.focusRequester(focus).testTag("wow-chat-voice")) { choose(SessionPrefs.WOW_CHAT_VOICE) }
}
}
}
+21 -14
View File
@@ -8,17 +8,6 @@
<string name="steamvr_recovery_restarting">جارٍ إعادة ضبط الرسومات وإعادة فتح SteamVR…</string>
<string name="steamvr_recovery_failed">تعذّر على DroidDeck إعادة التشغيل تلقائيًا. أغلق التطبيق تمامًا، ثم أعد فتحه.</string>
<string name="app_name">DroidDeck</string>
<string-array name="loading_hints">
<item>يُنزَّل Steam عند التشغيل الأول. قد يستغرق ذلك بضع دقائق.</item>
<item>افتراضيًا، يؤدي الخروج من التطبيق إلى إيقاف Steam مؤقتًا، ويُستأنف من حيث توقف عند عودتك.</item>
<item>تظهر وحدة التحكم لديك كوحدة تحكم Xbox 360. وإلا، فاستخدم عناصر التحكم على الشاشة.</item>
</string-array>
<string-array name="loading_hints_desktop">
<item>يُعَدّ سطح المكتب عند التشغيل الأول. قد يستغرق ذلك بضع دقائق.</item>
<item>افتراضيًا، يؤدي الخروج من التطبيق إلى إيقاف الجلسة مؤقتًا، وتُستأنف من حيث توقفت عند عودتك.</item>
<item>يعمل اللمس كلوحة لمس: اسحب لتحريك المؤشر، وانقر لتنفيذ نقرة.</item>
<item>تظهر وحدة التحكم لديك كوحدة تحكم Xbox 360. وإلا، فاستخدم عناصر التحكم على الشاشة.</item>
</string-array>
<string name="session_channel">الجلسة</string>
<string name="session_channel_description">يظهر أثناء فتح جلسة Steam أو جلسة سطح المكتب</string>
<string name="session_notification">Steam قيد التشغيل</string>
@@ -564,6 +553,19 @@
<string name="comp_tag_in_use">قيد الاستخدام</string>
<string name="comp_tag_new">جديد</string>
<string name="comp_tag_stored">مخزّن</string>
<string name="comp_mode_title">كيفية اختيار الإصدارات</string>
<string name="comp_mode_auto_detail">أحدث إصدار من DroidDeck، محدّث دائمًا</string>
<string name="comp_mode_manual_detail">اختر الإصدارات وثبّتها بنفسك</string>
<string name="comp_auto_title">يُبقي Auto ‏%1$s محدّثًا</string>
<string name="comp_auto_hint">تُثبَّت الإصدارات الجديدة في الخلفية. إذا أُغلقت لعبة كانت تعمل خلال ثوانٍ مرتين، يعود Auto إلى الإصدار الذي كان يعمل.</string>
<string name="comp_auto_fallback">الاحتياطي: %1$s</string>
<string name="comp_auto_checking">جارٍ البحث عن إصدارات…</string>
<string name="comp_auto_none">لا يستخدمه أي DroidDeck-Proton</string>
<string name="comp_auto_waits_network">تنتظر تحديثات Auto اتصالًا.</string>
<string name="comp_auto_check_failed">تعذّر البحث عن إصدارات جديدة.</string>
<string name="comp_auto_waits_unmetered">تنتظر الإصدارات الجديدة اتصالًا غير محدود، وإيقاف توفير البطارية، وجهازًا غير ساخن.</string>
<string name="comp_auto_updated">تم تحديث %1$s إلى %2$s.</string>
<string name="comp_auto_rolled_back">أغلق %1$s %2$s لعبة خلال ثوانٍ مرتين، لذا عاد Auto إلى %3$s.</string>
<string name="perf_title">الأداء</string>
<string name="perf_lede">أنوية CPU وإصلاحات الجلسة واسم المضيف. يُطبَّق في الجلسة التالية.</string>
<string name="perf_client_cores_group">أنوية عميل Steam</string>
@@ -2329,9 +2331,6 @@
<string name="source_added">مخصَّص</string>
<string name="setup_stores">المتاجر</string>
<string name="setup_stores_speed">فئة سرعة التنزيل</string>
<string name="setup_stores_engine">محرك المتاجر</string>
<string name="setup_stores_engine_chip_ready">● المحرك جاهز</string>
<string name="setup_stores_engine_chip_missing">غير مبنيّ</string>
<string name="stores_tier_balanced">متوازن</string>
<string name="stores_tier_fast">سريع</string>
<string name="stores_tier_max">أقصى</string>
@@ -3832,4 +3831,12 @@
<string name="stores_gamejolt_filter_status">الحالة</string>
<string name="stores_gamejolt_filter_partners">الشركاء</string>
<string name="stores_gamejolt_followed">الألعاب المتابَعة</string>
<string name="wow_chat_title">إدخال الدردشة</string>
<string name="wow_chat_voice">الكتابة بالصوت</string>
<string name="wow_chat_remember">تذكّر هذا الاختيار</string>
<string name="wow_chat_ask">السؤال في كل مرة</string>
<string name="wow_chat_send">إرسال</string>
<string name="wow_chat_listening">جارٍ الاستماع…</string>
<string name="wow_chat_voice_unavailable">الكتابة بالصوت غير متاحة على هذا الجهاز.</string>
<string name="wow_chat_mic_denied">يلزم الوصول إلى الميكروفون للكتابة بالصوت.</string>
</resources>
+21 -14
View File
@@ -8,17 +8,6 @@
<string name="steamvr_recovery_restarting">Restableciendo los gráficos y volviendo a abrir SteamVR…</string>
<string name="steamvr_recovery_failed">DroidDeck no pudo reiniciarse automáticamente. Cierra la app por completo y vuelve a abrirla.</string>
<string name="app_name">DroidDeck</string>
<string-array name="loading_hints">
<item>La primera vez se descarga Steam. Puede tardar unos minutos.</item>
<item>De forma predeterminada, al salir de la app Steam se pausa y continúa donde lo dejaste cuando vuelves.</item>
<item>Tu control aparece como un control de Xbox 360. Si no, usa los controles en pantalla.</item>
</string-array>
<string-array name="loading_hints_desktop">
<item>La primera vez se prepara el escritorio. Puede tardar unos minutos.</item>
<item>De forma predeterminada, al salir de la app la sesión se pausa y continúa donde la dejaste cuando vuelves.</item>
<item>El tacto funciona como un touchpad: arrastra para mover el puntero y toca para hacer clic.</item>
<item>Tu control aparece como un control de Xbox 360. Si no, usa los controles en pantalla.</item>
</string-array>
<string name="session_channel">Sesión</string>
<string name="session_channel_description">Se muestra mientras hay una sesión de Steam o de escritorio abierta</string>
<string name="session_notification">Steam se está ejecutando</string>
@@ -558,6 +547,19 @@
<string name="comp_tag_in_use">En uso</string>
<string name="comp_tag_new">Nuevo</string>
<string name="comp_tag_stored">Guardado</string>
<string name="comp_mode_title">Cómo se eligen las versiones</string>
<string name="comp_mode_auto_detail">Versión más reciente de DroidDeck, siempre al día</string>
<string name="comp_mode_manual_detail">Elige e instala las versiones tú mismo</string>
<string name="comp_auto_title">Auto mantiene %1$s al día</string>
<string name="comp_auto_hint">Las nuevas versiones se instalan en segundo plano. Si un juego que funcionaba se cierra en segundos dos veces, Auto vuelve a la versión que funcionaba.</string>
<string name="comp_auto_fallback">Respaldo: %1$s</string>
<string name="comp_auto_checking">Buscando versiones…</string>
<string name="comp_auto_none">Ningún DroidDeck-Proton lo usa</string>
<string name="comp_auto_waits_network">Las actualizaciones Auto esperan una conexión.</string>
<string name="comp_auto_check_failed">No se pudieron buscar nuevas versiones.</string>
<string name="comp_auto_waits_unmetered">Las nuevas versiones esperan una conexión sin límite de datos, el ahorro de batería desactivado y el dispositivo frío.</string>
<string name="comp_auto_updated">%1$s actualizado a %2$s.</string>
<string name="comp_auto_rolled_back">%1$s %2$s cerró un juego en segundos dos veces, así que Auto volvió a %3$s.</string>
<string name="perf_title">Rendimiento</string>
<string name="perf_lede">Núcleos de CPU, correcciones de sesión y nombre del host. Se aplica en la próxima sesión.</string>
<string name="perf_client_cores_group">Núcleos del cliente de Steam</string>
@@ -2298,9 +2300,6 @@
<string name="source_added">Personalizados</string>
<string name="setup_stores">Tiendas</string>
<string name="setup_stores_speed">Nivel de velocidad de descarga</string>
<string name="setup_stores_engine">Motor de las tiendas</string>
<string name="setup_stores_engine_chip_ready">● Motor listo</string>
<string name="setup_stores_engine_chip_missing">Sin compilar</string>
<string name="stores_tier_balanced">Equilibrado</string>
<string name="stores_tier_fast">Rápido</string>
<string name="stores_tier_max">Máximo</string>
@@ -3688,4 +3687,12 @@
<string name="stores_gamejolt_filter_status">Estado</string>
<string name="stores_gamejolt_filter_partners">Socios</string>
<string name="stores_gamejolt_followed">Juegos seguidos</string>
<string name="wow_chat_title">Entrada del chat</string>
<string name="wow_chat_voice">Dictado por voz</string>
<string name="wow_chat_remember">Recordar esta selección</string>
<string name="wow_chat_ask">Preguntar cada vez</string>
<string name="wow_chat_send">Enviar</string>
<string name="wow_chat_listening">Escuchando…</string>
<string name="wow_chat_voice_unavailable">El dictado por voz no está disponible en este dispositivo.</string>
<string name="wow_chat_mic_denied">Se necesita acceso al micrófono para el dictado por voz.</string>
</resources>
+21 -14
View File
@@ -8,17 +8,6 @@
<string name="steamvr_recovery_restarting">Нулиране на графиката и повторно отваряне на SteamVR…</string>
<string name="steamvr_recovery_failed">DroidDeck не успя да се рестартира автоматично. Затворете приложението напълно и след това го отворете отново.</string>
<string name="app_name">DroidDeck</string>
<string-array name="loading_hints">
<item>Първото стартиране изтегля Steam. Това може да отнеме няколко минути.</item>
<item>По подразбиране излизането от приложението поставя Steam на пауза; при връщане продължава от същото място.</item>
<item>Контролерът ви се разпознава като контролер Xbox 360. В противен случай използвайте екранните контроли.</item>
</string-array>
<string-array name="loading_hints_desktop">
<item>Първото стартиране подготвя работния плот. Това може да отнеме няколко минути.</item>
<item>По подразбиране излизането от приложението поставя сесията на пауза; при връщане тя продължава от същото място.</item>
<item>Докосването действа като тъчпад: плъзгане премества показалеца, докосване щраква.</item>
<item>Контролерът ви се разпознава като контролер Xbox 360. В противен случай използвайте екранните контроли.</item>
</string-array>
<string name="session_channel">Сесия</string>
<string name="session_channel_description">Показва се, докато има отворена сесия на Steam или работния плот</string>
<string name="session_notification">Steam работи</string>
@@ -556,6 +545,19 @@
<string name="comp_tag_in_use">В употреба</string>
<string name="comp_tag_new">Ново</string>
<string name="comp_tag_stored">Запазено</string>
<string name="comp_mode_title">Как се избират версиите</string>
<string name="comp_mode_auto_detail">Най-новата версия на DroidDeck, винаги актуална</string>
<string name="comp_mode_manual_detail">Избирайте и инсталирайте версии сами</string>
<string name="comp_auto_title">Auto поддържа %1$s актуален</string>
<string name="comp_auto_hint">Новите версии се инсталират във фонов режим. Ако игра, която е работила, се затвори до секунди два пъти, Auto се връща към работещата версия.</string>
<string name="comp_auto_fallback">Резервна: %1$s</string>
<string name="comp_auto_checking">Търсене на версии…</string>
<string name="comp_auto_none">Нито един DroidDeck-Proton не го използва</string>
<string name="comp_auto_waits_network">Актуализациите Auto чакат връзка.</string>
<string name="comp_auto_check_failed">Новите версии не можаха да бъдат проверени.</string>
<string name="comp_auto_waits_unmetered">Новите версии чакат неотчитана връзка, изключен режим за пестене на батерията и охладено устройство.</string>
<string name="comp_auto_updated">%1$s е обновен до %2$s.</string>
<string name="comp_auto_rolled_back">%1$s %2$s два пъти затвори игра до секунди, затова Auto се върна към %3$s.</string>
<string name="perf_title">Производителност</string>
<string name="perf_lede">CPU ядра, корекции на сесията и името на хоста. Прилага се от следващата сесия.</string>
<string name="perf_client_cores_group">Ядра за клиента на Steam</string>
@@ -2229,9 +2231,6 @@
<string name="source_added">Персонализирана</string>
<string name="setup_stores">Магазини</string>
<string name="setup_stores_speed">Скорост на изтегляне</string>
<string name="setup_stores_engine">Ядро на магазините</string>
<string name="setup_stores_engine_chip_ready">● Ядрото е готово</string>
<string name="setup_stores_engine_chip_missing">Не е компилирано</string>
<string name="stores_tier_balanced">Балансирано</string>
<string name="stores_tier_fast">Бързо</string>
<string name="stores_tier_max">Максимално</string>
@@ -3676,4 +3675,12 @@
<string name="stores_gamejolt_filter_status">Статус</string>
<string name="stores_gamejolt_filter_partners">Партньори</string>
<string name="stores_gamejolt_followed">Следвани игри</string>
<string name="wow_chat_title">Въвеждане в чата</string>
<string name="wow_chat_voice">Гласово въвеждане</string>
<string name="wow_chat_remember">Запомни този избор</string>
<string name="wow_chat_ask">Питай всеки път</string>
<string name="wow_chat_send">Изпрати</string>
<string name="wow_chat_listening">Слушане…</string>
<string name="wow_chat_voice_unavailable">Гласовото въвеждане не е налично на това устройство.</string>
<string name="wow_chat_mic_denied">За гласово въвеждане е нужен достъп до микрофона.</string>
</resources>
+21 -14
View File
@@ -8,17 +8,6 @@
<string name="steamvr_recovery_restarting">Resetování grafiky a opětovné otevírání SteamVR…</string>
<string name="steamvr_recovery_failed">DroidDeck se nepodařilo automaticky restartovat. Aplikaci úplně zavřete a pak ji znovu otevřete.</string>
<string name="app_name">DroidDeck</string>
<string-array name="loading_hints">
<item>První spuštění stáhne Steam. Může to trvat několik minut.</item>
<item>Ve výchozím nastavení se po opuštění aplikace Steam pozastaví; po návratu pokračuje, kde skončil.</item>
<item>Váš ovladač se zobrazí jako ovladač Xbox 360. Jinak použijte ovládání na obrazovce.</item>
</string-array>
<string-array name="loading_hints_desktop">
<item>První spuštění připraví Plochu. Může to trvat několik minut.</item>
<item>Ve výchozím nastavení se po opuštění aplikace relace pozastaví; po návratu pokračuje, kde skončila.</item>
<item>Dotyk funguje jako touchpad: tažením posouváte kurzor, dotykem klikáte.</item>
<item>Váš ovladač se zobrazí jako ovladač Xbox 360. Jinak použijte ovládání na obrazovce.</item>
</string-array>
<string name="session_channel">Relace</string>
<string name="session_channel_description">Zobrazuje se, když je otevřená relace Steamu nebo Plochy</string>
<string name="session_notification">Steam běží</string>
@@ -560,6 +549,19 @@
<string name="comp_tag_in_use">Používá se</string>
<string name="comp_tag_new">Nové</string>
<string name="comp_tag_stored">Uloženo</string>
<string name="comp_mode_title">Jak se vybírají sestavení</string>
<string name="comp_mode_auto_detail">Nejnovější sestavení DroidDeck, stále aktuální</string>
<string name="comp_mode_manual_detail">Vyberte a instalujte sestavení sami</string>
<string name="comp_auto_title">Auto udržuje %1$s aktuální</string>
<string name="comp_auto_hint">Nová sestavení se instalují na pozadí. Pokud se hra, která dřív fungovala, dvakrát zavře během pár sekund, Auto se vrátí k funkčnímu sestavení.</string>
<string name="comp_auto_fallback">Záložní: %1$s</string>
<string name="comp_auto_checking">Hledání sestavení…</string>
<string name="comp_auto_none">Žádný DroidDeck-Proton to nepoužívá</string>
<string name="comp_auto_waits_network">Aktualizace Auto čekají na připojení.</string>
<string name="comp_auto_check_failed">Nová sestavení nelze zkontrolovat.</string>
<string name="comp_auto_waits_unmetered">Nová sestavení čekají na neměřené připojení, vypnutou úsporu baterie a nepřehřáté zařízení.</string>
<string name="comp_auto_updated">%1$s aktualizováno na %2$s.</string>
<string name="comp_auto_rolled_back">%1$s %2$s dvakrát zavřelo hru během pár sekund, proto se Auto vrátilo k %3$s.</string>
<string name="perf_title">Výkon</string>
<string name="perf_lede">Jádra CPU, opravy relace a název hostitele. Použije se od příští relace.</string>
<string name="perf_client_cores_group">Jádra pro klienta Steam</string>
@@ -2279,9 +2281,6 @@
<string name="source_added">Vlastní</string>
<string name="setup_stores">Obchody</string>
<string name="setup_stores_speed">Úroveň rychlosti stahování</string>
<string name="setup_stores_engine">Jádro obchodů</string>
<string name="setup_stores_engine_chip_ready">● Jádro je připraveno</string>
<string name="setup_stores_engine_chip_missing">Není sestaveno</string>
<string name="stores_tier_balanced">Vyvážené</string>
<string name="stores_tier_fast">Rychlé</string>
<string name="stores_tier_max">Maximální</string>
@@ -3754,4 +3753,12 @@
<string name="stores_gamejolt_filter_status">Stav</string>
<string name="stores_gamejolt_filter_partners">Partneři</string>
<string name="stores_gamejolt_followed">Sledované hry</string>
<string name="wow_chat_title">Zadávání do chatu</string>
<string name="wow_chat_voice">Hlasové psaní</string>
<string name="wow_chat_remember">Zapamatovat tuto volbu</string>
<string name="wow_chat_ask">Pokaždé se zeptat</string>
<string name="wow_chat_send">Odeslat</string>
<string name="wow_chat_listening">Poslouchám…</string>
<string name="wow_chat_voice_unavailable">Hlasové psaní není na tomto zařízení k dispozici.</string>
<string name="wow_chat_mic_denied">Pro hlasové psaní je potřeba přístup k mikrofonu.</string>
</resources>
+21 -14
View File
@@ -8,17 +8,6 @@
<string name="steamvr_recovery_restarting">Nulstiller grafikken og åbner SteamVR igen…</string>
<string name="steamvr_recovery_failed">DroidDeck kunne ikke genstarte automatisk. Luk appen helt, og åbn den så igen.</string>
<string name="app_name">DroidDeck</string>
<string-array name="loading_hints">
<item>Den første start downloader Steam. Det kan tage nogle minutter.</item>
<item>Som standard sætter Steam på pause, når du forlader appen, og fortsætter, hvor du slap, når du kommer tilbage.</item>
<item>Din controller vises som en Xbox 360-controller. Brug ellers kontrollerne på skærmen.</item>
</string-array>
<string-array name="loading_hints_desktop">
<item>Den første start sætter skrivebordet op. Det kan tage nogle minutter.</item>
<item>Som standard sætter sessionen på pause, når du forlader appen, og fortsætter, hvor du slap, når du kommer tilbage.</item>
<item>Berøring fungerer som et touchpad: Træk for at flytte markøren, tryk for at klikke.</item>
<item>Din controller vises som en Xbox 360-controller. Brug ellers kontrollerne på skærmen.</item>
</string-array>
<string name="session_channel">Session</string>
<string name="session_channel_description">Vises, når en Steam- eller skrivebordssession er åben</string>
<string name="session_notification">Steam kører</string>
@@ -556,6 +545,19 @@
<string name="comp_tag_in_use">I brug</string>
<string name="comp_tag_new">Ny</string>
<string name="comp_tag_stored">Gemt</string>
<string name="comp_mode_title">Sådan vælges builds</string>
<string name="comp_mode_auto_detail">Nyeste DroidDeck-build, altid opdateret</string>
<string name="comp_mode_manual_detail">Vælg og installer builds selv</string>
<string name="comp_auto_title">Auto holder %1$s opdateret</string>
<string name="comp_auto_hint">Nye builds installeres i baggrunden. Hvis et spil, der virkede før, lukker inden for sekunder to gange, går Auto tilbage til det build, der virkede.</string>
<string name="comp_auto_fallback">Reserve: %1$s</string>
<string name="comp_auto_checking">Søger efter builds…</string>
<string name="comp_auto_none">Ingen DroidDeck-Proton bruger dette</string>
<string name="comp_auto_waits_network">Auto-opdateringer venter på en forbindelse.</string>
<string name="comp_auto_check_failed">Kunne ikke søge efter nye builds.</string>
<string name="comp_auto_waits_unmetered">Nye builds venter på en ubegrænset forbindelse, slået batterisparer fra og en kølig enhed.</string>
<string name="comp_auto_updated">%1$s opdateret til %2$s.</string>
<string name="comp_auto_rolled_back">%1$s %2$s lukkede et spil inden for sekunder to gange, så Auto gik tilbage til %3$s.</string>
<string name="perf_title">Ydelse</string>
<string name="perf_lede">CPU-kerner, sessionsrettelser og værtsnavn. Gælder fra næste session.</string>
<string name="perf_client_cores_group">Steam-klientens kerner</string>
@@ -2229,9 +2231,6 @@
<string name="source_added">Brugerdefineret</string>
<string name="setup_stores">Butikker</string>
<string name="setup_stores_speed">Downloadhastighed</string>
<string name="setup_stores_engine">Butiksmotor</string>
<string name="setup_stores_engine_chip_ready">● Motor klar</string>
<string name="setup_stores_engine_chip_missing">Ikke bygget</string>
<string name="stores_tier_balanced">Afbalanceret</string>
<string name="stores_tier_fast">Hurtig</string>
<string name="stores_tier_max">Maks</string>
@@ -3676,4 +3675,12 @@
<string name="stores_gamejolt_filter_status">Status</string>
<string name="stores_gamejolt_filter_partners">Partnere</string>
<string name="stores_gamejolt_followed">Fulgte spil</string>
<string name="wow_chat_title">Chatinput</string>
<string name="wow_chat_voice">Stemmeindtastning</string>
<string name="wow_chat_remember">Husk dette valg</string>
<string name="wow_chat_ask">Spørg hver gang</string>
<string name="wow_chat_send">Send</string>
<string name="wow_chat_listening">Lytter…</string>
<string name="wow_chat_voice_unavailable">Stemmeindtastning er ikke tilgængelig på denne enhed.</string>
<string name="wow_chat_mic_denied">Mikrofonadgang er nødvendig for stemmeindtastning.</string>
</resources>
+21 -14
View File
@@ -8,17 +8,6 @@
<string name="steamvr_recovery_restarting">Grafik wird zurückgesetzt und SteamVR neu geöffnet …</string>
<string name="steamvr_recovery_failed">DroidDeck konnte nicht automatisch neu starten. Schließe die App vollständig und öffne sie dann erneut.</string>
<string name="app_name">DroidDeck</string>
<string-array name="loading_hints">
<item>Beim ersten Start wird Steam heruntergeladen. Das kann ein paar Minuten dauern.</item>
<item>Standardmäßig pausiert Steam, wenn du die App verlässt, und macht bei deiner Rückkehr dort weiter, wo es aufgehört hat.</item>
<item>Dein Controller erscheint als Xbox-360-Pad. Ansonsten nutze die Bildschirmsteuerung.</item>
</string-array>
<string-array name="loading_hints_desktop">
<item>Beim ersten Start wird der Desktop eingerichtet. Das kann ein paar Minuten dauern.</item>
<item>Standardmäßig wird die Sitzung pausiert, wenn du die App verlässt, und macht bei deiner Rückkehr dort weiter, wo sie aufgehört hat.</item>
<item>Touch funktioniert wie ein Touchpad: Ziehen bewegt den Zeiger, Tippen klickt.</item>
<item>Dein Controller erscheint als Xbox-360-Pad. Ansonsten nutze die Bildschirmsteuerung.</item>
</string-array>
<string name="session_channel">Sitzung</string>
<string name="session_channel_description">Wird angezeigt, solange eine Steam- oder Desktop-Sitzung geöffnet ist</string>
<string name="session_notification">Steam läuft</string>
@@ -556,6 +545,19 @@
<string name="comp_tag_in_use">In Verwendung</string>
<string name="comp_tag_new">Neu</string>
<string name="comp_tag_stored">Gespeichert</string>
<string name="comp_mode_title">Wie Builds gewählt werden</string>
<string name="comp_mode_auto_detail">Neuester DroidDeck-Build, stets aktuell</string>
<string name="comp_mode_manual_detail">Builds selbst auswählen und installieren</string>
<string name="comp_auto_title">Auto hält %1$s aktuell</string>
<string name="comp_auto_hint">Neue Builds werden im Hintergrund installiert. Schließt sich ein Spiel, das vorher lief, zweimal nach wenigen Sekunden, kehrt Auto zum funktionierenden Build zurück.</string>
<string name="comp_auto_fallback">Ausweich-Build: %1$s</string>
<string name="comp_auto_checking">Suche nach Builds…</string>
<string name="comp_auto_none">Kein DroidDeck-Proton nutzt dies</string>
<string name="comp_auto_waits_network">Auto-Updates warten auf eine Verbindung.</string>
<string name="comp_auto_check_failed">Neue Builds konnten nicht geprüft werden.</string>
<string name="comp_auto_waits_unmetered">Neue Builds warten auf eine nicht getaktete Verbindung, ausgeschalteten Energiesparmodus und ein kühles Gerät.</string>
<string name="comp_auto_updated">%1$s auf %2$s aktualisiert.</string>
<string name="comp_auto_rolled_back">%1$s %2$s hat ein Spiel zweimal nach wenigen Sekunden beendet, daher ist Auto zu %3$s zurückgekehrt.</string>
<string name="perf_title">Leistung</string>
<string name="perf_lede">CPU-Kerne, Sitzungskorrekturen und der Hostname. Gilt ab der nächsten Sitzung.</string>
<string name="perf_client_cores_group">Steam-Client-Kerne</string>
@@ -2221,9 +2223,6 @@
<string name="source_added">Eigene</string>
<string name="setup_stores">Stores</string>
<string name="setup_stores_speed">Download-Tempo</string>
<string name="setup_stores_engine">Store-Engine</string>
<string name="setup_stores_engine_chip_ready">● Engine bereit</string>
<string name="setup_stores_engine_chip_missing">Nicht gebaut</string>
<string name="stores_tier_balanced">Ausgewogen</string>
<string name="stores_tier_fast">Schnell</string>
<string name="stores_tier_max">Max.</string>
@@ -3676,4 +3675,12 @@
<string name="stores_gamejolt_filter_status">Status</string>
<string name="stores_gamejolt_filter_partners">Partner</string>
<string name="stores_gamejolt_followed">Gefolgte Spiele</string>
<string name="wow_chat_title">Chat-Eingabe</string>
<string name="wow_chat_voice">Spracheingabe</string>
<string name="wow_chat_remember">Diese Auswahl merken</string>
<string name="wow_chat_ask">Jedes Mal fragen</string>
<string name="wow_chat_send">Senden</string>
<string name="wow_chat_listening">Hört zu…</string>
<string name="wow_chat_voice_unavailable">Spracheingabe ist auf diesem Gerät nicht verfügbar.</string>
<string name="wow_chat_mic_denied">Für die Spracheingabe wird Mikrofonzugriff benötigt.</string>
</resources>
+21 -14
View File
@@ -8,17 +8,6 @@
<string name="steamvr_recovery_restarting">Επαναφορά γραφικών και εκ νέου άνοιγμα του SteamVR…</string>
<string name="steamvr_recovery_failed">Το DroidDeck δεν μπόρεσε να κάνει αυτόματη επανεκκίνηση. Κλείστε εντελώς την εφαρμογή και ανοίξτε την ξανά.</string>
<string name="app_name">DroidDeck</string>
<string-array name="loading_hints">
<item>Η πρώτη εκτέλεση κάνει λήψη του Steam. Αυτό μπορεί να διαρκέσει μερικά λεπτά.</item>
<item>Από προεπιλογή, η έξοδος από την εφαρμογή θέτει το Steam σε παύση· συνεχίζει από εκεί που είχε μείνει όταν επιστρέψετε.</item>
<item>Το χειριστήριό σας εμφανίζεται ως χειριστήριο Xbox 360. Διαφορετικά, χρησιμοποιήστε τα χειριστήρια στην οθόνη.</item>
</string-array>
<string-array name="loading_hints_desktop">
<item>Η πρώτη εκτέλεση ρυθμίζει την επιφάνεια εργασίας. Αυτό μπορεί να διαρκέσει μερικά λεπτά.</item>
<item>Από προεπιλογή, η έξοδος από την εφαρμογή θέτει τη συνεδρία σε παύση· συνεχίζει από εκεί που είχε μείνει όταν επιστρέψετε.</item>
<item>Το άγγιγμα λειτουργεί ως πινακίδα αφής: σύρετε για να μετακινήσετε τον δείκτη, πατήστε για κλικ.</item>
<item>Το χειριστήριό σας εμφανίζεται ως χειριστήριο Xbox 360. Διαφορετικά, χρησιμοποιήστε τα χειριστήρια στην οθόνη.</item>
</string-array>
<string name="session_channel">Συνεδρία</string>
<string name="session_channel_description">Εμφανίζεται όσο μια συνεδρία Steam ή επιφάνειας εργασίας είναι ανοιχτή</string>
<string name="session_notification">Το Steam εκτελείται</string>
@@ -556,6 +545,19 @@
<string name="comp_tag_in_use">Σε χρήση</string>
<string name="comp_tag_new">Νέο</string>
<string name="comp_tag_stored">Αποθηκευμένο</string>
<string name="comp_mode_title">Πώς επιλέγονται οι εκδόσεις</string>
<string name="comp_mode_auto_detail">Νεότερη έκδοση DroidDeck, πάντα ενημερωμένη</string>
<string name="comp_mode_manual_detail">Επιλέξτε και εγκαταστήστε εκδόσεις μόνοι σας</string>
<string name="comp_auto_title">Το Auto κρατά το %1$s ενημερωμένο</string>
<string name="comp_auto_hint">Οι νέες εκδόσεις εγκαθίστανται στο παρασκήνιο. Αν ένα παιχνίδι που λειτουργούσε κλείσει μέσα σε δευτερόλεπτα δύο φορές, το Auto επιστρέφει στην έκδοση που λειτουργούσε.</string>
<string name="comp_auto_fallback">Εφεδρική: %1$s</string>
<string name="comp_auto_checking">Αναζήτηση εκδόσεων…</string>
<string name="comp_auto_none">Κανένα DroidDeck-Proton δεν το χρησιμοποιεί</string>
<string name="comp_auto_waits_network">Οι ενημερώσεις Auto περιμένουν σύνδεση.</string>
<string name="comp_auto_check_failed">Δεν ήταν δυνατός ο έλεγχος για νέες εκδόσεις.</string>
<string name="comp_auto_waits_unmetered">Οι νέες εκδόσεις περιμένουν σύνδεση χωρίς όριο δεδομένων, απενεργοποιημένη εξοικονόμηση μπαταρίας και δροσερή συσκευή.</string>
<string name="comp_auto_updated">Το %1$s ενημερώθηκε σε %2$s.</string>
<string name="comp_auto_rolled_back">Το %1$s %2$s έκλεισε ένα παιχνίδι μέσα σε δευτερόλεπτα δύο φορές, οπότε το Auto επέστρεψε στο %3$s.</string>
<string name="perf_title">Απόδοση</string>
<string name="perf_lede">Πυρήνες CPU, διορθώσεις συνεδρίας και το όνομα κεντρικού υπολογιστή. Εφαρμόζεται στην επόμενη συνεδρία.</string>
<string name="perf_client_cores_group">Πυρήνες πελάτη Steam</string>
@@ -2229,9 +2231,6 @@
<string name="source_added">Προσαρμοσμένο</string>
<string name="setup_stores">Καταστήματα</string>
<string name="setup_stores_speed">Επίπεδο ταχύτητας λήψης</string>
<string name="setup_stores_engine">Μηχανή καταστημάτων</string>
<string name="setup_stores_engine_chip_ready">● Η μηχανή είναι έτοιμη</string>
<string name="setup_stores_engine_chip_missing">Δεν έχει χτιστεί</string>
<string name="stores_tier_balanced">Ισορροπημένο</string>
<string name="stores_tier_fast">Ταχύ</string>
<string name="stores_tier_max">Μέγιστο</string>
@@ -3676,4 +3675,12 @@
<string name="stores_gamejolt_filter_status">Κατάσταση</string>
<string name="stores_gamejolt_filter_partners">Συνεργάτες</string>
<string name="stores_gamejolt_followed">Παιχνίδια που ακολουθείτε</string>
<string name="wow_chat_title">Εισαγωγή συνομιλίας</string>
<string name="wow_chat_voice">Φωνητική πληκτρολόγηση</string>
<string name="wow_chat_remember">Απομνημόνευση αυτής της επιλογής</string>
<string name="wow_chat_ask">Ερώτηση κάθε φορά</string>
<string name="wow_chat_send">Αποστολή</string>
<string name="wow_chat_listening">Ακρόαση…</string>
<string name="wow_chat_voice_unavailable">Η φωνητική πληκτρολόγηση δεν είναι διαθέσιμη σε αυτή τη συσκευή.</string>
<string name="wow_chat_mic_denied">Απαιτείται πρόσβαση στο μικρόφωνο για φωνητική πληκτρολόγηση.</string>
</resources>
+21 -14
View File
@@ -8,17 +8,6 @@
<string name="steamvr_recovery_restarting">Restableciendo los gráficos y volviendo a abrir SteamVR…</string>
<string name="steamvr_recovery_failed">DroidDeck no pudo reiniciarse automáticamente. Cierra la app por completo y vuelve a abrirla.</string>
<string name="app_name">DroidDeck</string>
<string-array name="loading_hints">
<item>La primera vez se descarga Steam. Puede tardar unos minutos.</item>
<item>De forma predeterminada, al salir de la app Steam se pausa y continúa donde lo dejaste cuando vuelves.</item>
<item>Tu mando aparece como un mando Xbox 360. Si no, usa los controles en pantalla.</item>
</string-array>
<string-array name="loading_hints_desktop">
<item>La primera vez se prepara el escritorio. Puede tardar unos minutos.</item>
<item>De forma predeterminada, al salir de la app la sesión se pausa y continúa donde la dejaste cuando vuelves.</item>
<item>El tacto funciona como un touchpad: arrastra para mover el puntero y toca para hacer clic.</item>
<item>Tu mando aparece como un mando Xbox 360. Si no, usa los controles en pantalla.</item>
</string-array>
<string name="session_channel">Sesión</string>
<string name="session_channel_description">Se muestra mientras hay una sesión de Steam o de escritorio abierta</string>
<string name="session_notification">Steam se está ejecutando</string>
@@ -558,6 +547,19 @@
<string name="comp_tag_in_use">En uso</string>
<string name="comp_tag_new">Nuevo</string>
<string name="comp_tag_stored">Guardado</string>
<string name="comp_mode_title">Cómo se eligen las versiones</string>
<string name="comp_mode_auto_detail">Versión más reciente de DroidDeck, siempre al día</string>
<string name="comp_mode_manual_detail">Elige e instala las versiones tú mismo</string>
<string name="comp_auto_title">Auto mantiene %1$s al día</string>
<string name="comp_auto_hint">Las nuevas versiones se instalan en segundo plano. Si un juego que funcionaba se cierra en segundos dos veces, Auto vuelve a la versión que funcionaba.</string>
<string name="comp_auto_fallback">Respaldo: %1$s</string>
<string name="comp_auto_checking">Buscando versiones…</string>
<string name="comp_auto_none">Ningún DroidDeck-Proton lo usa</string>
<string name="comp_auto_waits_network">Las actualizaciones Auto esperan una conexión.</string>
<string name="comp_auto_check_failed">No se pudieron buscar nuevas versiones.</string>
<string name="comp_auto_waits_unmetered">Las nuevas versiones esperan una conexión sin límite de datos, el ahorro de batería desactivado y el dispositivo frío.</string>
<string name="comp_auto_updated">%1$s actualizado a %2$s.</string>
<string name="comp_auto_rolled_back">%1$s %2$s cerró un juego en segundos dos veces, así que Auto volvió a %3$s.</string>
<string name="perf_title">Rendimiento</string>
<string name="perf_lede">Núcleos de CPU, correcciones de sesión y nombre del host. Se aplica en la próxima sesión.</string>
<string name="perf_client_cores_group">Núcleos del cliente de Steam</string>
@@ -2254,9 +2256,6 @@
<string name="source_added">Personalizados</string>
<string name="setup_stores">Tiendas</string>
<string name="setup_stores_speed">Nivel de velocidad de descarga</string>
<string name="setup_stores_engine">Motor de las tiendas</string>
<string name="setup_stores_engine_chip_ready">● Motor listo</string>
<string name="setup_stores_engine_chip_missing">Sin compilar</string>
<string name="stores_tier_balanced">Equilibrado</string>
<string name="stores_tier_fast">Rápido</string>
<string name="stores_tier_max">Máximo</string>
@@ -3715,4 +3714,12 @@
<string name="stores_gamejolt_filter_status">Estado</string>
<string name="stores_gamejolt_filter_partners">Socios</string>
<string name="stores_gamejolt_followed">Juegos seguidos</string>
<string name="wow_chat_title">Entrada del chat</string>
<string name="wow_chat_voice">Dictado por voz</string>
<string name="wow_chat_remember">Recordar esta selección</string>
<string name="wow_chat_ask">Preguntar cada vez</string>
<string name="wow_chat_send">Enviar</string>
<string name="wow_chat_listening">Escuchando…</string>
<string name="wow_chat_voice_unavailable">El dictado por voz no está disponible en este dispositivo.</string>
<string name="wow_chat_mic_denied">Se necesita acceso al micrófono para el dictado por voz.</string>
</resources>
+21 -14
View File
@@ -8,17 +8,6 @@
<string name="steamvr_recovery_restarting">Nollataan grafiikkaa ja avataan SteamVR uudelleen…</string>
<string name="steamvr_recovery_failed">DroidDeck ei pystynyt käynnistymään uudelleen automaattisesti. Sulje sovellus kokonaan ja avaa se sitten uudelleen.</string>
<string name="app_name">DroidDeck</string>
<string-array name="loading_hints">
<item>Ensimmäinen käynnistys lataa Steamin. Tämä voi kestää muutaman minuutin.</item>
<item>Oletuksena sovelluksesta poistuminen keskeyttää Steamin; se jatkuu siitä mihin jäit, kun palaat takaisin.</item>
<item>Ohjaimesi näkyy Xbox 360 -ohjaimena. Muussa tapauksessa käytä näytön ohjaimia.</item>
</string-array>
<string-array name="loading_hints_desktop">
<item>Ensimmäinen käynnistys määrittää työpöydän. Tämä voi kestää muutaman minuutin.</item>
<item>Oletuksena sovelluksesta poistuminen keskeyttää istunnon; se jatkuu siitä mihin jäit, kun palaat takaisin.</item>
<item>Kosketus toimii kosketuslevynä: vedä liikuttaaksesi osoitinta, napauta napsauttaaksesi.</item>
<item>Ohjaimesi näkyy Xbox 360 -ohjaimena. Muussa tapauksessa käytä näytön ohjaimia.</item>
</string-array>
<string name="session_channel">Istunto</string>
<string name="session_channel_description">Näkyy, kun Steam- tai työpöytäistunto on käynnissä</string>
<string name="session_notification">Steam on käynnissä</string>
@@ -556,6 +545,19 @@
<string name="comp_tag_in_use">Käytössä</string>
<string name="comp_tag_new">Uusi</string>
<string name="comp_tag_stored">Tallennettu</string>
<string name="comp_mode_title">Miten versiot valitaan</string>
<string name="comp_mode_auto_detail">Uusin DroidDeck-versio, aina ajan tasalla</string>
<string name="comp_mode_manual_detail">Valitse ja asenna versiot itse</string>
<string name="comp_auto_title">Auto pitää %1$s ajan tasalla</string>
<string name="comp_auto_hint">Uudet versiot asentuvat taustalla. Jos aiemmin toiminut peli sulkeutuu sekunneissa kahdesti, Auto palaa toimineeseen versioon.</string>
<string name="comp_auto_fallback">Varaversio: %1$s</string>
<string name="comp_auto_checking">Haetaan versioita…</string>
<string name="comp_auto_none">Mikään DroidDeck-Proton ei käytä tätä</string>
<string name="comp_auto_waits_network">Auto-päivitykset odottavat yhteyttä.</string>
<string name="comp_auto_check_failed">Uusia versioita ei voitu tarkistaa.</string>
<string name="comp_auto_waits_unmetered">Uudet versiot odottavat rajoittamatonta yhteyttä, virransäästön poistoa ja viileää laitetta.</string>
<string name="comp_auto_updated">%1$s päivitetty versioon %2$s.</string>
<string name="comp_auto_rolled_back">%1$s %2$s sulki pelin sekunneissa kahdesti, joten Auto palasi versioon %3$s.</string>
<string name="perf_title">Suorituskyky</string>
<string name="perf_lede">CPU-ytimet, istunnon korjaukset ja isäntänimi. Tulee voimaan seuraavassa istunnossa.</string>
<string name="perf_client_cores_group">Steam-asiakasohjelman ytimet</string>
@@ -2229,9 +2231,6 @@
<string name="source_added">Mukautettu</string>
<string name="setup_stores">Kaupat</string>
<string name="setup_stores_speed">Latausnopeuden taso</string>
<string name="setup_stores_engine">Kauppojen moottori</string>
<string name="setup_stores_engine_chip_ready">● Moottori valmis</string>
<string name="setup_stores_engine_chip_missing">Ei koottu</string>
<string name="stores_tier_balanced">Tasapainotettu</string>
<string name="stores_tier_fast">Nopea</string>
<string name="stores_tier_max">Maksimi</string>
@@ -3676,4 +3675,12 @@
<string name="stores_gamejolt_filter_status">Tila</string>
<string name="stores_gamejolt_filter_partners">Kumppanit</string>
<string name="stores_gamejolt_followed">Seuratut pelit</string>
<string name="wow_chat_title">Chatin syöttö</string>
<string name="wow_chat_voice">Puhekirjoitus</string>
<string name="wow_chat_remember">Muista tämä valinta</string>
<string name="wow_chat_ask">Kysy joka kerta</string>
<string name="wow_chat_send">Lähetä</string>
<string name="wow_chat_listening">Kuunnellaan…</string>
<string name="wow_chat_voice_unavailable">Puhekirjoitus ei ole käytettävissä tällä laitteella.</string>
<string name="wow_chat_mic_denied">Puhekirjoitus vaatii mikrofonin käyttöoikeuden.</string>
</resources>
+21 -14
View File
@@ -8,17 +8,6 @@
<string name="steamvr_recovery_restarting">Réinitialisation du rendu graphique et réouverture de SteamVR…</string>
<string name="steamvr_recovery_failed">DroidDeck n\'a pas pu redémarrer automatiquement. Fermez complètement l\'application, puis rouvrez-la.</string>
<string name="app_name">DroidDeck</string>
<string-array name="loading_hints">
<item>Le premier lancement télécharge Steam. Cela peut prendre quelques minutes.</item>
<item>Par défaut, quitter l\'application met Steam en pause ; y revenir reprend là où vous vous étiez arrêté.</item>
<item>Votre manette apparaîtra comme une manette Xbox 360. Sinon, utilisez les commandes à l\'écran.</item>
</string-array>
<string-array name="loading_hints_desktop">
<item>Le premier lancement configure le bureau. Cela peut prendre quelques minutes.</item>
<item>Par défaut, quitter l\'application met la session en pause ; y revenir reprend là où vous vous étiez arrêté.</item>
<item>Le tactile fonctionne comme un pavé tactile : faites glisser pour déplacer le pointeur, tapotez pour cliquer.</item>
<item>Votre manette apparaîtra comme une manette Xbox 360. Sinon, utilisez les commandes à l\'écran.</item>
</string-array>
<string name="session_channel">Session</string>
<string name="session_channel_description">Affiché lorsqu\'une session Steam ou Bureau est ouverte</string>
<string name="session_notification">Steam est en cours d\'exécution</string>
@@ -558,6 +547,19 @@
<string name="comp_tag_in_use">Utilisé</string>
<string name="comp_tag_new">Nouveau</string>
<string name="comp_tag_stored">Enregistré</string>
<string name="comp_mode_title">Choix des versions</string>
<string name="comp_mode_auto_detail">Dernière version DroidDeck, toujours à jour</string>
<string name="comp_mode_manual_detail">Choisir et installer les versions vous-même</string>
<string name="comp_auto_title">Auto garde %1$s à jour</string>
<string name="comp_auto_hint">Les nouvelles versions s\'installent en arrière-plan. Si un jeu qui fonctionnait se ferme deux fois en quelques secondes, Auto revient à la version qui fonctionnait.</string>
<string name="comp_auto_fallback">Secours : %1$s</string>
<string name="comp_auto_checking">Recherche de versions…</string>
<string name="comp_auto_none">Aucun DroidDeck-Proton ne l\'utilise</string>
<string name="comp_auto_waits_network">Les mises à jour Auto attendent une connexion.</string>
<string name="comp_auto_check_failed">Impossible de vérifier les nouvelles versions.</string>
<string name="comp_auto_waits_unmetered">Les nouvelles versions attendent une connexion non limitée, l\'économiseur de batterie désactivé et un appareil frais.</string>
<string name="comp_auto_updated">%1$s mis à jour vers %2$s.</string>
<string name="comp_auto_rolled_back">%1$s %2$s a fermé un jeu en quelques secondes deux fois, Auto est donc revenu à %3$s.</string>
<string name="perf_title">Performance</string>
<string name="perf_lede">Cœurs CPU, correctifs de session et nom d\'hôte. Prendra effet à la prochaine session.</string>
<string name="perf_client_cores_group">Cœurs du client Steam</string>
@@ -2254,9 +2256,6 @@
<string name="source_added">Personnalisé</string>
<string name="setup_stores">Boutiques</string>
<string name="setup_stores_speed">Niveau de vitesse de téléchargement</string>
<string name="setup_stores_engine">Moteur des boutiques</string>
<string name="setup_stores_engine_chip_ready">● Moteur prêt</string>
<string name="setup_stores_engine_chip_missing">Non compilé</string>
<string name="stores_tier_balanced">Équilibré</string>
<string name="stores_tier_fast">Rapide</string>
<string name="stores_tier_max">Max</string>
@@ -3715,4 +3714,12 @@
<string name="stores_gamejolt_filter_status">Statut</string>
<string name="stores_gamejolt_filter_partners">Partenaires</string>
<string name="stores_gamejolt_followed">Jeux suivis</string>
<string name="wow_chat_title">Saisie du chat</string>
<string name="wow_chat_voice">Saisie vocale</string>
<string name="wow_chat_remember">Mémoriser ce choix</string>
<string name="wow_chat_ask">Demander à chaque fois</string>
<string name="wow_chat_send">Envoyer</string>
<string name="wow_chat_listening">Écoute…</string>
<string name="wow_chat_voice_unavailable">La saisie vocale n\'est pas disponible sur cet appareil.</string>
<string name="wow_chat_mic_denied">L\'accès au micro est nécessaire pour la saisie vocale.</string>
</resources>
+21 -14
View File
@@ -8,17 +8,6 @@
<string name="steamvr_recovery_restarting">ग्राफ़िक्स रीसेट हो रहे हैं और SteamVR फिर से खुल रहा है…</string>
<string name="steamvr_recovery_failed">DroidDeck अपने-आप रीस्टार्ट नहीं हो सका। ऐप को पूरी तरह बंद करें, फिर उसे दोबारा खोलें।</string>
<string name="app_name">DroidDeck</string>
<string-array name="loading_hints">
<item>पहली बार चलाने पर Steam डाउनलोड होता है। इसमें कुछ मिनट लग सकते हैं।</item>
<item>डिफ़ॉल्ट रूप से, ऐप से बाहर जाने पर Steam रुक जाता है; वापस आने पर यह वहीं से शुरू होता है जहां छोड़ा था।</item>
<item>आपका कंट्रोलर Xbox 360 पैड के रूप में दिखाई देता है। नहीं तो, ऑन-स्क्रीन कंट्रोल का उपयोग करें।</item>
</string-array>
<string-array name="loading_hints_desktop">
<item>पहली बार चलाने पर डेस्कटॉप सेट हो जाता है। इसमें कुछ मिनट लग सकते हैं।</item>
<item>डिफ़ॉल्ट रूप से, ऐप से बाहर जाने पर सेशन रुक जाता है; वापस आने पर यह वहीं से शुरू होता है जहां छोड़ा था।</item>
<item>टच टचपैड की तरह काम करता है: पॉइंटर घुमाने के लिए ड्रैग करें, क्लिक के लिए टैप करें।</item>
<item>आपका कंट्रोलर Xbox 360 पैड के रूप में दिखाई देता है। नहीं तो, ऑन-स्क्रीन कंट्रोल का उपयोग करें।</item>
</string-array>
<string name="session_channel">सेशन</string>
<string name="session_channel_description">Steam या डेस्कटॉप सेशन खुला होने पर दिखता है</string>
<string name="session_notification">Steam चल रहा है</string>
@@ -556,6 +545,19 @@
<string name="comp_tag_in_use">उपयोग में</string>
<string name="comp_tag_new">नया</string>
<string name="comp_tag_stored">सेव है</string>
<string name="comp_mode_title">बिल्ड कैसे चुने जाते हैं</string>
<string name="comp_mode_auto_detail">सबसे नया DroidDeck बिल्ड, हमेशा अपडेट</string>
<string name="comp_mode_manual_detail">बिल्ड खुद चुनें और इंस्टॉल करें</string>
<string name="comp_auto_title">Auto %1$s को अपडेट रखता है</string>
<string name="comp_auto_hint">नए बिल्ड बैकग्राउंड में इंस्टॉल होते हैं। अगर पहले चलने वाला गेम दो बार कुछ सेकंड में बंद हो जाए, तो Auto काम करने वाले बिल्ड पर लौट जाता है।</string>
<string name="comp_auto_fallback">फ़ॉलबैक: %1$s</string>
<string name="comp_auto_checking">बिल्ड खोजे जा रहे हैं…</string>
<string name="comp_auto_none">कोई DroidDeck-Proton इसका उपयोग नहीं करता</string>
<string name="comp_auto_waits_network">Auto अपडेट कनेक्शन का इंतज़ार कर रहे हैं।</string>
<string name="comp_auto_check_failed">नए बिल्ड नहीं जाँचे जा सके।</string>
<string name="comp_auto_waits_unmetered">नए बिल्ड बिना सीमा वाले कनेक्शन, बंद बैटरी सेवर और ठंडे डिवाइस का इंतज़ार करते हैं।</string>
<string name="comp_auto_updated">%1$s को %2$s पर अपडेट किया गया।</string>
<string name="comp_auto_rolled_back">%1$s %2$s ने दो बार कुछ सेकंड में गेम बंद कर दिया, इसलिए Auto %3$s पर लौट गया।</string>
<string name="perf_title">परफ़ॉर्मेंस</string>
<string name="perf_lede">CPU कोर, सेशन फ़िक्स और होस्ट नाम। अगले सेशन से लागू होता है।</string>
<string name="perf_client_cores_group">Steam क्लाइंट कोर</string>
@@ -2229,9 +2231,6 @@
<string name="source_added">कस्टम</string>
<string name="setup_stores">स्टोर</string>
<string name="setup_stores_speed">डाउनलोड स्पीड टियर</string>
<string name="setup_stores_engine">स्टोर इंजन</string>
<string name="setup_stores_engine_chip_ready">● इंजन तैयार</string>
<string name="setup_stores_engine_chip_missing">नहीं बना</string>
<string name="stores_tier_balanced">बैलेंस्ड</string>
<string name="stores_tier_fast">फ़ास्ट</string>
<string name="stores_tier_max">मैक्स</string>
@@ -3676,4 +3675,12 @@
<string name="stores_gamejolt_filter_status">स्थिति</string>
<string name="stores_gamejolt_filter_partners">पार्टनर</string>
<string name="stores_gamejolt_followed">फ़ॉलो किए गए गेम</string>
<string name="wow_chat_title">चैट इनपुट</string>
<string name="wow_chat_voice">आवाज़ से टाइप करें</string>
<string name="wow_chat_remember">यह चुनाव याद रखें</string>
<string name="wow_chat_ask">हर बार पूछें</string>
<string name="wow_chat_send">भेजें</string>
<string name="wow_chat_listening">सुन रहा है…</string>
<string name="wow_chat_voice_unavailable">इस डिवाइस पर आवाज़ से टाइपिंग उपलब्ध नहीं है।</string>
<string name="wow_chat_mic_denied">आवाज़ से टाइपिंग के लिए माइक्रोफ़ोन की अनुमति चाहिए।</string>
</resources>
+21 -14
View File
@@ -8,17 +8,6 @@
<string name="steamvr_recovery_restarting">A grafika visszaállítása és a SteamVR újranyitása…</string>
<string name="steamvr_recovery_failed">A DroidDeck nem tudott automatikusan újraindulni. Zárja be teljesen az alkalmazást, majd nyissa meg újra.</string>
<string name="app_name">DroidDeck</string>
<string-array name="loading_hints">
<item>Az első indítás letölti a Steamet. Ez néhány percet vehet igénybe.</item>
<item>Alapértelmezés szerint az alkalmazás elhagyása szünetelteti a Steamet; visszatéréskor ott folytatja, ahol abbahagyta.</item>
<item>A kontroller Xbox 360 kontrollerként jelenik meg. Egyébként használja a képernyőn lévő vezérlőket.</item>
</string-array>
<string-array name="loading_hints_desktop">
<item>Az első indítás beállítja az asztalt. Ez néhány percet vehet igénybe.</item>
<item>Alapértelmezés szerint az alkalmazás elhagyása szünetelteti a munkamenetet; visszatéréskor ott folytatja, ahol abbahagyta.</item>
<item>Az érintés érintőpadként működik: húzással mozgathatja a kurzort, koppintással kattinthat.</item>
<item>A kontroller Xbox 360 kontrollerként jelenik meg. Egyébként használja a képernyőn lévő vezérlőket.</item>
</string-array>
<string name="session_channel">Munkamenet</string>
<string name="session_channel_description">Megjelenik, amíg egy Steam- vagy asztali munkamenet aktív</string>
<string name="session_notification">A Steam fut</string>
@@ -556,6 +545,19 @@
<string name="comp_tag_in_use">Használatban</string>
<string name="comp_tag_new">Új</string>
<string name="comp_tag_stored">Tárolva</string>
<string name="comp_mode_title">Hogyan választódnak a buildek</string>
<string name="comp_mode_auto_detail">Legújabb DroidDeck build, mindig naprakész</string>
<string name="comp_mode_manual_detail">Buildek kiválasztása és telepítése saját kézzel</string>
<string name="comp_auto_title">Az Auto naprakészen tartja: %1$s</string>
<string name="comp_auto_hint">Az új buildek a háttérben települnek. Ha egy korábban működő játék kétszer is másodperceken belül bezárul, az Auto visszaáll a működő buildre.</string>
<string name="comp_auto_fallback">Tartalék: %1$s</string>
<string name="comp_auto_checking">Buildek keresése…</string>
<string name="comp_auto_none">Egyik DroidDeck-Proton sem használja</string>
<string name="comp_auto_waits_network">Az Auto frissítések kapcsolatra várnak.</string>
<string name="comp_auto_check_failed">Nem sikerült új buildeket keresni.</string>
<string name="comp_auto_waits_unmetered">Az új buildek nem forgalomkorlátos kapcsolatra, kikapcsolt energiatakarékos módra és hűvös eszközre várnak.</string>
<string name="comp_auto_updated">%1$s frissítve: %2$s.</string>
<string name="comp_auto_rolled_back">%1$s %2$s kétszer is másodperceken belül bezárt egy játékot, ezért az Auto visszaállt erre: %3$s.</string>
<string name="perf_title">Teljesítmény</string>
<string name="perf_lede">CPU-magok, munkamenet-javítások és a hosztnév. A következő munkamenettől érvényes.</string>
<string name="perf_client_cores_group">Steam-kliens magok</string>
@@ -2229,9 +2231,6 @@
<string name="source_added">Egyéni</string>
<string name="setup_stores">Áruházak</string>
<string name="setup_stores_speed">Letöltési sebességi szint</string>
<string name="setup_stores_engine">Áruházmotor</string>
<string name="setup_stores_engine_chip_ready">● A motor kész</string>
<string name="setup_stores_engine_chip_missing">Nincs buildelve</string>
<string name="stores_tier_balanced">Kiegyensúlyozott</string>
<string name="stores_tier_fast">Gyors</string>
<string name="stores_tier_max">Max</string>
@@ -3676,4 +3675,12 @@
<string name="stores_gamejolt_filter_status">Állapot</string>
<string name="stores_gamejolt_filter_partners">Partnerek</string>
<string name="stores_gamejolt_followed">Követett játékok</string>
<string name="wow_chat_title">Csevegés bevitele</string>
<string name="wow_chat_voice">Hangalapú gépelés</string>
<string name="wow_chat_remember">Választás megjegyzése</string>
<string name="wow_chat_ask">Kérdezzen minden alkalommal</string>
<string name="wow_chat_send">Küldés</string>
<string name="wow_chat_listening">Figyelés…</string>
<string name="wow_chat_voice_unavailable">A hangalapú gépelés nem érhető el ezen az eszközön.</string>
<string name="wow_chat_mic_denied">A hangalapú gépeléshez mikrofon-hozzáférés szükséges.</string>
</resources>
+21 -14
View File
@@ -8,17 +8,6 @@
<string name="steamvr_recovery_restarting">Mereset grafis dan membuka kembali SteamVR…</string>
<string name="steamvr_recovery_failed">DroidDeck tidak bisa dimulai ulang secara otomatis. Tutup aplikasi sepenuhnya, lalu buka lagi.</string>
<string name="app_name">DroidDeck</string>
<string-array name="loading_hints">
<item>Proses pertama mengunduh Steam. Ini mungkin memerlukan beberapa menit.</item>
<item>Secara default, keluar dari aplikasi akan menjeda Steam; sesi dilanjutkan dari posisi terakhir saat Anda kembali.</item>
<item>Pengontrol Anda akan terlihat sebagai gamepad Xbox 360. Jika tidak, gunakan kontrol di layar.</item>
</string-array>
<string-array name="loading_hints_desktop">
<item>Proses pertama menyiapkan desktop. Ini mungkin memerlukan beberapa menit.</item>
<item>Secara default, keluar dari aplikasi akan menjeda sesi; sesi dilanjutkan dari posisi terakhir saat Anda kembali.</item>
<item>Sentuhan berfungsi seperti touchpad: geser untuk menggerakkan penunjuk, ketuk untuk mengeklik.</item>
<item>Pengontrol Anda akan terlihat sebagai gamepad Xbox 360. Jika tidak, gunakan kontrol di layar.</item>
</string-array>
<string name="session_channel">Sesi</string>
<string name="session_channel_description">Ditampilkan saat sesi Steam atau desktop berjalan</string>
<string name="session_notification">Steam berjalan</string>
@@ -554,6 +543,19 @@
<string name="comp_tag_in_use">Digunakan</string>
<string name="comp_tag_new">Baru</string>
<string name="comp_tag_stored">Tersimpan</string>
<string name="comp_mode_title">Cara build dipilih</string>
<string name="comp_mode_auto_detail">Build DroidDeck terbaru, selalu mutakhir</string>
<string name="comp_mode_manual_detail">Pilih dan pasang build sendiri</string>
<string name="comp_auto_title">Auto menjaga %1$s tetap mutakhir</string>
<string name="comp_auto_hint">Build baru dipasang di latar belakang. Jika game yang sebelumnya berjalan tertutup dalam hitungan detik dua kali, Auto kembali ke build yang berfungsi.</string>
<string name="comp_auto_fallback">Cadangan: %1$s</string>
<string name="comp_auto_checking">Mencari build…</string>
<string name="comp_auto_none">Tidak ada DroidDeck-Proton yang memakainya</string>
<string name="comp_auto_waits_network">Pembaruan Auto menunggu koneksi.</string>
<string name="comp_auto_check_failed">Tidak dapat memeriksa build baru.</string>
<string name="comp_auto_waits_unmetered">Build baru menunggu koneksi tanpa kuota, penghemat baterai mati, dan perangkat yang dingin.</string>
<string name="comp_auto_updated">%1$s diperbarui ke %2$s.</string>
<string name="comp_auto_rolled_back">%1$s %2$s menutup game dalam hitungan detik dua kali, jadi Auto kembali ke %3$s.</string>
<string name="perf_title">Performa</string>
<string name="perf_lede">Core CPU, perbaikan sesi, dan nama host. Berlaku pada sesi berikutnya.</string>
<string name="perf_client_cores_group">Core klien Steam</string>
@@ -2204,9 +2206,6 @@
<string name="source_added">Khusus</string>
<string name="setup_stores">Toko</string>
<string name="setup_stores_speed">Tingkat kecepatan unduhan</string>
<string name="setup_stores_engine">Mesin toko</string>
<string name="setup_stores_engine_chip_ready">● Mesin siap</string>
<string name="setup_stores_engine_chip_missing">Belum dibuat</string>
<string name="stores_tier_balanced">Seimbang</string>
<string name="stores_tier_fast">Cepat</string>
<string name="stores_tier_max">Maks</string>
@@ -3646,4 +3645,12 @@
<string name="stores_gamejolt_filter_status">Status</string>
<string name="stores_gamejolt_filter_partners">Mitra</string>
<string name="stores_gamejolt_followed">Game yang diikuti</string>
<string name="wow_chat_title">Input obrolan</string>
<string name="wow_chat_voice">Ketik dengan suara</string>
<string name="wow_chat_remember">Ingat pilihan ini</string>
<string name="wow_chat_ask">Tanya setiap kali</string>
<string name="wow_chat_send">Kirim</string>
<string name="wow_chat_listening">Mendengarkan…</string>
<string name="wow_chat_voice_unavailable">Ketik dengan suara tidak tersedia di perangkat ini.</string>
<string name="wow_chat_mic_denied">Akses mikrofon diperlukan untuk mengetik dengan suara.</string>
</resources>
+21 -14
View File
@@ -8,17 +8,6 @@
<string name="steamvr_recovery_restarting">Ripristino della grafica e riapertura di SteamVR…</string>
<string name="steamvr_recovery_failed">DroidDeck non è riuscito a riavviarsi automaticamente. Chiudi completamente l\'app, poi riaprila.</string>
<string name="app_name">DroidDeck</string>
<string-array name="loading_hints">
<item>Al primo avvio viene scaricato Steam. Potrebbero volerci alcuni minuti.</item>
<item>Per impostazione predefinita, uscire dall\'app mette in pausa Steam; al tuo ritorno riprende da dove si era interrotto.</item>
<item>Il tuo controller viene visto come un pad Xbox 360. In alternativa, usa i controlli su schermo.</item>
</string-array>
<string-array name="loading_hints_desktop">
<item>Al primo avvio viene configurato il desktop. Potrebbero volerci alcuni minuti.</item>
<item>Per impostazione predefinita, uscire dall\'app mette in pausa la sessione; al tuo ritorno riprende da dove si era interrotta.</item>
<item>Il touchscreen funziona come un touchpad: trascina per spostare il puntatore, tocca per fare clic.</item>
<item>Il tuo controller viene visto come un pad Xbox 360. In alternativa, usa i controlli su schermo.</item>
</string-array>
<string name="session_channel">Sessione</string>
<string name="session_channel_description">Visibile mentre è aperta una sessione Steam o desktop</string>
<string name="session_notification">Steam è in esecuzione</string>
@@ -558,6 +547,19 @@
<string name="comp_tag_in_use">In uso</string>
<string name="comp_tag_new">Nuovo</string>
<string name="comp_tag_stored">Salvato</string>
<string name="comp_mode_title">Come vengono scelte le build</string>
<string name="comp_mode_auto_detail">Build DroidDeck più recente, sempre aggiornata</string>
<string name="comp_mode_manual_detail">Scegli e installa tu le build</string>
<string name="comp_auto_title">Auto mantiene aggiornato %1$s</string>
<string name="comp_auto_hint">Le nuove build si installano in background. Se un gioco che funzionava si chiude in pochi secondi per due volte, Auto torna alla build che funzionava.</string>
<string name="comp_auto_fallback">Riserva: %1$s</string>
<string name="comp_auto_checking">Ricerca build…</string>
<string name="comp_auto_none">Nessun DroidDeck-Proton lo usa</string>
<string name="comp_auto_waits_network">Gli aggiornamenti Auto attendono una connessione.</string>
<string name="comp_auto_check_failed">Impossibile cercare nuove build.</string>
<string name="comp_auto_waits_unmetered">Le nuove build attendono una connessione non a consumo, il risparmio batteria disattivato e un dispositivo fresco.</string>
<string name="comp_auto_updated">%1$s aggiornato a %2$s.</string>
<string name="comp_auto_rolled_back">%1$s %2$s ha chiuso un gioco in pochi secondi per due volte, quindi Auto è tornato a %3$s.</string>
<string name="perf_title">Prestazioni</string>
<string name="perf_lede">Core della CPU, correzioni della sessione e nome host. Si applica dalla prossima sessione.</string>
<string name="perf_client_cores_group">Core del client Steam</string>
@@ -2246,9 +2248,6 @@
<string name="source_added">Personalizzati</string>
<string name="setup_stores">Negozi</string>
<string name="setup_stores_speed">Livello di velocità di download</string>
<string name="setup_stores_engine">Motore dei negozi</string>
<string name="setup_stores_engine_chip_ready">● Motore pronto</string>
<string name="setup_stores_engine_chip_missing">Non compilato</string>
<string name="stores_tier_balanced">Bilanciato</string>
<string name="stores_tier_fast">Veloce</string>
<string name="stores_tier_max">Massimo</string>
@@ -3715,4 +3714,12 @@
<string name="stores_gamejolt_filter_status">Stato</string>
<string name="stores_gamejolt_filter_partners">Partner</string>
<string name="stores_gamejolt_followed">Giochi seguiti</string>
<string name="wow_chat_title">Input della chat</string>
<string name="wow_chat_voice">Digitazione vocale</string>
<string name="wow_chat_remember">Ricorda questa scelta</string>
<string name="wow_chat_ask">Chiedi ogni volta</string>
<string name="wow_chat_send">Invia</string>
<string name="wow_chat_listening">In ascolto…</string>
<string name="wow_chat_voice_unavailable">La digitazione vocale non è disponibile su questo dispositivo.</string>
<string name="wow_chat_mic_denied">Per la digitazione vocale serve l\'accesso al microfono.</string>
</resources>
+21 -14
View File
@@ -8,17 +8,6 @@
<string name="steamvr_recovery_restarting">グラフィックをリセットして SteamVR を開き直しています…</string>
<string name="steamvr_recovery_failed">DroidDeck を自動的に再起動できませんでした。アプリを完全に終了してから、もう一度開いてください。</string>
<string name="app_name">DroidDeck</string>
<string-array name="loading_hints">
<item>初回起動時はSteamをダウンロードします。数分かかる場合があります。</item>
<item>デフォルトでは、アプリを離れるとSteamが一時停止し、戻ると前回の続きから再開します。</item>
<item>コントローラーはXbox 360コントローラーとして認識されます。それ以外の場合は、画面コントローラーを使用してください。</item>
</string-array>
<string-array name="loading_hints_desktop">
<item>初回起動時はデスクトップをセットアップします。数分かかる場合があります。</item>
<item>デフォルトでは、アプリを離れるとセッションが一時停止し、戻ると前回の続きから再開します。</item>
<item>タッチはトラックパッドのように機能します。ドラッグでポインタを移動し、タップでクリックします。</item>
<item>コントローラーはXbox 360コントローラーとして認識されます。それ以外の場合は、画面コントローラーを使用してください。</item>
</string-array>
<string name="session_channel">セッション</string>
<string name="session_channel_description">Steamまたはデスクトップのセッション中に表示</string>
<string name="session_notification">Steamが実行中です</string>
@@ -554,6 +543,19 @@
<string name="comp_tag_in_use">使用中</string>
<string name="comp_tag_new">新規</string>
<string name="comp_tag_stored">保存済み</string>
<string name="comp_mode_title">ビルドの選び方</string>
<string name="comp_mode_auto_detail">最新の DroidDeck ビルドを常に維持</string>
<string name="comp_mode_manual_detail">ビルドを自分で選んでインストール</string>
<string name="comp_auto_title">Auto が %1$s を最新に保ちます</string>
<string name="comp_auto_hint">新しいビルドはバックグラウンドでインストールされます。以前動いていたゲームが数秒で 2 回終了した場合、Auto は動作していたビルドに戻します。</string>
<string name="comp_auto_fallback">予備: %1$s</string>
<string name="comp_auto_checking">ビルドを確認中…</string>
<string name="comp_auto_none">これを使う DroidDeck-Proton はありません</string>
<string name="comp_auto_waits_network">Auto 更新は接続を待っています。</string>
<string name="comp_auto_check_failed">新しいビルドを確認できませんでした。</string>
<string name="comp_auto_waits_unmetered">新しいビルドは従量制でない接続、バッテリーセーバーのオフ、端末が熱くない状態を待ちます。</string>
<string name="comp_auto_updated">%1$s を %2$s に更新しました。</string>
<string name="comp_auto_rolled_back">%1$s %2$s でゲームが数秒で 2 回終了したため、Auto は %3$s に戻しました。</string>
<string name="perf_title">パフォーマンス</string>
<string name="perf_lede">CPUコア、セッション修正、ホスト名。次回のセッションで有効になります。</string>
<string name="perf_client_cores_group">Steamクライアントコア</string>
@@ -2204,9 +2206,6 @@
<string name="source_added">カスタム</string>
<string name="setup_stores">ストア</string>
<string name="setup_stores_speed">ダウンロード速度レベル</string>
<string name="setup_stores_engine">ストアエンジン</string>
<string name="setup_stores_engine_chip_ready">● エンジン準備完了</string>
<string name="setup_stores_engine_chip_missing">未ビルド</string>
<string name="stores_tier_balanced">バランス</string>
<string name="stores_tier_fast">高速</string>
<string name="stores_tier_max">最大</string>
@@ -3646,4 +3645,12 @@
<string name="stores_gamejolt_filter_status">ステータス</string>
<string name="stores_gamejolt_filter_partners">パートナー</string>
<string name="stores_gamejolt_followed">フォロー中のゲーム</string>
<string name="wow_chat_title">チャット入力</string>
<string name="wow_chat_voice">音声入力</string>
<string name="wow_chat_remember">この選択を記憶する</string>
<string name="wow_chat_ask">毎回確認する</string>
<string name="wow_chat_send">送信</string>
<string name="wow_chat_listening">聞き取り中…</string>
<string name="wow_chat_voice_unavailable">このデバイスでは音声入力を使用できません。</string>
<string name="wow_chat_mic_denied">音声入力にはマイクへのアクセスが必要です。</string>
</resources>
+21 -14
View File
@@ -8,17 +8,6 @@
<string name="steamvr_recovery_restarting">그래픽을 초기화하고 SteamVR을 다시 여는 중…</string>
<string name="steamvr_recovery_failed">DroidDeck을 자동으로 다시 시작하지 못했습니다. 앱을 완전히 종료한 후 다시 여세요.</string>
<string name="app_name">DroidDeck</string>
<string-array name="loading_hints">
<item>첫 실행 시 Steam을 다운로드합니다. 몇 분 정도 걸릴 수 있습니다.</item>
<item>기본적으로 앱을 나가면 Steam이 일시 중지되며, 다시 돌아오면 중단된 부분부터 재개됩니다.</item>
<item>컨트롤러는 Xbox 360 컨트롤러로 인식됩니다. 컨트롤러가 없으면 화면 컨트롤을 사용하세요.</item>
</string-array>
<string-array name="loading_hints_desktop">
<item>첫 실행 시 데스크톱을 설정합니다. 몇 분 정도 걸릴 수 있습니다.</item>
<item>기본적으로 앱을 나가면 세션이 일시 중지되며, 다시 돌아오면 중단된 부분부터 재개됩니다.</item>
<item>터치는 트랙패드처럼 작동합니다. 드래그하여 포인터를 이동하고, 탭하여 클릭합니다.</item>
<item>컨트롤러는 Xbox 360 컨트롤러로 인식됩니다. 컨트롤러가 없으면 화면 컨트롤을 사용하세요.</item>
</string-array>
<string name="session_channel">세션</string>
<string name="session_channel_description">Steam 또는 데스크톱 세션이 실행 중일 때 표시</string>
<string name="session_notification">Steam이 실행 중입니다</string>
@@ -554,6 +543,19 @@
<string name="comp_tag_in_use">사용 중</string>
<string name="comp_tag_new">신규</string>
<string name="comp_tag_stored">저장됨</string>
<string name="comp_mode_title">빌드 선택 방식</string>
<string name="comp_mode_auto_detail">최신 DroidDeck 빌드, 항상 최신 유지</string>
<string name="comp_mode_manual_detail">빌드를 직접 선택하고 설치</string>
<string name="comp_auto_title">Auto가 %1$s을(를) 최신으로 유지합니다</string>
<string name="comp_auto_hint">새 빌드는 백그라운드에서 설치됩니다. 이전에 실행되던 게임이 몇 초 만에 두 번 종료되면 Auto가 작동하던 빌드로 되돌립니다.</string>
<string name="comp_auto_fallback">대체: %1$s</string>
<string name="comp_auto_checking">빌드 확인 중…</string>
<string name="comp_auto_none">이를 사용하는 DroidDeck-Proton이 없습니다</string>
<string name="comp_auto_waits_network">Auto 업데이트가 연결을 기다리는 중입니다.</string>
<string name="comp_auto_check_failed">새 빌드를 확인할 수 없습니다.</string>
<string name="comp_auto_waits_unmetered">새 빌드는 데이터 무제한 연결, 배터리 세이버 꺼짐, 기기가 뜨겁지 않은 상태를 기다립니다.</string>
<string name="comp_auto_updated">%1$s을(를) %2$s(으)로 업데이트했습니다.</string>
<string name="comp_auto_rolled_back">%1$s %2$s(으)로 게임이 몇 초 만에 두 번 종료되어 Auto가 %3$s(으)로 되돌렸습니다.</string>
<string name="perf_title">성능</string>
<string name="perf_lede">CPU 코어, 세션 수정 및 호스트 이름. 다음 세션에 적용됩니다.</string>
<string name="perf_client_cores_group">Steam 클라이언트 코어</string>
@@ -2204,9 +2206,6 @@
<string name="source_added">사용자 정의</string>
<string name="setup_stores">스토어</string>
<string name="setup_stores_speed">다운로드 속도 단계</string>
<string name="setup_stores_engine">스토어 엔진</string>
<string name="setup_stores_engine_chip_ready">● 엔진 준비됨</string>
<string name="setup_stores_engine_chip_missing">빌드되지 않음</string>
<string name="stores_tier_balanced">균형</string>
<string name="stores_tier_fast">빠름</string>
<string name="stores_tier_max">최대</string>
@@ -3646,4 +3645,12 @@
<string name="stores_gamejolt_filter_status">상태</string>
<string name="stores_gamejolt_filter_partners">파트너</string>
<string name="stores_gamejolt_followed">팔로우한 게임</string>
<string name="wow_chat_title">채팅 입력</string>
<string name="wow_chat_voice">음성 입력</string>
<string name="wow_chat_remember">이 선택 기억하기</string>
<string name="wow_chat_ask">매번 묻기</string>
<string name="wow_chat_send">보내기</string>
<string name="wow_chat_listening">듣는 중…</string>
<string name="wow_chat_voice_unavailable">이 기기에서는 음성 입력을 사용할 수 없습니다.</string>
<string name="wow_chat_mic_denied">음성 입력을 하려면 마이크 접근 권한이 필요합니다.</string>
</resources>
+21 -14
View File
@@ -8,17 +8,6 @@
<string name="steamvr_recovery_restarting">Menetapkan semula grafik dan membuka SteamVR sekali lagi…</string>
<string name="steamvr_recovery_failed">DroidDeck tidak dapat dimulakan semula secara automatik. Tutup apl sepenuhnya, kemudian buka semula.</string>
<string name="app_name">DroidDeck</string>
<string-array name="loading_hints">
<item>Larian pertama memuat turun Steam. Ini mungkin mengambil beberapa minit.</item>
<item>Secara lalai, meninggalkan apl ini menjedakan Steam; ia menyambung semula dari tempat ia berhenti apabila anda kembali.</item>
<item>Pengawal anda akan muncul sebagai pad Xbox 360. Jika tidak, gunakan kawalan pada skrin.</item>
</string-array>
<string-array name="loading_hints_desktop">
<item>Larian pertama menyediakan desktop. Ini mungkin mengambil beberapa minit.</item>
<item>Secara lalai, meninggalkan apl ini menjedakan sesi; ia menyambung semula dari tempat ia berhenti apabila anda kembali.</item>
<item>Sentuhan berfungsi sebagai pad sentuh: seret untuk menggerakkan penunjuk, ketik untuk klik.</item>
<item>Pengawal anda akan muncul sebagai pad Xbox 360. Jika tidak, gunakan kawalan pada skrin.</item>
</string-array>
<string name="session_channel">Sesi</string>
<string name="session_channel_description">Dipaparkan semasa sesi Steam atau desktop sedang dibuka</string>
<string name="session_notification">Steam sedang berjalan</string>
@@ -554,6 +543,19 @@
<string name="comp_tag_in_use">Digunakan</string>
<string name="comp_tag_new">Baharu</string>
<string name="comp_tag_stored">Disimpan</string>
<string name="comp_mode_title">Cara binaan dipilih</string>
<string name="comp_mode_auto_detail">Binaan DroidDeck terbaharu, sentiasa terkini</string>
<string name="comp_mode_manual_detail">Pilih dan pasang binaan sendiri</string>
<string name="comp_auto_title">Auto memastikan %1$s sentiasa terkini</string>
<string name="comp_auto_hint">Binaan baharu dipasang di latar belakang. Jika permainan yang pernah berjalan tertutup dalam beberapa saat dua kali, Auto kembali ke binaan yang berfungsi.</string>
<string name="comp_auto_fallback">Sandaran: %1$s</string>
<string name="comp_auto_checking">Mencari binaan…</string>
<string name="comp_auto_none">Tiada DroidDeck-Proton yang menggunakannya</string>
<string name="comp_auto_waits_network">Kemas kini Auto menunggu sambungan.</string>
<string name="comp_auto_check_failed">Tidak dapat menyemak binaan baharu.</string>
<string name="comp_auto_waits_unmetered">Binaan baharu menunggu sambungan tanpa meter, penjimat bateri dimatikan dan peranti yang sejuk.</string>
<string name="comp_auto_updated">%1$s dikemas kini ke %2$s.</string>
<string name="comp_auto_rolled_back">%1$s %2$s menutup permainan dalam beberapa saat dua kali, jadi Auto kembali ke %3$s.</string>
<string name="perf_title">Prestasi</string>
<string name="perf_lede">Teras CPU, pembaikan sesi dan nama hos. Berkuat kuasa pada sesi seterusnya.</string>
<string name="perf_client_cores_group">Teras klien Steam</string>
@@ -2204,9 +2206,6 @@
<string name="source_added">Tersuai</string>
<string name="setup_stores">Kedai</string>
<string name="setup_stores_speed">Tahap kelajuan muat turun</string>
<string name="setup_stores_engine">Enjin kedai</string>
<string name="setup_stores_engine_chip_ready">● Enjin sedia</string>
<string name="setup_stores_engine_chip_missing">Tidak dibina</string>
<string name="stores_tier_balanced">Seimbang</string>
<string name="stores_tier_fast">Pantas</string>
<string name="stores_tier_max">Maksimum</string>
@@ -3646,4 +3645,12 @@
<string name="stores_gamejolt_filter_status">Status</string>
<string name="stores_gamejolt_filter_partners">Rakan kongsi</string>
<string name="stores_gamejolt_followed">Permainan diikuti</string>
<string name="wow_chat_title">Input sembang</string>
<string name="wow_chat_voice">Taip dengan suara</string>
<string name="wow_chat_remember">Ingat pilihan ini</string>
<string name="wow_chat_ask">Tanya setiap kali</string>
<string name="wow_chat_send">Hantar</string>
<string name="wow_chat_listening">Mendengar…</string>
<string name="wow_chat_voice_unavailable">Taip dengan suara tidak tersedia pada peranti ini.</string>
<string name="wow_chat_mic_denied">Akses mikrofon diperlukan untuk menaip dengan suara.</string>
</resources>
+21 -14
View File
@@ -8,17 +8,6 @@
<string name="steamvr_recovery_restarting">Tilbakestiller grafikken og åpner SteamVR igjen…</string>
<string name="steamvr_recovery_failed">DroidDeck kunne ikke starte på nytt automatisk. Lukk appen helt, og åpne den igjen.</string>
<string name="app_name">DroidDeck</string>
<string-array name="loading_hints">
<item>Første gang lastes Steam ned. Dette kan ta noen minutter.</item>
<item>Som standard settes Steam på pause når du lukker appen, og fortsetter der du slapp når du kommer tilbake.</item>
<item>Kontrolleren din vises som en Xbox 360-kontroller. Hvis ikke, bruk skjermkontrollene.</item>
</string-array>
<string-array name="loading_hints_desktop">
<item>Første gang settes skrivebordet opp. Dette kan ta noen minutter.</item>
<item>Som standard settes sesjonen på pause når du lukker appen, og fortsetter der du slapp når du kommer tilbake.</item>
<item>Berøring fungerer som styreflate: dra for å flytte pekeren, trykk for å klikke.</item>
<item>Kontrolleren din vises som en Xbox 360-kontroller. Hvis ikke, bruk skjermkontrollene.</item>
</string-array>
<string name="session_channel">Sesjon</string>
<string name="session_channel_description">Vises mens en Steam- eller skrivebordssesjon er åpen</string>
<string name="session_notification">Steam kjører</string>
@@ -556,6 +545,19 @@
<string name="comp_tag_in_use">I bruk</string>
<string name="comp_tag_new">Ny</string>
<string name="comp_tag_stored">Lagret</string>
<string name="comp_mode_title">Slik velges bygg</string>
<string name="comp_mode_auto_detail">Nyeste DroidDeck-bygg, alltid oppdatert</string>
<string name="comp_mode_manual_detail">Velg og installer bygg selv</string>
<string name="comp_auto_title">Auto holder %1$s oppdatert</string>
<string name="comp_auto_hint">Nye bygg installeres i bakgrunnen. Hvis et spill som fungerte før lukkes innen sekunder to ganger, går Auto tilbake til bygget som fungerte.</string>
<string name="comp_auto_fallback">Reserve: %1$s</string>
<string name="comp_auto_checking">Ser etter bygg…</string>
<string name="comp_auto_none">Ingen DroidDeck-Proton bruker dette</string>
<string name="comp_auto_waits_network">Auto-oppdateringer venter på en tilkobling.</string>
<string name="comp_auto_check_failed">Kunne ikke se etter nye bygg.</string>
<string name="comp_auto_waits_unmetered">Nye bygg venter på en ubegrenset tilkobling, avslått batterisparing og en kjølig enhet.</string>
<string name="comp_auto_updated">%1$s oppdatert til %2$s.</string>
<string name="comp_auto_rolled_back">%1$s %2$s lukket et spill innen sekunder to ganger, så Auto gikk tilbake til %3$s.</string>
<string name="perf_title">Ytelse</string>
<string name="perf_lede">CPU-kjerner, sesjonsfikser og vertsnavnet. Gjelder fra neste sesjon.</string>
<string name="perf_client_cores_group">Steam-klientens kjerner</string>
@@ -2229,9 +2231,6 @@
<string name="source_added">Egendefinert</string>
<string name="setup_stores">Butikker</string>
<string name="setup_stores_speed">Nedlastingshastighetsnivå</string>
<string name="setup_stores_engine">Butikkmotor</string>
<string name="setup_stores_engine_chip_ready">● Motor klar</string>
<string name="setup_stores_engine_chip_missing">Ikke bygget</string>
<string name="stores_tier_balanced">Balansert</string>
<string name="stores_tier_fast">Rask</string>
<string name="stores_tier_max">Maks</string>
@@ -3676,4 +3675,12 @@
<string name="stores_gamejolt_filter_status">Status</string>
<string name="stores_gamejolt_filter_partners">Partnere</string>
<string name="stores_gamejolt_followed">Fulgte spill</string>
<string name="wow_chat_title">Chatinndata</string>
<string name="wow_chat_voice">Taleinntasting</string>
<string name="wow_chat_remember">Husk dette valget</string>
<string name="wow_chat_ask">Spør hver gang</string>
<string name="wow_chat_send">Send</string>
<string name="wow_chat_listening">Lytter…</string>
<string name="wow_chat_voice_unavailable">Taleinntasting er ikke tilgjengelig på denne enheten.</string>
<string name="wow_chat_mic_denied">Mikrofontilgang trengs for taleinntasting.</string>
</resources>
+21 -14
View File
@@ -8,17 +8,6 @@
<string name="steamvr_recovery_restarting">Grafische weergave resetten en SteamVR opnieuw openen…</string>
<string name="steamvr_recovery_failed">DroidDeck kon niet automatisch opnieuw starten. Sluit de app volledig af en open hem daarna opnieuw.</string>
<string name="app_name">DroidDeck</string>
<string-array name="loading_hints">
<item>Bij de eerste start wordt Steam gedownload. Dit kan een paar minuten duren.</item>
<item>Standaard wordt Steam gepauzeerd als je de app verlaat; als je terugkomt, gaat het verder waar je was.</item>
<item>Je controller wordt gezien als Xbox 360-controller. Gebruik anders de schermbediening.</item>
</string-array>
<string-array name="loading_hints_desktop">
<item>Bij de eerste start wordt de desktop ingericht. Dit kan een paar minuten duren.</item>
<item>Standaard wordt de sessie gepauzeerd als je de app verlaat; als je terugkomt, gaat die verder waar je was.</item>
<item>Aanraken werkt als touchpad: sleep om de aanwijzer te verplaatsen, tik om te klikken.</item>
<item>Je controller wordt gezien als Xbox 360-controller. Gebruik anders de schermbediening.</item>
</string-array>
<string name="session_channel">Sessie</string>
<string name="session_channel_description">Wordt getoond zolang er een Steam- of desktopsessie open is</string>
<string name="session_notification">Steam is actief</string>
@@ -556,6 +545,19 @@
<string name="comp_tag_in_use">In gebruik</string>
<string name="comp_tag_new">Nieuw</string>
<string name="comp_tag_stored">Bewaard</string>
<string name="comp_mode_title">Hoe builds worden gekozen</string>
<string name="comp_mode_auto_detail">Nieuwste DroidDeck-build, altijd actueel</string>
<string name="comp_mode_manual_detail">Kies en installeer builds zelf</string>
<string name="comp_auto_title">Auto houdt %1$s actueel</string>
<string name="comp_auto_hint">Nieuwe builds worden op de achtergrond geïnstalleerd. Sluit een game die eerder werkte twee keer binnen enkele seconden, dan gaat Auto terug naar de build die werkte.</string>
<string name="comp_auto_fallback">Terugval: %1$s</string>
<string name="comp_auto_checking">Zoeken naar builds…</string>
<string name="comp_auto_none">Geen DroidDeck-Proton gebruikt dit</string>
<string name="comp_auto_waits_network">Auto-updates wachten op een verbinding.</string>
<string name="comp_auto_check_failed">Kon niet op nieuwe builds controleren.</string>
<string name="comp_auto_waits_unmetered">Nieuwe builds wachten op een onbeperkte verbinding, batterijbesparing uit en een koel apparaat.</string>
<string name="comp_auto_updated">%1$s bijgewerkt naar %2$s.</string>
<string name="comp_auto_rolled_back">%1$s %2$s sloot een game twee keer binnen enkele seconden, dus Auto ging terug naar %3$s.</string>
<string name="perf_title">Prestaties</string>
<string name="perf_lede">CPU-cores, sessiefixes en de hostnaam. Geldt vanaf de volgende sessie.</string>
<string name="perf_client_cores_group">Cores voor de Steam-client</string>
@@ -2229,9 +2231,6 @@
<string name="source_added">Aangepast</string>
<string name="setup_stores">Winkels</string>
<string name="setup_stores_speed">Niveau voor downloadsnelheid</string>
<string name="setup_stores_engine">Winkels-engine</string>
<string name="setup_stores_engine_chip_ready">● Engine gereed</string>
<string name="setup_stores_engine_chip_missing">Niet gebouwd</string>
<string name="stores_tier_balanced">Gebalanceerd</string>
<string name="stores_tier_fast">Snel</string>
<string name="stores_tier_max">Max</string>
@@ -3676,4 +3675,12 @@
<string name="stores_gamejolt_filter_status">Status</string>
<string name="stores_gamejolt_filter_partners">Partners</string>
<string name="stores_gamejolt_followed">Gevolgde games</string>
<string name="wow_chat_title">Chatinvoer</string>
<string name="wow_chat_voice">Spraakinvoer</string>
<string name="wow_chat_remember">Deze keuze onthouden</string>
<string name="wow_chat_ask">Elke keer vragen</string>
<string name="wow_chat_send">Verzenden</string>
<string name="wow_chat_listening">Luisteren…</string>
<string name="wow_chat_voice_unavailable">Spraakinvoer is niet beschikbaar op dit apparaat.</string>
<string name="wow_chat_mic_denied">Voor spraakinvoer is toegang tot de microfoon nodig.</string>
</resources>
+21 -14
View File
@@ -8,17 +8,6 @@
<string name="steamvr_recovery_restarting">Resetowanie grafiki i ponowne otwieranie SteamVR…</string>
<string name="steamvr_recovery_failed">Nie udało się automatycznie uruchomić ponownie DroidDeck. Całkowicie zamknij aplikację, a potem otwórz ją ponownie.</string>
<string name="app_name">DroidDeck</string>
<string-array name="loading_hints">
<item>Przy pierwszym uruchomieniu pobierany jest Steam. Może to potrwać kilka minut.</item>
<item>Domyślnie wyjście z aplikacji wstrzymuje Steam; po powrocie wznowi on działanie tam, gdzie przerwał.</item>
<item>Twój kontroler jest widoczny jako pad Xbox 360. Bez niego użyj sterowania ekranowego.</item>
</string-array>
<string-array name="loading_hints_desktop">
<item>Przy pierwszym uruchomieniu konfigurowany jest pulpit. Może to potrwać kilka minut.</item>
<item>Domyślnie wyjście z aplikacji wstrzymuje sesję; po powrocie wznowi się ona tam, gdzie została przerwana.</item>
<item>Ekran działa jak touchpad: przeciągnij, aby przesunąć kursor, dotknij, aby kliknąć.</item>
<item>Twój kontroler jest widoczny jako pad Xbox 360. Bez niego użyj sterowania ekranowego.</item>
</string-array>
<string name="session_channel">Sesja</string>
<string name="session_channel_description">Widoczne, gdy otwarta jest sesja Steam lub pulpitu</string>
<string name="session_notification">Steam działa</string>
@@ -560,6 +549,19 @@
<string name="comp_tag_in_use">W użyciu</string>
<string name="comp_tag_new">Nowy</string>
<string name="comp_tag_stored">Zapisany</string>
<string name="comp_mode_title">Jak wybierane są wersje</string>
<string name="comp_mode_auto_detail">Najnowsza wersja DroidDeck, zawsze aktualna</string>
<string name="comp_mode_manual_detail">Wybieraj i instaluj wersje samodzielnie</string>
<string name="comp_auto_title">Auto utrzymuje %1$s w aktualności</string>
<string name="comp_auto_hint">Nowe wersje instalują się w tle. Jeśli gra, która działała, dwa razy zamknie się po kilku sekundach, Auto wróci do działającej wersji.</string>
<string name="comp_auto_fallback">Zapasowa: %1$s</string>
<string name="comp_auto_checking">Szukanie wersji…</string>
<string name="comp_auto_none">Żaden DroidDeck-Proton tego nie używa</string>
<string name="comp_auto_waits_network">Aktualizacje Auto czekają na połączenie.</string>
<string name="comp_auto_check_failed">Nie udało się sprawdzić nowych wersji.</string>
<string name="comp_auto_waits_unmetered">Nowe wersje czekają na połączenie bez limitu, wyłączone oszczędzanie baterii i chłodne urządzenie.</string>
<string name="comp_auto_updated">%1$s zaktualizowano do %2$s.</string>
<string name="comp_auto_rolled_back">%1$s %2$s dwa razy zamknął grę po kilku sekundach, więc Auto wróciło do %3$s.</string>
<string name="perf_title">Wydajność</string>
<string name="perf_lede">Rdzenie CPU, poprawki sesji i nazwa hosta. Działa od następnej sesji.</string>
<string name="perf_client_cores_group">Rdzenie klienta Steam</string>
@@ -2279,9 +2281,6 @@
<string name="source_added">Własna</string>
<string name="setup_stores">Sklepy</string>
<string name="setup_stores_speed">Poziom szybkości pobierania</string>
<string name="setup_stores_engine">Mechanizm sklepów</string>
<string name="setup_stores_engine_chip_ready">● Mechanizm gotowy</string>
<string name="setup_stores_engine_chip_missing">Nie zbudowano</string>
<string name="stores_tier_balanced">Zbalansowany</string>
<string name="stores_tier_fast">Szybki</string>
<string name="stores_tier_max">Maksymalny</string>
@@ -3754,4 +3753,12 @@
<string name="stores_gamejolt_filter_status">Status</string>
<string name="stores_gamejolt_filter_partners">Partnerzy</string>
<string name="stores_gamejolt_followed">Obserwowane gry</string>
<string name="wow_chat_title">Wpisywanie na czacie</string>
<string name="wow_chat_voice">Pisanie głosowe</string>
<string name="wow_chat_remember">Zapamiętaj ten wybór</string>
<string name="wow_chat_ask">Pytaj za każdym razem</string>
<string name="wow_chat_send">Wyślij</string>
<string name="wow_chat_listening">Słucham…</string>
<string name="wow_chat_voice_unavailable">Pisanie głosowe nie jest dostępne na tym urządzeniu.</string>
<string name="wow_chat_mic_denied">Pisanie głosowe wymaga dostępu do mikrofonu.</string>
</resources>
+21 -14
View File
@@ -8,17 +8,6 @@
<string name="steamvr_recovery_restarting">A repor os gráficos e a reabrir o SteamVR…</string>
<string name="steamvr_recovery_failed">O DroidDeck não conseguiu reiniciar automaticamente. Feche completamente a app e volte a abri-la.</string>
<string name="app_name">DroidDeck</string>
<string-array name="loading_hints">
<item>A primeira execução transfere o Steam. Isto pode demorar alguns minutos.</item>
<item>Por predefinição, sair da app coloca o Steam em pausa; ao voltar, continua onde ficou.</item>
<item>O seu comando aparece como um comando Xbox 360. Se não tiver comando, utilize os controlos no ecrã.</item>
</string-array>
<string-array name="loading_hints_desktop">
<item>A primeira execução configura o ambiente de trabalho. Isto pode demorar alguns minutos.</item>
<item>Por predefinição, sair da app coloca a sessão em pausa; ao voltar, continua onde ficou.</item>
<item>O toque funciona como um painel tátil: arraste para mover o ponteiro, toque para clicar.</item>
<item>O seu comando aparece como um comando Xbox 360. Se não tiver comando, utilize os controlos no ecrã.</item>
</string-array>
<string name="session_channel">Sessão</string>
<string name="session_channel_description">Apresentada enquanto houver uma sessão do Steam ou do ambiente de trabalho aberta</string>
<string name="session_notification">O Steam está em execução</string>
@@ -558,6 +547,19 @@
<string name="comp_tag_in_use">Em utilização</string>
<string name="comp_tag_new">Novo</string>
<string name="comp_tag_stored">Guardado</string>
<string name="comp_mode_title">Como as versões são escolhidas</string>
<string name="comp_mode_auto_detail">Versão mais recente do DroidDeck, sempre atualizada</string>
<string name="comp_mode_manual_detail">Escolha e instale as versões manualmente</string>
<string name="comp_auto_title">Auto mantém %1$s atualizado</string>
<string name="comp_auto_hint">As novas versões são instaladas em segundo plano. Se um jogo que funcionava fechar em segundos duas vezes, Auto volta à versão que funcionava.</string>
<string name="comp_auto_fallback">Reserva: %1$s</string>
<string name="comp_auto_checking">A procurar versões…</string>
<string name="comp_auto_none">Nenhum DroidDeck-Proton usa isto</string>
<string name="comp_auto_waits_network">As atualizações Auto aguardam uma ligação.</string>
<string name="comp_auto_check_failed">Não foi possível procurar novas versões.</string>
<string name="comp_auto_waits_unmetered">As novas versões aguardam uma ligação não limitada, a poupança de bateria desligada e o dispositivo fresco.</string>
<string name="comp_auto_updated">%1$s atualizado para %2$s.</string>
<string name="comp_auto_rolled_back">%1$s %2$s fechou um jogo em segundos duas vezes, por isso Auto voltou a %3$s.</string>
<string name="perf_title">Desempenho</string>
<string name="perf_lede">Núcleos da CPU, correções da sessão e nome do anfitrião. Aplica-se na próxima sessão.</string>
<string name="perf_client_cores_group">Núcleos do cliente Steam</string>
@@ -2246,9 +2248,6 @@
<string name="source_added">Personalizados</string>
<string name="setup_stores">Lojas</string>
<string name="setup_stores_speed">Nível de velocidade de transferência</string>
<string name="setup_stores_engine">Motor das lojas</string>
<string name="setup_stores_engine_chip_ready">● Motor pronto</string>
<string name="setup_stores_engine_chip_missing">Não compilado</string>
<string name="stores_tier_balanced">Equilibrado</string>
<string name="stores_tier_fast">Rápido</string>
<string name="stores_tier_max">Máximo</string>
@@ -3715,4 +3714,12 @@
<string name="stores_gamejolt_filter_status">Estado</string>
<string name="stores_gamejolt_filter_partners">Parceiros</string>
<string name="stores_gamejolt_followed">Jogos seguidos</string>
<string name="wow_chat_title">Introdução no chat</string>
<string name="wow_chat_voice">Escrita por voz</string>
<string name="wow_chat_remember">Lembrar esta escolha</string>
<string name="wow_chat_ask">Perguntar sempre</string>
<string name="wow_chat_send">Enviar</string>
<string name="wow_chat_listening">A ouvir…</string>
<string name="wow_chat_voice_unavailable">A escrita por voz não está disponível neste dispositivo.</string>
<string name="wow_chat_mic_denied">É necessário acesso ao microfone para a escrita por voz.</string>
</resources>
+21 -14
View File
@@ -8,17 +8,6 @@
<string name="steamvr_recovery_restarting">Redefinindo os gráficos e reabrindo o SteamVR…</string>
<string name="steamvr_recovery_failed">O DroidDeck não conseguiu reiniciar automaticamente. Feche o app completamente e abra-o de novo.</string>
<string name="app_name">DroidDeck</string>
<string-array name="loading_hints">
<item>Na primeira execução, a Steam é baixada. Isso pode levar alguns minutos.</item>
<item>Por padrão, sair do app pausa a Steam; ela continua de onde parou quando você voltar.</item>
<item>Seu controle aparece como um controle de Xbox 360. Sem controle, use os controles na tela.</item>
</string-array>
<string-array name="loading_hints_desktop">
<item>Na primeira execução, a área de trabalho é preparada. Isso pode levar alguns minutos.</item>
<item>Por padrão, sair do app pausa a sessão; ela continua de onde parou quando você voltar.</item>
<item>O toque funciona como um touchpad: arraste para mover o ponteiro, toque para clicar.</item>
<item>Seu controle aparece como um controle de Xbox 360. Sem controle, use os controles na tela.</item>
</string-array>
<string name="session_channel">Sessão</string>
<string name="session_channel_description">Aparece enquanto uma sessão da Steam ou da área de trabalho está aberta</string>
<string name="session_notification">A Steam está em execução</string>
@@ -558,6 +547,19 @@
<string name="comp_tag_in_use">Em uso</string>
<string name="comp_tag_new">Novo</string>
<string name="comp_tag_stored">Armazenado</string>
<string name="comp_mode_title">Como as versões são escolhidas</string>
<string name="comp_mode_auto_detail">Versão mais recente do DroidDeck, sempre atualizada</string>
<string name="comp_mode_manual_detail">Escolha e instale as versões você mesmo</string>
<string name="comp_auto_title">Auto mantém %1$s atualizado</string>
<string name="comp_auto_hint">Novas versões são instaladas em segundo plano. Se um jogo que funcionava fechar em segundos duas vezes, Auto volta para a versão que funcionava.</string>
<string name="comp_auto_fallback">Reserva: %1$s</string>
<string name="comp_auto_checking">Procurando versões…</string>
<string name="comp_auto_none">Nenhum DroidDeck-Proton usa isto</string>
<string name="comp_auto_waits_network">As atualizações Auto aguardam uma conexão.</string>
<string name="comp_auto_check_failed">Não foi possível procurar novas versões.</string>
<string name="comp_auto_waits_unmetered">Novas versões aguardam uma conexão não limitada, a economia de bateria desligada e o aparelho frio.</string>
<string name="comp_auto_updated">%1$s atualizado para %2$s.</string>
<string name="comp_auto_rolled_back">%1$s %2$s fechou um jogo em segundos duas vezes, então Auto voltou para %3$s.</string>
<string name="perf_title">Desempenho</string>
<string name="perf_lede">Núcleos da CPU, correções da sessão e o nome do host. Vale a partir da próxima sessão.</string>
<string name="perf_client_cores_group">Núcleos do cliente Steam</string>
@@ -2246,9 +2248,6 @@
<string name="source_added">Personalizado</string>
<string name="setup_stores">Lojas</string>
<string name="setup_stores_speed">Nível de velocidade de download</string>
<string name="setup_stores_engine">Mecanismo das lojas</string>
<string name="setup_stores_engine_chip_ready">● Mecanismo pronto</string>
<string name="setup_stores_engine_chip_missing">Não compilado</string>
<string name="stores_tier_balanced">Equilibrado</string>
<string name="stores_tier_fast">Rápido</string>
<string name="stores_tier_max">Máximo</string>
@@ -3715,4 +3714,12 @@
<string name="stores_gamejolt_filter_status">Status</string>
<string name="stores_gamejolt_filter_partners">Parceiros</string>
<string name="stores_gamejolt_followed">Jogos seguidos</string>
<string name="wow_chat_title">Entrada do chat</string>
<string name="wow_chat_voice">Digitação por voz</string>
<string name="wow_chat_remember">Lembrar esta escolha</string>
<string name="wow_chat_ask">Perguntar sempre</string>
<string name="wow_chat_send">Enviar</string>
<string name="wow_chat_listening">Ouvindo…</string>
<string name="wow_chat_voice_unavailable">A digitação por voz não está disponível neste dispositivo.</string>
<string name="wow_chat_mic_denied">É preciso acesso ao microfone para a digitação por voz.</string>
</resources>
+21 -14
View File
@@ -8,17 +8,6 @@
<string name="steamvr_recovery_restarting">Se resetează grafica și se redeschide SteamVR…</string>
<string name="steamvr_recovery_failed">DroidDeck nu a putut reporni automat. Închide complet aplicația, apoi redeschide-o.</string>
<string name="app_name">DroidDeck</string>
<string-array name="loading_hints">
<item>Prima rulare descarcă Steam. Poate dura câteva minute.</item>
<item>În mod implicit, la părăsirea aplicației, Steam este pus pe pauză; continuă de unde ai rămas când te întorci.</item>
<item>Controllerul tău apare ca un gamepad Xbox 360. Altfel, folosește comenzile de pe ecran.</item>
</string-array>
<string-array name="loading_hints_desktop">
<item>Prima rulare configurează desktopul. Poate dura câteva minute.</item>
<item>În mod implicit, la părăsirea aplicației, sesiunea este pusă pe pauză; continuă de unde ai rămas când te întorci.</item>
<item>Atingerea funcționează ca un touchpad: trage pentru a mișca cursorul, apasă scurt pentru a face clic.</item>
<item>Controllerul tău apare ca un gamepad Xbox 360. Altfel, folosește comenzile de pe ecran.</item>
</string-array>
<string name="session_channel">Sesiune</string>
<string name="session_channel_description">Apare cât timp o sesiune Steam sau desktop este activă</string>
<string name="session_notification">Steam este în execuție</string>
@@ -558,6 +547,19 @@
<string name="comp_tag_in_use">În uz</string>
<string name="comp_tag_new">Nou</string>
<string name="comp_tag_stored">Salvat</string>
<string name="comp_mode_title">Cum se aleg versiunile</string>
<string name="comp_mode_auto_detail">Cea mai nouă versiune DroidDeck, mereu actualizată</string>
<string name="comp_mode_manual_detail">Alege și instalează singur versiunile</string>
<string name="comp_auto_title">Auto menține %1$s actualizat</string>
<string name="comp_auto_hint">Versiunile noi se instalează în fundal. Dacă un joc care mergea se închide în câteva secunde de două ori, Auto revine la versiunea care funcționa.</string>
<string name="comp_auto_fallback">Rezervă: %1$s</string>
<string name="comp_auto_checking">Se caută versiuni…</string>
<string name="comp_auto_none">Niciun DroidDeck-Proton nu folosește asta</string>
<string name="comp_auto_waits_network">Actualizările Auto așteaptă o conexiune.</string>
<string name="comp_auto_check_failed">Nu s-au putut căuta versiuni noi.</string>
<string name="comp_auto_waits_unmetered">Versiunile noi așteaptă o conexiune fără limită de date, economisirea bateriei oprită și un dispozitiv răcit.</string>
<string name="comp_auto_updated">%1$s actualizat la %2$s.</string>
<string name="comp_auto_rolled_back">%1$s %2$s a închis un joc în câteva secunde de două ori, așa că Auto a revenit la %3$s.</string>
<string name="perf_title">Performanță</string>
<string name="perf_lede">Nuclee CPU, remedieri de sesiune și numele hostului. Se aplică la sesiunea următoare.</string>
<string name="perf_client_cores_group">Nuclee pentru clientul Steam</string>
@@ -2254,9 +2256,6 @@
<string name="source_added">Personalizat</string>
<string name="setup_stores">Magazine</string>
<string name="setup_stores_speed">Nivelul vitezei de descărcare</string>
<string name="setup_stores_engine">Motorul magazinelor</string>
<string name="setup_stores_engine_chip_ready">● Motor pregătit</string>
<string name="setup_stores_engine_chip_missing">Necompilat</string>
<string name="stores_tier_balanced">Echilibrat</string>
<string name="stores_tier_fast">Rapid</string>
<string name="stores_tier_max">Maxim</string>
@@ -3715,4 +3714,12 @@
<string name="stores_gamejolt_filter_status">Stare</string>
<string name="stores_gamejolt_filter_partners">Parteneri</string>
<string name="stores_gamejolt_followed">Jocuri urmărite</string>
<string name="wow_chat_title">Introducere în chat</string>
<string name="wow_chat_voice">Tastare vocală</string>
<string name="wow_chat_remember">Ține minte această alegere</string>
<string name="wow_chat_ask">Întreabă de fiecare dată</string>
<string name="wow_chat_send">Trimite</string>
<string name="wow_chat_listening">Se ascultă…</string>
<string name="wow_chat_voice_unavailable">Tastarea vocală nu este disponibilă pe acest dispozitiv.</string>
<string name="wow_chat_mic_denied">Pentru tastarea vocală este necesar accesul la microfon.</string>
</resources>
+21 -14
View File
@@ -8,17 +8,6 @@
<string name="steamvr_recovery_restarting">Сброс графики и повторный запуск SteamVR…</string>
<string name="steamvr_recovery_failed">Не удалось автоматически перезапустить DroidDeck. Полностью закройте приложение и откройте его снова.</string>
<string name="app_name">DroidDeck</string>
<string-array name="loading_hints">
<item>При первом запуске будет загружен Steam. Это может занять несколько минут.</item>
<item>По умолчанию при выходе из приложения Steam приостанавливается; при возвращении работа возобновляется с того же места.</item>
<item>Ваш контроллер отображается как геймпад Xbox 360. Если контроллера нет, используйте экранные элементы управления.</item>
</string-array>
<string-array name="loading_hints_desktop">
<item>При первом запуске будет настроен рабочий стол. Это может занять несколько минут.</item>
<item>По умолчанию при выходе из приложения сессия приостанавливается; при возвращении работа возобновляется с того же места.</item>
<item>Сенсорный ввод работает как тачпад: перетаскивайте для перемещения курсора, нажимайте для клика.</item>
<item>Ваш контроллер отображается как геймпад Xbox 360. Если контроллера нет, используйте экранные элементы управления.</item>
</string-array>
<string name="session_channel">Сессия</string>
<string name="session_channel_description">Отображается, пока открыта сессия Steam или рабочего стола</string>
<string name="session_notification">Steam запущен</string>
@@ -579,6 +568,19 @@
<string name="comp_tag_in_use">Используется</string>
<string name="comp_tag_new">Новый</string>
<string name="comp_tag_stored">Сохранено</string>
<string name="comp_mode_title">Как выбираются сборки</string>
<string name="comp_mode_auto_detail">Новейшая сборка DroidDeck, всегда актуальна</string>
<string name="comp_mode_manual_detail">Выбирайте и устанавливайте сборки сами</string>
<string name="comp_auto_title">Auto поддерживает %1$s в актуальном состоянии</string>
<string name="comp_auto_hint">Новые сборки ставятся в фоне. Если игра, которая раньше работала, дважды закроется за несколько секунд, Auto вернётся к рабочей сборке.</string>
<string name="comp_auto_fallback">Запасная: %1$s</string>
<string name="comp_auto_checking">Поиск сборок…</string>
<string name="comp_auto_none">Ни один DroidDeck-Proton это не использует</string>
<string name="comp_auto_waits_network">Обновления Auto ждут подключения.</string>
<string name="comp_auto_check_failed">Не удалось проверить новые сборки.</string>
<string name="comp_auto_waits_unmetered">Новые сборки ждут безлимитного подключения, выключенной экономии заряда и остывшего устройства.</string>
<string name="comp_auto_updated">%1$s обновлён до %2$s.</string>
<string name="comp_auto_rolled_back">%1$s %2$s дважды закрыл игру за несколько секунд, поэтому Auto вернулся к %3$s.</string>
<string name="perf_title">Производительность</string>
<string name="perf_lede">Ядра CPU, исправления сессии и имя хоста. Вступит в силу в следующей сессии.</string>
<string name="perf_client_cores_group">Ядра клиента Steam</string>
@@ -2362,9 +2364,6 @@
<string name="source_added">Свои</string>
<string name="setup_stores">Магазины</string>
<string name="setup_stores_speed">Уровень скорости загрузки</string>
<string name="setup_stores_engine">Движок магазинов</string>
<string name="setup_stores_engine_chip_ready">● Движок готов</string>
<string name="setup_stores_engine_chip_missing">Не собран</string>
<string name="stores_tier_balanced">Сбалансированный</string>
<string name="stores_tier_fast">Быстрый</string>
<string name="stores_tier_max">Максимальный</string>
@@ -3837,4 +3836,12 @@
<string name="stores_gamejolt_filter_status">Статус</string>
<string name="stores_gamejolt_filter_partners">Партнёры</string>
<string name="stores_gamejolt_followed">Отслеживаемые игры</string>
<string name="wow_chat_title">Ввод в чат</string>
<string name="wow_chat_voice">Голосовой ввод</string>
<string name="wow_chat_remember">Запомнить этот выбор</string>
<string name="wow_chat_ask">Спрашивать каждый раз</string>
<string name="wow_chat_send">Отправить</string>
<string name="wow_chat_listening">Слушаю…</string>
<string name="wow_chat_voice_unavailable">Голосовой ввод недоступен на этом устройстве.</string>
<string name="wow_chat_mic_denied">Для голосового ввода нужен доступ к микрофону.</string>
</resources>
+21 -14
View File
@@ -8,17 +8,6 @@
<string name="steamvr_recovery_restarting">Återställer grafiken och öppnar SteamVR igen…</string>
<string name="steamvr_recovery_failed">DroidDeck kunde inte starta om automatiskt. Stäng appen helt och öppna den sedan igen.</string>
<string name="app_name">DroidDeck</string>
<string-array name="loading_hints">
<item>Första starten laddar ned Steam. Det kan ta några minuter.</item>
<item>Som standard pausas Steam när du lämnar appen. Det fortsätter där det slutade när du kommer tillbaka.</item>
<item>Din handkontroll visas som en Xbox 360-kontroll. Annars kan du använda skärmkontrollerna.</item>
</string-array>
<string-array name="loading_hints_desktop">
<item>Första starten konfigurerar skrivbordet. Det kan ta några minuter.</item>
<item>Som standard pausas sessionen när du lämnar appen. Den fortsätter där den slutade när du kommer tillbaka.</item>
<item>Pekskärmen fungerar som en pekplatta: dra för att flytta pekaren, tryck för att klicka.</item>
<item>Din handkontroll visas som en Xbox 360-kontroll. Annars kan du använda skärmkontrollerna.</item>
</string-array>
<string name="session_channel">Session</string>
<string name="session_channel_description">Visas medan en Steam- eller skrivbordssession är öppen</string>
<string name="session_notification">Steam körs</string>
@@ -556,6 +545,19 @@
<string name="comp_tag_in_use">I bruk</string>
<string name="comp_tag_new">Ny</string>
<string name="comp_tag_stored">Sparad</string>
<string name="comp_mode_title">Hur byggen väljs</string>
<string name="comp_mode_auto_detail">Senaste DroidDeck-bygget, alltid aktuellt</string>
<string name="comp_mode_manual_detail">Välj och installera byggen själv</string>
<string name="comp_auto_title">Auto håller %1$s aktuellt</string>
<string name="comp_auto_hint">Nya byggen installeras i bakgrunden. Om ett spel som fungerade stängs inom sekunder två gånger går Auto tillbaka till bygget som fungerade.</string>
<string name="comp_auto_fallback">Reserv: %1$s</string>
<string name="comp_auto_checking">Söker efter byggen…</string>
<string name="comp_auto_none">Ingen DroidDeck-Proton använder detta</string>
<string name="comp_auto_waits_network">Auto-uppdateringar väntar på en anslutning.</string>
<string name="comp_auto_check_failed">Kunde inte söka efter nya byggen.</string>
<string name="comp_auto_waits_unmetered">Nya byggen väntar på en obegränsad anslutning, avstängt batterisparläge och en sval enhet.</string>
<string name="comp_auto_updated">%1$s uppdaterat till %2$s.</string>
<string name="comp_auto_rolled_back">%1$s %2$s stängde ett spel inom sekunder två gånger, så Auto gick tillbaka till %3$s.</string>
<string name="perf_title">Prestanda</string>
<string name="perf_lede">CPU-kärnor, sessionsfixar och värdnamnet. Gäller från nästa session.</string>
<string name="perf_client_cores_group">Steam-klientens kärnor</string>
@@ -2229,9 +2231,6 @@
<string name="source_added">Egna</string>
<string name="setup_stores">Butiker</string>
<string name="setup_stores_speed">Nedladdningshastighet</string>
<string name="setup_stores_engine">Butiksmotor</string>
<string name="setup_stores_engine_chip_ready">● Motor redo</string>
<string name="setup_stores_engine_chip_missing">Inte byggd</string>
<string name="stores_tier_balanced">Balanserad</string>
<string name="stores_tier_fast">Snabb</string>
<string name="stores_tier_max">Max</string>
@@ -3676,4 +3675,12 @@
<string name="stores_gamejolt_filter_status">Status</string>
<string name="stores_gamejolt_filter_partners">Partner</string>
<string name="stores_gamejolt_followed">Följda spel</string>
<string name="wow_chat_title">Chattinmatning</string>
<string name="wow_chat_voice">Röstinmatning</string>
<string name="wow_chat_remember">Kom ihåg det här valet</string>
<string name="wow_chat_ask">Fråga varje gång</string>
<string name="wow_chat_send">Skicka</string>
<string name="wow_chat_listening">Lyssnar…</string>
<string name="wow_chat_voice_unavailable">Röstinmatning är inte tillgänglig på den här enheten.</string>
<string name="wow_chat_mic_denied">Mikrofonåtkomst krävs för röstinmatning.</string>
</resources>
+21 -14
View File
@@ -8,17 +8,6 @@
<string name="steamvr_recovery_restarting">กำลังรีเซ็ตกราฟิกและเปิด SteamVR อีกครั้ง…</string>
<string name="steamvr_recovery_failed">DroidDeck รีสตาร์ทโดยอัตโนมัติไม่ได้ ปิดแอปให้สนิทแล้วเปิดใหม่อีกครั้ง</string>
<string name="app_name">DroidDeck</string>
<string-array name="loading_hints">
<item>การเริ่มใช้งานครั้งแรกจะดาวน์โหลด Steam ซึ่งอาจใช้เวลาสักครู่</item>
<item>ตามค่าเริ่มต้น การออกจากแอปจะหยุด Steam ไว้ชั่วคราว แล้วกลับมาทำงานต่อจากจุดเดิมเมื่อคุณเปิดแอปอีกครั้ง</item>
<item>คอนโทรลเลอร์ของคุณจะปรากฏเป็นจอย Xbox 360 หากไม่เช่นนั้นให้ใช้ปุ่มควบคุมบนหน้าจอ</item>
</string-array>
<string-array name="loading_hints_desktop">
<item>การเริ่มใช้งานครั้งแรกจะตั้งค่าเดสก์ท็อป ซึ่งอาจใช้เวลาสักครู่</item>
<item>ตามค่าเริ่มต้น การออกจากแอปจะหยุดเซสชันไว้ชั่วคราว แล้วกลับมาทำงานต่อจากจุดเดิมเมื่อคุณเปิดแอปอีกครั้ง</item>
<item>การสัมผัสหน้าจอทำงานเหมือนทัชแพด ลากเพื่อเลื่อนตัวชี้ แตะเพื่อคลิก</item>
<item>คอนโทรลเลอร์ของคุณจะปรากฏเป็นจอย Xbox 360 หากไม่เช่นนั้นให้ใช้ปุ่มควบคุมบนหน้าจอ</item>
</string-array>
<string name="session_channel">เซสชัน</string>
<string name="session_channel_description">แสดงขณะมีเซสชัน Steam หรือเดสก์ท็อปเปิดอยู่</string>
<string name="session_notification">Steam กำลังทำงาน</string>
@@ -554,6 +543,19 @@
<string name="comp_tag_in_use">ใช้งานอยู่</string>
<string name="comp_tag_new">ใหม่</string>
<string name="comp_tag_stored">บันทึกไว้</string>
<string name="comp_mode_title">วิธีเลือกบิลด์</string>
<string name="comp_mode_auto_detail">บิลด์ DroidDeck ล่าสุด อัปเดตอยู่เสมอ</string>
<string name="comp_mode_manual_detail">เลือกและติดตั้งบิลด์เอง</string>
<string name="comp_auto_title">Auto ทำให้ %1$s เป็นปัจจุบัน</string>
<string name="comp_auto_hint">บิลด์ใหม่จะติดตั้งในเบื้องหลัง หากเกมที่เคยเล่นได้ปิดลงภายในไม่กี่วินาทีสองครั้ง Auto จะกลับไปใช้บิลด์ที่ใช้งานได้</string>
<string name="comp_auto_fallback">สำรอง: %1$s</string>
<string name="comp_auto_checking">กำลังค้นหาบิลด์…</string>
<string name="comp_auto_none">ไม่มี DroidDeck-Proton ที่ใช้สิ่งนี้</string>
<string name="comp_auto_waits_network">การอัปเดต Auto กำลังรอการเชื่อมต่อ</string>
<string name="comp_auto_check_failed">ตรวจหาบิลด์ใหม่ไม่ได้</string>
<string name="comp_auto_waits_unmetered">บิลด์ใหม่จะรอการเชื่อมต่อที่ไม่จำกัดปริมาณ ปิดโหมดประหยัดแบตเตอรี่ และอุปกรณ์ที่ไม่ร้อน</string>
<string name="comp_auto_updated">อัปเดต %1$s เป็น %2$s แล้ว</string>
<string name="comp_auto_rolled_back">%1$s %2$s ทำให้เกมปิดภายในไม่กี่วินาทีสองครั้ง Auto จึงกลับไปใช้ %3$s</string>
<string name="perf_title">ประสิทธิภาพ</string>
<string name="perf_lede">คอร์ CPU การแก้ไขเซสชัน และชื่อโฮสต์ จะมีผลในเซสชันถัดไป</string>
<string name="perf_client_cores_group">คอร์สำหรับไคลเอนต์ Steam</string>
@@ -2204,9 +2206,6 @@
<string name="source_added">กำหนดเอง</string>
<string name="setup_stores">สโตร์</string>
<string name="setup_stores_speed">ระดับความเร็วการดาวน์โหลด</string>
<string name="setup_stores_engine">เอนจินสโตร์</string>
<string name="setup_stores_engine_chip_ready">● เอนจินพร้อมใช้งาน</string>
<string name="setup_stores_engine_chip_missing">ยังไม่สร้าง</string>
<string name="stores_tier_balanced">สมดุล</string>
<string name="stores_tier_fast">เร็ว</string>
<string name="stores_tier_max">สูงสุด</string>
@@ -3646,4 +3645,12 @@
<string name="stores_gamejolt_filter_status">สถานะ</string>
<string name="stores_gamejolt_filter_partners">พาร์ตเนอร์</string>
<string name="stores_gamejolt_followed">เกมที่ติดตาม</string>
<string name="wow_chat_title">การป้อนแชท</string>
<string name="wow_chat_voice">พิมพ์ด้วยเสียง</string>
<string name="wow_chat_remember">จดจำตัวเลือกนี้</string>
<string name="wow_chat_ask">ถามทุกครั้ง</string>
<string name="wow_chat_send">ส่ง</string>
<string name="wow_chat_listening">กำลังฟัง…</string>
<string name="wow_chat_voice_unavailable">อุปกรณ์นี้ไม่รองรับการพิมพ์ด้วยเสียง</string>
<string name="wow_chat_mic_denied">ต้องอนุญาตให้เข้าถึงไมโครโฟนเพื่อพิมพ์ด้วยเสียง</string>
</resources>
+21 -14
View File
@@ -8,17 +8,6 @@
<string name="steamvr_recovery_restarting">Grafikler sıfırlanıyor ve SteamVR yeniden açılıyor…</string>
<string name="steamvr_recovery_failed">DroidDeck otomatik olarak yeniden başlatılamadı. Uygulamayı tamamen kapatıp yeniden açın.</string>
<string name="app_name">DroidDeck</string>
<string-array name="loading_hints">
<item>İlk çalıştırmada Steam indirilir. Bu işlem birkaç dakika sürebilir.</item>
<item>Varsayılan olarak uygulamadan çıkınca Steam duraklatılır; geri döndüğünüzde kaldığı yerden devam eder.</item>
<item>Kumandanız Xbox 360 kumandası olarak görünür. Kumanda yoksa ekran üstü kontrolleri kullanın.</item>
</string-array>
<string-array name="loading_hints_desktop">
<item>İlk çalıştırmada masaüstü kurulur. Bu işlem birkaç dakika sürebilir.</item>
<item>Varsayılan olarak uygulamadan çıkınca oturum duraklatılır; geri döndüğünüzde kaldığı yerden devam eder.</item>
<item>Dokunma, dokunmatik yüzey gibi çalışır: işaretçiyi taşımak için sürükleyin, tıklamak için dokunun.</item>
<item>Kumandanız Xbox 360 kumandası olarak görünür. Kumanda yoksa ekran üstü kontrolleri kullanın.</item>
</string-array>
<string name="session_channel">Oturum</string>
<string name="session_channel_description">Steam veya masaüstü oturumu açıkken gösterilir</string>
<string name="session_notification">Steam çalışıyor</string>
@@ -556,6 +545,19 @@
<string name="comp_tag_in_use">Kullanımda</string>
<string name="comp_tag_new">Yeni</string>
<string name="comp_tag_stored">Saklandı</string>
<string name="comp_mode_title">Sürümler nasıl seçilir</string>
<string name="comp_mode_auto_detail">En yeni DroidDeck sürümü, hep güncel</string>
<string name="comp_mode_manual_detail">Sürümleri kendin seç ve yükle</string>
<string name="comp_auto_title">Auto %1$s bileşenini güncel tutar</string>
<string name="comp_auto_hint">Yeni sürümler arka planda yüklenir. Önceden çalışan bir oyun iki kez saniyeler içinde kapanırsa Auto çalışan sürüme geri döner.</string>
<string name="comp_auto_fallback">Yedek: %1$s</string>
<string name="comp_auto_checking">Sürümler aranıyor…</string>
<string name="comp_auto_none">Hiçbir DroidDeck-Proton bunu kullanmıyor</string>
<string name="comp_auto_waits_network">Auto güncellemeleri bağlantı bekliyor.</string>
<string name="comp_auto_check_failed">Yeni sürümler denetlenemedi.</string>
<string name="comp_auto_waits_unmetered">Yeni sürümler sınırsız bir bağlantı, kapalı pil tasarrufu ve serin bir cihaz bekler.</string>
<string name="comp_auto_updated">%1$s, %2$s sürümüne güncellendi.</string>
<string name="comp_auto_rolled_back">%1$s %2$s bir oyunu iki kez saniyeler içinde kapattı, bu yüzden Auto %3$s sürümüne döndü.</string>
<string name="perf_title">Performans</string>
<string name="perf_lede">CPU çekirdekleri, oturum düzeltmeleri ve ana bilgisayar adı. Sonraki oturumda uygulanır.</string>
<string name="perf_client_cores_group">Steam istemcisi çekirdekleri</string>
@@ -2221,9 +2223,6 @@
<string name="source_added">Özel</string>
<string name="setup_stores">Mağazalar</string>
<string name="setup_stores_speed">İndirme hızı katmanı</string>
<string name="setup_stores_engine">Mağazalar motoru</string>
<string name="setup_stores_engine_chip_ready">● Motor hazır</string>
<string name="setup_stores_engine_chip_missing">Derlenmedi</string>
<string name="stores_tier_balanced">Dengeli</string>
<string name="stores_tier_fast">Hızlı</string>
<string name="stores_tier_max">Maks</string>
@@ -3676,4 +3675,12 @@
<string name="stores_gamejolt_filter_status">Durum</string>
<string name="stores_gamejolt_filter_partners">Ortaklar</string>
<string name="stores_gamejolt_followed">Takip edilen oyunlar</string>
<string name="wow_chat_title">Sohbet girişi</string>
<string name="wow_chat_voice">Sesle yazma</string>
<string name="wow_chat_remember">Bu seçimi hatırla</string>
<string name="wow_chat_ask">Her seferinde sor</string>
<string name="wow_chat_send">Gönder</string>
<string name="wow_chat_listening">Dinleniyor…</string>
<string name="wow_chat_voice_unavailable">Sesle yazma bu cihazda kullanılamıyor.</string>
<string name="wow_chat_mic_denied">Sesle yazma için mikrofon erişimi gerekiyor.</string>
</resources>
+21 -14
View File
@@ -8,17 +8,6 @@
<string name="steamvr_recovery_restarting">Скидання графіки й повторне відкриття SteamVR…</string>
<string name="steamvr_recovery_failed">DroidDeck не вдалося перезапуститися автоматично. Повністю закрийте додаток, а потім відкрийте його знову.</string>
<string name="app_name">DroidDeck</string>
<string-array name="loading_hints">
<item>Під час першого запуску завантажується Steam. Це може зайняти кілька хвилин.</item>
<item>За замовчуванням вихід із додатка ставить Steam на паузу; коли ви повернетеся, усе продовжиться з того самого місця.</item>
<item>Ваш контролер відображається як геймпад Xbox 360. Якщо контролера немає, скористайтеся екранним керуванням.</item>
</string-array>
<string-array name="loading_hints_desktop">
<item>Під час першого запуску налаштовується робочий стіл. Це може зайняти кілька хвилин.</item>
<item>За замовчуванням вихід із додатка ставить сеанс на паузу; коли ви повернетеся, усе продовжиться з того самого місця.</item>
<item>Екран працює як сенсорна панель: проводьте пальцем, щоб рухати вказівник, і торкайтеся, щоб клацнути.</item>
<item>Ваш контролер відображається як геймпад Xbox 360. Якщо контролера немає, скористайтеся екранним керуванням.</item>
</string-array>
<string name="session_channel">Сеанс</string>
<string name="session_channel_description">Відображається, поки відкрито сеанс Steam або робочого столу</string>
<string name="session_notification">Steam працює</string>
@@ -560,6 +549,19 @@
<string name="comp_tag_in_use">Використовується</string>
<string name="comp_tag_new">Нове</string>
<string name="comp_tag_stored">Збережено</string>
<string name="comp_mode_title">Як вибираються збірки</string>
<string name="comp_mode_auto_detail">Найновіша збірка DroidDeck, завжди актуальна</string>
<string name="comp_mode_manual_detail">Вибирайте та встановлюйте збірки самостійно</string>
<string name="comp_auto_title">Auto підтримує %1$s в актуальному стані</string>
<string name="comp_auto_hint">Нові збірки встановлюються у фоні. Якщо гра, яка раніше працювала, двічі закриється за кілька секунд, Auto повернеться до робочої збірки.</string>
<string name="comp_auto_fallback">Запасна: %1$s</string>
<string name="comp_auto_checking">Пошук збірок…</string>
<string name="comp_auto_none">Жоден DroidDeck-Proton цього не використовує</string>
<string name="comp_auto_waits_network">Оновлення Auto чекають на з’єднання.</string>
<string name="comp_auto_check_failed">Не вдалося перевірити нові збірки.</string>
<string name="comp_auto_waits_unmetered">Нові збірки чекають на безлімітне з’єднання, вимкнену економію заряду та охолоджений пристрій.</string>
<string name="comp_auto_updated">%1$s оновлено до %2$s.</string>
<string name="comp_auto_rolled_back">%1$s %2$s двічі закрив гру за кілька секунд, тому Auto повернувся до %3$s.</string>
<string name="perf_title">Продуктивність</string>
<string name="perf_lede">Ядра CPU, виправлення сеансу та ім’я хоста. Застосовується в наступному сеансі.</string>
<string name="perf_client_cores_group">Ядра клієнта Steam</string>
@@ -2279,9 +2281,6 @@
<string name="source_added">Власна</string>
<string name="setup_stores">Магазини</string>
<string name="setup_stores_speed">Рівень швидкості завантаження</string>
<string name="setup_stores_engine">Рушій магазинів</string>
<string name="setup_stores_engine_chip_ready">● Рушій готовий</string>
<string name="setup_stores_engine_chip_missing">Не зібрано</string>
<string name="stores_tier_balanced">Збалансований</string>
<string name="stores_tier_fast">Швидкий</string>
<string name="stores_tier_max">Максимальний</string>
@@ -3754,4 +3753,12 @@
<string name="stores_gamejolt_filter_status">Статус</string>
<string name="stores_gamejolt_filter_partners">Партнери</string>
<string name="stores_gamejolt_followed">Ігри, за якими ви стежите</string>
<string name="wow_chat_title">Введення в чат</string>
<string name="wow_chat_voice">Голосове введення</string>
<string name="wow_chat_remember">Запам\'ятати цей вибір</string>
<string name="wow_chat_ask">Питати щоразу</string>
<string name="wow_chat_send">Надіслати</string>
<string name="wow_chat_listening">Слухаю…</string>
<string name="wow_chat_voice_unavailable">Голосове введення недоступне на цьому пристрої.</string>
<string name="wow_chat_mic_denied">Для голосового введення потрібен доступ до мікрофона.</string>
</resources>
+21 -14
View File
@@ -8,17 +8,6 @@
<string name="steamvr_recovery_restarting">Đang đặt lại đồ họa và mở lại SteamVR…</string>
<string name="steamvr_recovery_failed">DroidDeck không thể tự khởi động lại. Hãy đóng hẳn ứng dụng rồi mở lại.</string>
<string name="app_name">DroidDeck</string>
<string-array name="loading_hints">
<item>Lần chạy đầu tải Steam. Quá trình này có thể mất vài phút.</item>
<item>Theo mặc định, rời khỏi ứng dụng sẽ tạm dừng Steam; nó tiếp tục từ nơi bạn dừng lại khi bạn quay lại.</item>
<item>Tay cầm của bạn hiện ra như tay cầm Xbox 360. Nếu không, hãy dùng các nút điều khiển trên màn hình.</item>
</string-array>
<string-array name="loading_hints_desktop">
<item>Lần chạy đầu thiết lập desktop. Quá trình này có thể mất vài phút.</item>
<item>Theo mặc định, rời khỏi ứng dụng sẽ tạm dừng phiên; nó tiếp tục từ nơi bạn dừng lại khi bạn quay lại.</item>
<item>Chạm hoạt động như touchpad: kéo để di chuyển con trỏ, chạm để nhấp.</item>
<item>Tay cầm của bạn hiện ra như tay cầm Xbox 360. Nếu không, hãy dùng các nút điều khiển trên màn hình.</item>
</string-array>
<string name="session_channel">Phiên</string>
<string name="session_channel_description">Hiển thị khi có phiên Steam hoặc desktop đang mở</string>
<string name="session_notification">Steam đang chạy</string>
@@ -554,6 +543,19 @@
<string name="comp_tag_in_use">Đang dùng</string>
<string name="comp_tag_new">Mới</string>
<string name="comp_tag_stored">Đã lưu</string>
<string name="comp_mode_title">Cách chọn bản dựng</string>
<string name="comp_mode_auto_detail">Bản dựng DroidDeck mới nhất, luôn cập nhật</string>
<string name="comp_mode_manual_detail">Tự chọn và cài bản dựng</string>
<string name="comp_auto_title">Auto giữ %1$s luôn cập nhật</string>
<string name="comp_auto_hint">Bản dựng mới được cài ở nền. Nếu một trò chơi từng chạy được bị đóng sau vài giây hai lần, Auto quay lại bản dựng đã chạy được.</string>
<string name="comp_auto_fallback">Dự phòng: %1$s</string>
<string name="comp_auto_checking">Đang tìm bản dựng…</string>
<string name="comp_auto_none">Không DroidDeck-Proton nào dùng thành phần này</string>
<string name="comp_auto_waits_network">Cập nhật Auto đang chờ kết nối.</string>
<string name="comp_auto_check_failed">Không thể kiểm tra bản dựng mới.</string>
<string name="comp_auto_waits_unmetered">Bản dựng mới chờ kết nối không đo lưu lượng, tắt tiết kiệm pin và thiết bị đủ mát.</string>
<string name="comp_auto_updated">Đã cập nhật %1$s lên %2$s.</string>
<string name="comp_auto_rolled_back">%1$s %2$s làm trò chơi đóng sau vài giây hai lần, nên Auto đã quay lại %3$s.</string>
<string name="perf_title">Hiệu năng</string>
<string name="perf_lede">Lõi CPU, các bản sửa phiên và tên máy chủ. Áp dụng từ phiên sau.</string>
<string name="perf_client_cores_group">Lõi client Steam</string>
@@ -2204,9 +2206,6 @@
<string name="source_added">Tùy chỉnh</string>
<string name="setup_stores">Cửa hàng</string>
<string name="setup_stores_speed">Mức tốc độ tải</string>
<string name="setup_stores_engine">Engine stores</string>
<string name="setup_stores_engine_chip_ready">● Engine sẵn sàng</string>
<string name="setup_stores_engine_chip_missing">Chưa build</string>
<string name="stores_tier_balanced">Cân bằng</string>
<string name="stores_tier_fast">Nhanh</string>
<string name="stores_tier_max">Tối đa</string>
@@ -3646,4 +3645,12 @@
<string name="stores_gamejolt_filter_status">Trạng thái</string>
<string name="stores_gamejolt_filter_partners">Đối tác</string>
<string name="stores_gamejolt_followed">Game đang theo dõi</string>
<string name="wow_chat_title">Nhập trò chuyện</string>
<string name="wow_chat_voice">Nhập bằng giọng nói</string>
<string name="wow_chat_remember">Ghi nhớ lựa chọn này</string>
<string name="wow_chat_ask">Hỏi mỗi lần</string>
<string name="wow_chat_send">Gửi</string>
<string name="wow_chat_listening">Đang nghe…</string>
<string name="wow_chat_voice_unavailable">Thiết bị này không hỗ trợ nhập bằng giọng nói.</string>
<string name="wow_chat_mic_denied">Cần quyền truy cập micrô để nhập bằng giọng nói.</string>
</resources>
+21 -14
View File
@@ -8,17 +8,6 @@
<string name="steamvr_recovery_restarting">正在重置图形并重新打开 SteamVR…</string>
<string name="steamvr_recovery_failed">DroidDeck 无法自动重启。请完全关闭应用,然后重新打开。</string>
<string name="app_name">DroidDeck</string>
<string-array name="loading_hints">
<item>首次运行会下载 Steam,这可能需要几分钟。</item>
<item>默认情况下,离开应用会暂停 Steam;返回后会从原处继续。</item>
<item>你的手柄会被识别为 Xbox 360 手柄;若没有手柄,请使用屏幕控制。</item>
</string-array>
<string-array name="loading_hints_desktop">
<item>首次运行会设置桌面环境,这可能需要几分钟。</item>
<item>默认情况下,离开应用会暂停会话;返回后会从原处继续。</item>
<item>触控区域可当作触控板使用:拖动可移动指针,轻点即可点击。</item>
<item>你的手柄会被识别为 Xbox 360 手柄;若没有手柄,请使用屏幕控制。</item>
</string-array>
<string name="session_channel">会话</string>
<string name="session_channel_description">在 Steam 或桌面会话打开时显示</string>
<string name="session_notification">Steam 正在运行</string>
@@ -554,6 +543,19 @@
<string name="comp_tag_in_use">使用中</string>
<string name="comp_tag_new">新</string>
<string name="comp_tag_stored">已存储</string>
<string name="comp_mode_title">版本的选择方式</string>
<string name="comp_mode_auto_detail">最新 DroidDeck 版本,始终保持更新</string>
<string name="comp_mode_manual_detail">自行选择并安装版本</string>
<string name="comp_auto_title">Auto 会让 %1$s 保持最新</string>
<string name="comp_auto_hint">新版本会在后台安装。如果之前能运行的游戏两次在几秒内关闭,Auto 会退回到之前可用的版本。</string>
<string name="comp_auto_fallback">备用:%1$s</string>
<string name="comp_auto_checking">正在检查版本…</string>
<string name="comp_auto_none">没有 DroidDeck-Proton 使用它</string>
<string name="comp_auto_waits_network">Auto 更新正在等待网络连接。</string>
<string name="comp_auto_check_failed">无法检查新版本。</string>
<string name="comp_auto_waits_unmetered">新版本将等待不按流量计费的网络、关闭省电模式且设备不过热时下载。</string>
<string name="comp_auto_updated">%1$s 已更新到 %2$s。</string>
<string name="comp_auto_rolled_back">%1$s %2$s 两次在几秒内关闭了游戏,因此 Auto 已退回到 %3$s。</string>
<string name="perf_title">性能</string>
<string name="perf_lede">CPU 核心、会话修复和主机名。将在下次会话生效。</string>
<string name="perf_client_cores_group">Steam 客户端核心</string>
@@ -2285,9 +2287,6 @@
<string name="source_added">自定义</string>
<string name="setup_stores">游戏商店</string>
<string name="setup_stores_speed">下载速度档位</string>
<string name="setup_stores_engine">商店引擎</string>
<string name="setup_stores_engine_chip_ready">● 引擎就绪</string>
<string name="setup_stores_engine_chip_missing">未构建</string>
<string name="stores_tier_balanced">均衡</string>
<string name="stores_tier_fast">快速</string>
<string name="stores_tier_max">最高</string>
@@ -3650,4 +3649,12 @@
<string name="stores_gamejolt_filter_status">状态</string>
<string name="stores_gamejolt_filter_partners">合作伙伴</string>
<string name="stores_gamejolt_followed">已关注的游戏</string>
<string name="wow_chat_title">聊天输入</string>
<string name="wow_chat_voice">语音输入</string>
<string name="wow_chat_remember">记住此选择</string>
<string name="wow_chat_ask">每次询问</string>
<string name="wow_chat_send">发送</string>
<string name="wow_chat_listening">正在聆听…</string>
<string name="wow_chat_voice_unavailable">此设备不支持语音输入。</string>
<string name="wow_chat_mic_denied">语音输入需要麦克风权限。</string>
</resources>
+21 -14
View File
@@ -8,17 +8,6 @@
<string name="steamvr_recovery_restarting">正在重設圖形並重新開啟 SteamVR…</string>
<string name="steamvr_recovery_failed">DroidDeck 無法自動重新啟動。請完全關閉應用程式,然後重新開啟。</string>
<string name="app_name">DroidDeck</string>
<string-array name="loading_hints">
<item>首次執行會下載 Steam,可能需要幾分鐘。</item>
<item>預設情況下,離開應用程式會暫停 Steam;返回時會從上次中斷的地方繼續。</item>
<item>你的控制器會顯示為 Xbox 360 控制器。如果沒有控制器,請使用螢幕控制。</item>
</string-array>
<string-array name="loading_hints_desktop">
<item>首次執行會設定桌面,可能需要幾分鐘。</item>
<item>預設情況下,離開應用程式會暫停會話;返回時會從上次中斷的地方繼續。</item>
<item>觸控會當作觸控板使用:拖曳以移動指標,輕按即點擊。</item>
<item>你的控制器會顯示為 Xbox 360 控制器。如果沒有控制器,請使用螢幕控制。</item>
</string-array>
<string name="session_channel">會話</string>
<string name="session_channel_description">Steam 或桌面會話開啟時顯示</string>
<string name="session_notification">Steam 正在執行</string>
@@ -554,6 +543,19 @@
<string name="comp_tag_in_use">使用中</string>
<string name="comp_tag_new">新</string>
<string name="comp_tag_stored">已儲存</string>
<string name="comp_mode_title">版本的選擇方式</string>
<string name="comp_mode_auto_detail">最新 DroidDeck 版本,持續保持更新</string>
<string name="comp_mode_manual_detail">自行選擇並安裝版本</string>
<string name="comp_auto_title">Auto 會令 %1$s 保持最新</string>
<string name="comp_auto_hint">新版本會在背景安裝。如果之前能執行的遊戲兩次在幾秒內關閉,Auto 會退回到之前可用的版本。</string>
<string name="comp_auto_fallback">備用:%1$s</string>
<string name="comp_auto_checking">正在檢查版本…</string>
<string name="comp_auto_none">沒有 DroidDeck-Proton 使用它</string>
<string name="comp_auto_waits_network">Auto 更新正在等待網絡連線。</string>
<string name="comp_auto_check_failed">無法檢查新版本。</string>
<string name="comp_auto_waits_unmetered">新版本將等待非按流量收費的網絡、關閉省電模式且裝置不過熱時下載。</string>
<string name="comp_auto_updated">%1$s 已更新至 %2$s。</string>
<string name="comp_auto_rolled_back">%1$s %2$s 兩次在幾秒內關閉了遊戲,因此 Auto 已退回到 %3$s。</string>
<string name="perf_title">效能</string>
<string name="perf_lede">CPU 核心、會話修正和主機名稱。下次會話生效。</string>
<string name="perf_client_cores_group">Steam 用戶端核心</string>
@@ -2204,9 +2206,6 @@
<string name="source_added">自訂</string>
<string name="setup_stores">商店</string>
<string name="setup_stores_speed">下載速度等級</string>
<string name="setup_stores_engine">商店引擎</string>
<string name="setup_stores_engine_chip_ready">● 引擎已就緒</string>
<string name="setup_stores_engine_chip_missing">未建置</string>
<string name="stores_tier_balanced">平衡</string>
<string name="stores_tier_fast">快速</string>
<string name="stores_tier_max">最高</string>
@@ -3649,4 +3648,12 @@
<string name="setup_gpu_bridged">GPU 透過 Mali 橋接</string>
<string name="gpuinfo_bridge">透過 Mali 橋接執行,使用手機內建的 Vulkan 驅動程式</string>
<string name="gpuinfo_bridge_off">關閉 Mali 橋接時不受支援:Turnip 需要 Adreno(Snapdragon)GPU</string>
<string name="wow_chat_title">聊天輸入</string>
<string name="wow_chat_voice">語音輸入</string>
<string name="wow_chat_remember">記住此選擇</string>
<string name="wow_chat_ask">每次詢問</string>
<string name="wow_chat_send">傳送</string>
<string name="wow_chat_listening">正在聆聽…</string>
<string name="wow_chat_voice_unavailable">此裝置不支援語音輸入。</string>
<string name="wow_chat_mic_denied">語音輸入需要咪高峰權限。</string>
</resources>
+21 -14
View File
@@ -8,17 +8,6 @@
<string name="steamvr_recovery_restarting">正在重設圖形並重新開啟 SteamVR…</string>
<string name="steamvr_recovery_failed">DroidDeck 無法自動重新啟動。請完全關閉應用程式,然後重新開啟。</string>
<string name="app_name">DroidDeck</string>
<string-array name="loading_hints">
<item>首次執行會下載 Steam。這可能需要幾分鐘。</item>
<item>預設情況下,離開應用程式會暫停 Steam;返回時會從上次中斷處繼續。</item>
<item>你的控制器會被識別為 Xbox 360 手把。沒有控制器時,可使用螢幕控制。</item>
</string-array>
<string-array name="loading_hints_desktop">
<item>首次執行會設定桌面。這可能需要幾分鐘。</item>
<item>預設情況下,離開應用程式會暫停工作階段;返回時會從上次中斷處繼續。</item>
<item>觸控會像觸控板一樣運作:拖曳移動指標,輕觸即點擊。</item>
<item>你的控制器會被識別為 Xbox 360 手把。沒有控制器時,可使用螢幕控制。</item>
</string-array>
<string name="session_channel">工作階段</string>
<string name="session_channel_description">在 Steam 或桌面工作階段開啟時顯示</string>
<string name="session_notification">Steam 正在執行</string>
@@ -554,6 +543,19 @@
<string name="comp_tag_in_use">使用中</string>
<string name="comp_tag_new">新</string>
<string name="comp_tag_stored">已儲存</string>
<string name="comp_mode_title">版本的選擇方式</string>
<string name="comp_mode_auto_detail">最新 DroidDeck 版本,持續保持更新</string>
<string name="comp_mode_manual_detail">自行選擇並安裝版本</string>
<string name="comp_auto_title">Auto 會讓 %1$s 保持最新</string>
<string name="comp_auto_hint">新版本會在背景安裝。如果先前能執行的遊戲兩次在幾秒內關閉,Auto 會退回到先前可用的版本。</string>
<string name="comp_auto_fallback">備用:%1$s</string>
<string name="comp_auto_checking">正在檢查版本…</string>
<string name="comp_auto_none">沒有 DroidDeck-Proton 使用它</string>
<string name="comp_auto_waits_network">Auto 更新正在等待網路連線。</string>
<string name="comp_auto_check_failed">無法檢查新版本。</string>
<string name="comp_auto_waits_unmetered">新版本將等待非計量網路、關閉省電模式且裝置不過熱時下載。</string>
<string name="comp_auto_updated">%1$s 已更新至 %2$s。</string>
<string name="comp_auto_rolled_back">%1$s %2$s 兩次在幾秒內關閉了遊戲,因此 Auto 已退回到 %3$s。</string>
<string name="perf_title">效能</string>
<string name="perf_lede">CPU 核心、工作階段修復和主機名稱。下次工作階段生效。</string>
<string name="perf_client_cores_group">Steam 用戶端核心</string>
@@ -2204,9 +2206,6 @@
<string name="source_added">自訂</string>
<string name="setup_stores">遊戲商店</string>
<string name="setup_stores_speed">下載速度等級</string>
<string name="setup_stores_engine">商店引擎</string>
<string name="setup_stores_engine_chip_ready">● 引擎已就緒</string>
<string name="setup_stores_engine_chip_missing">尚未組建</string>
<string name="stores_tier_balanced">平衡</string>
<string name="stores_tier_fast">快速</string>
<string name="stores_tier_max">最高</string>
@@ -3649,4 +3648,12 @@
<string name="setup_gpu_bridged">GPU 透過 Mali 橋接</string>
<string name="gpuinfo_bridge">透過 Mali 橋接執行,使用手機內建的 Vulkan 驅動程式</string>
<string name="gpuinfo_bridge_off">關閉 Mali 橋接時不受支援:Turnip 需要 Adreno(Snapdragon)GPU</string>
<string name="wow_chat_title">聊天輸入</string>
<string name="wow_chat_voice">語音輸入</string>
<string name="wow_chat_remember">記住此選擇</string>
<string name="wow_chat_ask">每次詢問</string>
<string name="wow_chat_send">傳送</string>
<string name="wow_chat_listening">正在聆聽…</string>
<string name="wow_chat_voice_unavailable">此裝置不支援語音輸入。</string>
<string name="wow_chat_mic_denied">語音輸入需要麥克風權限。</string>
</resources>
+45 -7
View File
@@ -37,14 +37,34 @@
<string name="wall_settings_speed" translatable="false">Scroll speed</string>
<string-array name="loading_hints">
<item>First run downloads Steam. This may take a few minutes.</item>
<item>By default, leaving the app pauses Steam; it picks up where it left off when you come back.</item>
<item>Your controller appears as an Xbox 360 pad. Otherwise, use the on-screen controls.</item>
<item>Your controller shows up to Steam as a Steam Deck controller. Switch it to a Steam Controller, DualShock 4, DualSense or Xbox 360 pad in the session settings; it applies next session.</item>
<item>Hold Back on your controller to open Steam\'s Quick Access menu.</item>
<item>Background behavior decides what happens when you leave the app: Native readies Steam for sleep, Auto pauses it, Manual waits for Resume, Never keeps it running.</item>
<item>Steam games use Steam\'s Frame Limit in Quick Access. The display switches to a refresh rate the cap divides evenly.</item>
<item>Low latency mode in the session menu sends controller input at 1000 Hz instead of 250 Hz, for a bit more CPU and battery.</item>
<item>Each game has its own profile with Proton, FEX, components and environment settings.</item>
<item>The session menu has a microphone toggle for voice chat and a Suspend now button to freeze the session on demand.</item>
<item>Drag the DroidDeck HUD to move it, pinch to resize it, and push it off an edge to dock it as a small FPS tab.</item>
<item>On dual-screen handhelds, the bottom screen can show downloads, notifications, screenshots and Steam chat. Swap screens puts the game down there instead.</item>
<item>Add Steam libraries from an SD card, USB drive or SSD, and DroidDeck keeps the games together in your Library.</item>
<item>Stores lets you sign in to GOG, itch.io, Battle.net, Amazon Games and Game Jolt, then install and update their games from DroidDeck.</item>
<item>Components like FEX, DXVK and VKD3D-Proton update themselves, and DroidDeck-Proton is the default.</item>
<item>Compress Steam while a game runs frees RAM on devices that run low.</item>
<item>Swipe in from the right edge to open the session menu: touchpad mode, PC keyboard, low latency, game profile and more.</item>
<item>Game running slow? Cap the frame rate in Quick Access, then try another Proton version or FEX preset in the game's components.</item>
<item>Crackling or missing game audio? Try the DirectAudio for games toggle in Steam settings under Audio.</item>
<item>Setup, then Storage, can clear shader caches and leftover prefixes from games you removed.</item>
<item>Hitting a bug? Share logs in the session menu bundles diagnostics. Post them in #bug-reports on Discord.</item>
</string-array>
<string-array name="loading_hints_desktop">
<item>First run sets up the desktop. This may take a few minutes.</item>
<item>By default, leaving the app pauses the session; it picks up where it left off when you come back.</item>
<item>Touch works as a touchpad: drag to move the pointer, tap to click.</item>
<item>Your controller appears as an Xbox 360 pad. Otherwise, use the on-screen controls.</item>
<item>Background behavior decides what happens when you leave the app: Manual waits for Resume by default, Auto pauses, Never keeps it running.</item>
<item>Swipe in from the right edge to open the session menu, then pick PC keyboard to type in apps that ignore the Android keyboard.</item>
<item>The PC keyboard in the session menu has adjustable size, opacity and key groups.</item>
<item>Your controller shows up as an Xbox 360 pad. Otherwise, use the on-screen controls.</item>
<item>The session menu has a microphone toggle and a Suspend now button to freeze the session on demand.</item>
<item>Hitting a bug? Share logs in the session menu bundles diagnostics. Post them in #bug-reports on Discord.</item>
</string-array>
<string name="session_channel">Session</string>
<string name="session_channel_description">Shows while a Steam or desktop session is open</string>
@@ -604,6 +624,19 @@
<string name="comp_tag_in_use">In use</string>
<string name="comp_tag_new">New</string>
<string name="comp_tag_stored">Stored</string>
<string name="comp_mode_title">How builds are chosen</string>
<string name="comp_mode_auto_detail">Newest DroidDeck build, kept current</string>
<string name="comp_mode_manual_detail">Pick and install builds yourself</string>
<string name="comp_auto_title">Auto keeps %1$s current</string>
<string name="comp_auto_hint">New builds install in the background. If a game that ran before closes within seconds twice, Auto goes back to the build that worked.</string>
<string name="comp_auto_fallback">Fallback: %1$s</string>
<string name="comp_auto_checking">Checking for builds…</string>
<string name="comp_auto_none">No DroidDeck-Proton uses this</string>
<string name="comp_auto_waits_network">Auto updates wait for a connection.</string>
<string name="comp_auto_check_failed">Couldn\'t check for new builds.</string>
<string name="comp_auto_waits_unmetered">New builds wait for an unmetered connection, battery saver off and a cool device.</string>
<string name="comp_auto_updated">%1$s updated to %2$s.</string>
<string name="comp_auto_rolled_back">%1$s %2$s closed a game within seconds twice, so Auto went back to %3$s.</string>
<string name="perf_title">Performance</string>
<string name="perf_lede">CPU cores, session fixes and the host name. Applies next session.</string>
<string name="perf_client_cores_group">Steam client cores</string>
@@ -2454,9 +2487,6 @@
<string name="source_added">Custom</string>
<string name="setup_stores">Stores</string>
<string name="setup_stores_speed">Download speed tier</string>
<string name="setup_stores_engine">Stores engine</string>
<string name="setup_stores_engine_chip_ready">● Engine ready</string>
<string name="setup_stores_engine_chip_missing">Not built</string>
<string name="stores_tier_balanced">Balanced</string>
<string name="stores_tier_fast">Fast</string>
<string name="stores_tier_max">Max</string>
@@ -3834,4 +3864,12 @@
<string name="stores_gamejolt_filter_status">Status</string>
<string name="stores_gamejolt_filter_partners">Partners</string>
<string name="stores_gamejolt_followed">Followed games</string>
<string name="wow_chat_title">Chat input</string>
<string name="wow_chat_voice">Voice type</string>
<string name="wow_chat_remember">Remember this selection</string>
<string name="wow_chat_ask">Ask each time</string>
<string name="wow_chat_send">Send</string>
<string name="wow_chat_listening">Listening…</string>
<string name="wow_chat_voice_unavailable">Voice typing is not available on this device.</string>
<string name="wow_chat_mic_denied">Microphone access is needed for voice typing.</string>
</resources>
@@ -134,6 +134,19 @@ class ProtonMenuAutoTest {
assertEquals(listOf("droiddeck"), attempts)
}
@Test fun aFinishedInstallStartsTheComponentCheckAndAFailedOneDoesNot() {
var checks = 0
val watched = ProtonMenu(activity, Handler(Looper.getMainLooper()), onInstalled = { checks++ }) { idle }
watched.installer = { tool, _ -> install("${tool.prefix}-11-12"); null }
watched.installProton("droiddeck")
waitUntil { watched.protonBusyId == null && checks == 1 }
assertEquals(1, checks)
watched.installer = { _, _ -> "failed" }
watched.installProton("ge")
waitUntil { watched.protonBusyId == null }
assertEquals(1, checks)
}
@Test fun aFailedUpdateDoesNotLoopOrBlockOtherInstalledFamilies() {
val ge = install("GE-Proton11-11")
install("DroidDeck-Proton-11-11.1")
@@ -117,7 +117,10 @@ class SteamLaunchOptionsTest {
assertTrue(SteamLaunchOptions.write(home, 1790600, "-dx12"))
config.writeText(SteamLaunchOptions.withLaunchOptions(config.readText(), 1790600, "-vulkan")!!)
assertEquals("-vulkan", SteamLaunchOptions.steam(home, 1790600))
assertNull(SteamLaunchOptions.steam(home, 400))
assertEquals("", SteamLaunchOptions.steam(home, 400))
assertNull(SteamLaunchOptions.steam(steamHome(tmp.newFolder("Unread")), 1790600))
config.writeText("")
assertNull(SteamLaunchOptions.steam(home, 1790600))
}
private val app get() = RuntimeEnvironment.getApplication()
@@ -139,6 +139,36 @@ class DroidDeckHudPanelTest {
assertFalse(line, "BAT" in line)
}
@Test fun stripGraphClearsThePreviousTraceWhenFrameHistoryChanges() {
val app = RuntimeEnvironment.getApplication()
val parent = FrameLayout(app)
val view = DroidDeckHudView(app)
parent.addView(view)
parent.layout(0, 0, 2400, 1080)
val stats = FrameStats()
var now = 1_000_000_000L
repeat(300) { now += 25_000_000L; stats.add(25_000_000L, now) }
view.setFrameStats(stats)
view.applyConfig(DroidDeckHudConfig.encode(DroidDeckHudConfig.DEFAULTS +
("hudSize" to "strip") + ("showFPSGraph" to "1") + ("hudOpacity" to "100")))
view.showForTest(SNAPSHOT, "DXVK", now)
val unspecified = View.MeasureSpec.makeMeasureSpec(0, View.MeasureSpec.UNSPECIFIED)
view.measure(unspecified, unspecified)
view.layout(0, 0, view.measuredWidth, view.measuredHeight)
fun render() = Bitmap.createBitmap(view.width, view.height, Bitmap.Config.ARGB_8888).also {
view.draw(Canvas(it))
}
val graph = requireNotNull(view.graphForTest())
val x = (graph.left + graph.width() * 0.75f).toInt()
val y = graph.centerY().toInt()
val before = render()
stats.reset()
val after = render()
assertTrue("the graph initially crosses its midpoint", before.getPixel(x, y) != after.getPixel(x, y))
assertEquals("the previous trace is cleared to the strip background",
after.getPixel(x, graph.top.toInt() + 2), after.getPixel(x, y))
}
@Test fun stripFollowsTheHudScaleInHeightAndKeepsTheFullWidth() {
val app = RuntimeEnvironment.getApplication()
val parent = FrameLayout(app)
@@ -169,6 +169,59 @@ class OnScreenControlsTest {
assertEquals(true, field(control("ls"), "clicked"))
}
@Test fun aCancelledSecondFingerDoesNotArmAStickClick() {
touch(MotionEvent.ACTION_DOWN, 7 to (660f to 400f))
touch(MotionEvent.ACTION_POINTER_DOWN or (1 shl 8), 7 to (660f to 400f), 9 to (540f to 400f))
touch(MotionEvent.ACTION_POINTER_UP or (1 shl 8), 7 to (660f to 400f), 9 to (540f to 400f), flags = MotionEvent.FLAG_CANCELED)
touch(MotionEvent.ACTION_POINTER_DOWN or (1 shl 8), 7 to (660f to 400f), 9 to (540f to 400f))
assertEquals(false, field(control("ls"), "clicked"))
touch(MotionEvent.ACTION_POINTER_UP or (1 shl 8), 7 to (660f to 400f), 9 to (540f to 400f))
touch(MotionEvent.ACTION_POINTER_DOWN or (1 shl 8), 7 to (660f to 400f), 9 to (540f to 400f))
assertEquals(true, field(control("ls"), "clicked"))
}
@Test fun movingTheStickAndTouchingItAgainDoesNotClickIt() {
touch(MotionEvent.ACTION_DOWN, 1 to (540f to 400f))
touch(MotionEvent.ACTION_MOVE, 1 to (600f to 400f))
touch(MotionEvent.ACTION_UP, 1 to (600f to 400f))
touch(MotionEvent.ACTION_DOWN, 1 to (540f to 400f))
assertEquals(false, field(control("ls"), "clicked"))
}
@Test fun liftingFarFromTheTouchPointDoesNotArmAStickClick() {
touch(MotionEvent.ACTION_DOWN, 1 to (540f to 400f))
touch(MotionEvent.ACTION_UP, 1 to (600f to 400f))
touch(MotionEvent.ACTION_DOWN, 1 to (540f to 400f))
assertEquals(false, field(control("ls"), "clicked"))
}
@Test fun aLongHoldThenAQuickTouchDoesNotClickTheStick() {
touch(MotionEvent.ACTION_DOWN, 1 to (540f to 400f))
time += 600
touch(MotionEvent.ACTION_UP, 1 to (540f to 400f))
touch(MotionEvent.ACTION_DOWN, 1 to (540f to 400f))
assertEquals(false, field(control("ls"), "clicked"))
}
@Test fun aFastDoubleTapClicksTheStickAndCanBeHeldWhileMoving() {
touch(MotionEvent.ACTION_DOWN, 1 to (540f to 400f))
touch(MotionEvent.ACTION_UP, 1 to (540f to 400f))
touch(MotionEvent.ACTION_DOWN, 1 to (540f to 400f))
touch(MotionEvent.ACTION_MOVE, 1 to (600f to 400f))
assertEquals(true, field(control("ls"), "clicked"))
touch(MotionEvent.ACTION_UP, 1 to (600f to 400f))
touch(MotionEvent.ACTION_DOWN, 1 to (540f to 400f))
assertEquals(false, field(control("ls"), "clicked"))
}
@Test fun aSlowSecondTapDoesNotClickTheStick() {
touch(MotionEvent.ACTION_DOWN, 1 to (540f to 400f))
touch(MotionEvent.ACTION_UP, 1 to (540f to 400f))
time += 400
touch(MotionEvent.ACTION_DOWN, 1 to (540f to 400f))
assertEquals(false, field(control("ls"), "clicked"))
}
@Test fun buttonsTakePriorityOverAdaptiveRegions() {
val button = control("a")
touch(MotionEvent.ACTION_DOWN, 3 to (value(button, "cx") to value(button, "cy")))
@@ -461,10 +514,10 @@ class OnScreenControlsTest {
return color
}
private fun touch(action: Int, vararg pointers: Pair<Int, Pair<Float, Float>>): Boolean {
private fun touch(action: Int, vararg pointers: Pair<Int, Pair<Float, Float>>, flags: Int = 0): Boolean {
val properties = pointers.map { (id, _) -> MotionEvent.PointerProperties().apply { this.id = id; toolType = MotionEvent.TOOL_TYPE_FINGER } }.toTypedArray()
val coords = pointers.map { (_, position) -> MotionEvent.PointerCoords().apply { x = position.first; y = position.second; pressure = 1f; size = 1f } }.toTypedArray()
val event = MotionEvent.obtain(1000L, time++, action, pointers.size, properties, coords, 0, 0, 1f, 1f, 0, 0, 0, 0)
val event = MotionEvent.obtain(1000L, time++, action, pointers.size, properties, coords, 0, 0, 1f, 1f, 0, 0, 0, flags)
return try { view.onTouchEvent(event) } finally { event.recycle() }
}
}
@@ -17,8 +17,23 @@ class RuntimeAutoUpdateTest {
assertTrue(RuntimeAutoUpdate.claim(context, "ubuntu-r1-rc1", "ubuntu-r1-rc2"))
}
@Test fun noRuntimeOrTheSameOneIsLeftAlone() {
@Test fun aFirstLaunchInstallsTheRuntimeByItself() {
assertTrue(RuntimeAutoUpdate.claim(context, null, "ubuntu-r1-rc2"))
}
@Test fun aRuntimeTheUserRemovedIsNotPutBack() {
RuntimeAutoUpdate.seed(context)
assertFalse(RuntimeAutoUpdate.claim(context, null, "ubuntu-r1-rc2"))
assertTrue(RuntimeAutoUpdate.claim(context, "ubuntu-r1-rc1", "ubuntu-r1-rc2"))
}
@Test fun aFailingFirstInstallStopsTryingAfterAFewLaunches() {
repeat(RuntimeAutoUpdate.MAX_ATTEMPTS) { assertTrue(RuntimeAutoUpdate.claim(context, null, "ubuntu-r1-rc2", it * RuntimeAutoUpdate.RETRY_MS)) }
assertFalse(RuntimeAutoUpdate.claim(context, null, "ubuntu-r1-rc2", 10 * RuntimeAutoUpdate.RETRY_MS))
}
@Test fun theSameOrNoReleaseIsLeftAlone() {
assertFalse(RuntimeAutoUpdate.claim(context, null, null))
assertFalse(RuntimeAutoUpdate.claim(context, "ubuntu-r1-rc2", "ubuntu-r1-rc2"))
assertFalse(RuntimeAutoUpdate.claim(context, "ubuntu-r1-rc1", null))
assertFalse(RuntimeAutoUpdate.claim(context, "ubuntu-r1-rc1", ""))
@@ -0,0 +1,534 @@
package com.droiddeck.launcher.session
import org.junit.Assert.assertEquals
import org.junit.Assert.assertFalse
import org.junit.Assert.assertNull
import org.junit.Assert.assertTrue
import android.content.Context
import androidx.test.core.app.ApplicationProvider
import com.github.luben.zstd.ZstdOutputStream
import org.apache.commons.compress.archivers.tar.TarArchiveEntry
import org.apache.commons.compress.archivers.tar.TarArchiveOutputStream
import org.json.JSONArray
import org.json.JSONObject
import org.junit.Test
import org.junit.runner.RunWith
import org.robolectric.RobolectricTestRunner
import java.io.File
import java.nio.ByteBuffer
import java.nio.ByteOrder
import java.nio.file.Files
@RunWith(RobolectricTestRunner::class)
class ComponentUpdatesTest {
private fun item(file: String, comp: String, release: String = ComponentsManager.DD_NIGHTLY) =
ComponentsManager.CatalogItem(file, comp, release, "https://github.com/Droid-Deck/DroidDeck-Components/releases/download/x/$file", 1, "sha256:" + "0".repeat(64))
private val catalog = listOf(
item("DD-dxvk-3.1.1+188-nightly.wcp", "dxvk"),
item("DD-dxvk-3.1.1+190-nightly.wcp", "dxvk"),
item("DD-dxvk-arm64ec-3.1.1+188-nightly.wcp", "dxvk"),
item("DD-dxvk-gplasync-3.1.1+190-nightly.wcp", "dxvk"),
item("dxvk-3.1.1-linux.wcp", "dxvk", "Dxvk-Linux"),
item("DD-vkd3d-proton-3.1+30-nightly.wcp", "vkd3d"),
item("DD-vkd3d-proton-arm64ec-3.1+30-nightly.wcp", "vkd3d"),
item("DD-FEXCore-2611+3-nightly.wcp", "fex"),
)
@Test fun eachLineFollowsOnlyItsOwnPublishedFlavour() {
assertEquals("DD-dxvk-3.1.1+190-nightly.wcp", ComponentUpdates.pick(catalog, ComponentUpdates.line("dxvk", false), emptySet())?.file)
assertEquals("DD-dxvk-arm64ec-3.1.1+188-nightly.wcp", ComponentUpdates.pick(catalog, ComponentUpdates.line("dxvk", true), emptySet())?.file)
assertEquals("DD-vkd3d-proton-arm64ec-3.1+30-nightly.wcp", ComponentUpdates.pick(catalog, ComponentUpdates.line("vkd3d", true), emptySet())?.file)
assertEquals("DD-vkd3d-proton-3.1+30-nightly.wcp", ComponentUpdates.pick(catalog, ComponentUpdates.line("vkd3d", false), emptySet())?.file)
assertEquals("DD-FEXCore-2611+3-nightly.wcp", ComponentUpdates.pick(catalog, ComponentUpdates.line("fex", true), emptySet())?.file)
assertEquals("fex", ComponentUpdates.line("fex", false).key)
}
@Test fun aBadBuildIsSkippedAndNothingElseIsPickedInItsPlace() {
val plain = ComponentUpdates.line("dxvk", false)
assertNull(ComponentUpdates.pick(catalog, plain, setOf("DD-dxvk-3.1.1+190-nightly.wcp")))
assertEquals("DD-dxvk-3.1.1+190-nightly.wcp", ComponentUpdates.pick(catalog, plain, setOf("DD-dxvk-3.1.1+188-nightly.wcp"))?.file)
assertEquals("DD-dxvk-3.1.1+190-nightly.wcp", ComponentUpdates.pick(catalog, plain, setOf("DD-vkd3d-proton-3.1+40-nightly.wcp"))?.file)
assertNull(ComponentUpdates.pick(catalog, ComponentUpdates.line("fex", false), setOf("DD-FEXCore-2611+3-nightly.wcp")))
assertNull(ComponentUpdates.pick(catalog.filter { it.release != ComponentsManager.DD_NIGHTLY }, plain, emptySet()))
}
private fun pe(machine: Int, pe32plus: Boolean, hybrid: Boolean?): ByteArray {
val b = ByteBuffer.allocate(0x800).order(ByteOrder.LITTLE_ENDIAN)
b.put(0, 'M'.code.toByte()).put(1, 'Z'.code.toByte()).putInt(0x3c, 0x80)
b.putInt(0x80, 0x4550).putShort(0x84, machine.toShort()).putShort(0x86, 1)
val optional = 0x98
val optionalSize = if (pe32plus) 240 else 224
b.putShort(0x94, optionalSize.toShort())
b.putShort(optional, if (pe32plus) 0x20b.toShort() else 0x10b.toShort())
val section = optional + optionalSize
b.putInt(section + 8, 0x1000).putInt(section + 12, 0x1000).putInt(section + 20, 0x400)
if (hybrid != null && pe32plus) {
b.putInt(optional + 112 + 10 * 8, 0x1100)
b.putInt(0x500, 0x140)
if (hybrid) b.putLong(0x500 + 0xC8, 0x180001000L)
}
return b.array()
}
@Test fun arm64ecAndArm64xImagesAreToldApartFromPlainX64ByTheirHybridMetadata() {
assertEquals(true, ComponentUpdates.arm64ec(pe(0xaa64, true, true)))
assertEquals(true, ComponentUpdates.arm64ec(pe(0x8664, true, true)))
assertEquals(false, ComponentUpdates.arm64ec(pe(0x8664, true, false)))
assertEquals(false, ComponentUpdates.arm64ec(pe(0x8664, true, null)))
assertEquals(false, ComponentUpdates.arm64ec(pe(0x14c, false, null)))
assertNull(ComponentUpdates.arm64ec(ByteArray(16)))
}
@Test fun onlyAHybridArm64ImageCountsAsArm64x() {
assertTrue(ComponentUpdates.arm64x(pe(0xaa64, true, true)))
assertFalse(ComponentUpdates.arm64x(pe(0x8664, true, true)))
assertFalse(ComponentUpdates.arm64x(pe(0xaa64, true, false)))
assertFalse(ComponentUpdates.arm64x(ByteArray(16)))
}
@Test fun anArm64ecLineNeedsArm64xUnlessTheProtonsOwnBuildIsKnownNotToBe() {
val ec = ComponentUpdates.line("dxvk", true)
assertTrue(ComponentUpdates.needsArm64x(ec, listOf(true)))
assertTrue(ComponentUpdates.needsArm64x(ec, listOf(null)))
assertFalse(ComponentUpdates.needsArm64x(ec, listOf(false)))
assertTrue(ComponentUpdates.needsArm64x(ec, listOf(false, true)))
assertFalse(ComponentUpdates.needsArm64x(ec, listOf(false, false)))
assertFalse(ComponentUpdates.needsArm64x(ComponentUpdates.line("dxvk", false), listOf(null)))
assertFalse(ComponentUpdates.needsArm64x(ComponentUpdates.line("fex", false), listOf(null)))
}
private val log = """
[2026-11-05 20:00:00] Game process added : AppID 49520 "/root/.local/share/Steam/linuxarm64/steam-launch-wrapper -- /root/.local/share/Steam/steamrtarm64/reaper SteamLaunch AppId=49520 -- '/root/.local/share/Steam/compatibilitytools.d/DroidDeck-Proton 11-12.1'/droiddeck-proton-wrap waitforexitandrun 'Borderlands2.exe'"
[2026-11-05 20:00:05] Game process updated : AppID 49520 "x"
[2026-11-05 20:10:00] Game process removed: AppID 49520 "x"
[2026-11-05 20:11:00] Game process added : AppID 413150 "/root/.local/share/Steam/steamapps/common/Proton - Experimental/proton waitforexitandrun 'Stardew Valley.exe'"
[2026-11-05 20:11:12] Game process removed: AppID 413150 "x"
[2026-11-05 20:12:00] Game process added : AppID 22380 "/usr/bin/true no tool here"
[2026-11-05 20:12:02] Game process removed: AppID 22380 "x"
""".trimIndent().lines().asSequence()
@Test fun launchesComeFromSteamsOwnProcessLinesWithTheirProtonAndDuration() {
val runs = ComponentUpdates.launches(log, "")
assertEquals(listOf(
ComponentUpdates.Launch("49520", "DroidDeck-Proton 11-12.1", "2026-11-05 20:00:00", 600),
ComponentUpdates.Launch("413150", "Proton - Experimental", "2026-11-05 20:11:00", 12),
), runs)
assertEquals(listOf("413150"), ComponentUpdates.launches(log, "2026-11-05 20:10:30").map { it.app })
}
private fun run(app: String, start: String, seconds: Long) = ComponentUpdates.Launch(app, "DroidDeck-Proton 11-12.1", start, seconds)
private val trial = ComponentUpdates.Trial("new.wcp", ComponentUpdates.ORIGINAL, "2026-11-05 12:00:00", emptyMap(), emptyMap())
@Test fun aGameThatBootedOnThePreviousBuildAndTwiceFailsOnTheNewOneRollsTheLineBack() {
val verdict = ComponentUpdates.judge(trial, listOf(
run("100", "2026-11-05 10:00:00", 900),
run("100", "2026-11-05 13:00:00", 4),
run("100", "2026-11-05 13:05:00", 6),
))
assertTrue(verdict.rollBack)
assertEquals(2, verdict.failed["100"])
}
@Test fun oneFailedBootOrAGameThatNeverBootedBeforeDoesNotRollBack() {
assertFalse(ComponentUpdates.judge(trial, listOf(run("100", "2026-11-05 10:00:00", 900), run("100", "2026-11-05 13:00:00", 4))).rollBack)
assertFalse(ComponentUpdates.judge(trial, listOf(run("200", "2026-11-05 13:00:00", 4), run("200", "2026-11-05 13:05:00", 4))).rollBack)
}
@Test fun aGameThatBootsOnTheNewBuildStopsCountingShortRunsAsFailures() {
val verdict = ComponentUpdates.judge(trial, listOf(
run("100", "2026-11-05 10:00:00", 900),
run("100", "2026-11-05 13:00:00", 4),
run("100", "2026-11-05 13:10:00", 1200),
run("100", "2026-11-05 14:00:00", 4),
))
assertFalse(verdict.rollBack)
assertEquals("new.wcp", verdict.booted["100"])
assertNull(verdict.failed["100"])
}
@Test fun bootsOnTheOriginalBeforeTheFirstUpdateLetTheFirstBuildRollBack() {
val original = ComponentUpdates.Trial(ComponentUpdates.ORIGINAL, ComponentUpdates.ORIGINAL, "", emptyMap(), emptyMap())
val before = ComponentUpdates.judge(original, listOf(run("100", "2026-11-05 10:00:00", 900), run("200", "2026-11-05 10:30:00", 3)))
assertFalse(before.rollBack)
assertEquals(mapOf("100" to ComponentUpdates.ORIGINAL), before.booted)
val after = ComponentUpdates.judge(trial.copy(booted = before.booted), listOf(run("100", "2026-11-05 13:00:00", 4), run("100", "2026-11-05 13:05:00", 6)))
assertTrue(after.rollBack)
}
@Test fun aProtonUpdatedInPlaceIsReadFromItsOwnFilesAgain() {
val context = ApplicationProvider.getApplicationContext<Context>()
val dir = Files.createTempDirectory(context.cacheDir.toPath(), "proton").toFile()
File(dir, "files/lib/wine/dxvk/aarch64-windows").apply { mkdirs() }.resolve("d3d11.dll").writeBytes(pe(0xaa64, true, true))
val proton = ComponentsManager.Proton("Test-Proton", "Test Proton", dir, "/p", "Test-Proton-2", false)
val state = File(context.filesDir, "components/state.json").apply { parentFile?.mkdirs() }
fun active(version: String) = state.writeText(JSONObject().put("active", JSONObject().put("Test-Proton", JSONObject()
.put("dxvk", JSONObject().put("file", "DD-dxvk-arm64ec-3.1.1+188-nightly.wcp").put("protonVersion", version)))).toString())
active("Test-Proton-1")
assertEquals(true, ComponentsManager.arm64ec(context, proton, "dxvk"))
active("Test-Proton-2")
assertNull(ComponentsManager.arm64ec(context, proton, "dxvk"))
}
@Test fun aFailedSaveKeepsTheLastGoodStateAndTheNextSaveReplacesItWhole() {
val context = ApplicationProvider.getApplicationContext<Context>()
val dir = File(context.filesDir, "components").apply { mkdirs() }
val state = File(dir, "updates.json")
val saved = JSONObject().put("checkedAt", System.currentTimeMillis()).put("lines", JSONObject().put("fex", JSONObject()
.put("current", "DD-FEXCore-2611+3-nightly.wcp").put("bad", JSONArray().put("DD-FEXCore-2611+2-nightly.wcp")))).toString()
state.writeText(saved)
state.setWritable(false)
dir.setWritable(false)
try {
org.junit.Assume.assumeFalse(state.canWrite() || dir.canWrite())
ComponentUpdater.ensure(context, false)
} finally {
dir.setWritable(true)
state.setWritable(true)
}
assertEquals(saved, state.readText())
ComponentUpdater.ensure(context, false)
val fex = JSONObject(state.readText()).getJSONObject("lines").getJSONObject("fex")
assertEquals("DD-FEXCore-2611+3-nightly.wcp", fex.getString("current"))
assertEquals("DD-FEXCore-2611+2-nightly.wcp", fex.getJSONArray("bad").getString(0))
assertFalse(state.readText() == saved)
assertTrue(dir.list()!!.none { it.startsWith("updates.json.") })
}
@Test fun aLineWithAnUnfinishedRollbackTakesNoNewerBuildUntilItIsApplied() {
assertTrue(ComponentUpdater.rollingBack(JSONObject().put("restore", JSONObject().put("proton", "DD-dxvk-2.5.wcp"))))
assertTrue(ComponentUpdater.rollingBack(JSONObject().put("pendingNote", "rolled back")))
assertFalse(ComponentUpdater.rollingBack(JSONObject().put("restore", JSONObject()).put("current", "DD-dxvk-2.4.wcp")))
}
@Test fun aRemovedProtonNoLongerHoldsItsLineInARollback() {
val line = JSONObject().put("restore", JSONObject().put("gone", "DD-dxvk-2.5.wcp"))
val state = JSONObject().put("lines", JSONObject().put("dxvk", line))
ComponentUpdater.dropRemoved(state, emptyList())
assertFalse(ComponentUpdater.rollingBack(line))
}
@Test fun aTwoDigitNightlyBuildIsNewerThanASingleDigitOne() {
val items = listOf(item("DD-FEXCore-2611+9-nightly.wcp", "fex"), item("DD-FEXCore-2611+10-nightly.wcp", "fex"))
assertEquals("DD-FEXCore-2611+10-nightly.wcp", ComponentUpdates.pick(items, ComponentUpdates.line("fex", false), emptySet())?.file)
assertNull(ComponentUpdates.pick(items, ComponentUpdates.line("fex", false), setOf("DD-FEXCore-2611+10-nightly.wcp")))
}
@Test fun readsDuringSavesNeverLoseTheState() {
val context = ApplicationProvider.getApplicationContext<Context>()
val state = File(context.filesDir, "components/updates.json").apply { parentFile?.mkdirs() }
state.writeText(JSONObject().put("checkedAt", System.currentTimeMillis()).put("lines", JSONObject().put("fex", JSONObject()
.put("current", "DD-FEXCore-2611+3-nightly.wcp").put("bad", JSONArray().put("DD-FEXCore-2611+2-nightly.wcp")))).toString())
val done = java.util.concurrent.atomic.AtomicBoolean(false)
val reader = Thread {
while (!done.get()) {
ComponentUpdater.settle(context)
ComponentUpdater.status(context, "fex")
}
}.apply { start() }
try {
repeat(400) { ComponentUpdater.ensure(context, false) }
} finally {
done.set(true)
reader.join()
}
val fex = JSONObject(state.readText()).getJSONObject("lines").getJSONObject("fex")
assertEquals("DD-FEXCore-2611+2-nightly.wcp", fex.getJSONArray("bad").getString(0))
}
private fun proton(dir: String, valve: Boolean = false, version: String = "$dir-1") =
ComponentsManager.Proton(dir, dir, File("/steam/compatibilitytools.d/$dir"), "/p/$dir", version, valve)
@Test fun autoFollowsOnlyDroidDeckProton() {
assertTrue(ComponentUpdates.follows(proton("DroidDeck-Proton-10.0-7")))
assertFalse(ComponentUpdates.follows(proton("DroidDeck-Proton-10.0-7", valve = true)))
assertFalse(ComponentUpdates.follows(proton("droiddeck-proton-arm64")))
assertFalse(ComponentUpdates.follows(proton("droiddeck-proton-11-arm64")))
assertFalse(ComponentUpdates.follows(proton("GE-Proton10-25")))
assertFalse(ComponentUpdates.follows(proton("proton-cachyos-10.0-20251101-slr-arm64")))
}
@Test fun onlyAutosOwnBuildsInOtherProtonsAreHandedBack() {
val dd = proton("DroidDeck-Proton-10.0-7")
val ge = proton("GE-Proton10-25")
val valve = proton("Proton - Experimental", valve = true)
val choices = mapOf(
dd.id to mapOf("dxvk" to ComponentsManager.Choice("DD-dxvk-3.1.1+190-nightly.wcp", false, dd.version)),
ge.id to mapOf(
"dxvk" to ComponentsManager.Choice("DD-dxvk-3.1.1+190-nightly.wcp", false, ge.version),
"vkd3d" to ComponentsManager.Choice("vkd3d-proton-3.0-mine.wcp", false, ge.version),
"fex" to ComponentsManager.Choice("DD-FEXCore-2611+3-nightly.wcp", false, "GE-Proton10-24"),
),
valve.id to mapOf(
"fex" to ComponentsManager.Choice("DD-FEXCore-2611+3-nightly.wcp", false, valve.version),
"dxvk" to ComponentsManager.Choice("DD-dxvk-3.1.1+190-nightly.wcp", true, valve.version),
),
)
val placed = setOf("DD-dxvk-3.1.1+190-nightly.wcp", "DD-FEXCore-2611+3-nightly.wcp")
assertEquals(
setOf(ge.id to "dxvk", valve.id to "fex"),
ComponentUpdates.strays(listOf(dd, ge, valve), choices, placed).map { (p, c) -> p.id to c }.toSet(),
)
}
@Test fun aProtonMissingFromDiscoveryKeepsTheHandBackPending() {
val dd = proton("DroidDeck-Proton-10.0-7")
val ge = proton("GE-Proton10-25")
val placed = setOf("DD-dxvk-3.1.1+190-nightly.wcp")
val choices = mapOf(ge.id to mapOf("dxvk" to ComponentsManager.Choice("DD-dxvk-3.1.1+190-nightly.wcp", false, ge.version)))
assertTrue(ComponentUpdates.unseen(listOf(dd), choices, placed))
assertFalse(ComponentUpdates.unseen(listOf(dd, ge), choices, placed))
assertFalse(ComponentUpdates.unseen(listOf(dd), choices, emptySet()))
}
@Test fun queuedAutoBuildsInOtherProtonsAreDroppedAndEverythingElseStays() {
org.junit.Assume.assumeFalse(System.getProperty("os.name").startsWith("Windows"))
val context = ApplicationProvider.getApplicationContext<Context>()
val state = File(context.filesDir, "components/state.json").apply { parentFile?.mkdirs() }
fun pkg(file: String, auto: Boolean = false) = JSONObject().put("kind", "package").put("file", file).put("label", file).apply { if (auto) put("auto", true) }
state.writeText(JSONObject().put("queued", JSONObject()
.put("GE-Proton10-25", JSONObject().put("dxvk", pkg("DD-dxvk-3.1.1+190-nightly.wcp")).put("vkd3d", pkg("vkd3d-proton-3.0-mine.wcp"))
.put("fex", pkg("DD-FEXCore-2611+3-nightly.wcp", auto = true)))
.put("proton-cachyos-10.0-arm64", JSONObject().put("dxvk", pkg("DD-dxvk-3.1.1+190-nightly.wcp")))
.put("DroidDeck-Proton-10.0-7", JSONObject().put("dxvk", pkg("DD-dxvk-3.1.1+190-nightly.wcp")))).toString())
val placed = setOf("DD-dxvk-3.1.1+190-nightly.wcp", "DD-FEXCore-2611+3-nightly.wcp")
assertEquals(2, ComponentsManager.dropQueuedPackages(context, placed, ComponentUpdates::ownId))
val queued = JSONObject(state.readText()).getJSONObject("queued")
assertEquals(setOf("vkd3d", "fex"), queued.getJSONObject("GE-Proton10-25").keys().asSequence().toSet())
assertFalse(queued.has("proton-cachyos-10.0-arm64"))
assertTrue(queued.getJSONObject("DroidDeck-Proton-10.0-7").has("dxvk"))
}
@Test fun aQueuedSwapIsNotReplacedByTheHandBack() {
val context = ApplicationProvider.getApplicationContext<Context>()
val state = File(context.filesDir, "components/state.json").apply { parentFile?.mkdirs() }
val queued = JSONObject().put("kind", "package").put("file", "vkd3d-proton-3.0-mine.wcp").put("label", "3.0")
state.writeText(JSONObject().put("queued", JSONObject().put("GE-Proton10-25", JSONObject().put("vkd3d", queued))).toString())
assertFalse(ComponentsManager.restoreUnlessQueued(context, "GE-Proton10-25", "vkd3d", "GE-Proton10-25"))
val after = JSONObject(state.readText()).getJSONObject("queued").getJSONObject("GE-Proton10-25").getJSONObject("vkd3d")
assertEquals("vkd3d-proton-3.0-mine.wcp", after.getString("file"))
}
@Test fun aSourceThatFailsToListKeepsItsPreviousPackages() {
val previous = JSONArray()
.put(JSONObject().put("file", "DD-dxvk-arm64ec-3.1.1+198-nightly.wcp").put("release", ComponentsManager.DD_NIGHTLY))
.put(JSONObject().put("file", "dxvk-3.1.1-linux.wcp").put("release", "Dxvk-Linux"))
val into = JSONArray()
ComponentsManager.keepSource(previous, ComponentsManager.DD_NIGHTLY, into)
assertEquals(1, into.length())
assertEquals("DD-dxvk-arm64ec-3.1.1+198-nightly.wcp", into.getJSONObject(0).getString("file"))
ComponentsManager.keepSource(null, ComponentsManager.DD_NIGHTLY, into)
assertEquals(1, into.length())
}
@Test fun everyBuildAutoEverPlacedCountsAsItsOwn() {
val state = JSONObject().put("lines", JSONObject()
.put("dxvk", JSONObject().put("current", "b.wcp").put("previous", ComponentUpdates.ORIGINAL)
.put("owned", JSONArray().put("a.wcp").put("b.wcp")).put("bad", JSONArray().put("c.wcp")))
.put("fex", JSONObject().put("current", "f2.wcp").put("previous", "f1.wcp")))
assertEquals(setOf("a.wcp", "b.wcp", "c.wcp", "f1.wcp", "f2.wcp"), ComponentUpdater.placed(state))
assertEquals(emptySet<String>(), ComponentUpdater.placed(JSONObject()))
}
@Test fun aBuildAutoMovedPastStillCountsUntilTheHandBackCompletes() {
val first = JSONObject().put("lines", JSONObject().put("dxvk", JSONObject().put("current", "a.wcp")))
val remembered = ComponentUpdater.placed(first)
val later = JSONObject().put(ComponentUpdater.HAND_BACK_FILES, JSONArray(remembered.sorted()))
.put("lines", JSONObject().put("dxvk", JSONObject().put("current", "c.wcp").put("previous", "b.wcp")))
val placed = ComponentUpdater.placed(later)
assertEquals(setOf("a.wcp", "b.wcp", "c.wcp"), placed)
val proton = proton("GE-Proton10-25")
val choices = mapOf(proton.id to mapOf("dxvk" to ComponentsManager.Choice("a.wcp", false, proton.version)))
assertTrue(ComponentUpdates.unseen(emptyList(), choices, placed))
}
@Test fun onlyTheCurrentAndThePreviousBuildAndUserSelectionsAreKept() {
assertEquals(setOf("b.wcp", "a.wcp", "mine.wcp"), ComponentUpdates.keep("b.wcp", "a.wcp", setOf("mine.wcp")))
assertEquals(setOf("b.wcp"), ComponentUpdates.keep("b.wcp", ComponentUpdates.ORIGINAL, emptySet()))
}
private fun choice(file: String, auto: Boolean = false) = ComponentsManager.Choice(file, auto, "1")
@Test fun aBuildTheUserPickedOnAProtonIsNeverReplacedByAuto() {
val auto = setOf("DD-dxvk-3.1.1+188-nightly.wcp", "DD-dxvk-3.1.1+190-nightly.wcp")
assertTrue(ComponentUpdates.manual(choice("DD-dxvk-gplasync-3.1.1+190-nightly.wcp"), null, auto))
assertTrue(ComponentUpdates.manual(choice("DD-dxvk-3.1.1+188-nightly.wcp"), "DD-dxvk-3.1.1+190-nightly.wcp", auto))
assertFalse(ComponentUpdates.manual(choice("DD-dxvk-3.1.1+190-nightly.wcp"), "DD-dxvk-3.1.1+190-nightly.wcp", auto))
assertFalse(ComponentUpdates.manual(choice("DD-dxvk-3.1.1+188-nightly.wcp"), null, auto))
assertFalse(ComponentUpdates.manual(choice("original 11-7.4"), null, auto))
assertFalse(ComponentUpdates.manual(choice("dxvk-2.6.2-linux.wcp", auto = true), null, auto))
assertFalse(ComponentUpdates.manual(null, null, auto))
}
@Test fun aQueuedSwapThatNeverRanLeavesTheOldAutoBuildToAuto() {
val auto = setOf("DD-dxvk-3.1.1+188-nightly.wcp", "DD-dxvk-3.1.1+190-nightly.wcp")
assertFalse(ComponentUpdates.manual(choice("DD-dxvk-3.1.1+188-nightly.wcp"), "DD-dxvk-3.1.1+188-nightly.wcp", auto, "DD-dxvk-3.1.1+190-nightly.wcp"))
assertFalse(ComponentUpdates.manual(choice("DD-dxvk-3.1.1+190-nightly.wcp"), "DD-dxvk-3.1.1+188-nightly.wcp", auto, "DD-dxvk-3.1.1+190-nightly.wcp"))
assertTrue(ComponentUpdates.manual(choice("DD-dxvk-3.1.1+186-nightly.wcp"), "DD-dxvk-3.1.1+188-nightly.wcp", auto, "DD-dxvk-3.1.1+190-nightly.wcp"))
}
@Test fun oldBuildsOfAnAutoLineAreDeletedButOtherFlavoursAndKeptBuildsStay() {
val line = ComponentUpdates.line("dxvk", false)
val stored = listOf(
"DD-dxvk-3.1.1+186-nightly.wcp", "DD-dxvk-3.1.1+188-nightly.wcp", "DD-dxvk-3.1.1+190-nightly.wcp",
"DD-dxvk-arm64ec-3.1.1+188-nightly.wcp", "DD-dxvk-gplasync-3.1.1+190-nightly.wcp", "dxvk-2.6.2-linux.wcp",
)
val keep = ComponentUpdates.keep("DD-dxvk-3.1.1+190-nightly.wcp", "DD-dxvk-3.1.1+188-nightly.wcp", emptySet())
assertEquals(listOf("DD-dxvk-3.1.1+186-nightly.wcp"), ComponentUpdates.stale(line, emptySet(), stored, keep, "DD-dxvk-3.1.1+190-nightly.wcp"))
assertEquals(listOf("DD-dxvk-3.1.1+184-nightly.wcp", "DD-dxvk-3.1.1+186-nightly.wcp"),
ComponentUpdates.stale(line, setOf("DD-dxvk-3.1.1+184-nightly.wcp", "DD-dxvk-3.1.1+190-nightly.wcp"), stored, keep, "DD-dxvk-3.1.1+190-nightly.wcp"))
assertEquals(emptyList<String>(), ComponentUpdates.stale(line, emptySet(), stored, keep + "DD-dxvk-3.1.1+186-nightly.wcp", "DD-dxvk-3.1.1+190-nightly.wcp"))
assertEquals(emptyList<String>(), ComponentUpdates.stale(null, emptySet(), stored, keep, "DD-dxvk-3.1.1+190-nightly.wcp"))
}
@Test fun aNewerAutoBuildIsKeptUnlessItWasRolledBack() {
val line = ComponentUpdates.line("dxvk", false)
val owned = setOf("DD-dxvk-3.1.1+188-nightly.wcp", "DD-dxvk-3.1.1+192-nightly.wcp", "DD-dxvk-3.1.1+194-nightly.wcp")
val current = "DD-dxvk-3.1.1+190-nightly.wcp"
val keep = ComponentUpdates.keep(current, ComponentUpdates.ORIGINAL, emptySet())
assertEquals(listOf("DD-dxvk-3.1.1+188-nightly.wcp", "DD-dxvk-3.1.1+194-nightly.wcp"),
ComponentUpdates.stale(line, owned, emptyList(), keep, current, setOf("DD-dxvk-3.1.1+194-nightly.wcp")))
assertEquals(listOf("DD-dxvk-3.1.1+188-nightly.wcp"), ComponentUpdates.stale(line, owned, emptyList(), keep, current))
}
@Test fun anImportBeforePruningTakesTheFileAwayFromAuto() {
val context = ApplicationProvider.getApplicationContext<Context>()
val updates = File(context.filesDir, "components/updates.json").apply { parentFile?.mkdirs() }
updates.writeText(JSONObject().put("lines", JSONObject().put("dxvk", JSONObject()
.put("current", "DD-dxvk-3.1.1+190-nightly.wcp")
.put("owned", JSONArray(listOf("DD-dxvk-3.1.1+190-nightly.wcp", "DD-dxvk-3.1.1+194-nightly.wcp")))
.put("bad", JSONArray(listOf("DD-dxvk-3.1.1+194-nightly.wcp"))))).toString())
ComponentUpdater.disown(context, "DD-dxvk-3.1.1+194-nightly.wcp")
val line = JSONObject(updates.readText()).getJSONObject("lines").getJSONObject("dxvk")
val owned = (0 until line.getJSONArray("owned").length()).map { line.getJSONArray("owned").getString(it) }.toSet()
assertEquals(setOf("DD-dxvk-3.1.1+190-nightly.wcp"), owned)
val auto = ComponentUpdater.placed(JSONObject(updates.readText()))
assertFalse("DD-dxvk-3.1.1+194-nightly.wcp" in auto)
assertTrue(ComponentUpdates.manual(choice("DD-dxvk-3.1.1+194-nightly.wcp"), null, auto, "DD-dxvk-3.1.1+190-nightly.wcp"))
val keep = ComponentUpdates.keep("DD-dxvk-3.1.1+190-nightly.wcp", ComponentUpdates.ORIGINAL, emptySet())
assertEquals(emptyList<String>(), ComponentUpdates.stale(ComponentUpdates.line("dxvk", false), owned,
listOf("DD-dxvk-3.1.1+194-nightly.wcp"), keep, "DD-dxvk-3.1.1+190-nightly.wcp", setOf("DD-dxvk-3.1.1+194-nightly.wcp")))
}
@Test fun aRolledBackBuildTheUserImportsAgainIsKept() {
val line = ComponentUpdates.line("dxvk", false)
val current = "DD-dxvk-3.1.1+190-nightly.wcp"
val bad = setOf("DD-dxvk-3.1.1+194-nightly.wcp")
val keep = ComponentUpdates.keep(current, ComponentUpdates.ORIGINAL, emptySet())
assertEquals(listOf("DD-dxvk-3.1.1+194-nightly.wcp"),
ComponentUpdates.stale(line, bad, listOf("DD-dxvk-3.1.1+194-nightly.wcp"), keep, current, bad))
assertEquals(emptyList<String>(), ComponentUpdates.stale(line, emptySet(), listOf("DD-dxvk-3.1.1+194-nightly.wcp"), keep, current, bad))
}
@Test fun aNewerBuildTheUserStoredIsNotPrunedBeforeAutoCatchesUp() {
val line = ComponentUpdates.line("dxvk", false)
val stored = listOf("DD-dxvk-3.1.1+186-nightly.wcp", "DD-dxvk-3.1.1+192-nightly.wcp")
val keep = ComponentUpdates.keep("DD-dxvk-3.1.1+190-nightly.wcp", ComponentUpdates.ORIGINAL, emptySet())
assertEquals(listOf("DD-dxvk-3.1.1+186-nightly.wcp"), ComponentUpdates.stale(line, emptySet(), stored, keep, "DD-dxvk-3.1.1+190-nightly.wcp"))
assertEquals(listOf("DD-dxvk-3.1.1+184-nightly.wcp"),
ComponentUpdates.stale(line, setOf("DD-dxvk-3.1.1+184-nightly.wcp"), stored, emptySet(), ComponentUpdates.ORIGINAL))
}
@Test fun aPackageLeftSelectedOnARemovedProtonIsNoLongerKept() {
val context = ApplicationProvider.getApplicationContext<Context>()
File(context.filesDir, "components/state.json").apply { parentFile?.mkdirs() }.writeText(JSONObject()
.put("active", JSONObject().put("DroidDeck-Proton-11-7.4", JSONObject()
.put("dxvk", JSONObject().put("file", "DD-dxvk-3.1.1+186-nightly.wcp").put("protonVersion", "11-7.4"))))
.put("profiles", JSONObject().put("app:1", JSONObject().put("dxvk", "mine.wcp"))).toString())
assertEquals(setOf("mine.wcp"), ComponentsManager.selectedPackages(context))
}
@Test fun pruningNeverDeletesAPackageAGameProfileSelected() {
val context = ApplicationProvider.getApplicationContext<Context>()
val dir = File(context.filesDir, "components").apply { mkdirs() }
File(dir, "state.json").writeText(JSONObject()
.put("profiles", JSONObject().put("app:1", JSONObject().put("dxvk", "DD-dxvk-3.1.1+186-nightly.wcp"))).toString())
val packages = File(dir, "packages").apply { mkdirs() }
val kept = File(packages, "DD-dxvk-3.1.1+186-nightly.wcp").apply { writeText("x") }
val old = File(packages, "DD-dxvk-3.1.1+184-nightly.wcp").apply { writeText("x") }
assertFalse(ComponentsManager.deleteUnused(context, kept.name))
assertTrue(kept.isFile)
assertEquals("DD-dxvk-3.1.1+186-nightly.wcp", JSONObject(File(dir, "state.json").readText())
.getJSONObject("profiles").getJSONObject("app:1").getString("dxvk"))
assertTrue(ComponentsManager.deleteUnused(context, old.name))
assertFalse(old.isFile)
assertFalse(ComponentsManager.deleteUnused(context, old.name))
}
@Test fun aSelectionStaysLiveWhenAnotherInstallSharesItsProtonId() {
fun p(version: String) = ComponentsManager.Proton("GE-Proton", "GE-Proton", File("/steam/$version"), "/p", version, false)
val state = JSONObject().put("active", JSONObject().put("GE-Proton", JSONObject()
.put("dxvk", JSONObject().put("file", "DD-dxvk-3.1.1+186-nightly.wcp").put("protonVersion", "2"))))
assertTrue("DD-dxvk-3.1.1+186-nightly.wcp" in ComponentsManager.packagesInUse(state, listOf(p("1"), p("2"))))
assertTrue("DD-dxvk-3.1.1+186-nightly.wcp" in ComponentsManager.packagesInUse(state, listOf(p("2"), p("1"))))
assertFalse("DD-dxvk-3.1.1+186-nightly.wcp" in ComponentsManager.packagesInUse(state, listOf(p("1"))))
val entry = state.getJSONObject("active").getJSONObject("GE-Proton").getJSONObject("dxvk")
assertTrue(ComponentsManager.liveOnAny("active", entry, listOf(p("1"), p("2"))))
assertFalse(ComponentsManager.liveOnAny("active", entry, listOf(p("1"))))
assertFalse(ComponentsManager.liveOnAny("active", entry, null))
}
@Test fun aPackageIsArm64xOnlyWhenEveryGraphicsDllInItIs() {
val owned = setOf("d3d11.dll", "dxgi.dll", "d3d9.dll")
val hybrid = pe(0xaa64, true, true)
val ec = pe(0x8664, true, true)
assertTrue(ComponentUpdates.allArm64x(mapOf("d3d11.dll" to hybrid, "dxgi.dll" to hybrid, "openvr_api_dxvk.dll" to pe(0x8664, true, false)), owned))
assertFalse(ComponentUpdates.allArm64x(mapOf("d3d11.dll" to hybrid, "dxgi.dll" to ec), owned))
assertFalse(ComponentUpdates.allArm64x(mapOf("D3D9.DLL" to ec, "d3d11.dll" to hybrid), owned))
assertFalse(ComponentUpdates.allArm64x(mapOf("openvr_api_dxvk.dll" to hybrid), owned))
assertFalse(ComponentUpdates.allArm64x(emptyMap(), owned))
}
private fun wcp(context: Context, name: String, dlls: Map<String, ByteArray>) {
val out = File(context.filesDir, "components/packages/$name").apply { parentFile!!.mkdirs() }
TarArchiveOutputStream(ZstdOutputStream(out.outputStream())).use { tar ->
for ((path, body) in dlls) {
tar.putArchiveEntry(TarArchiveEntry(path).apply { size = body.size.toLong() })
tar.write(body)
tar.closeArchiveEntry()
}
}
}
@Test fun anImportWaitsForPruningAndLeavesTheFileTheUsers() {
val context = ApplicationProvider.getApplicationContext<Context>()
val name = "DD-dxvk-3.1.1+194-nightly.wcp"
File(context.filesDir, "components/updates.json").apply { parentFile?.mkdirs() }.writeText(JSONObject().put("lines", JSONObject()
.put("dxvk", JSONObject().put("current", "DD-dxvk-3.1.1+190-nightly.wcp").put("owned", JSONArray(listOf(name))))).toString())
wcp(context, "source.wcp", mapOf("profile.json" to JSONObject().put("type", "DXVK-Linux").put("versionName", "3.1.1+194").toString().toByteArray()))
val source = File(context.filesDir, "source.wcp").also { File(context.filesDir, "components/packages/source.wcp").renameTo(it) }
val lock = ComponentUpdater::class.java.getDeclaredField("lock").apply { isAccessible = true }.get(ComponentUpdater)
val held = java.util.concurrent.CountDownLatch(1)
val release = java.util.concurrent.CountDownLatch(1)
val pruning = Thread { synchronized(lock) { held.countDown(); release.await() } }.apply { start() }
held.await()
val importing = Thread { ComponentUpdater.importPackage(context, source, name) }.apply { start() }
importing.join(300)
assertTrue(importing.isAlive)
assertFalse(File(context.filesDir, "components/packages/$name").exists())
release.countDown()
pruning.join()
importing.join(5000)
assertTrue(File(context.filesDir, "components/packages/$name").isFile)
val state = JSONObject(File(context.filesDir, "components/updates.json").readText())
assertEquals(0, state.getJSONObject("lines").getJSONObject("dxvk").getJSONArray("owned").length())
assertFalse(name in ComponentUpdater.placed(state))
}
@Test fun aStoredPackageWithOneArm64ecOnlyDllIsNotArm64x() {
val context = ApplicationProvider.getApplicationContext<Context>()
val dir = "files/lib/wine/dxvk/aarch64-windows"
val hybrid = pe(0xaa64, true, true)
wcp(context, "mixed.wcp", mapOf("$dir/d3d11.dll" to hybrid, "$dir/dxgi.dll" to pe(0x8664, true, true),
"files/lib/wine/dxvk/i386-windows/d3d11.dll" to pe(0x14c, false, null)))
wcp(context, "hybrid.wcp", mapOf("./$dir/d3d11.dll" to hybrid, "./$dir/dxgi.dll" to hybrid,
"$dir/openvr_api_dxvk.dll" to pe(0x8664, true, false)))
assertFalse(ComponentsManager.packageArm64x(context, "mixed.wcp", "dxvk"))
assertTrue(ComponentsManager.packageArm64x(context, "hybrid.wcp", "dxvk"))
assertFalse(ComponentsManager.packageArm64x(context, "hybrid.wcp", "vkd3d"))
assertFalse(ComponentsManager.packageArm64x(context, "missing.wcp", "dxvk"))
}
}
@@ -2,6 +2,8 @@ package com.droiddeck.launcher.session
import android.view.Display
import org.junit.Assert.assertEquals
import org.junit.Assert.assertFalse
import org.junit.Assert.assertTrue
import org.junit.Test
import org.junit.runner.RunWith
import org.robolectric.RobolectricTestRunner
@@ -34,4 +36,24 @@ class PerfModeTest {
val otherResolution = mode(3, 60f, 720)
assertEquals(low, PerfMode.selectMode(arrayOf(low, high, otherResolution), high, 60, 60f))
}
@Test fun anotherModeWithTheRequestedRefreshRateKeepsTheRequestInsteadOfSwitchingBackAndForth() {
val sixty = mode(2, 60f)
val requested = mode(1, 120f)
val reported = mode(255, 120f, 1440)
val modes = arrayOf(sixty, requested, reported)
assertEquals(reported, PerfMode.selectMode(modes, reported, 0, Float.POSITIVE_INFINITY))
assertFalse(PerfMode.needsChange(modes, requested.modeId, reported))
assertFalse(PerfMode.needsChange(modes, reported.modeId, requested))
}
@Test fun aDifferentRefreshRateNoRequestOrAMissingModeStillChangesTheMode() {
val sixty = mode(2, 60f)
val oneTwenty = mode(1, 120f)
val modes = arrayOf(sixty, oneTwenty)
assertTrue(PerfMode.needsChange(modes, sixty.modeId, oneTwenty))
assertTrue(PerfMode.needsChange(modes, oneTwenty.modeId, sixty))
assertTrue(PerfMode.needsChange(modes, 0, oneTwenty))
assertTrue(PerfMode.needsChange(modes, 99, oneTwenty))
}
}
@@ -24,10 +24,4 @@ class PipPolicyTest {
assertEquals(100 to 239, PipPolicy.aspect(1080, 5760))
assertEquals(16 to 9, PipPolicy.aspect(0, 0))
}
@Test fun pipOutputFitsTheResizedSurfaceWithoutChangingAspectRatio() {
assertEquals(640 to 360, PipPolicy.outputSize(640, 480, 1920, 1080))
assertEquals(480 to 270, PipPolicy.outputSize(480, 270, 1920, 1080))
assertNull(PipPolicy.outputSize(0, 270, 1920, 1080))
}
}
@@ -0,0 +1,21 @@
package com.droiddeck.launcher.ui
import com.droiddeck.launcher.frontend.Library
import org.junit.Assert.assertFalse
import org.junit.Assert.assertTrue
import org.junit.Test
class SteamLaunchOptionsVisibilityTest {
private fun game(library: String = "main", source: String = Library.SOURCE_STEAM) =
Library.SteamGame(42, "Test game", null, library, source = source)
@Test fun nativeSteamGamesExposeLaunchOptionsOutsideShortcutPicking() {
assertTrue(canEditSteamLaunchOptions(game(), shortcutPicker = false))
}
@Test fun pickerAddedAndStoreGamesDoNotExposeSteamLaunchOptions() {
assertFalse(canEditSteamLaunchOptions(game(), shortcutPicker = true))
assertFalse(canEditSteamLaunchOptions(game(library = Library.ADDED), shortcutPicker = false))
assertFalse(canEditSteamLaunchOptions(game(source = "gog"), shortcutPicker = false))
}
}
@@ -0,0 +1,21 @@
package com.droiddeck.launcher.ui
import org.junit.Assert.assertFalse
import org.junit.Assert.assertTrue
import org.junit.Test
class WowChatTest {
@Test
fun everyWowClientMatches() {
listOf("wow.exe", "Wow.exe", "wowclassic.exe", "wowclassict.exe", "wowb.exe", "wow-64.exe", "wow-arm64.exe", "wowt.exe").forEach {
assertTrue(it, WowChat.isWow(it))
}
}
@Test
fun otherProgramsAreUnaffected() {
listOf(null, "", "battle.net.exe", "steam.exe", "notwow.exe", "wow.exe.bak", "wow", "cyberpunk2077.exe").forEach {
assertFalse(it.toString(), WowChat.isWow(it))
}
}
}
+23
View File
@@ -35,6 +35,29 @@ selection, and restores Default component choices for the next game. **Use
Default** removes the game override. Component packages must first be downloaded
or imported on the Default Components page.
FEX, DXVK and VKD3D-Proton each have an **Auto** or **Manual** mode on the Default
Components page. Auto is the default unless a component already uses a package the
user picked, which keeps it on Manual. In Auto, DroidDeck checks the
DroidDeck-Components nightly release when DroidDeck is opened, at most hourly and
never during a running session, and installs the newest build into DroidDeck-Proton.
Valve's Proton, GE-Proton, proton-cachyos and any other tool keep their own components;
builds an earlier Auto had placed in them go back to the original once. A build the user
picks on a DroidDeck-Proton by hand stays until they restore the original. DXVK and
VKD3D-Proton follow the flavour of each Proton's own files: ARM64EC/ARM64X builds get the
`arm64ec` package, plain x64 builds the plain one. Where the Proton's own build is ARM64X, an
`arm64ec` package is used only when every Direct3D and DXGI DLL in it is ARM64X, because native
ARM64 programs such as `Wow-arm64.exe` cannot load an ARM64EC-only `dxgi.dll`. A rollback whose
previous build fails that check, or was deleted, goes back to the original. Auto keeps the current build and the one
it replaced and deletes every older stored build of the same line, including ones that were
stored before Auto took over, and downloads the current build again if it was deleted.
Other flavours such as gplasync and packages selected on an installed Proton or in a game
profile are never removed. Steam's process log tells it how long each game ran. A game that ran
for at least a minute on the previous build and then exits within 30 seconds twice
on the new one rolls back every component line that changed since that boot, and
each failed build is skipped until a newer one is published. Games with their own component override are
not counted. DXVK stays on Manual while a GPU rule pins a DXVK version (Adreno 6xx and
the Vulkan bridge). Manual keeps the existing download, import and restore lists.
Before publishing game component choices, DroidDeck preserves each Proton build's
original components, including an explicitly empty bundle when a component is not
shipped. Those restore choices remain after the last override is cleared. A missing
+57
View File
@@ -41,6 +41,63 @@ reduced aggregate launcher RSS only from 263 to 255 MiB and backed off. Thus lau
does not resolve this load failure, and this run did not exhaust swap. The unevictable/shmem
growth needs allocation attribution before treating it as a driver or GPU-memory diagnosis.
## Low-RAM devices: compressing idle game memory
On devices with at most 13 GiB `MemTotal` (every 12 GB phone and handheld and below), the
monitor also compresses the game session itself when memory runs out. The trigger is absolute
headroom, not a percentage: Android's `lmkd` killed a session on a 7.5 GiB device with
490 MiB still available, so a 95% used threshold (about 400 MiB left on 8 GB) is already past the
kill. A world load added 1 GiB of GPU memory in about ten seconds, and compressing 1.4 GiB took
2.6 seconds, so the request has to leave room for both.
The session request runs when available memory falls below a quarter of RAM (bounded to
768–2560 MiB, 1.85 GiB on an 8 GB device), or below one and a half times that with at least 1%
`some avg10` memory stall. One request pages out every session process that loaded
`libblsession.so`: the game, Wine and its services, plus the launcher processes unless their
compression is turned off, under a single wait. It runs before the launcher-only check, which
can wait up to 12 seconds for acknowledgements. While a game runs and available memory is under
twice that level, memory is read every second instead of every five. Requests are at least 30 seconds apart and need the same swap reserve as launcher
requests. When the session has read back more than half of what the previous request compressed,
that memory is in use: the request is skipped and the wait doubles, up to five minutes. A
request whose memory stayed compressed resets it to 30 seconds. Below half the headroom the
wait is ignored and only the 30-second spacing applies. The state resets when the game exits. Devices above 13 GiB keep the
launcher-only behaviour.
Measured on a Retroid Pocket Duo (7.5 GiB, kernel 6.6, 6 GiB lzo-rle zram plus a 4 GiB swap file)
entering the world in World of Warcraft: Forever, one run each:
| | No session page-out | Session compressed at character select |
| --- | --- | --- |
| available before Enter World | 1,569–1,800 MiB | 2,329 MiB |
| game resident / in zram before Enter World | 1,919 / 13 MiB | 409 / 1,523 MiB |
| GPU (KGSL) memory during the load | 0.97 → 1.99 GiB | 0.96 → 1.99 GiB |
| lowest available | 491 MiB | 623 MiB |
| result | `LOW_MEMORY` kill of the whole session 58 s after Enter World | in the world at 35 FPS after three minutes |
The killed run had 7.4 GiB of swap free at the time: reclaim did not keep up with the GPU
allocation, not swap capacity. Little Nightmares at the same settings compressed from 1.41 GiB to
0.25 GiB resident in 2.6 seconds (1.1 GiB stored in about 0.5 GiB of zram) and read back 70 MiB in
the following minute at its menu and 440 MiB while loading the next area; frame rate stayed at
44–46 FPS with one or two short frame-time spikes. These are single runs, not averages.
On the PR build without any manual step, the monitor compressed WoW before character select and
again during the load; the game entered the world and stayed there at 33 FPS. Once in the world,
available memory settled near 1 GiB, under the headroom, and the first version of the policy
compressed 200–400 MiB every 30 seconds while the game read back about 15 MiB/s. The
read-back check therefore compares against the last request instead of all compressed memory.
A second run on that build showed the trigger arriving too late: character select sat at
1.5–1.6 GiB available, just above the original one-fifth headroom, so nothing was compressed
there. During the load, available memory crossed the headroom 9 seconds after Enter World, but the
launcher check ran first and waited 12 seconds for acknowledgements; the game request started at
21 seconds, moved about 200 MiB, and `lmkd` ended the session at 30 seconds. The quarter-of-RAM
level and the game-first single request address both.
Without root an app cannot write its memory cgroup's `memory.reclaim` (owned by `system`),
`process_madvise` on another process returns `EPERM`, `PAGEMAP_SCAN` is absent before Linux 6.7,
and KGSL's per-process GPU reclaim `state` is root-only. Page-out therefore runs inside each
process through the preload, and GPU memory itself cannot be compressed from userspace.
## CRAM and the bounded pager experiment
[Meta's CRAM proposal](https://lpc.events/event/20/contributions/2424/) uses memory compression
+3 -3
View File
@@ -1,3 +1,3 @@
GAMESCOPE_REPO=Droid-Deck/DroidDeck
GAMESCOPE_TAG=gamescope-3.16.29-pr2029-22b50d182
GAMESCOPE_SHA256=d6bf0fe2ebb98e1fc7c9a09fccce7d7916c9cc6a10016018040cda01a940b781
GAMESCOPE_REPO=Droid-Deck/DroidDeck-Components
GAMESCOPE_TAG=gamescope-3.16.29-p22
GAMESCOPE_SHA256=2b7e7481df5f5f7355d224d3a0d4205557367172653dff1d7206a230b6c78b46
@@ -31,6 +31,9 @@ WALLPAPER_DIR = ("users", "steamuser", "AppData", "Local", "DroidDeck")
DESKTOP_KEY = "[Control Panel\\\\Desktop]"
COLORS_KEY = "[Control Panel\\\\Colors]"
WALLPAPER_EDGE = (4, 6, 11)
USER_PREFERENCES = "UserPreferencesMask"
WINE_USER_PREFERENCES = bytes((0x10, 0x00, 0x00, 0x80, 0x12, 0x00, 0x00, 0x00))
CLIENT_AREA_ANIMATION = (4, 0x02)
WALLPAPER_MAX = (1280, 720)
FEX_REQUEST = "battlenet-fex.request"
FEX_RESULT = "battlenet-fex.result"
@@ -331,24 +334,47 @@ def reg_dword(reg, key, name, number):
return reg_line(reg, key, name, '"%s"=dword:%08x' % (name, number))
def reg_line(reg, key, name, line):
try:
with open(reg, encoding="utf-8", errors="replace") as f:
rows = f.read().splitlines()
except OSError:
return False
def reg_read(reg, key, name):
with open(reg, encoding="utf-8", errors="replace") as f:
rows = f.read().splitlines()
inside, last = False, None
for row in rows:
if row.startswith("["):
inside = row.startswith(key + " ") or row == key
elif inside and row.startswith('"%s"=' % name):
last = row
return last
def reg_line(reg, key, name, line):
try:
last = reg_read(reg, key, name)
except OSError:
return False
if last != line:
with open(reg, "a", encoding="utf-8") as out:
out.write("\n%s %d\n%s\n" % (key, int(time.time()), line))
return True
def still_pages(root):
reg = os.path.join(prefix(root), "pfx", "user.reg")
if not os.path.isfile(reg):
return False
row = reg_read(reg, DESKTOP_KEY, USER_PREFERENCES)
found = re.fullmatch(r'"%s"=hex:([0-9a-fA-F]{2}(?:,[0-9a-fA-F]{2})*)' % USER_PREFERENCES, row or "")
mask = bytearray(bytes.fromhex(found.group(1).replace(",", "")) if found else WINE_USER_PREFERENCES)
at, bit = CLIENT_AREA_ANIMATION
if len(mask) <= at or not mask[at] & bit:
return False
mask[at] &= ~bit
if wine_processes(os.path.join(prefix(root), "pfx")):
say("Battle.net's prefix is in use; Windows animations go off the next time it starts on its own")
return False
reg_line(reg, DESKTOP_KEY, USER_PREFERENCES, '"%s"=hex:%s' % (USER_PREFERENCES, ",".join("%02x" % b for b in mask)))
return True
def desktop_size():
width, height = os.environ.get("BL_WIDTH", ""), os.environ.get("BL_HEIGHT", "")
if width.isdigit() and height.isdigit() and 0 < int(width) <= 7680 and 0 < int(height) <= 4320:
@@ -515,6 +541,11 @@ def configure(root, library=None):
dpi(root)
except OSError as error:
say("Battle.net's desktop scale not set: %s" % error)
try:
if still_pages(root):
say("Battle.net's desktop: Windows animations off, so its pages do not play videos the CPU has to draw")
except OSError as error:
say("Battle.net's desktop animations not set: %s" % error)
wow_settings(root)
if change == "first":
say("wrote Battle.net's first config: hardware acceleration off, streaming off, closing itself as it launches a game, "
@@ -840,6 +840,9 @@ case $mode in
_bl_headroom=$((_bl_mem_total / 5))
[ "$_bl_headroom" -ge $((512 * 1024)) ] || _bl_headroom=$((512 * 1024))
[ "$_bl_headroom" -le $((1536 * 1024)) ] || _bl_headroom=$((1536 * 1024))
_bl_tier_headroom=$((_bl_mem_total / 4))
[ "$_bl_tier_headroom" -ge $((768 * 1024)) ] || _bl_tier_headroom=$((768 * 1024))
[ "$_bl_tier_headroom" -le $((2560 * 1024)) ] || _bl_tier_headroom=$((2560 * 1024))
_bl_swap_reserve=$((_bl_swap_total / 5))
[ "$_bl_swap_reserve" -ge $((256 * 1024)) ] || _bl_swap_reserve=$((256 * 1024))
}
@@ -884,6 +887,88 @@ case $mode in
_bl_pressure_backoff=30
fi
}
bl_memory_critical() {
[ "$_bl_mem_total" -gt 0 ] && [ "$_bl_available" -ge 0 ] &&
{ [ "$_bl_available" -lt "$_bl_tier_headroom" ] ||
{ [ "$_bl_psi" -ge 1 ] && [ "$_bl_available" -lt $((_bl_tier_headroom * 3 / 2)) ]; }; }
}
bl_low_ram() {
[ "$_bl_mem_total" -gt 0 ] && [ "$_bl_mem_total" -le $((13 * 1024 * 1024)) ]
}
bl_session_pressure() {
bl_memory_read
[ "$_bl_game" = 1 ] && bl_low_ram && bl_memory_critical || return 0
local _wait=$_bl_tier_backoff _urgent=0 _before _pids _targets
[ "$_bl_available" -ge $((_bl_headroom / 2)) ] || _urgent=1 _wait=30
[ "$_bl_tier_at" -lt 0 ] || [ $((SECONDS - _bl_tier_at)) -ge "$_wait" ] || return 0
if [ "$_bl_tier_at" -ge 0 ] && [ "$_bl_tier_pushed" -gt 0 ]; then
bl_tier_readback
if [ "$_bl_tier_back" -le $((_bl_tier_pushed / 2)) ]; then
_bl_tier_backoff=30
elif [ "$_urgent" = 0 ]; then
_bl_tier_backoff=$((_bl_tier_backoff * 2 > 300 ? 300 : _bl_tier_backoff * 2))
_bl_tier_at=$SECONDS _bl_tier_pushed=0
step "zRAM: the game read back most of its last page-out, next try in ${_bl_tier_backoff} s"
return 0
fi
fi
_bl_tier_at=$SECONDS
bl_zram_pick game
bl_zram_usage "${_pids[@]}" > /dev/null
[ "$_bl_rss" -ge $((256 * 1024)) ] || return 0
if [ "$_bl_swap_total" -le 0 ] || [ $((_bl_swap_free - _bl_rss)) -lt "$_bl_swap_reserve" ]; then
step "zRAM: low-memory page-out skipped, swap headroom too small ($((_bl_swap_free / 1024)) MB free)"
return 0
fi
_targets=("${_pids[@]}") _before=$_bl_swap
bl_zram_send 1 "low memory (available $((_bl_available / 1024)) MB), compressing idle game memory" game
bl_zram_usage "${_targets[@]}" > /dev/null
_bl_tier_pushed=$((_bl_swap - _before))
bl_tier_note "${_targets[@]}"
if [ "${_bl_pageout_complete:-0}" = 1 ] && [ "${BL_ZRAM_CLIENT:-1}" = 1 ] && [ "${#_bl_pids[@]}" -gt 0 ]; then
_bl_pressure_at=$SECONDS _bl_packed=$SECONDS
fi
}
bl_proc_start() {
local _s=
_bl_start=
read -r _s 2>/dev/null < "/proc/$1/stat" || return 1
_s=${_s##*) }
set -- $_s
[ "$1" != Z ] && [ "$#" -ge 20 ] || return 1
_bl_start=${20}
}
bl_tier_note() {
local _p
_bl_tier_targets=() _bl_tier_swaps=() _bl_tier_starts=()
for _p in "$@"; do
bl_proc_start "$_p" || continue
bl_zram_usage "$_p" > /dev/null
_bl_tier_targets+=("$_p") _bl_tier_swaps+=("$_bl_swap") _bl_tier_starts+=("$_bl_start")
done
}
bl_tier_readback() {
local _i _back=0
for _i in "${!_bl_tier_targets[@]}"; do
bl_proc_start "${_bl_tier_targets[$_i]}" && [ "$_bl_start" = "${_bl_tier_starts[$_i]}" ] || continue
bl_zram_usage "${_bl_tier_targets[$_i]}" > /dev/null
[ "${_bl_tier_swaps[$_i]}" -le "$_bl_swap" ] || _back=$((_back + _bl_tier_swaps[_i] - _bl_swap))
done
_bl_tier_back=$_back
}
bl_watch_nap() {
local _i
if [ "$_bl_game" = 1 ] && bl_low_ram && [ "$_bl_available" -ge 0 ] && [ "$_bl_available" -lt $((_bl_tier_headroom * 2)) ]; then
for _i in 1 2 3 4 5; do
nap 1
bl_memory_read
bl_memory_critical && return 0
done
else
nap 5
fi
return 0
}
bl_session_others() {
local _p
_bl_session=()
@@ -892,7 +977,11 @@ case $mode in
done
}
bl_zram_pick() {
if [ "$1" = session ]; then
if [ "$1" = game ]; then
bl_session_others
_pids=("${_bl_session[@]}")
[ "${BL_ZRAM_CLIENT:-1}" != 1 ] || _pids+=("${_bl_pids[@]}")
elif [ "$1" = session ]; then
bl_session_others
_pids=("${_bl_session[@]}")
else
@@ -954,7 +1043,7 @@ case $mode in
[ "$_bl_ram_cap" -gt 0 ] && { [ "$_bl_measured" -lt 0 ] || [ $((SECONDS - _bl_measured)) -ge 30 ]; }
}
_bl_since=-1 _bl_since_apps= _bl_packed=-1 _bl_measured=-1 _bl_unpack=0 _bl_capped=-1 _bl_backoff=30
_bl_pressure_at=-1 _bl_pressure_backoff=30
_bl_pressure_at=-1 _bl_pressure_backoff=30 _bl_tier_at=-1 _bl_tier_backoff=30 _bl_tier_pushed=0 _bl_tier_back=0 _bl_tier_targets=() _bl_tier_swaps=() _bl_tier_starts=()
while kill -0 $$ 2>/dev/null; do
bl_zram_scan
bl_memory_read
@@ -981,6 +1070,7 @@ case $mode in
_bl_backoff=30
fi
fi
bl_session_pressure
bl_client_pressure
if [ "$_bl_game" = 1 ] && bl_game_focused; then
if [ "$_bl_since" -lt 0 ] || [ "$_bl_since_apps" != "$_bl_apps" ]; then
@@ -995,7 +1085,7 @@ case $mode in
if [ "$_bl_packed" -ge 0 ]; then
_bl_unpack=1
fi
_bl_since=-1 _bl_packed=-1
_bl_since=-1 _bl_packed=-1 _bl_tier_at=-1 _bl_tier_backoff=30 _bl_tier_pushed=0 _bl_tier_targets=() _bl_tier_swaps=() _bl_tier_starts=()
else
_bl_since=-1
fi
@@ -1003,7 +1093,7 @@ case $mode in
bl_zram_send 0 "memory recovered, reading the client back" client
_bl_unpack=0
fi
nap 5
bl_watch_nap
done
) &
fi
+42
View File
@@ -909,6 +909,48 @@ class LaunchTest(Harness):
self.assertEqual(image.read_bytes(), b"BM1152x720")
self.assertIn('"WallpaperStyle"="2"', (pfx / "user.reg").read_text())
def test_battle_nets_desktop_turns_windows_animations_off_once(self):
self.install()
reg = Path(self.root) / ("steamapps/compatdata/%s/pfx/user.reg" % BNET["APP_ID"])
reg.write_text('WINE REGISTRY Version 2\n\n[Control Panel\\\\Desktop] 1\n"UserPreferencesMask"=hex:30,00,02,80,12,00,00,00\n')
self.assertTrue(BNET["still_pages"](self.root))
self.assertFalse(BNET["still_pages"](self.root))
text = reg.read_text()
self.assertEqual(text.count('"UserPreferencesMask"=hex:30,00,02,80,10,00,00,00'), 1)
self.assertEqual(BNET["reg_read"](str(reg), BNET["DESKTOP_KEY"], "UserPreferencesMask"),
'"UserPreferencesMask"=hex:30,00,02,80,10,00,00,00')
def test_a_new_prefix_gets_wines_default_preferences_without_animations(self):
self.install()
reg = Path(self.root) / ("steamapps/compatdata/%s/pfx/user.reg" % BNET["APP_ID"])
reg.write_text("WINE REGISTRY Version 2\n")
self.assertTrue(BNET["still_pages"](self.root))
self.assertEqual(BNET["reg_read"](str(reg), BNET["DESKTOP_KEY"], "UserPreferencesMask"),
'"UserPreferencesMask"=hex:10,00,00,80,10,00,00,00')
def test_animations_already_off_and_other_preferences_are_left_alone(self):
self.install()
reg = Path(self.root) / ("steamapps/compatdata/%s/pfx/user.reg" % BNET["APP_ID"])
before = 'WINE REGISTRY Version 2\n\n[Control Panel\\\\Desktop] 1\n"UserPreferencesMask"=hex:9e,3e,07,80,31,00,00,00\n'
reg.write_text(before)
self.assertFalse(BNET["still_pages"](self.root))
self.assertEqual(reg.read_text(), before)
reg.write_text('WINE REGISTRY Version 2\n\n[Control Panel\\\\Desktop] 1\n"UserPreferencesMask"=hex:9e,3e,07,80,33,00,00,00\n')
self.assertTrue(BNET["still_pages"](self.root))
self.assertEqual(BNET["reg_read"](str(reg), BNET["DESKTOP_KEY"], "UserPreferencesMask"),
'"UserPreferencesMask"=hex:9e,3e,07,80,31,00,00,00')
self.assertFalse(BNET["still_pages"](self.root + "/missing"))
def test_a_prefix_in_use_keeps_its_registry_until_it_starts_on_its_own(self):
self.install()
reg = Path(self.root) / ("steamapps/compatdata/%s/pfx/user.reg" % BNET["APP_ID"])
before = 'WINE REGISTRY Version 2\n\n[Control Panel\\\\Desktop] 1\n"UserPreferencesMask"=hex:30,00,02,80,12,00,00,00\n'
reg.write_text(before)
with mock.patch.dict(BNET, {"wine_processes": lambda pfx: [(1, ["C:\\Games\\game.exe"])]}):
self.assertFalse(BNET["still_pages"](self.root))
self.assertEqual(reg.read_text(), before)
self.assertTrue(BNET["still_pages"](self.root))
def test_a_large_desktop_scales_battle_net_to_fit_it(self):
sizes = {("1280", "720"): 96, ("1920", "1080"): 96, ("2400", "1504"): 120, ("2448", "1848"): 120,
("1080", "2400"): 96, ("3840", "2160"): 192, ("2560", "1600"): 120}
+3 -2
View File
@@ -81,7 +81,7 @@ class ClientZramFocusTest(unittest.TestCase):
def test_refocus_restarts_the_delay_and_game_exit_restores_a_packed_client(self):
source = SCRIPT.read_text()
start = source.index(' if [ "$_bl_game" = 1 ] && bl_game_focused; then')
stop = source.index(' nap 5', start)
stop = source.index(' bl_watch_nap\n', start)
body = source[start:stop]
harness = '''
_bl_since=-1 _bl_since_apps= _bl_packed=-1 _bl_unpack=0 _bl_apps=" 413150"
@@ -112,7 +112,7 @@ tick 60 0 0
def monitor_harness(self):
source = SCRIPT.read_text()
start = source.index(' bl_zram_scan\n bl_memory_read\n')
stop = source.index(' nap 5', start)
stop = source.index(' bl_watch_nap\n', start)
return '''
_bl_since=-1 _bl_since_apps= _bl_packed=-1 _bl_measured=-1 _bl_unpack=0 _bl_capped=-1 _bl_backoff=30
_bl_apps=" 413150"
@@ -121,6 +121,7 @@ step() { :; }
bl_zram_scan() { :; }
bl_memory_read() { :; }
bl_client_pressure() { :; }
bl_session_pressure() { :; }
bl_client_can_pageout() { return 0; }
bl_client_can_readback() { [ "${readback:-1}" = 1 ]; }
bl_game_focused() { [ "$focused" = 1 ]; }
+182
View File
@@ -137,5 +137,187 @@ echo "backoff:$_bl_pressure_backoff"
self.assertEqual(['no'], self.run_policy('if bl_client_can_readback; then echo yes; else echo no; fi'))
class SessionPressureTest(unittest.TestCase):
def setUp(self):
self.directory = tempfile.TemporaryDirectory()
self.addCleanup(self.directory.cleanup)
self.root = Path(self.directory.name)
self.memory()
def memory(self, available=1024, swap_free=6144, total=7168, swap_total=8192, psi='0.00'):
(self.root / 'meminfo').write_text(
f'MemTotal: {total * 1024} kB\nMemAvailable: {available * 1024} kB\n'
f'SwapTotal: {swap_total * 1024} kB\nSwapFree: {swap_free * 1024} kB\n')
(self.root / 'pressure').write_text(f'some avg10={psi} avg60=0.00 avg300=0.00 total=1234\n')
def run_policy(self, body):
functions = '\n'.join(function_source(name) for name in (
'bl_memory_read', 'bl_memory_tight', 'bl_memory_critical', 'bl_low_ram',
'bl_session_pressure', 'bl_watch_nap', 'bl_zram_pick', 'bl_tier_note', 'bl_tier_readback'))
functions = functions.replace('/proc/meminfo', str(self.root / 'meminfo'))
functions = functions.replace('/proc/pressure/memory', str(self.root / 'pressure'))
harness = '''
set -u
_bl_game=1 _bl_tier_at=-1 _bl_tier_backoff=30 _bl_tier_pushed=0 _bl_tier_back=0 _bl_tier_targets=() _bl_tier_swaps=() _bl_tier_starts=() _bl_pids=(999)
_bl_pressure_at=-1 _bl_packed=-1
rss=$((1536 * 1024)) swap=0 pushed=$((1024 * 1024)) alive=999 started=100 complete=1
step() { :; }
bl_proc_start() { _bl_start=; case " $alive " in *" $1 "*) _bl_start=$started; return 0 ;; esac; return 1; }
nap() { echo "nap:$1"; }
bl_session_others() { _bl_session=(321 654); }
bl_zram_usage() { _bl_rss=$rss _bl_swap=$swap; }
bl_zram_send() { echo "send:$1:$3"; rss=$((rss - pushed)); swap=$((swap + pushed)); _bl_pageout_complete=$complete; }
''' + functions + '\nbl_memory_read\n' + body
result = run_bash(harness)
self.assertEqual(0, result.returncode, result.stderr)
self.assertEqual('', result.stderr)
return result.stdout.splitlines()
def test_low_headroom_compresses_the_game_session_at_most_every_30_seconds(self):
self.assertEqual(['send:1:game', 'send:1:game'], self.run_policy('''
SECONDS=0; bl_session_pressure
SECONDS=10; bl_session_pressure
SECONDS=29; bl_session_pressure
SECONDS=30; bl_session_pressure
'''))
def test_twelve_gigabyte_devices_are_included_and_larger_ones_are_not(self):
for total, expected in ((11264, ['send:1:game']), (12288, ['send:1:game']), (15360, [])):
with self.subTest(total=total):
self.memory(total=total)
self.assertEqual(expected, self.run_policy('bl_session_pressure'))
def test_no_game_does_not_compress_the_session(self):
self.assertEqual([], self.run_policy('_bl_game=0; bl_session_pressure'))
def test_enough_available_memory_does_not_compress_even_with_stalls(self):
self.memory(available=3072, psi='5.00')
self.assertEqual([], self.run_policy('bl_session_pressure'))
def test_stalls_with_moderate_headroom_compress_before_the_hard_limit(self):
self.memory(available=2048, psi='1.50')
self.assertEqual(['send:1:game'], self.run_policy('bl_session_pressure'))
def test_moderate_headroom_without_stalls_waits(self):
self.memory(available=2048)
self.assertEqual([], self.run_policy('bl_session_pressure'))
def test_game_reading_back_most_of_a_pageout_skips_and_doubles_the_wait(self):
self.assertEqual(['send:1:game', 'backoff:60', 'send:1:game', 'backoff:60',
'send:1:game', 'backoff:30'], self.run_policy('''
pushed=$((512 * 1024))
SECONDS=0; bl_session_pressure
swap=$((swap / 4))
SECONDS=30; bl_session_pressure
echo "backoff:$_bl_tier_backoff"
SECONDS=60; bl_session_pressure
SECONDS=90; bl_session_pressure
echo "backoff:$_bl_tier_backoff"
SECONDS=150; bl_session_pressure
echo "backoff:$_bl_tier_backoff"
'''))
def test_repeated_read_back_keeps_doubling_up_to_five_minutes(self):
self.assertEqual(['backoff:300'], self.run_policy('''
_bl_tier_backoff=240 _bl_tier_at=0 _bl_tier_targets=(999) _bl_tier_swaps=($((1024 * 1024))) _bl_tier_starts=(100) _bl_tier_pushed=$((1024 * 1024)) swap=0
SECONDS=240; bl_session_pressure
echo "backoff:$_bl_tier_backoff"
'''))
def test_nearly_out_of_memory_ignores_the_backoff_but_keeps_30_seconds(self):
self.memory(available=512)
self.assertEqual(['send:1:game', 'send:1:game'], self.run_policy('''
_bl_tier_backoff=300
SECONDS=0; bl_session_pressure
swap=0
SECONDS=20; bl_session_pressure
SECONDS=30; bl_session_pressure
'''))
def test_small_swap_headroom_skips_the_pageout(self):
self.memory(swap_free=1536, swap_total=2048)
self.assertEqual([], self.run_policy('bl_session_pressure'))
def test_small_session_is_left_alone(self):
self.assertEqual([], self.run_policy('rss=$((128 * 1024)); bl_session_pressure'))
def test_watch_polls_every_second_near_the_limit_and_stops_when_critical(self):
self.memory(available=2560)
self.assertEqual(['nap:1', 'nap:1', 'nap:1', 'nap:1', 'nap:1'], self.run_policy('bl_watch_nap'))
self.memory(available=1024)
self.assertEqual(['nap:1'], self.run_policy('_bl_available=$((2560 * 1024)); bl_watch_nap'))
self.memory(available=4096)
self.assertEqual(['nap:5'], self.run_policy('bl_watch_nap'))
self.assertEqual(['nap:5'], self.run_policy('_bl_game=0; bl_watch_nap'))
def test_one_push_compresses_the_game_and_the_launcher_and_restarts_the_launcher_wait(self):
self.assertEqual(['send:1:game', 'pids:321 654 999', 'client:0:0'], self.run_policy('''
SECONDS=0; bl_session_pressure
bl_zram_pick game; echo "pids:${_pids[*]}"
echo "client:$_bl_pressure_at:$_bl_packed"
'''))
def test_launcher_compression_turned_off_keeps_the_launcher_out(self):
self.assertEqual(['pids:321 654', 'client:-1:-1'], self.run_policy('''
BL_ZRAM_CLIENT=0
SECONDS=0; bl_session_pressure
bl_zram_pick game; echo "pids:${_pids[*]}"
echo "client:$_bl_pressure_at:$_bl_packed"
''')[1:])
def test_low_ram_devices_compress_with_a_quarter_of_memory_left(self):
self.memory(available=1700)
self.assertEqual(['send:1:game'], self.run_policy('bl_session_pressure'))
self.memory(available=1900)
self.assertEqual([], self.run_policy('bl_session_pressure'))
def test_a_compressed_process_that_exits_is_not_counted_as_read_back(self):
self.assertEqual(['send:1:game', 'send:1:game', 'backoff:30'], self.run_policy('''
SECONDS=0; bl_session_pressure
alive=""; swap=0
SECONDS=30; bl_session_pressure
echo "backoff:$_bl_tier_backoff"
'''))
def test_a_process_that_starts_after_a_pageout_is_not_in_its_read_back(self):
self.assertEqual(['send:1:game', 'targets:999', 'send:1:game', 'backoff:30'], self.run_policy('''
SECONDS=0; bl_session_pressure
echo "targets:${_bl_tier_targets[*]}"
alive="999 1234"
SECONDS=30; bl_session_pressure
echo "backoff:$_bl_tier_backoff"
'''))
def test_an_incomplete_pageout_does_not_restart_the_launcher_wait(self):
self.assertEqual(['send:1:game', 'client:-1:-1'], self.run_policy('''
complete=0
SECONDS=0; bl_session_pressure
echo "client:$_bl_pressure_at:$_bl_packed"
'''))
def test_a_reused_process_id_is_not_counted_as_read_back(self):
self.assertEqual(['send:1:game', 'send:1:game', 'backoff:30'], self.run_policy('''
SECONDS=0; bl_session_pressure
started=200; swap=0
SECONDS=30; bl_session_pressure
echo "backoff:$_bl_tier_backoff"
'''))
def test_process_start_comes_from_stat_and_zombies_have_none(self):
stat = self.root / 'stat'
source = function_source('bl_proc_start').replace('/proc/$1/stat', str(stat))
fields = ' '.join(['0'] * 17)
for state, expected in (('S', ['ok:4242']), ('Z', ['gone:'])):
with self.subTest(state=state):
stat.write_text(f'77 (Wow (arm64).exe) {state} 1 {fields} 4242 99 0\n')
result = run_bash(source + '\nif bl_proc_start 77; then echo "ok:$_bl_start"; else echo "gone:$_bl_start"; fi')
self.assertEqual(expected, result.stdout.split())
stat.unlink()
result = run_bash(source + '\nbl_proc_start 77 || echo gone')
self.assertEqual(['gone'], result.stdout.split())
if __name__ == '__main__':
unittest.main()