Compare commits

...
Author SHA1 Message Date
xXJsonDeruloXx c26fa0981c wip: multi-controller session and pad bridge changes 2026-10-14 12:16:24 -04:00
Kurt Himebauch ec24b3a472 feat: the built-in pad can be a DualShock 4 or DualSense, its touchpad fed by the trackpads (#559) 2026-10-11 04:21:31 +00:00
Kurt Himebauch 6455b1dade feat: the built-in pad's controller type switches from the session menu while Steam runs (#558) 2026-10-11 00:12:53 -04:00
Kurt Himebauch 542ad3e060 feat: the built-in pad can be a Steam Controller to Steam (#545) 2026-10-11 03:24:22 +00:00
Kurt Himebauch 910fb47692 fix: the Steam Controller's hidraw node reaches the input shim again (#557) 2026-10-10 23:20:09 -04:00
Kurt Himebauch 3dd14dd625 refactor: Steam always runs in Deck mode on the Deck beta branch (#546) 2026-10-10 22:15:42 -04:00
The412Banner d1b0ac4df1 Windows components: picked automatically by each game's source (#554)
Each game's Windows components now come from the list its own source
trusts, and the ones that list names turn on by themselves. The user's own
on/off switches always win; each automatic row says why it is on.

- Steam games: the Steamworks Shared redistributables Steam installs with
  the game (appmanifest SharedDepots, else the client's appinfo.vdf).
  Steam keeps its install scripts marked as already run.
- GOG: the build manifest's dependencies. Epic: the manifest's
  prerequisite (Unreal's maps to VC++ and DirectX). Amazon: fuel.json
  PostInstall. Older GOG/Epic installs get their list fetched once with the
  saved sign-in; game files are never downloaded again.
- Custom games (the + button, Add all, migrated folders): one shared Steam
  match per game, also used for artwork. An appid from the game's files
  turns components on; a name-only match stays Recommended; no match falls
  back to the folder scan.
- Steam app info for apps the client never saw comes from an anonymous
  Steam PICS lookup (CM websocket, anonymous logon, product info), cached
  per appid; the user's Steam sign-in is never used.
- Missing components download in the background on one low-priority
  worker that pauses during sessions and waits on battery saver, heat or a
  metered network unless the user asked. A game Steam installs during a
  session is readied right away so it can be played without ending the
  session; the launch waits up to 10 s only for a component still on its
  way.
- Page: "On for this game" with reasons, a quiet "Checking what this game
  needs..." only for games never worked out, rows that ease in, pieces that
  need a download greyed out while offline. The intro names no other app.
- The folder scan skips Unity's *_Data/Managed (Mono, not .NET Framework).
2026-10-10 21:15:56 -04:00
MaxsTechReview 0e225d0c6e FEX: preload the fault report only where x86 loaders look (#551)
Preload the FEX fault report only where x86 loaders look

droiddeck-fex put the x86 libfaultreport.so in LD_PRELOAD, which FEX's own
arm64 loader reads too - and FEXServer's, and every child FEX's - so each
x86 start printed "ERROR: ld.so: object '.../libfaultreport.so' from
LD_PRELOAD cannot be preloaded", and an i386 child added "wrong ELF class".
It is now listed in the x86 runtime's own /etc/ld.so.preload, which only
the loaders inside FEX read, as /usr/$LIB/droiddeck/libfaultreport.so.

faultreport.c builds for i386 too, so 32-bit programs get the report as
well instead of an error.
2026-10-10 19:12:51 -04:00
MaxsTechReview d9cd0d2cc8 Desktop: run programs, scripts and AppImages; Switch to Desktop without Deck mode (#549)
* Run programs, scripts and AppImages from the desktop

Windows programs open with the Proton Steam has chosen, and a Steam game's own
launch program is handed to Steam. x86-64 Linux programs and game scripts run
under FEX, and an exec the kernel refuses goes to droiddeck-open through
libblsession's binfmt.c. Programs on noexec shared storage pass KIO's checks; a
folder of Linux programs there, and sharun AppImages, are added by the app once
and run from the copy. Executable scripts run without KIO's prompt, mpv plays
on the software desktop, FEXServer's socket moves under XDG_RUNTIME_DIR, and
Proton runtimes no longer show up as games.

* Switch to Desktop from Big Picture without Deck mode

Big Picture's Switch to Desktop asks the client's SteamOS manager, which the
client only has when started as SteamOS (Deck mode). Without it the request
failed and the screen stayed on "Switching to Desktop..." for good. The live
compat helper now sees the request on its way to the client and hands it to
steamos-session-select, which opens the KDE desktop as Deck mode already did.

* Run x86 programs from shared storage where they lie

FEX asks for the guest's PROT_EXEC when it maps a program or library, which
the kernel refuses on noexec shared storage; FEX never runs those pages
itself, so libblsession maps them without it in FEX's processes. An x86
game, its libraries and its start script now run in place instead of being
copied in; only native code on noexec storage is still copied.

A Steam library's games are never copied, and a Linux game's launch program
opened from the desktop goes to Steam like a Windows one.
2026-10-10 19:12:20 -04:00
MaxsTechReview eadebc46d5 Performance: idle front end, proot stops, launch-time passes and log collection (#529)
* Front end: draw the capsule wall without clipping it every frame

The tilted wall clipped each capsule to a rounded rect and recomposed its columns on every drift
step, and a rotated rounded clip is rasterised in software on the render threads: the idle main
page held about 1.7 cores. The art is now loaded at the capsule's size with its corners already
rounded, each column draws its capsules itself in one layer moved by the drift, and the drift
advances at most every 15 ms. The idle page takes about a third of a core.

* libfakeinput: let calls on descriptors it does not serve skip its lock

Every read, write, ioctl, close and poll of every process in a Proton session went through the
controller lock and the three map lookups, Wine's wineserver pipes and the game's files alike.
A bitmap of the descriptors the maps hold, set before one is handed out and cleared when it is
closed, lets everything else go straight to the kernel.

* Pad motion: ask the display for its rotation four times a second

The rotation was looked up for every sensor reading, every 2 ms, and cost more than the rest of
the reading; it is now refreshed every 250 ms, and the readings no longer allocate pairs.

* Session: keep the library passes out of a game's launch

The redist seeder stamped each prefix with the checksum of the whole set of names, which differs
between the session's pass and the game launch's (the game's own names are added), so every
launch re-appended the markers to every prefix in the library while the game loaded. Each
prefix's marker now lists the names it was given and only the missing ones are added.

The beat's seed and compatibility passes, and the Lossless Scaling ownership probe (which loads
steamclient.so), also wait while a wineserver runs: a launch rewrites the manifest and made them
due at exactly that moment. The game's own launch seeds its prefix regardless.

* Agent: keep the focus watcher's X connection open while nothing changes

The connect timeout stayed on the socket, so with no property change the read timed out, and the
watcher dropped the connection, reconnected and took a fresh snapshot every few seconds.

* Game environment: start Cyberpunk 2077 without REDlauncher

The prelauncher opens REDlauncher, a Qt WebEngine window that hands over to the game half a
minute later and then stays up beside it, well over a core busy for as long as the game runs; its
own --launcher-skip started the game twice. The game's executable is started directly instead.

* netif: answer Wine's network driver from a reading of the link

Mortal Kombat X has Wine's network driver enumerate every interface, address and route table
hundreds of times a second. Each enumeration read /etc/droiddeck-net several times, asked the
kernel for its interface dump again (a netlink socket bound under proot, refused each time) and
looked the real functions up with dlsym. The link file is now read at most once a second, a
refused dump is not asked for again within that second, and the real functions are looked up
once.

* Fast path: create memfds without a stop in proot

proot looks at a memfd's name only to refuse the few that are then executed or locked; every other
memfd_create is now issued from the trampoline. The session's /proc/net and status copies made
one per read, a stop each. Without memfd in the kernel the call still goes to proot's ashmem.

* Logs: run each redaction rule only on a line that could match it

A session's end copies some 70 MB of the Steam client's logs and puts every line through all
twenty-odd rules, most of a minute of a core on the phone after every session. Each rule now runs
only on a line holding what it cannot match without: its keyword, the five separators of a MAC,
the four dashes of a GUID, a long enough hex or token run, the learned account or address text.
Lines with non-ASCII text still meet every case-insensitive rule. Over 895,000 lines of real
client logs, with the device's accounts and addresses learned, the output is identical and the
pass takes a twentieth of the time.
2026-10-10 19:00:33 -04:00
SG3xHERO 96d7c66535 Add Steam Controller support over Bluetooth LE (#404) 2026-10-10 20:53:07 +00:00
The412Banner 90690d1c5c Read the package catalogs from Droid-Deck/DroidDeck-Components (#544)
The Windows components catalog, the desktop and emulator packages, the Linux runtime and the
Steam seed now live on the organisation's components repository, mirrored and built there by
CI; the app reads the catalogs from its main and the files from its releases. The personal
winlator-contents mirror keeps serving older app versions and Bannerlator.
2026-10-10 16:52:33 -04:00
The412Banner abbcb8e3ad Games: old Games folders become entries, not Steam libraries (#542)
Games folders saved before the settings section went away were still
registered as Steam libraries on Automatic, with no way left to remove
them. Once, at app start and before any scan, each game such a folder
listed becomes an entry of its own, keeping everything keyed by its folder
(chosen exe, shortcut appid, name, Start in, launch options, art,
removal); store installs and steamapps are skipped. The folders stay bound
at their old guest paths, so no shortcut changes, and the saved roots are
cleared. A Games folder stays a library only when Steam installed a game
into it; the others leave the library list, and the runtime forgets them.
2026-10-10 16:19:17 -04:00
David Kubeša fe60b3c279 Add global manual landscape orientation setting (#441) 2026-10-10 16:07:12 -04:00
Kurt Himebauch c7455b74ff Merge rules (#540) 2026-10-10 19:13:01 +00:00
Ashish Ajin c875ceda4d Hide the touchpad cursor when switching to direct touch (#424) 2026-10-10 15:02:38 -04:00
The412Banner 666abd134f Windows components: a recommended component is listed once, not again under All (#537)
The full list left out only the 'installed for this game' rows, never the recommendations, so
every recommended component appeared twice (DirectX 9 on Final Fantasy Resonance, both rows
switched on).
2026-10-10 14:54:28 -04:00
Muaz Kadan c115916962 Read the phone's motion sensors only while Steam asks for them (#534)
perf: only read the phone's motion sensors when Steam wants them

The gyro and accelerometer ran at 4 ms for the whole session, even in games without gyro, costing about 6% of a core. Steam already tells the Deck pad when it wants motion (setting 48), so libfakeinput now passes that on and PadMotion follows it, with a short delay to ride out Steam's brief toggles.
2026-10-10 14:44:05 -04:00
0579e688ba fix: preserve keyboard input on composite devices (#435)
* fix: preserve keyboard input on composite devices

* Fix composite input merge noteDevice call

---------

Co-authored-by: Jammo <[email protected]>
Co-authored-by: xXJsonDeruloXx <[email protected]>
2026-10-10 14:40:53 -04:00
Muaz Kadan bc50de3b55 Fix CPU temperature showing 0°C in the performance overlay (#516)
Since the proot speed-up in #494, the overlay couldn't find the CPU sensor and showed 0°C. It shows the real temperature again.
2026-10-10 14:39:30 -04:00
ArthurandJames Yeung 6489232598 Fix NativeAOT GC region range defaults (#385)
Use the bare hex value accepted by older NativeAOT runtimes and CoreCLR. Cover the default and shared and per-game overrides.

Co-authored-by: James Yeung <[email protected]>
2026-10-10 14:38:47 -04:00
0x142857andxXJsonDeruloXx c23c436502 Controller: optional Select button that opens the Steam menu (#384)
* Controller: optional Select button that opens the Steam menu

* Controller: translate the Select-opens-Steam-menu option

* Controller: clearer wording for the Select-opens-Steam-menu option

* docs: evidence for the Select-opens-Steam-menu option

---------

Co-authored-by: xXJsonDeruloXx <[email protected]>
2026-10-10 14:38:07 -04:00
abduznik fad52ec125 Terminal: release createSubprocess's JNI local references, and the cwd chars against cwd (#527)
Fix JNI local reference leak in createSubprocess

The argv and envp loops took one local reference per element from
GetObjectArrayElement and never released it, so the references piled up
until the native call returned. ART aborts the process at 512 local
references ("local reference table overflow"), and both loops share the
table, so a long argument list plus a large environment could hit it.

Delete each element's local reference once its chars are copied and
released, after ReleaseStringUTFChars since the chars depend on the string.

Also release the cwd chars against cwd rather than cmd; the call passed
the wrong jstring.

Adds a host-side test that runs termux.c against a mock JNIEnv that counts
local references and checks release pairing. It needs a jni.h (JNI_INCLUDE,
JAVA_HOME or the NDK) and skips without one.

Fixes #507
2026-10-10 14:29:51 -04:00
Nicolás Gómez Solano d22e458d64 fix(audio): avoid InputStream.readAllBytes() for Android 12 compatibility (#531) 2026-10-10 14:29:46 -04:00
The412Banner 1b7cb4179d Windows components: leave out WebView2, which only Microsoft's installer can lay out (#532)
Its files sit in a container no extractor opens and may not be hosted, so a recording cannot
source them; installing it means running the installer in the prefix, which no runtime here
does. With it gone the waiting section is empty.
2026-10-10 14:03:04 -04:00
MaxsTechReviewandxXJsonDeruloXx c1c22dfd37 Launch the executable a Store package declares (#400)
Store and GDK folders name their game in MicrosoftGame.config or
appxmanifest.xml. Picking the top-level Unreal bootstrap instead failed
with "CreateProcess() returned 2" when the Store had renamed the
shipping exe.

Co-authored-by: xXJsonDeruloXx <[email protected]>
2026-10-10 13:46:03 -04:00
kmnkv1 0043cefab5 Rumble: don't restart the vibrator on every force-feedback packet (#514)
fix: stop restarting the vibrator on every force-feedback packet
2026-10-10 13:34:19 -04:00
kaosjammoandJammo 41f68ef1a1 Fix launcher fullscreen around display cutouts (#432)
Co-authored-by: Jammo <[email protected]>
2026-10-10 13:33:59 -04:00
The412Banner 9743ac3abc Windows components: leave out the OpenAL setup program, a twin of the DLL copy that installs here (#528) 2026-10-10 13:32:12 -04:00
SG3xHERO ca6cfcd193 Unfolded deck controls (#407) 2026-10-10 17:24:23 +00:00
128 changed files with 10291 additions and 734 deletions

No files matched your search

+13
View File
@@ -44,6 +44,19 @@ jobs:
ref: ${{ github.event_name == 'pull_request' && github.event.pull_request.head.sha || github.sha }}
fetch-depth: 0
- name: Check co-authors
if: github.event_name == 'pull_request'
run: |
set -euo pipefail
git fetch --no-tags origin main
base=$(git merge-base origin/main HEAD)
messages=$(git log --format=%B "$base..HEAD")
messages+=$'\n'"$(jq -r '.pull_request.title, (.pull_request.body // "")' "$GITHUB_EVENT_PATH")"
if grep -Eiq '^[[:space:]]*Co-authored-by:.*(Claude|Anthropic|OpenAI|ChatGPT|GPT|Copilot|Cursor|Gemini|Codex|Devin|Windsurf|Codeium|Amazon Q|Tabnine|Cody|\[bot\])' <<<"$messages"; then
echo "::error::AI and bot identities cannot be co-authors; remove the Co-authored-by trailer."
exit 1
fi
- name: The packages a signed build ships as
id: variants
run: echo "list=$(awk '!/^#/ && NF { print $1 }' tools/release/variants.txt | jq -R . | jq -sc .)" >> "$GITHUB_OUTPUT"
+3
View File
@@ -30,6 +30,9 @@
<uses-permission android:name="android.permission.RECORD_AUDIO" />
<!-- Installs DroidDeck's own updates (Updates page) over itself, after the user allows it. -->
<uses-permission android:name="android.permission.REQUEST_INSTALL_PACKAGES" />
<!-- A paired Steam Controller, read over Bluetooth LE out of lizard mode (SteamControllerBle).
Granted at install below targetSdk 31; no scan, so no location. -->
<uses-permission android:name="android.permission.BLUETOOTH" />
<uses-feature android:name="android.hardware.vulkan.version" android:version="0x401000" android:required="true" />
<uses-feature android:name="android.hardware.gamepad" android:required="false" />
File diff suppressed because it is too large. Load diff
+3 -1
View File
@@ -136,6 +136,7 @@ JNIEXPORT jint JNICALL Java_com_termux_terminal_JNI_createSubprocess(
if (!arg_utf8) return throw_runtime_exception(env, "GetStringUTFChars() failed for argv");
argv[i] = strdup(arg_utf8);
(*env)->ReleaseStringUTFChars(env, arg_java_string, arg_utf8);
(*env)->DeleteLocalRef(env, arg_java_string);
}
argv[size] = NULL;
}
@@ -151,6 +152,7 @@ JNIEXPORT jint JNICALL Java_com_termux_terminal_JNI_createSubprocess(
if (!env_utf8) return throw_runtime_exception(env, "GetStringUTFChars() failed for env");
envp[i] = strdup(env_utf8);
(*env)->ReleaseStringUTFChars(env, env_java_string, env_utf8);
(*env)->DeleteLocalRef(env, env_java_string);
}
envp[size] = NULL;
}
@@ -160,7 +162,7 @@ JNIEXPORT jint JNICALL Java_com_termux_terminal_JNI_createSubprocess(
char const* cmd_utf8 = (*env)->GetStringUTFChars(env, cmd, NULL);
int ptm = create_subprocess(env, cmd_utf8, cmd_cwd, argv, envp, &procId, rows, columns);
(*env)->ReleaseStringUTFChars(env, cmd, cmd_utf8);
(*env)->ReleaseStringUTFChars(env, cmd, cmd_cwd);
(*env)->ReleaseStringUTFChars(env, cwd, cmd_cwd);
if (argv) {
for (char** tmp = argv; *tmp; ++tmp) free(*tmp);
@@ -22,8 +22,6 @@ class App : Application() {
override fun onCreate() {
super.onCreate()
CrashHandler.install(this)
// Before anything else touches the session prefs, so the first session already runs in Deck mode.
com.droiddeck.launcher.session.SessionPrefs.settleDeckModeDefault(this)
com.droiddeck.launcher.session.SessionPrefs.settleGpuDriverMode(this)
com.droiddeck.launcher.update.AppUpdates.init(this)
GpuClockPin.clearLeftover(this)
@@ -14,6 +14,7 @@ class HomeActivity : Activity() {
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
requestedOrientation = com.droiddeck.launcher.session.SessionPrefs.orientation(this)
routeHome()
}
@@ -10,6 +10,7 @@ import java.io.File
import android.content.pm.PackageManager
import android.hardware.display.DisplayManager
import android.net.Uri
import android.os.Build
import android.os.Bundle
import android.os.Handler
import android.os.Looper
@@ -17,6 +18,7 @@ import android.util.Log
import android.view.Display
import android.view.KeyEvent
import android.view.View
import android.view.WindowManager
import androidx.activity.ComponentActivity
import androidx.activity.compose.setContent
import androidx.activity.result.contract.ActivityResultContracts
@@ -119,7 +121,6 @@ class MainActivity : ComponentActivity() {
private var showNonAdreno by mutableStateOf<LinuxRuntimeInstaller.Release?>(null)
private var glThread by mutableStateOf(true)
private var noGlError by mutableStateOf(true)
private var steamDeckMode by mutableStateOf(false)
private var steamRepairQueued by mutableStateOf(false)
private var mangoapp by mutableStateOf(true)
private var steamController by mutableStateOf(SessionPrefs.CONTROLLER_DECK)
@@ -162,6 +163,7 @@ class MainActivity : ComponentActivity() {
private var showPhantomGate by mutableStateOf(false)
private var directAudio by mutableStateOf(false)
private var stretch16x9 by mutableStateOf(false)
private var orientation by mutableStateOf(android.content.pm.ActivityInfo.SCREEN_ORIENTATION_SENSOR_LANDSCAPE)
private var launcherFullscreen by mutableStateOf(true)
private var animationsEnabled by mutableStateOf(true)
private var storeEnabled by mutableStateOf(false)
@@ -341,7 +343,6 @@ class MainActivity : ComponentActivity() {
private var resolution by mutableStateOf(com.droiddeck.launcher.session.SessionDisplay.DEFAULT_RESOLUTION)
private var fexPreset by mutableStateOf("")
private var forceSsbs by mutableStateOf(false)
private var steamChannel by mutableStateOf("steamdeck_publicbeta")
private var runSteamAtStartup by mutableStateOf(false)
private var theme by mutableStateOf("graphite")
private var appScale by mutableStateOf(com.droiddeck.launcher.core.AppUiPrefs.DEFAULT_SCALE)
@@ -363,6 +364,8 @@ class MainActivity : ComponentActivity() {
}
private var romsDir by mutableStateOf<String?>(null)
private var steamGames by mutableStateOf<List<Library.SteamGame>>(emptyList())
@Volatile private var componentSweepDone = false
@Volatile private var componentSweepSession: String? = null
private var emulatorList by mutableStateOf<List<Library.Emulator>>(emptyList())
private var runningLabel by mutableStateOf<String?>(null)
private var logsEnabled by mutableStateOf(true)
@@ -532,6 +535,8 @@ class MainActivity : ComponentActivity() {
// Last start's game list, so the Steam wall is up on the first frame; refresh() replaces it.
steamGames = if (shortcutPicker) emptyList() else com.droiddeck.launcher.frontend.LibraryCache.load(this)
backActionsInverted = SessionPrefs.backActionsInverted(this)
orientation = SessionPrefs.orientation(this)
requestedOrientation = orientation
launcherFullscreen = SessionPrefs.launcherFullscreen(this)
animationsEnabled = SessionPrefs.animationsEnabled(this)
storeEnabled = SessionPrefs.storeEnabled(this)
@@ -589,6 +594,7 @@ class MainActivity : ComponentActivity() {
updates = updates.state(),
phantomProcessStatus = phantomProcessStatus,
showPhantomGate = showPhantomGate,
orientation = orientation,
launcherFullscreen = launcherFullscreen,
animationsEnabled = animationsEnabled,
storeEnabled = storeEnabled,
@@ -610,7 +616,15 @@ class MainActivity : ComponentActivity() {
.putExtra(SessionService.EXTRA_STEAM_UI, "desktop"), steamSession = true)
},
// A store game may need something done first (an Epic game its sign-in code).
onSteamGame = { g -> if (shortcutPicker) chooseGameShortcut(g) else com.droiddeck.launcher.stores.StoreLaunch.prepare(this, g) { launchGame(g) } },
onSteamGame = { g ->
if (shortcutPicker) chooseGameShortcut(g)
else {
// The launch takes the Windows components already on; the game's list is
// looked at again in the background once the session is over.
com.droiddeck.launcher.frontend.AutoComponents.beforeLaunch(this, g)
com.droiddeck.launcher.stores.StoreLaunch.prepare(this, g) { launchGame(g) }
}
},
onGameShortcut = { g -> com.droiddeck.launcher.frontend.GameShortcuts.pin(this, g) },
onExportGameFile = { g -> pickGameExport(g) },
onSyncGameFiles = { pickGameExport(null) },
@@ -735,6 +749,11 @@ class MainActivity : ComponentActivity() {
com.droiddeck.launcher.core.AppUiPrefs.setScale(this, percent)
appScale = com.droiddeck.launcher.core.AppUiPrefs.scale(this)
},
onOrientation = { value ->
SessionPrefs.setOrientation(this, value)
orientation = value
requestedOrientation = value
},
onLauncherFullscreen = { on ->
SessionPrefs.setLauncherFullscreen(this, on)
launcherFullscreen = on
@@ -824,6 +843,7 @@ class MainActivity : ComponentActivity() {
onSteamButton = { on -> ControllerPrefs.setSteamButton(this, on); refreshController() },
onQamButton = { on -> ControllerPrefs.setQamButton(this, on); refreshController() },
onKeyboardButton = { on -> ControllerPrefs.setKeyboardButton(this, on); refreshController() },
onSelectSteam = { on -> ControllerPrefs.setSelectSteam(this, on); refreshController() },
onEditLayout = { startActivity(Intent(this, ControllerEditorActivity::class.java)) },
onResetLayout = { ControllerPrefs.resetAllLayouts(this); refreshController() },
onMapping = { settingsMode = null; showPerformance = false; showProtons = false; showComponents = false; showMapping = true },
@@ -893,6 +913,7 @@ class MainActivity : ComponentActivity() {
// A session folder left without its ending - the process was killed - gets it now.
if (!SessionState.running) Thread({
com.droiddeck.launcher.session.LogMigration.run(this)
com.droiddeck.launcher.frontend.GamesFolderMigration.run(this)
SessionLogShare.clear(this)
SessionArtifacts.finishAbandoned(this)
SessionArtifacts.scrubOlder(this)
@@ -934,6 +955,15 @@ class MainActivity : ComponentActivity() {
} else {
View.SYSTEM_UI_FLAG_LAYOUT_STABLE
}
if (Build.VERSION.SDK_INT >= 28) {
window.attributes = window.attributes.apply {
layoutInDisplayCutoutMode = if (launcherFullscreen) {
WindowManager.LayoutParams.LAYOUT_IN_DISPLAY_CUTOUT_MODE_SHORT_EDGES
} else {
WindowManager.LayoutParams.LAYOUT_IN_DISPLAY_CUTOUT_MODE_DEFAULT
}
}
}
}
/** On the Components page the pad's LB / RB step through GPU drivers, FEX, DXVK and VKD3D-Proton, wrapping around. */
@@ -960,6 +990,8 @@ class MainActivity : ComponentActivity() {
override fun onResume() {
super.onResume()
orientation = SessionPrefs.orientation(this)
requestedOrientation = orientation
refreshWifiDiscovery()
// Back from a store's sign-in page: the Stores chips show the account at once. A store
// install finishing while this screen is up rebuilds the Games list through the listener.
@@ -1254,6 +1286,8 @@ class MainActivity : ComponentActivity() {
steamDownloadsInBackground = steamDownloadsInBackground,
pipSupported = com.droiddeck.launcher.session.SessionPipController.supported(this),
pipAutoEnter = pipAutoEnter,
unfoldedControls = if (mode == SessionService.MODE_STEAM &&
com.droiddeck.launcher.session.SessionDisplay.foldable(this)) unfoldedControls else null,
oscMode = if (mode == SessionService.MODE_STEAM) oscMode else null,
backActionsInverted = backActionsInverted,
directAudio = if (mode == SessionService.MODE_STEAM) directAudio else null,
@@ -1265,8 +1299,6 @@ class MainActivity : ComponentActivity() {
fexPreset = if (mode == SessionService.MODE_STEAM) fexPreset else null,
forceSsbs = forceSsbs,
syncBackend = if (mode == SessionService.MODE_STEAM) SessionPrefs.syncBackendOf(fastSync, fsyncFirst, syncFallback) else null,
steamChannel = if (mode == SessionService.MODE_STEAM) steamChannel else null,
steamDeckMode = mode == SessionService.MODE_STEAM && steamDeckMode,
steamRepairQueued = steamRepairQueued,
mangoapp = mangoapp,
steamController = if (mode == SessionService.MODE_STEAM) steamController else null,
@@ -1311,6 +1343,7 @@ class MainActivity : ComponentActivity() {
steamDownloadsInBackground = enabled
},
onPipAutoEnter = { on -> SessionPrefs.setPipAutoEnter(this, on); pipAutoEnter = on },
onUnfoldedControls = { on -> SessionPrefs.setUnfoldedControls(this, on); unfoldedControls = on },
onOsc = { o -> SessionPrefs.setOscMode(this, o); oscMode = o },
onBackActionsInverted = { inverted ->
SessionPrefs.setBackActionsInverted(this, inverted)
@@ -1343,13 +1376,7 @@ class MainActivity : ComponentActivity() {
fsyncFirst = SessionPrefs.fsyncFirst(this)
syncFallback = SessionPrefs.syncFallback(this)
},
onSteamChannel = { id -> SessionPrefs.setSteamChannel(this, id); steamChannel = id },
onSteamRepair = { steamRepairQueued = SteamRepair.queue(this) },
onSteamDeckMode = { on ->
SessionPrefs.setSteamDeckMode(this, on)
steamDeckMode = on
steamChannel = SessionPrefs.steamChannel(this)
},
onMangoapp = { on -> SessionPrefs.setMangoapp(this, on); mangoapp = on },
onSteamController = { id -> SessionPrefs.setSteamController(this, id); steamController = id },
onRunSteamAtStartup = { on ->
@@ -1420,6 +1447,8 @@ class MainActivity : ComponentActivity() {
val fresh = folders.mapNotNull { f ->
com.droiddeck.launcher.frontend.AddedGames.single(this, f)?.let { com.droiddeck.launcher.frontend.Library.addedGame(this, it) }
}
// An added or edited game's Windows components, from its source's list.
fresh.forEach { com.droiddeck.launcher.frontend.AutoComponents.refreshLater(this, it) }
ui.post {
val paths = folders.toSet()
steamGames = (steamGames.filterNot { it.library == Library.ADDED && it.gameFiles?.path in paths } + fresh).distinctBy { it.gameId }
@@ -1465,9 +1494,11 @@ class MainActivity : ComponentActivity() {
}
private var pipAutoEnter by mutableStateOf(false)
private var unfoldedControls by mutableStateOf(false)
private fun openModeSettings(mode: String) {
pipAutoEnter = SessionPrefs.pipAutoEnter(this)
unfoldedControls = SessionPrefs.unfoldedControls(this)
showPerformance = false
showProtons = false
showComponents = false
@@ -1478,8 +1509,6 @@ class MainActivity : ComponentActivity() {
fastSync = SessionPrefs.fastSync(this)
fsyncFirst = SessionPrefs.fsyncFirst(this)
syncFallback = SessionPrefs.syncFallback(this)
steamChannel = SessionPrefs.steamChannel(this)
steamDeckMode = SessionPrefs.steamDeckMode(this)
steamRepairQueued = SteamRepair.queued(this)
mangoapp = SessionPrefs.mangoapp(this)
steamController = SessionPrefs.steamController(this)
@@ -1538,7 +1567,6 @@ class MainActivity : ComponentActivity() {
clientOverride = SessionPrefs.clientCpusOverride(this)
glThread = SessionPrefs.glThread(this)
noGlError = SessionPrefs.noGlError(this)
steamDeckMode = SessionPrefs.steamDeckMode(this)
clientCores = CpuCores.parse(SessionPrefs.clientCpus(this)).ifEmpty { CpuCores.all.toSet() }
gameCores = CpuCores.parse(SessionPrefs.gameCpus(this)).ifEmpty { CpuCores.all.toSet() }
tuSysmem = SessionPrefs.tuSysmem(this)
@@ -1579,13 +1607,39 @@ class MainActivity : ComponentActivity() {
val scanGeneration = ++libraryScanGeneration
if (shortcutPicker) shortcutLibraryScanning = true
Thread({
// One update with the whole list: the wall places games by their position in it, so a
// partial list first would shuffle every capsule when the rest arrived. LibraryCache
// covers the wait.
// Steam's own games first, from their manifests alone (quick), beside the added games
// already listed: a game Steam installed during the session shows as the session ends,
// without waiting for the added games' walk below. Its Windows components' list is
// queued then too, on the low-priority worker, so its page is usually ready.
if (ready) {
val quick = runCatching { Library.steamGames(this) }.getOrNull()
if (quick != null) ui.post {
if (scanGeneration == libraryScanGeneration && steamGames.isNotEmpty()) {
val before = steamGames.map { it.gameId }.toSet()
val fresh = quick.filter { it.gameId !in before }
if (fresh.isNotEmpty()) {
steamGames = (quick + steamGames.filter { it.library == Library.ADDED }).distinctBy { it.gameId }
fresh.forEach { com.droiddeck.launcher.frontend.AutoComponents.queueGame(this, it, asked = false) }
}
}
}
}
// Then one update with the whole list: the wall places games by their position in it,
// so a partial list first would shuffle every capsule when the rest arrived.
// LibraryCache covers the wait.
val games = if (ready) Library.launchableGames(this) else emptyList()
val emus = Library.emulators(this) { id -> DesktopCatalog.installed(this, id) != null }
val all = games.distinctBy { it.gameId }
if (ready) com.droiddeck.launcher.frontend.LibraryCache.save(this, all)
// Once per app start: the Steam games' Windows components from Steam's lists.
// Queued only, after the launcher has been up a little; the worker takes them one at a time.
// And again after each session (a game installed in it, files changed).
val endedSession = SessionState.sessionId.takeIf { !SessionState.running && it != null && it != componentSweepSession }
if (ready && (!componentSweepDone || endedSession != null)) {
componentSweepDone = true
endedSession?.let { componentSweepSession = it }
ui.postDelayed({ Thread({ com.droiddeck.launcher.frontend.AutoComponents.sweep(this, all) }, "components-sweep").start() }, 10_000)
}
ui.post {
if (scanGeneration == libraryScanGeneration) {
steamGames = all
@@ -19,6 +19,7 @@ import android.view.MotionEvent
import android.view.ViewConfiguration
import android.view.Display
import android.view.SurfaceHolder
import android.view.Gravity
import android.view.SurfaceView
import android.view.View
import android.view.WindowManager
@@ -51,6 +52,7 @@ import com.droiddeck.launcher.input.ControllerPrefs
import com.droiddeck.launcher.input.SessionClipboard
import com.droiddeck.launcher.input.OnScreenControls
import com.droiddeck.launcher.input.PadBridge
import com.droiddeck.launcher.input.SteamControllerBle
import com.droiddeck.launcher.input.PointerGestures
import com.droiddeck.launcher.input.SecondScreenDisplay
import com.droiddeck.launcher.input.SecondScreenDisplays
@@ -109,6 +111,10 @@ class SessionActivity : ComponentActivity(), SurfaceHolder.Callback {
private lateinit var pip: com.droiddeck.launcher.session.SessionPipController
private var pipUi by mutableStateOf(false)
private var pipAutoEnter by mutableStateOf(false)
/** Unfolded controls: the setting, and the Deck controls under the game while they show. */
private var unfoldedControls by mutableStateOf(false)
private var unfoldedPanel: com.droiddeck.launcher.ui.DeckControlsPanel? = null
private lateinit var sessionRoot: FrameLayout
private val guestKeysDown = mutableSetOf<Int>()
private lateinit var surfaceView: SurfaceView
private lateinit var sessionOverlay: ComposeView
@@ -119,7 +125,7 @@ class SessionActivity : ComponentActivity(), SurfaceHolder.Callback {
private var padMotion: com.droiddeck.launcher.input.PadMotion? = null
/** Between onResume and onPause. */
private var resumed = false
private val deckPadListener: () -> Unit = { uiHandler.post { updatePadMotion(); openDeckRing("deck pad changed") } }
private val deckPadListener: () -> Unit = { uiHandler.post { updatePadMotion(); openDeckRing("deck pad changed"); applyUnfoldedControls() } }
/**
* A Deck's controller is there from the moment it boots, and the client looks for it once, as
@@ -132,6 +138,8 @@ class SessionActivity : ComponentActivity(), SurfaceHolder.Callback {
private fun openDeckRing(why: String) {
val deck = SessionState.deckPad
val opened = if (deck) padBridge?.start() else null
// Every pad already connected is a player from the start, before its first press.
padBridge?.claimConnected()
Log.i(TAG, "deck ring ($why): deck pad $deck" + when (opened) {
null -> ", left for first input"
true -> ", ring open"
@@ -142,8 +150,11 @@ class SessionActivity : ComponentActivity(), SurfaceHolder.Callback {
/** Motion is read while the session shows and only if the pad is a Deck controller - which
* the service can decide after this activity has resumed (SessionState.deckPadListener). */
private fun updatePadMotion() {
if (resumed && !pipUi && SessionState.deckPad) padMotion?.start() else padMotion?.stop()
// A Steam Controller brings its own gyro (SteamControllerBle); the phone's would fight it.
if (resumed && !pipUi && SessionState.deckPad && !SteamControllerBle.connected) padMotion?.start()
else padMotion?.stop()
}
private var steamController: SteamControllerBle? = null
private var onScreenControls: OnScreenControls? = null
/** Steam's touch controls (SteamTouchControls), made once the session offers the device. */
private var steamTouchControls: com.droiddeck.launcher.input.SteamTouchControls? = null
@@ -241,6 +252,8 @@ class SessionActivity : ComponentActivity(), SurfaceHolder.Callback {
private var drawerPage by mutableStateOf(SessionDrawerPage.CONTROLLER)
private var drawerControllerActive by mutableStateOf(false)
private var backActionsInverted by mutableStateOf(false)
/** What the pad is to the Steam client (SessionPrefs.CONTROLLER_*), switched from the drawer. */
private var padType by mutableStateOf(SessionPrefs.CONTROLLER_DECK)
/** The on-screen PC keyboard (ui/PcKeyboard): real key presses, Esc and F1 included. */
private var pcKeyboardOpen by mutableStateOf(false)
private var hudOn by mutableStateOf(true)
@@ -299,6 +312,7 @@ class SessionActivity : ComponentActivity(), SurfaceHolder.Callback {
private val deviceListener = object : InputManager.InputDeviceListener {
override fun onInputDeviceAdded(deviceId: Int) {
logInputDevice("connected", deviceId)
padBridge?.claimConnected()
updateOnScreenControls()
updatePointerCapture()
}
@@ -315,6 +329,7 @@ class SessionActivity : ComponentActivity(), SurfaceHolder.Callback {
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
requestedOrientation = SessionPrefs.orientation(this)
if (com.droiddeck.launcher.runtime.LinuxRuntimeInstaller.isRemoving() ||
com.droiddeck.launcher.runtime.LinuxRuntimeInstaller.isMaintaining()) {
runtimeRemovalBlocked = true
@@ -370,13 +385,22 @@ class SessionActivity : ComponentActivity(), SurfaceHolder.Callback {
Log.i(TAG, "pointer capture " + if (hasCapture) "on" else "off")
}
}
sessionRoot = root
surfaceView = SurfaceView(this)
surfaceView.holder.addCallback(this)
root.addView(surfaceView)
// The window's size is only known once laid out, and changes as the phone folds or turns.
root.addOnLayoutChangeListener { _, l, t, r, b, ol, ot, or, ob ->
if (r - l != or - ol || b - t != ob - ot) uiHandler.post { applyUnfoldedControls() }
}
val bridge = PadBridge(File(LinuxRuntime.sessionRoot(this), "dev/input"))
bridge.setSelectSteam(ControllerPrefs.read(this).selectSteam)
padBridge = bridge
SessionState.padBridge = bridge
// A paired Steam Controller is read over Bluetooth LE, out of lizard mode, as Steam Link reads it.
SteamControllerBle.listener = { uiHandler.post { updatePadMotion(); updateOnScreenControls() } }
steamController = SteamControllerBle(this).also { it.start() }
padMotion = com.droiddeck.launcher.input.PadMotion(this) {
@Suppress("DEPRECATION")
(if (Build.VERSION.SDK_INT >= 30) display else windowManager.defaultDisplay)?.rotation ?: android.view.Surface.ROTATION_0
@@ -568,6 +592,14 @@ class SessionActivity : ComponentActivity(), SurfaceHolder.Callback {
onKeyboardButton = { on -> updateControllerPrefs { ControllerPrefs.setKeyboardButton(this@SessionActivity, on) } },
onSteamMenu = if (SessionState.mode == SessionService.MODE_STEAM) ({ sendSteamGuide() }) else null,
onQam = if (SessionState.mode == SessionService.MODE_STEAM) ({ triggerSteamQam() }) else null,
// Only where the session set the pad up to be switched (SessionService: deckPad).
padType = if (SessionState.mode == SessionService.MODE_STEAM && SessionState.deckPad) padType else null,
onPadType = { id ->
SessionPrefs.setSteamController(this@SessionActivity, id)
com.droiddeck.launcher.session.SteamDeckPad.showPad(SessionPrefs.padType(id))
com.droiddeck.launcher.input.FakeInputWriter.setPadType(SessionPrefs.padType(id))
padType = id
},
backActionsInverted = backActionsInverted,
onBackActionsInverted = { inverted ->
SessionPrefs.setBackActionsInverted(this@SessionActivity, inverted)
@@ -620,6 +652,8 @@ class SessionActivity : ComponentActivity(), SurfaceHolder.Callback {
pipAutoEnter = on
pip.refresh(true)
},
unfoldedControls = if (com.droiddeck.launcher.session.SessionDisplay.foldable(this@SessionActivity)) unfoldedControls else null,
onUnfoldedControls = ::chooseUnfoldedControls,
onBackground = { drawerOpen = false; pip.background(); moveTaskToBack(true) },
onShareLogs = { drawerOpen = false; shareCurrentSessionLogs() },
onStop = { drawerOpen = false; SessionService.stop(this@SessionActivity); finish() },
@@ -800,6 +834,7 @@ class SessionActivity : ComponentActivity(), SurfaceHolder.Callback {
private fun readPrefs() {
pipAutoEnter = SessionPrefs.pipAutoEnter(this)
unfoldedControls = SessionPrefs.unfoldedControls(this)
hudOn = SessionPrefs.hudEnabled(this)
upscaler = SessionPrefs.upscaler(this)
upscaleSharpness = SessionPrefs.upscaleSharpness(this)
@@ -815,7 +850,7 @@ class SessionActivity : ComponentActivity(), SurfaceHolder.Callback {
WaylandCompositor.nativeSendSceneInput(3, it, 0)
}
}
if (nextTouchMode == SessionPrefs.TOUCH_OFF) cursorVisible = false
if (nextTouchMode == SessionPrefs.TOUCH_OFF || !usingTouchpad()) cursorVisible = false
}
touchMode = nextTouchMode
routedTouchMode = effectiveTouchMode()
@@ -825,6 +860,7 @@ class SessionActivity : ComponentActivity(), SurfaceHolder.Callback {
steamDownloadsInBackground = SessionPrefs.steamDownloadsInBackground(this)
oscMode = SessionPrefs.oscMode(this)
backActionsInverted = SessionPrefs.backActionsInverted(this)
padType = SessionPrefs.steamController(this)
}
// ── Compositor ──────────────────────────────────────────────────────────────────────────
@@ -1125,6 +1161,7 @@ class SessionActivity : ComponentActivity(), SurfaceHolder.Callback {
override fun onConfigurationChanged(newConfig: android.content.res.Configuration) {
super.onConfigurationChanged(newConfig)
applyFollowOrientation(newConfig)
applyUnfoldedControls()
}
override fun surfaceDestroyed(holder: SurfaceHolder) {
@@ -1352,7 +1389,7 @@ class SessionActivity : ComponentActivity(), SurfaceHolder.Callback {
return super.dispatchKeyEvent(event)
}
if (pipUi) return true
val fromController = PadBridge.isControllerKey(event)
val fromController = PadBridge.isControllerKeyEvent(event)
if (fromController && event.action == KeyEvent.ACTION_DOWN) {
if (drawerOpen && !drawerControllerActive) sessionOverlay.requestFocus()
drawerControllerActive = true
@@ -1408,8 +1445,7 @@ class SessionActivity : ComponentActivity(), SurfaceHolder.Callback {
if (event.keyCode != KeyEvent.KEYCODE_BACK && padBridge?.onKeyEvent(event) == true) return true
// A hardware keyboard, forwarded to the compositor's wl_keyboard. Back is left to the
// activity, which opens the drawer.
val fromPad = fromController
if (CompositorHost.isStarted && event.keyCode != KeyEvent.KEYCODE_BACK && !fromPad) {
if (CompositorHost.isStarted && event.keyCode != KeyEvent.KEYCODE_BACK && !fromController) {
val down = event.action == KeyEvent.ACTION_DOWN
if (down || event.action == KeyEvent.ACTION_UP) {
var evdev = EvdevKeys.fromKeyCode(event.keyCode)
@@ -1447,7 +1483,7 @@ class SessionActivity : ComponentActivity(), SurfaceHolder.Callback {
override fun dispatchGenericMotionEvent(event: MotionEvent): Boolean {
if (pipUi) return true
if (drawerOpen && event.device != null && PadBridge.isFromController(event.device)) {
if (drawerOpen && PadBridge.isControllerMotionEvent(event)) {
if (!drawerControllerActive) sessionOverlay.requestFocus()
drawerControllerActive = true
if (event.actionMasked == MotionEvent.ACTION_MOVE &&
@@ -1458,7 +1494,7 @@ class SessionActivity : ComponentActivity(), SurfaceHolder.Callback {
return true
}
if (drawerDirectionKey != KeyEvent.KEYCODE_UNKNOWN) releaseDrawerDirection()
if (pcKeyboardOpen && event.device != null && PadBridge.isFromController(event.device)) return super.dispatchGenericMotionEvent(event)
if (pcKeyboardOpen && PadBridge.isControllerMotionEvent(event)) return super.dispatchGenericMotionEvent(event)
if (padBridge?.onMotionEvent(event) == true) return true
if (event.isFromSource(android.view.InputDevice.SOURCE_MOUSE) && !drawerOpen && onMouse(event)) return true
return super.dispatchGenericMotionEvent(event)
@@ -1682,17 +1718,8 @@ class SessionActivity : ComponentActivity(), SurfaceHolder.Callback {
WaylandCompositor.nativeSendSceneInput(3, button, if (pressed) 1 else 0)
},
sendWheel = { steps -> WaylandCompositor.nativeSendSceneInput(4, steps, 0) },
onSteamMenu = {
if (SessionState.mode == SessionService.MODE_STEAM) {
padBridge?.applyTouch { st -> st.press(com.droiddeck.launcher.input.PadState.GUIDE, true) }
Handler(Looper.getMainLooper()).postDelayed({
padBridge?.applyTouch { st -> st.press(com.droiddeck.launcher.input.PadState.GUIDE, false) }
}, 90)
}
},
onQam = {
if (SessionState.mode == SessionService.MODE_STEAM) padBridge?.triggerQam()
},
onSteamMenu = ::tapSteamButton,
onQam = ::tapQam,
onClose = { selectSecondScreenMode(SecondScreenMode.NONE) },
)
secondScreenPresentation = presentation
@@ -1709,6 +1736,64 @@ class SessionActivity : ComponentActivity(), SurfaceHolder.Callback {
}
}
private fun tapSteamButton() {
if (SessionState.mode == SessionService.MODE_STEAM) {
padBridge?.applyTouch { st -> st.press(com.droiddeck.launcher.input.PadState.GUIDE, true) }
Handler(Looper.getMainLooper()).postDelayed({
padBridge?.applyTouch { st -> st.press(com.droiddeck.launcher.input.PadState.GUIDE, false) }
}, 90)
}
}
private fun tapQam() {
if (SessionState.mode == SessionService.MODE_STEAM) padBridge?.triggerQam()
}
private fun chooseUnfoldedControls(on: Boolean) {
SessionPrefs.setUnfoldedControls(this, on)
unfoldedControls = on
applyUnfoldedControls()
}
/**
* Unfolded controls: on a foldable's inner screen, the game above and the Steam Deck controller's
* trackpads and back grips below ([com.droiddeck.launcher.ui.DeckControlsPanel], as a second
* screen shows them, plus a whole gamepad). The on-screen pad is hidden meanwhile. The surface shrinks to the top part, so a Follow screen session resizes
* to it and any other is fitted into it. Folded onto the cover screen - a phone-sized panel -
* or in picture-in-picture, the game is full screen again.
*/
private fun applyUnfoldedControls() {
if (!::sessionRoot.isInitialized) return
val width = sessionRoot.width
val height = sessionRoot.height
val wanted = unfoldedControls && com.droiddeck.launcher.session.SessionDisplay.foldable(this) &&
SessionState.mode == SessionService.MODE_STEAM && SessionState.deckPad &&
!pipUi && !isInPictureInPictureMode && width > 0 && height > 0 &&
resources.configuration.smallestScreenWidthDp >= com.droiddeck.launcher.session.SessionDisplay.UNFOLDED_MIN_DP
val gameHeight = if (wanted) com.droiddeck.launcher.session.SessionDisplay.unfoldedGameHeight(width, height)
else FrameLayout.LayoutParams.MATCH_PARENT
val params = surfaceView.layoutParams as FrameLayout.LayoutParams
if (params.height != gameHeight) {
surfaceView.layoutParams = FrameLayout.LayoutParams(FrameLayout.LayoutParams.MATCH_PARENT, gameHeight, Gravity.TOP)
Log.i(TAG, if (wanted) "unfolded controls: game ${width}x$gameHeight, controls ${width}x${height - gameHeight}"
else "unfolded controls: game full screen")
}
var panel = unfoldedPanel
if (!wanted) {
if (panel != null) { sessionRoot.removeView(panel); unfoldedPanel = null; updateOnScreenControls() }
return
}
if (panel == null) {
panel = com.droiddeck.launcher.ui.DeckControlsPanel(this, onClose = { chooseUnfoldedControls(false) },
onSteamMenu = ::tapSteamButton, onQam = ::tapQam, pad = padBridge)
// Just above the surface: the on-screen pad, keyboard and drawer still draw over it.
sessionRoot.addView(panel, sessionRoot.indexOfChild(surfaceView) + 1)
unfoldedPanel = panel
updateOnScreenControls()
}
panel.layoutParams = FrameLayout.LayoutParams(FrameLayout.LayoutParams.MATCH_PARENT, height - gameHeight, Gravity.BOTTOM)
}
private fun moveSecondScreenPointer(x: Float, y: Float, width: Float, height: Float) {
if (width <= 0f || height <= 0f) return
val nx = (x / width).coerceIn(0f, 1f)
@@ -1936,7 +2021,9 @@ class SessionActivity : ComponentActivity(), SurfaceHolder.Callback {
?.let { FileUtils.readString(it)?.trim()?.lowercase() }
?: SessionPrefs.oscMode(this)
val controls = onScreenControls ?: return
if (pipUi) {
// In picture-in-picture, or with the unfolded screen's gamepad below the game, the on-screen
// pad would only cover the picture or that panel.
if (pipUi || unfoldedPanel != null) {
onScreenButtonsVisible = false
controls.releaseAll()
controls.visibility = View.GONE
@@ -1971,7 +2058,8 @@ class SessionActivity : ComponentActivity(), SurfaceHolder.Callback {
// Auto: the touch pad when there is no controller - except on the desktop, where the
// screen is a touchpad for the pointer and a pad over it would be in the way. A game
// started from the rail, or Steam, gets it; the drawer turns it on anywhere.
else -> !PadBridge.anyControllerConnected() && SessionState.mode != SessionService.MODE_DESKTOP
else -> !PadBridge.anyControllerConnected() && !SteamControllerBle.connected &&
SessionState.mode != SessionService.MODE_DESKTOP
}
onScreenButtonsVisible = show
if (steam != null) {
@@ -2056,6 +2144,7 @@ class SessionActivity : ComponentActivity(), SurfaceHolder.Callback {
if (::pip.isInitialized) pip.changed(inPip)
// The panel may have changed while the session was a thumbnail (folded or unfolded in PiP).
if (!inPip && surfaceW > 0) followWindow(surfaceW, surfaceH)
applyUnfoldedControls()
}
override fun startActivityForResult(intent: Intent, requestCode: Int, options: Bundle?) {
@@ -2112,6 +2201,7 @@ class SessionActivity : ComponentActivity(), SurfaceHolder.Callback {
override fun onResume() {
super.onResume()
requestedOrientation = SessionPrefs.orientation(this)
if (runtimeRemovalBlocked) return
com.droiddeck.launcher.ui.Motion.refresh(this)
refreshHomeApp()
@@ -2218,6 +2308,7 @@ class SessionActivity : ComponentActivity(), SurfaceHolder.Callback {
closeSecondScreen(reset = false)
if (::hud.isInitialized) hud.stop()
padBridge?.stop()
steamController?.stop()
if (SessionState.padBridge === padBridge) SessionState.padBridge = null
padMotion?.stop()
if (SessionState.deckPadListener === deckPadListener) SessionState.deckPadListener = null
@@ -14,6 +14,7 @@ import org.json.JSONObject
class AgentStartActivity : Activity() {
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
requestedOrientation = com.droiddeck.launcher.session.SessionPrefs.orientation(this)
val request = decodeRequest(intent.getStringExtra(EXTRA_REQUEST))
if (request == null) {
finish()
@@ -182,26 +182,30 @@ public class PulseAudioComponent extends SessionPart {
* client is never left without an output. Says so in the audio log either way.
*/
private void ensureClientSink(java.io.PrintWriter out) {
long deadline = System.currentTimeMillis() + 15000L;
String sinks = null;
while (System.currentTimeMillis() < deadline && pid > 1) {
if (socket().exists() && (sinks = pactl("list", "short", "sinks")) != null) break;
try { Thread.sleep(250L); } catch (InterruptedException e) { Thread.currentThread().interrupt(); return; }
try {
long deadline = System.currentTimeMillis() + 15000L;
String sinks = null;
while (System.currentTimeMillis() < deadline && pid > 1) {
if (socket().exists() && (sinks = pactl("list", "short", "sinks")) != null) break;
try { Thread.sleep(250L); } catch (InterruptedException e) { Thread.currentThread().interrupt(); return; }
}
String line;
if (sinks == null) {
line = "== client sink: the daemon did not answer in 15 s; no fallback attempted";
} else if (sinks.contains("\tDirectAudio\t") || sinks.startsWith("DirectAudio\t") || sinks.contains("\tDirectAudio\n") || sinks.matches("(?s).*\\bDirectAudio\\b.*")) {
line = "== client sink: DirectAudio (module-directaudio-native-sink)";
} else {
boolean loaded = pactl("load-module", "module-aaudio-sink", "sink_name=AAudioSink", "performance_mode=1", "adaptive=1", "volume=1.0") != null
&& pactl("set-default-sink", "AAudioSink") != null;
line = loaded ? "== client sink: DirectAudio did not come up; FALLBACK to the plain AAudio sink (AAudioSink)"
: "== client sink: DirectAudio did not come up and the AAudio fallback failed too - no client sound";
Log.w(TAG, line);
}
Log.i(TAG, line);
if (out != null) synchronized (out) { out.println(line); out.flush(); }
} catch (Throwable t) {
Log.w(TAG, "ensureClientSink failed", t);
}
String line;
if (sinks == null) {
line = "== client sink: the daemon did not answer in 15 s; no fallback attempted";
} else if (sinks.contains("\tDirectAudio\t") || sinks.startsWith("DirectAudio\t") || sinks.contains("\tDirectAudio\n") || sinks.matches("(?s).*\\bDirectAudio\\b.*")) {
line = "== client sink: DirectAudio (module-directaudio-native-sink)";
} else {
boolean loaded = pactl("load-module", "module-aaudio-sink", "sink_name=AAudioSink", "performance_mode=1", "adaptive=1", "volume=1.0") != null
&& pactl("set-default-sink", "AAudioSink") != null;
line = loaded ? "== client sink: DirectAudio did not come up; FALLBACK to the plain AAudio sink (AAudioSink)"
: "== client sink: DirectAudio did not come up and the AAudio fallback failed too - no client sound";
Log.w(TAG, line);
}
Log.i(TAG, line);
if (out != null) synchronized (out) { out.println(line); out.flush(); }
}
/** Runs pactl against this daemon; its stdout, or null when it failed or timed out. */
@@ -223,8 +227,14 @@ public class PulseAudioComponent extends SessionPart {
builder.environment().put("PULSE_SERVER", "unix:" + socket().getAbsolutePath());
process = builder.start();
String output;
try (InputStream in = process.getInputStream()) {
output = new String(in.readAllBytes(), java.nio.charset.StandardCharsets.UTF_8);
try (InputStream in = process.getInputStream();
java.io.ByteArrayOutputStream outStream = new java.io.ByteArrayOutputStream()) {
byte[] buf = new byte[4096];
int n;
while ((n = in.read(buf)) != -1) {
outStream.write(buf, 0, n);
}
output = outStream.toString(java.nio.charset.StandardCharsets.UTF_8.name());
}
if (!process.waitFor(5, TimeUnit.SECONDS)) {
process.destroyForcibly();
@@ -234,7 +244,7 @@ public class PulseAudioComponent extends SessionPart {
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
return null;
} catch (Exception e) {
} catch (Throwable t) {
return null;
} finally {
if (process != null && process.isAlive()) process.destroyForcibly();
@@ -152,7 +152,6 @@ object DeviceReport {
k("Zink lazy descriptors", SessionPrefs.zinkLazy(context))
k("Threaded GL (glthread)", SessionPrefs.glThread(context))
k("No GL error checks", SessionPrefs.noGlError(context))
k("Steam Deck mode", SessionPrefs.steamDeckMode(context))
k("Steam controller", SessionPrefs.steamController(context))
k("Upscaler", (SessionPrefs.upscalerChoices.firstOrNull { it.first == SessionPrefs.upscaler(context) }?.second ?: "Off") +
" (sharpness ${SessionPrefs.upscaleSharpness(context)}%)")
@@ -31,7 +31,8 @@ object GameEnvironment {
"VKD3D_SHADER_MODEL" to "6_9",
// .NET 7 and later reserve 256 GB of address space for the GC heap at startup, more than an
// Android process has free, and the runtime fails to start. 32 GB is plenty for a game.
"DOTNET_GCRegionRange" to "0x800000000",
// Older NativeAOT runtimes require bare hex digits. CoreCLR accepts this form too.
"DOTNET_GCRegionRange" to "800000000",
// .NET's W^X writes JIT code through a second mapping that FEX does not watch, so FEX keeps
// running the stale translation and the game hangs at startup.
"DOTNET_EnableWriteXorExecute" to "0",
@@ -93,9 +93,12 @@ object LogRedactor {
"(?i)(?<![A-Za-z0-9_])(login)(\\s*=\\s*|[\"']\\s*[=:]\\s*|[\"']\\s+)" + ACCOUNT_VALUE
)
/** A learned pattern and the text (lowercase) a line must hold for it to match; null when none can be said. */
private class Learned(val regex: Regex, val needs: String?)
/** The device's Steam accounts and persona names as patterns (see [learnAccounts]). */
@Volatile
private var accounts: List<Regex> = emptyList()
private var accounts: List<Learned> = emptyList()
/**
* Learns every Steam account on the device from the client's loginusers.vdf - its AccountName
@@ -103,12 +106,13 @@ object LogRedactor {
* Names under three characters are skipped: blanking every "a" would ruin a log.
*/
fun learnAccounts(loginUsers: java.io.File) {
val found = ArrayList<Regex>()
val found = ArrayList<Learned>()
try {
if (loginUsers.isFile) {
val kv = Regex("\"(AccountName|PersonaName)\"\\s+\"([^\"]*)\"")
kv.findAll(loginUsers.readText()).map { it.groupValues[2].trim() }.filter { it.length >= 3 }.distinct().forEach { name ->
found += Regex("(?i)(?<![A-Za-z0-9_])" + Regex.escape(name) + "(?![A-Za-z0-9_])")
val needs = name.lowercase().takeIf { name.all { it.code < 0x80 } }
found += Learned(Regex("(?i)(?<![A-Za-z0-9_])" + Regex.escape(name) + "(?![A-Za-z0-9_])"), needs)
}
}
} catch (e: Exception) {
@@ -125,7 +129,7 @@ object LogRedactor {
/** This device's public addresses as patterns (see [learnOwnAddresses]); empty until learned. */
@Volatile
private var own: List<Regex> = emptyList()
private var own: List<Learned> = emptyList()
/**
* Learns the device's addresses from the link file the app writes for the session
@@ -133,7 +137,7 @@ object LogRedactor {
* private, link-local and loopback addresses identify nobody and are left out.
*/
fun learnOwnAddresses(linkFile: java.io.File) {
val found = ArrayList<Regex>()
val found = ArrayList<Learned>()
try {
if (linkFile.isFile) linkFile.forEachLine { line ->
val addr = line.trim().takeIf { it.startsWith("addr ") }?.split(Regex("\\s+"))?.getOrNull(1) ?: return@forEachLine
@@ -142,9 +146,12 @@ object LogRedactor {
val groups = expand6(addr)?.take(4) ?: return@forEachLine
// Any spelling of an address in this /64: leading zeros dropped, :: anywhere after.
val prefix = groups.joinToString(":") { g -> "0{0,3}" + Regex.escape(g.trimStart('0').ifEmpty { "0" }) }
found += Regex("(?i)(?<![0-9A-Fa-f:])$prefix(?::[0-9A-Fa-f]{0,4}){1,4}(?:%[A-Za-z0-9_.]+)?")
found += Learned(
Regex("(?i)(?<![0-9A-Fa-f:])$prefix(?::[0-9A-Fa-f]{0,4}){1,4}(?:%[A-Za-z0-9_.]+)?"),
groups[0].trimStart('0').ifEmpty { "0" },
)
}
"public IPv4" -> found += Regex("(?<![0-9.])" + Regex.escape(addr) + "(?![0-9.])")
"public IPv4" -> found += Learned(Regex("(?<![0-9.])" + Regex.escape(addr) + "(?![0-9.])"), addr)
}
}
} catch (e: Exception) {
@@ -263,41 +270,87 @@ object LogRedactor {
return "$q<redacted:account>$q"
}
/**
* A line on its way through [redact]. Each rule runs only on a line holding what it cannot match
* without - a word, enough separators, a long enough run: most of a Steam log is lines no rule
* touches, and a session's logs went through every rule at about a megabyte a second. A line with anything but ASCII in it goes through
* every case-insensitive rule, since Android's regex folds case more widely than lowercase().
*/
private class Line(var text: String) {
private val unfolded = text.any { it.code >= 0x80 }
private var lower: String? = null
fun has(vararg words: String): Boolean {
if (unfolded) return true
val folded = lower ?: text.lowercase().also { lower = it }
return words.any { folded.contains(it) }
}
fun set(next: String) {
if (next != text) {
text = next
lower = null
}
}
fun holds(c: Char, times: Int): Boolean {
var n = 0
for (ch in text) if (ch == c && ++n >= times) return true
return false
}
inline fun hasRun(length: Int, inRun: (Char) -> Boolean): Boolean {
var n = 0
for (ch in text) if (!inRun(ch)) n = 0 else if (++n >= length) return true
return false
}
}
private fun isHex(c: Char) = c in '0'..'9' || c in 'a'..'f' || c in 'A'..'F'
/** [line] with every credential shape replaced. Null- and exception-safe by construction. */
fun redact(line: String): String {
if (line.isEmpty()) return line
return try {
var out = line
out = MAC_FIELD.replace(out) { "${it.groupValues[1]}<redacted:mac>" }
out = MAC.replace(out, "<redacted:mac>")
out = DEVICE_IDENTIFIER.replace(out) {
val out = Line(line)
if (out.has("mac")) out.set(MAC_FIELD.replace(out.text) { "${it.groupValues[1]}<redacted:mac>" })
if (out.holds(':', 5) || out.holds('-', 5)) out.set(MAC.replace(out.text, "<redacted:mac>"))
if (out.has("serial", "android")) out.set(DEVICE_IDENTIFIER.replace(out.text) {
val value = it.groupValues[2]
val quote = value.first().takeIf { char -> char == '\"' || char == '\'' }?.toString().orEmpty()
"${it.groupValues[1]}${quote}<redacted:serial>${quote}"
})
if (out.holds('-', 4)) out.set(GUID.replace(out.text, "<redacted:guid>"))
if (out.has("jwt")) out.set(JWT_LABELLED.replace(out.text) { "${it.groupValues[1]}<redacted:jwt>" })
if ("ey" in out.text) out.set(JWT_BASE64.replace(out.text, "<redacted:jwt>"))
if (out.has("token", "auth", "ticket", "sessionid", "steamloginsecure", "api", "machine", "pass", "pwd", "secret")) {
out.set(SECRET_KV.replace(out.text) { "${it.groupValues[1]}${it.groupValues[2]}<redacted:token>" })
}
out = GUID.replace(out, "<redacted:guid>")
out = JWT_LABELLED.replace(out) { "${it.groupValues[1]}<redacted:jwt>" }
out = JWT_BASE64.replace(out, "<redacted:jwt>")
out = SECRET_KV.replace(out) { "${it.groupValues[1]}${it.groupValues[2]}<redacted:token>" }
out = GUARD_CODE.replace(out) { "${it.groupValues[1]}<redacted:code>" }
out = WEBAPI_KEY.replace(out, "<redacted:key>")
if (out.has("guard", "factor", "2fa")) out.set(GUARD_CODE.replace(out.text) { "${it.groupValues[1]}<redacted:code>" })
if (out.hasRun(32, ::isHex)) out.set(WEBAPI_KEY.replace(out.text, "<redacted:key>"))
// Mask, not delete: the last four digits let a reader correlate lines to one account.
out = STEAMID64.replace(out) { "${it.groupValues[1]}********${it.groupValues[3]}" }
out = STEAMID3.replace(out) { m ->
if ("76561" in out.text) out.set(STEAMID64.replace(out.text) { "${it.groupValues[1]}********${it.groupValues[3]}" })
if ("[U:1:" in out.text) out.set(STEAMID3.replace(out.text) { m ->
val id = m.groupValues[1]
"[U:1:${if (id.length > 4) "*".repeat(id.length - 4) + id.takeLast(4) else id}]"
})
if (out.has("external")) out.set(EXTERNAL_ADDR.replace(out.text) { "${it.groupValues[1]}<redacted:ip>" })
for (r in own) if (r.needs == null || out.has(r.needs)) out.set(r.regex.replace(out.text, "<redacted:ip>"))
if (out.has("onloginstatechange")) out.set(LOGIN_STATE.replace(out.text) { "${it.groupValues[1]}<redacted:account>" })
if ("OnLoginUsersChanged " in out.text) out.set(LOGIN_USERS.replace(out.text) { "${it.groupValues[1]}<redacted:account>" })
if (out.has("account", "user")) {
out.set(ACCOUNT_KV.replace(out.text) { "${it.groupValues[1]}${it.groupValues[2]}${quoted(it.groupValues[3])}" })
}
out = EXTERNAL_ADDR.replace(out) { "${it.groupValues[1]}<redacted:ip>" }
for (r in own) out = r.replace(out, "<redacted:ip>")
out = LOGIN_STATE.replace(out) { "${it.groupValues[1]}<redacted:account>" }
out = LOGIN_USERS.replace(out) { "${it.groupValues[1]}<redacted:account>" }
out = ACCOUNT_KV.replace(out) { "${it.groupValues[1]}${it.groupValues[2]}${quoted(it.groupValues[3])}" }
out = LOGIN_KV.replace(out) { "${it.groupValues[1]}${it.groupValues[2]}${quoted(it.groupValues[3])}" }
for (r in accounts) out = r.replace(out, "<redacted:account>")
out = EMAIL.replace(out, "<redacted:email>")
out = RESIDUAL.replace(out) { "${it.groupValues[1]}=<redacted:token>" }
out = LONG_TOKEN.replace(out, "<redacted:token>")
out
if (out.has("login")) out.set(LOGIN_KV.replace(out.text) { "${it.groupValues[1]}${it.groupValues[2]}${quoted(it.groupValues[3])}" })
for (r in accounts) if (r.needs == null || out.has(r.needs)) out.set(r.regex.replace(out.text, "<redacted:account>"))
if ('@' in out.text) out.set(EMAIL.replace(out.text, "<redacted:email>"))
if (out.has("jwt", "token", "ticket", "sessionid", "steamloginsecure", "machineauth")) {
out.set(RESIDUAL.replace(out.text) { "${it.groupValues[1]}=<redacted:token>" })
}
if (out.hasRun(88) { it in 'a'..'z' || it in 'A'..'Z' || it in '0'..'9' || it == '_' || it == '-' }) {
out.set(LONG_TOKEN.replace(out.text, "<redacted:token>"))
}
out.text
} catch (t: Throwable) {
// A log line is never worth crashing a session over, but an unscrubbed one must not
// reach the file either.
@@ -22,6 +22,7 @@ class FileManagerActivity : ComponentActivity() {
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
requestedOrientation = com.droiddeck.launcher.session.SessionPrefs.orientation(this)
if (checkSelfPermission(Manifest.permission.READ_EXTERNAL_STORAGE) != PackageManager.PERMISSION_GRANTED) {
requestPermissions(arrayOf(Manifest.permission.READ_EXTERNAL_STORAGE), 1)
}
@@ -35,6 +35,7 @@ class FilePickerActivity : ComponentActivity() {
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
requestedOrientation = com.droiddeck.launcher.session.SessionPrefs.orientation(this)
// targetSdk 28: the old storage permission is exactly what lets us list the card.
if (checkSelfPermission(Manifest.permission.READ_EXTERNAL_STORAGE) != PackageManager.PERMISSION_GRANTED) {
requestPermissions(arrayOf(Manifest.permission.READ_EXTERNAL_STORAGE), 1)
@@ -137,8 +137,7 @@ object AddedGameArt {
val out = ArrayList<Option>()
folderImages(game.folder).forEach { out.add(Option(FOLDER, it.path, it.path)) }
val cache = cacheDir(context, game)
val steamId = runCatching { GameIdentifier.identify(game.exe).appId?.toString() }.getOrNull()
?: File(cache, "steam-appid").takeIf { it.isFile }?.readText()?.trim()?.takeIf { it.isNotEmpty() && it != "none" }
val steamId = SteamMatch.resolve(context, game.folder.path, game.exe, game.name, legacy = File(cache, "steam-appid")).appId?.toString()
val remote = when (slot) { Slot.COVER -> "library_600x900.jpg"; Slot.BACKGROUND -> "library_hero.jpg"; Slot.LOGO -> "logo.png"; Slot.ICON -> null }
if (steamId != null && remote != null) {
val url = "$CDN$steamId/$remote".takeIf { exists(it) } ?: steamAssetUrl(steamId, remote)
@@ -222,10 +221,11 @@ object AddedGameArt {
val cache = cacheDir(context, game)
if (File(cache, "p.jpg").isFile) continue
cache.mkdirs()
val appId = runCatching { GameIdentifier.identify(game.exe).appId }.getOrNull()
val id = steamAppId(game.name, appId, File(cache, "steam-appid")) { name ->
runCatching { search(name) }.onFailure { Log.w(TAG, "${game.name}: store search failed (${it.message})") }.getOrNull()
val match = steamMatch(context, game) { name ->
runCatching { search(name) }.onFailure { Log.w(TAG, "${game.name}: store search failed (${it.message})") }.getOrNull()?.toIntOrNull()
}
val appId = match.appId?.takeIf { match.certainty == SteamMatch.Certainty.FILES }
val id = match.appId?.toString()
if (id != null && download(id, cache)) changed = true
if (File(cache, "p.jpg").isFile || sgdbKey.isEmpty()) continue
val gridId = remembered(File(cache, "sgdb-id")) {
@@ -237,16 +237,13 @@ object AddedGameArt {
}
/**
* The game's Steam appid for its art: the one its files name ([appId]) as it is, else the
* remembered or freshly searched one ([search] on [name]); null for none.
* The game's Steam app, the one record its Windows components use too ([SteamMatch]): what its
* files name, else a store search on its title ([search]) whose result has the same name.
*/
internal fun steamAppId(name: String, appId: Int?, lookup: File, search: (String) -> String?): String? {
if (appId != null) {
lookup.writeText(appId.toString())
Log.i(TAG, "$name: Steam app $appId (from its files)")
return appId.toString()
}
return remembered(lookup) { search(name)?.also { Log.i(TAG, "$name: Steam app $it") } }
internal fun steamMatch(context: Context, game: AddedGames.Game, search: (String) -> Int?): SteamMatch.Match {
val match = SteamMatch.resolve(context, game.folder.path, game.exe, game.name, legacy = File(cacheDir(context, game), "steam-appid"), search = search)
match.appId?.let { Log.i(TAG, "${game.name}: Steam app $it (${match.certainty.name.lowercase()})") }
return match
}
/** What [marker] remembers (an id, or "none" for a week), else [find]'s answer written to it. */
@@ -43,22 +43,17 @@ object AddedGames {
fun folderName(): String = folder.name
}
/** One Games folder and where the session sees it. */
/** A Games folder an earlier build had, and where the session sees it. */
class Root(val host: File, val guest: String)
/**
* The chosen folders with their guest paths: /root/Games/<folder name>, or with a short hash of
* the host path when two chosen folders share a name (so a folder's guest path, and with it
* every shortcut's appid, does not change when another folder is added or removed).
* The Games folders earlier builds had, bound into the session at the guest paths they had
* then (/root/Games/<name>), so the games in them (entries now, [GamesFolderMigration]) keep
* their paths and shortcuts. Nothing adds one any more.
*/
fun roots(context: Context): List<Root> {
val hosts = SessionPrefs.addedGamesDirs(context).map { File(it) }
val names = hosts.groupingBy { it.name.lowercase() }.eachCount()
return hosts.map { host ->
val name = host.name.ifEmpty { "games" }
val guestName = if ((names[name.lowercase()] ?: 0) > 1) name + "-" + "%08x".format(CRC32().apply { update(host.path.toByteArray()) }.value).take(4) else name
Root(host, "$GUEST_DIR/$guestName")
}
GamesFolderMigration.run(context)
return SessionPrefs.gameFolderBinds(context).map { (host, guest) -> Root(File(host), guest) }
}
private val SKIP = Regex(
@@ -98,18 +93,26 @@ object AddedGames {
return null
}
/** The .exe files a game folder offers, best first ([GameExePicker]); also what its exe box lists, in that order. */
/** The .exe files a game folder offers, best first ([GameExePicker]); declared Store exes lead. */
fun candidates(folder: File): List<File> = rank(folder).exes
/** [GameExePicker]'s ranking of [folder], without the names this list never offers. */
/** [GameExePicker]'s ranking, with a Store-declared executable taking precedence when present. */
fun rank(folder: File): GameExePicker.Ranking {
val key = "${folder.path}|${folder.lastModified()}"
val now = System.currentTimeMillis()
ranked[key]?.takeIf { now - it.first < RANK_FRESH_MS }?.let { return it.second }
return GameExePicker.rank(folder) { !SKIP.matches(it.name) }.also {
if (ranked.size > 256) ranked.clear()
ranked[key] = now to it
}
val guessed = GameExePicker.rank(folder) { !SKIP.matches(it.name) }
val declared = declaredExes(folder)
val preferred = declared.map { it.path }.toSet()
val result = if (declared.isEmpty()) guessed else GameExePicker.Ranking(
declared + guessed.exes.filter { it.path !in preferred },
List(declared.size) { Int.MAX_VALUE } + guessed.exes.zip(guessed.scores)
.filter { it.first.path !in preferred }.map { it.second },
uncertain = false,
)
if (ranked.size > 256) ranked.clear()
ranked[key] = now to result
return result
}
/** A folder's ranking for a minute, so one add (rank, then the game built from it) walks it once. */
@@ -160,10 +163,29 @@ object AddedGames {
else -> peName
}
}
/** The executables MicrosoftGame.config or appxmanifest.xml declares that exist inside [folder]. */
internal fun declaredExes(folder: File): List<File> {
val names = DECLARED.flatMap { (file, pattern) ->
runCatching { pattern.findAll(File(folder, file).readText()).map { it.groupValues[1] }.toList() }.getOrDefault(emptyList())
}
val root = runCatching { folder.canonicalPath + File.separator }.getOrNull() ?: return emptyList()
return names.asSequence()
.map { File(folder, it.trim().replace('\\', '/').trimStart('/')) }
.filter { it.name.endsWith(".exe", ignoreCase = true) && !SKIP.matches(it.name) && it.isFile }
.filter { runCatching { it.canonicalPath.startsWith(root) }.getOrDefault(false) }
.distinctBy { it.path }
.toList()
}
private val DECLARED = listOf(
"MicrosoftGame.config" to Regex("<Executable\\b[^>]*?\\bName\\s*=\\s*\"([^\"]+)\"", RegexOption.IGNORE_CASE),
"appxmanifest.xml" to Regex("<Application\\b[^>]*?\\bExecutable\\s*=\\s*\"([^\"]+)\"", RegexOption.IGNORE_CASE),
)
fun scan(context: Context): List<Game> {
val out = ArrayList<Game>()
val folders = roots(context).map { it.host } + listOfNotNull(
GamesFolderMigration.run(context)
val folders = listOfNotNull(
GameStorage.effective(context)?.let { File(it.path) },
GameStorage.effective(context)?.let { File(it.path, "steamapps/common") },
)
@@ -237,17 +259,19 @@ object AddedGames {
private fun inside(path: String, folder: String) = path.startsWith(folder.trimEnd('/') + "/")
/**
* The game folder [exe] belongs to, without a scan: an entry from the Games tab, a removed
* game, a store install, or the first-level folder under a Games folder (or the Steam library)
* that holds it. Null when it is in none.
* The game folder [exe] belongs to, without a scan: an entry from the Games tab (Games folders
* of earlier builds included), a removed game, a store install, or the first-level folder under
* the Steam library that holds it. Null when it is in none.
*/
private fun ownerFolder(context: Context, exe: File): File? {
GamesFolderMigration.run(context)
val path = canonical(exe)
fun holds(f: File) = inside(path, canonical(f))
AddedExes.list(context).map { File(it.folder) }.firstOrNull(::holds)?.let { return it }
SessionPrefs.removedAddedGames(context).map { File(it) }.firstOrNull(::holds)?.let { return it }
StoreInstallRoot.gameFolders(context).firstOrNull(::holds)?.let { return it }
val dirs = roots(context).map { it.host } + listOfNotNull(
// Games folders of earlier builds are entries now, found above.
val dirs = listOfNotNull(
GameStorage.effective(context)?.let { File(it.path, "steamapps/common") },
GameStorage.effective(context)?.let { File(it.path) },
)
@@ -321,12 +345,13 @@ object AddedGames {
/**
* The game folders already listed, without building any game: the first-level folders of the
* Games folders and the Steam library (as [scan] walks them), the Games tab's entries and the
* store installs. Canonical paths; removed games are left out.
* Steam library (as [scan] walks them), the Games tab's entries (Games folders of earlier builds
* included) and the store installs. Canonical paths; removed games are left out.
*/
private fun listedFolders(context: Context, removed: Set<String>): List<String> {
val out = ArrayList<String>()
val dirs = roots(context).map { it.host } + listOfNotNull(
GamesFolderMigration.run(context)
val dirs = listOfNotNull(
GameStorage.effective(context)?.let { File(it.path) },
GameStorage.effective(context)?.let { File(it.path, "steamapps/common") },
)
@@ -0,0 +1,419 @@
package com.droiddeck.launcher.frontend
import android.content.Context
import android.net.ConnectivityManager
import android.os.Build
import android.os.PowerManager
import android.util.AtomicFile
import android.util.Log
import androidx.annotation.VisibleForTesting
import com.droiddeck.launcher.session.SessionState
import com.droiddeck.launcher.session.WinComponents
import com.droiddeck.launcher.stores.StoreGameSidecar
import kotlinx.coroutines.flow.MutableStateFlow
import org.json.JSONArray
import org.json.JSONObject
import java.io.File
import java.util.concurrent.PriorityBlockingQueue
/**
* Turns on, per game, the Windows components its source's list names ([WinCompSources]). Only the
* findings marked auto are turned on, only the ones that install here, and never one the user
* switched off: the user's switches are kept apart ([WinComponents.Selection.user]) and win.
*
* All the work is done by one background thread (low priority, so are the runtime helpers it
* starts), one job at a time from one queue: working out a game's list (kept, so a page opens on
* it) and downloading a component (each once, whichever games want it). The queue waits while a
* session runs; jobs nobody asked for (the start-up sweep, a game being launched) also wait on
* battery saver, a warm device or a metered network. Nothing here runs on the caller's thread.
*/
object AutoComponents {
private const val TAG = "AutoComponents"
/** The key a game's picks are kept under: the appid its prefix is named after. */
fun appKey(game: Library.SteamGame): String? =
if (game.library == Library.ADDED) Integer.toUnsignedString(game.appId) else game.appId.takeIf { it > 0 }?.toString()
/**
* What turns on by itself for [findings], given [catalog] and what is [installed]: the catalog
* name each auto finding installs as, with its reason. A component that needs a Windows
* installer, or is not in the catalog, stays out (the editor lists it as a suggestion).
*/
fun autoPicks(findings: List<WinCompSources.Finding>, catalog: Map<String, WinComponents.Component>): Map<String, WinComponents.AutoReason> {
val out = LinkedHashMap<String, WinComponents.AutoReason>()
for (f in findings) {
if (!f.auto || f.component == null) continue
val id = WinComponents.installable(f.component, catalog)
val c = catalog[id] ?: continue
if (WinComponents.support(c, catalog) != WinComponents.Support.READY) continue
if (id !in out) out[id] = WinComponents.AutoReason(f.origin.name, listOf(f.original) + f.args)
}
return out
}
// ---- seams for tests ----
@VisibleForTesting internal var catalogOf: (Context, Boolean) -> List<WinComponents.Component>? =
{ c, network -> if (network) WinComponents.fetch(c) else WinComponents.cached(c) }
@VisibleForTesting internal var installedOf: (Context) -> Set<String> = { WinComponents.installedIds(it).toSet() }
@VisibleForTesting internal var installer: (Context, WinComponents.Component, Map<String, WinComponents.Component>, (WinComponents.Progress) -> Unit) -> String? =
{ c, comp, all, progress -> WinComponents.install(c, comp, all, progress) }
@VisibleForTesting internal var sessionRunning: () -> Boolean = { SessionState.running }
/** Battery saver, a warm device or a metered network: no work nobody asked for. */
@VisibleForTesting internal var unfavourable: (Context) -> Boolean = { c -> constrained(c) }
/** A usable network: a download waits for one. */
@VisibleForTesting internal var online: (Context) -> Boolean = { c -> isOnline(c) }
/** Tests run the queue on the caller's thread ([drain]) instead of the worker. */
@VisibleForTesting @Volatile internal var runNow = false
/** Bumped whenever a game's list or automatic components change, so an open page reads them again. */
val changes = MutableStateFlow(0)
/** The components downloading for an automatic pick right now, with their progress. */
val downloading = MutableStateFlow<Map<String, WinComponents.Progress>>(emptyMap())
/** Games (by [appKey]) whose list is queued or being worked out: a page waits on it. */
val pendingGames = MutableStateFlow<Set<String>>(emptySet())
/** Components queued for an automatic pick, not yet downloaded. */
val pendingComponents = MutableStateFlow<Set<String>>(emptySet())
private fun mark(job: Job, on: Boolean) {
if (job.pics != null) return
val flow = if (job.component != null) pendingComponents else pendingGames
val id = job.component ?: job.game?.let { appKey(it) } ?: return
flow.value = if (on) flow.value + id else flow.value - id
}
/** One piece of work: a game's list ([game]) or one component's download ([component]). [asked]: the user is waiting on it. */
internal class Job(
val game: Library.SteamGame?, val component: String?, val asked: Boolean, val seq: Long, val pics: List<Int>? = null,
/** For a game Steam installed during the running session: may run in it, unless a game is running. */
val inSession: Boolean = false,
) {
val key get() = when {
pics != null -> "pics"
component != null -> "c:$component"
else -> "g:" + (game?.let { appKey(it) } ?: "")
}
/** Needs the network: a download, or Steam asked anonymously. */
val online get() = component != null || pics != null
}
private val seq = java.util.concurrent.atomic.AtomicLong()
/** What the user asked for first, then in the order queued. */
private val jobs = PriorityBlockingQueue<Job>(16, compareByDescending<Job> { it.asked }.thenBy { it.seq })
@Volatile private var draining = false
private val queuedKeys = java.util.Collections.synchronizedSet(HashSet<String>())
/** The games waiting on a component's download, to turn it on for when it lands. */
private val waiting = HashMap<String, MutableList<Library.SteamGame>>()
@Volatile private var worker: Thread? = null
private const val PAUSE_MS = 15_000L
/**
* [game]'s list worked out again on the worker, and its components queued: [asked] when the
* user is waiting (its page is open, or they installed or added it).
*/
fun queueGame(context: Context, game: Library.SteamGame, asked: Boolean) {
if (appKey(game) == null) return
enqueue(context, Job(game, null, asked, seq.incrementAndGet()))
}
private fun queueDownload(context: Context, id: String, asked: Boolean, inSession: Boolean = false) =
enqueue(context, Job(null, id, asked, seq.incrementAndGet(), inSession = inSession))
/** Adds [job] unless the same one is queued (a game or a component, once each). */
private fun enqueue(context: Context, job: Job) {
if (!queuedKeys.add(job.key)) {
// Asked for now: ahead of what nobody asked for, so the open page does not wait behind the sweep.
if (job.asked && jobs.removeIf { it.key == job.key && !it.asked }) jobs.add(job)
if (runNow && !draining) drain(context)
return
}
if (job.component != null) Log.i(TAG, "wincomp: queued ${job.component}")
mark(job, true)
jobs.add(job)
if (runNow) { if (!draining) drain(context) } else ensureWorker(context.applicationContext)
}
/** Whether [job] has to wait: always while a session runs; one nobody asked for also when it is unfavourable. */
internal fun mustWait(context: Context, job: Job): Boolean =
if (job.inSession && sessionRunning()) gameRunning() || (job.online && !online(context))
else sessionRunning() || (job.online && !online(context)) || (!job.asked && unfavourable(context))
/** A game is running in the session (Steam's reaper, or Proton waiting on its game). */
@VisibleForTesting internal var gameRunning: () -> Boolean = { gameProcessRunning() }
private fun gameProcessRunning(): Boolean = File("/proc").listFiles()?.any { p ->
p.name.all(Char::isDigit) && runCatching { String(File(p, "cmdline").readBytes()).replace('\u0000', ' ') }.getOrNull()
?.let { it.contains("SteamLaunch AppId=") || it.contains("proton waitforexitandrun") } == true
} == true
/**
* A game Steam installed during the running session: its list worked out and its components
* readied in the session (one job, low priority), so a launch right after finds them; it
* waits while a game runs. All other work stays paused until the session ends.
*/
fun queueInSession(context: Context, game: Library.SteamGame) {
if (appKey(game) == null) return
enqueue(context, Job(game, null, true, seq.incrementAndGet(), inSession = true))
}
/** Runs the queued jobs on this thread until the queue is empty or the next one has to wait. Tests. */
@VisibleForTesting
internal fun drain(context: Context) {
draining = true
try {
while (true) {
val job = next(context) ?: return
queuedKeys.remove(job.key)
run(context, job)
}
} finally {
draining = false
}
}
/** The first job in order that may run now, taken off the queue; null when none may. */
private fun next(context: Context): Job? = synchronized(jobs) {
val job = jobs.sortedWith(order).firstOrNull { !mustWait(context, it) } ?: return null
jobs.remove(job)
job
}
private val order = compareByDescending<Job> { it.asked }.thenBy { it.seq }
@VisibleForTesting internal fun queued(): List<String> = jobs.sortedWith(compareByDescending<Job> { it.asked }.thenBy { it.seq }).map { it.key }
@Synchronized
private fun ensureWorker(app: Context) {
if (worker?.isAlive == true) return
worker = Thread({
android.os.Process.setThreadPriority(android.os.Process.THREAD_PRIORITY_BACKGROUND)
while (true) {
// Something queued, then the first that may run (one for a game installed in the
// session goes ahead of paused work); else a look again in a while.
try { jobs.put(jobs.take()) } catch (e: InterruptedException) { return@Thread }
val job = next(app)
if (job == null) {
try { Thread.sleep(PAUSE_MS) } catch (e: InterruptedException) { return@Thread }
continue
}
queuedKeys.remove(job.key)
WinComponents.background { run(app, job) }
}
}, "auto-components").apply { isDaemon = true; start() }
}
private fun run(context: Context, job: Job) {
try {
when {
job.pics != null -> SteamAppInfo.lookup(context, job.pics)
job.component != null -> download(context, job.component, job.asked, job.inSession)
else -> job.game?.let { apply(context, it, job.asked, job.inSession) }
}
} catch (e: Exception) {
Log.w(TAG, "wincomp: ${job.key}: ${e.javaClass.simpleName}: ${e.message}")
} finally {
mark(job, false)
changes.value = changes.value + 1
}
}
/**
* [game]'s list (worked out again only when what it is read from changed), its automatic
* components that are downloaded turned on, and the rest queued for download.
*/
private fun apply(context: Context, game: Library.SteamGame, asked: Boolean, inSession: Boolean = false) {
val key = appKey(game) ?: return
val folder = game.gameFiles
if (game.library == Library.ADDED && game.source == Library.ADDED && folder != null && SteamMatch.get(context, folder.path) == null) {
AddedGames.single(context, folder.path)?.let { SteamMatch.resolve(context, folder.path, it.exe, it.name) }
}
// A GOG or Epic game installed before its sidecar kept the store's list: read it once from the store.
val store = com.droiddeck.launcher.stores.Store.byId(game.source)
if (folder != null && (store == com.droiddeck.launcher.stores.Store.GOG || store == com.droiddeck.launcher.stores.Store.EPIC)) StoreListBackfill.fill(context, folder)
// Its Steam app, when the client never saw it: Steam asked anonymously now if the user is
// waiting on this game (a pass over every game asks for all of them in one go, [sweep]).
if (asked && online(context)) steamAppOf(context, game)?.let { SteamAppInfo.lookup(context, listOf(it)) }
val findings = findings(context, game)
val catalog = (catalogOf(context, false) ?: catalogOf(context, true))?.associateBy { it.name } ?: return
val wanted = autoPicks(findings, catalog)
val user = WinComponents.selection(context, key).user
val installed = installedOf(context)
val kept = LinkedHashMap<String, WinComponents.AutoReason>()
val missing = ArrayList<String>()
for ((id, reason) in wanted) {
if (user[id] == false) { kept[id] = reason; continue }
if (id in installed) { kept[id] = reason; continue }
synchronized(waiting) { waiting.getOrPut(id) { ArrayList() }.let { if (it.none { g -> appKey(g) == key }) it.add(game) } }
queueDownload(context, id, asked, inSession)
missing.add(id)
}
// In a session the launch waits a little for these (droiddeck-wincomponents reads the list).
if (inSession) Pending.set(context, key, missing)
val before = WinComponents.selection(context, key).auto
if (before.keys != kept.keys) {
WinComponents.setAuto(context, key, kept)
Log.i(TAG, "${game.name}: on by itself ${kept.keys.joinToString().ifEmpty { "(none)" }}")
}
changes.value = changes.value + 1
}
/** One component downloaded and installed, then turned on for every game waiting on it. */
private fun download(context: Context, id: String, asked: Boolean, inSession: Boolean = false) {
val catalog = catalogOf(context, true)?.associateBy { it.name } ?: run { Pending.drop(context, id); return }
if (id !in installedOf(context)) {
val c = catalog[id] ?: run { Pending.drop(context, id); return }
val started = System.currentTimeMillis()
downloading.value = downloading.value + (id to WinComponents.Progress(id, "", WinComponents.Phase.DOWNLOAD, -1))
val problem = try {
installer(context, c, catalog) { p -> downloading.value = downloading.value + (id to p) }
} finally {
downloading.value = downloading.value - id
}
if (problem != null) { Log.w(TAG, "wincomp: $id did not install: $problem"); Pending.drop(context, id); return }
Log.i(TAG, "wincomp: $id installed in ${System.currentTimeMillis() - started}ms")
}
val games = synchronized(waiting) { waiting.remove(id).orEmpty() }
games.forEach { apply(context, it, asked, inSession) }
Pending.drop(context, id)
}
/**
* The components still being readied for games installed in the session, where the launch
* reads them (~/.config/droiddeck/wincomponents-pending.json in the runtime).
*/
internal object Pending {
private fun file(context: Context) = File(com.droiddeck.launcher.runtime.LinuxRuntime.rootDir(context), "root/.config/droiddeck/wincomponents-pending.json")
@Synchronized fun read(context: Context): JSONObject =
runCatching { JSONObject(file(context).readText()) }.getOrDefault(JSONObject()).let { it.optJSONObject("games") ?: JSONObject() }
@Synchronized fun set(context: Context, key: String, ids: List<String>) {
val games = read(context)
if (ids.isEmpty()) games.remove(key) else games.put(key, JSONArray(ids))
write(context, games)
}
@Synchronized fun drop(context: Context, id: String) {
val games = read(context)
for (k in games.keys().asSequence().toList()) {
val left = games.optJSONArray(k)?.let { a -> (0 until a.length()).map { a.optString(it) } }.orEmpty() - id
if (left.isEmpty()) games.remove(k) else games.put(k, JSONArray(left))
}
write(context, games)
}
private fun write(context: Context, games: JSONObject) {
val f = file(context)
runCatching {
f.parentFile?.mkdirs()
val tmp = File(f.path + ".tmp")
tmp.writeText(JSONObject().put("games", games).toString())
if (!tmp.renameTo(f)) { f.delete(); tmp.renameTo(f) }
}
}
}
// ---- the list, kept per game ----
private const val FINDINGS = "wincomp-findings.json"
/** What the list is read from, by modification time: a change means it is worked out again. */
internal fun signature(context: Context, game: Library.SteamGame): String {
val folder = game.gameFiles
val parts = listOfNotNull(
folder?.lastModified(),
folder?.let { SteamRedists.manifest(it, game.appId)?.lastModified() },
folder?.let { StoreGameSidecar.file(it).lastModified() },
folder?.let { File(it, "fuel.json").lastModified() },
SteamAppInfo.appinfoFile(context).lastModified(),
folder?.let { SteamMatch.get(context, it.path) }?.let { "${it.appId}/${it.certainty}" },
steamAppOf(context, game)?.let { SteamAppInfo.state(context, it) },
)
return parts.joinToString("|")
}
/** [game]'s list: the one kept when nothing it is read from changed, else worked out again and kept. Worker only. */
internal fun findings(context: Context, game: Library.SteamGame): List<WinCompSources.Finding> {
val key = appKey(game) ?: return emptyList()
val sig = signature(context, game)
val all = readFindings(context)
all.optJSONObject(key)?.takeIf { it.optString("sig") == sig }?.let { return decode(it.optJSONArray("findings")) }
val fresh = WinCompSources.forGame(context, game)
writeFindings(context, all.put(key, JSONObject().put("sig", sig).put("findings", encode(fresh))))
return fresh
}
/** The list kept for [appKey], without working anything out; null before the first time. */
fun cachedFindings(context: Context, appKey: String): List<WinCompSources.Finding>? =
readFindings(context).optJSONObject(appKey)?.let { decode(it.optJSONArray("findings")) }
@Synchronized
private fun readFindings(context: Context): JSONObject =
runCatching { JSONObject(AtomicFile(File(context.filesDir, FINDINGS)).readFully().toString(Charsets.UTF_8)) }.getOrDefault(JSONObject())
@Synchronized
private fun writeFindings(context: Context, json: JSONObject) {
val atomic = AtomicFile(File(context.filesDir, FINDINGS))
val out = atomic.startWrite()
try { out.write(json.toString().toByteArray()); atomic.finishWrite(out) } catch (e: Exception) { atomic.failWrite(out) }
}
private fun encode(list: List<WinCompSources.Finding>) = JSONArray().apply {
list.forEach { f ->
put(JSONObject().put("original", f.original).put("origin", f.origin.name).put("auto", f.auto).put("args", JSONArray(f.args))
.apply { f.component?.let { put("component", it) } })
}
}
private fun decode(arr: JSONArray?): List<WinCompSources.Finding> = if (arr == null) emptyList() else (0 until arr.length()).mapNotNull { i ->
val o = arr.optJSONObject(i) ?: return@mapNotNull null
val origin = runCatching { WinCompSources.Origin.valueOf(o.optString("origin")) }.getOrNull() ?: return@mapNotNull null
val args = o.optJSONArray("args")?.let { a -> (0 until a.length()).map { a.optString(it) } }.orEmpty()
WinCompSources.Finding(o.optString("component").takeIf { it.isNotEmpty() }, o.optString("original"), origin, o.optBoolean("auto"), args)
}
// ---- when ----
/** Before a launch from DroidDeck: nothing but a queued look (it waits for the session to end). */
fun beforeLaunch(context: Context, game: Library.SteamGame) = queueGame(context, game, asked = false)
/** After an install or an add: the user did it, so it does not wait on a metered network. */
fun refreshLater(context: Context, game: Library.SteamGame) = queueGame(context, game, asked = true)
/**
* Every game, whatever its source, at app start and after each session (Steam installs its
* games without a word to DroidDeck; older GOG and Epic installs get their list read once):
* only queued, one game at a time, behind anything asked for, under the same conditions. A
* game whose files did not change costs a look at their times.
*/
fun sweep(context: Context, games: List<Library.SteamGame>) {
// First every Steam app these games are that the client never saw, asked of Steam in one
// connection; then each game's list.
val apps = games.mapNotNull { steamAppOf(context, it) }.distinct()
if (apps.isNotEmpty() && SteamAppInfo.needLookup(context, apps).isNotEmpty()) enqueue(context, Job(null, null, false, seq.incrementAndGet(), pics = apps))
games.forEach { queueGame(context, it, asked = false) }
}
/** The Steam app whose list a game takes: a Steam game's own, or an added game's match. */
private fun steamAppOf(context: Context, game: Library.SteamGame): Int? =
if (game.library != Library.ADDED) game.appId.takeIf { it > 0 }
else game.gameFiles?.let { SteamMatch.get(context, it.path) }?.appId
/** The default network is there and validated (reaches the internet). */
fun isOnline(context: Context): Boolean {
val cm = context.getSystemService(ConnectivityManager::class.java) ?: return false
val caps = cm.getNetworkCapabilities(cm.activeNetwork ?: return false) ?: return false
return caps.hasCapability(android.net.NetworkCapabilities.NET_CAPABILITY_INTERNET) &&
caps.hasCapability(android.net.NetworkCapabilities.NET_CAPABILITY_VALIDATED)
}
/** Battery saver, thermal status moderate or worse, or a metered network. */
private fun constrained(context: Context): Boolean {
val power = context.getSystemService(PowerManager::class.java)
if (power?.isPowerSaveMode == true) return true
if (Build.VERSION.SDK_INT >= 29 && (power?.currentThermalStatus ?: 0) >= PowerManager.THERMAL_STATUS_MODERATE) return true
return context.getSystemService(ConnectivityManager::class.java)?.isActiveNetworkMetered == true
}
}
@@ -134,6 +134,9 @@ object DependencyDetector {
val lower = f.name.lowercase(Locale.US)
if (f.isDirectory) {
if (SKIP_DIR_RE.matches(lower)) continue
// Unity's <Game>_Data/Managed holds the game's own Mono assemblies, not a .NET
// Framework to install.
if (lower == "managed" && dir.name.endsWith("_Data", ignoreCase = true)) continue
val isRedistDir = insideRedist || REDIST_DIR_RE.matches(lower)
if (isRedistDir) consider(f, isDir = true, insideRedist = true)
// Aggressive coverage: descend redist containers fully, and otherwise sweep the game
@@ -157,6 +160,19 @@ object DependencyDetector {
}
}
/**
* The catalog components one installer stands for, by its path ("_CommonRedist/vcredist/2015/
* vc_redist.x64.exe", "DXSETUP.exe"): what a store's install list names. A VC++ installer
* with no year is the latest; empty for one that is none of ours.
*/
fun componentsFor(path: String): List<String> {
val p = path.replace('\\', '/').lowercase(Locale.US)
return classify(p, p.substringAfterLast('/'), isDir = false).map { if (it == VCREDIST_LOOSE) "vcredist2022" else it }
}
/** The name the catalog component [name] goes by, for a reason line. */
fun label(name: String): String = LABELS[name] ?: name
/** A loose VC++ installer resolves to the latest 14.x only if no specific vcredistYYYY was found. */
private fun resolveLooseVcredist(hits: LinkedHashMap<String, Recommendation>) {
val loose = hits.remove(VCREDIST_LOOSE) ?: return
@@ -48,7 +48,7 @@ object GameFileSync {
check(File(previousStorage).isDirectory) { context.getString(R.string.game_sync_err_previous_sd) }
}
storage?.let { check(File(it.path).listFiles() != null) { context.getString(R.string.game_sync_err_game_storage) } }
SessionPrefs.addedGamesDirs(context).forEach { check(File(it).listFiles() != null) { context.getString(R.string.game_sync_err_added_games) } }
AddedGames.roots(context).forEach { check(it.host.listFiles() != null) { context.getString(R.string.game_sync_err_added_games) } }
GameFiles.sync(context, folder, Library.launchableGames(context, strictRead = true))
prefs(context).edit().putString("storageSetting", storageSetting).putString("storagePath", storage?.path).apply()
}
@@ -0,0 +1,66 @@
package com.droiddeck.launcher.frontend
import android.content.Context
import android.util.Log
import com.droiddeck.launcher.session.SessionPrefs
import com.droiddeck.launcher.stores.StoreGameSidecar
import com.droiddeck.launcher.stores.StoreInstallRoot
import java.io.File
import java.util.zip.CRC32
/**
* Games folders (the roots earlier builds let the user add in Steam settings) become what the
* Games tab's "Add all games in this folder" makes: one entry per game ([AddedExes]). Everything
* a game had is keyed by its own folder and stays as it was - the chosen exe, the shortcut appid,
* the name, Start in, launch options, art choices, a removal. The roots stay bound into the
* session at the same guest paths ([SessionPrefs.gameFolderBinds]), so every exe keeps its path
* inside the session and every Steam shortcut its target; they are no longer Steam libraries.
* Runs whenever saved roots remain (so twice is the same as once) and clears them after.
*/
object GamesFolderMigration {
private const val TAG = "GamesFolderMigration"
@Synchronized
fun run(context: Context) {
val dirs = SessionPrefs.addedGamesDirs(context)
if (dirs.isEmpty()) return
val roots = legacyRoots(SessionPrefs.gameFolderBinds(context).map { it.first } + dirs)
.filter { root -> root.host.path in dirs }
// The binds first: a game's guest path (and with it the shortcut's appid) depends on them.
SessionPrefs.setGameFolderBinds(context, SessionPrefs.gameFolderBinds(context) + roots.map { it.host.path to it.guest })
val removed = SessionPrefs.removedAddedGames(context).toSet()
val entries = AddedExes.list(context).map { it.folder }.toMutableSet()
val storeRoots = StoreInstallRoot.roots(context).map { it.absolutePath }
for (root in roots) {
var n = 0
val steamInstalls = AddedGames.steamInstallDirs(root.host)
for (folder in root.host.listFiles { f -> f.isDirectory }?.sortedBy { it.name.lowercase() }.orEmpty()) {
if (folder.name.equals("steamapps", ignoreCase = true) || folder.name.lowercase() in steamInstalls) continue
if (folder.absolutePath in storeRoots || StoreGameSidecar.read(folder) != null) continue
if (folder.path in entries) continue
val picked = SessionPrefs.addedGameExe(context, folder.path).takeIf { it.isNotEmpty() }?.let { File(it) }?.takeIf { it.isFile }
val exe = picked ?: AddedGames.rank(folder).exes.firstOrNull() ?: continue
AddedExes.put(context, AddedExes.Entry(folder.path, exe.path, picked = picked != null))
entries.add(folder.path)
if (folder.path !in removed) n++
}
Log.i(TAG, "games: migrated ${root.host} → $n games")
}
SessionPrefs.setAddedGamesDirs(context, emptyList())
SessionPrefs.setGamesFoldersMigrated(context)
}
/**
* The guest paths earlier builds gave Games folders: /root/Games/<folder name>, with a short
* hash of the host path when two share a name. Kept exactly, so nothing moves in the session.
*/
internal fun legacyRoots(paths: List<String>): List<AddedGames.Root> {
val hosts = paths.distinct().map { File(it) }
val names = hosts.groupingBy { it.name.lowercase() }.eachCount()
return hosts.map { host ->
val name = host.name.ifEmpty { "games" }
val guestName = if ((names[name.lowercase()] ?: 0) > 1) name + "-" + "%08x".format(CRC32().apply { update(host.path.toByteArray()) }.value).take(4) else name
AddedGames.Root(host, "${AddedGames.GUEST_DIR}/$guestName")
}
}
}
@@ -0,0 +1,95 @@
package com.droiddeck.launcher.frontend
import android.content.Context
import android.util.Log
import com.droiddeck.launcher.runtime.LinuxRuntime
import com.droiddeck.launcher.session.GameStorage
import com.droiddeck.launcher.session.SessionState
import java.io.File
/**
* A Steam game whose download finishes during a session, noticed by its appmanifest (a new one,
* or StateFlags turning to 4, fully installed) with a light look every five seconds while the
* session runs, and handed to [AutoComponents] as a single job that may run in the session, so its
* Windows components are in place when the user plays it right after the download.
*/
object SessionInstalls {
private const val TAG = "SessionInstalls"
private const val POLL_MS = 5_000L
/** One appmanifest as read: the app, its name and folder, whether it is fully installed. */
class Manifest(val appId: Int, val name: String, val installDir: String, val installed: Boolean, val steamapps: File)
private val APPID = Regex("\"appid\"\\s*\"(\\d+)\"", RegexOption.IGNORE_CASE)
private val NAME = Regex("\"name\"\\s*\"([^\"]*)\"", RegexOption.IGNORE_CASE)
private val DIR = Regex("\"installdir\"\\s*\"([^\"]*)\"", RegexOption.IGNORE_CASE)
private val STATE = Regex("\"StateFlags\"\\s*\"(\\d+)\"", RegexOption.IGNORE_CASE)
internal fun parse(file: File): Manifest? = runCatching {
val text = file.readText()
val id = APPID.find(text)?.groupValues?.get(1)?.toInt() ?: return null
Manifest(id, NAME.find(text)?.groupValues?.get(1).orEmpty(), DIR.find(text)?.groupValues?.get(1).orEmpty(),
STATE.find(text)?.groupValues?.get(1)?.toIntOrNull() == 4, file.parentFile!!)
}.getOrNull()
/** The libraries the session's client installs to, as host folders. */
internal fun steamappsDirs(context: Context): List<File> = listOfNotNull(
File(LinuxRuntime.rootDir(context), "root/.local/share/Steam/steamapps"),
GameStorage.effective(context)?.let { File(it.path, "steamapps") },
).filter { it.isDirectory }
/**
* Keeps what the libraries hold between looks: a manifest re-read only when its time changed.
* [poll] returns the apps that became fully installed since the last look (none on the first).
*/
internal class Watch(private val dirs: () -> List<File>) {
private val seen = HashMap<String, Pair<Long, Manifest?>>()
private var first = true
fun poll(): List<Manifest> {
val fresh = ArrayList<Manifest>()
for (dir in dirs()) for (f in dir.listFiles { x -> x.isFile && x.name.startsWith("appmanifest_") && x.name.endsWith(".acf") }.orEmpty()) {
val before = seen[f.path]
if (before != null && before.first == f.lastModified()) continue
val now = parse(f)
seen[f.path] = f.lastModified() to now
if (!first && now != null && now.installed && before?.second?.installed != true) fresh.add(now)
}
first = false
return fresh
}
}
/** A finished install as the Games tab would list it. */
internal fun gameOf(m: Manifest): Library.SteamGame =
Library.SteamGame(m.appId, m.name.ifEmpty { m.appId.toString() }, null, m.steamapps.parentFile?.path ?: "steam",
gameFiles = File(m.steamapps, "common/${m.installDir}"), source = Library.SOURCE_STEAM)
/**
* Looks every five seconds while the session runs, on a thread of its own at background
* priority; each finished install becomes one job for its Windows components.
*/
fun watch(context: Context) {
val app = context.applicationContext
Thread({
android.os.Process.setThreadPriority(android.os.Process.THREAD_PRIORITY_BACKGROUND)
val watch = Watch { steamappsDirs(app) }
try {
// The session is still starting when this is called: up to two minutes for it.
var waited = 0L
while (!SessionState.running && waited < 120_000L) { Thread.sleep(POLL_MS); waited += POLL_MS }
watch.poll()
while (SessionState.running) {
for (m in watch.poll()) {
Log.i(TAG, "wincomp: ${m.name} (${m.appId}) finished installing in the session")
AutoComponents.queueInSession(app, gameOf(m))
}
Thread.sleep(POLL_MS)
}
} catch (e: InterruptedException) {
} catch (e: Exception) {
Log.w(TAG, "install watch: ${e.message}")
}
}, "session-installs").apply { isDaemon = true }.start()
}
}
@@ -0,0 +1,202 @@
package com.droiddeck.launcher.frontend
import android.content.Context
import android.util.AtomicFile
import android.util.Log
import com.droiddeck.launcher.runtime.LinuxRuntime
import org.json.JSONArray
import org.json.JSONObject
import java.io.DataInputStream
import java.io.File
import java.io.RandomAccessFile
import java.nio.ByteBuffer
import java.nio.ByteOrder
/**
* Steam's own data for an app, from the client in the runtime: its app-info cache
* (Steam/appcache/appinfo.vdf, the PICS data the client keeps for every app it has seen). What is
* read is which Steamworks Shared depots (the VC++, DirectX... redistributables of app 228980) the
* app's depots point at, and its name. Kept per appid in the app's files, so it works offline; an
* app the client has not seen is looked for again after a week.
*/
object SteamAppInfo {
private const val TAG = "SteamAppInfo"
private const val CACHE = "steam-appinfo.json"
private const val RETRY_AFTER_MS = 7L * 24 * 3600 * 1000
/** The Steamworks Shared app the redistributable depots belong to. */
const val SHARED_APP = "228980"
class Info(val name: String, val sharedDepots: List<String>)
fun appinfoFile(context: Context) = File(LinuxRuntime.rootDir(context), "root/.local/share/Steam/appcache/appinfo.vdf")
/** The shared depots for [appId]: from the cache, else the client's app-info file; null when unknown. */
@Synchronized
fun info(context: Context, appId: Int, appinfo: File = appinfoFile(context)): Info? {
val cacheFile = AtomicFile(File(context.filesDir, CACHE))
val cache = runCatching { JSONObject(cacheFile.readFully().toString(Charsets.UTF_8)) }.getOrDefault(JSONObject())
val now = System.currentTimeMillis()
val mtime = appinfo.lastModified()
val known = cache.optJSONObject(appId.toString())
known?.let { o ->
if (!o.optBoolean("miss")) return Info(o.optString("name"), o.optJSONArray("depots").strings())
// A miss is read again when the client has written its cache since (the user may have
// looked at the app in Steam), else a week later.
if (now - o.optLong("at") < RETRY_AFTER_MS && o.optLong("mtime") == mtime) return null
}
val read = runCatching { read(appinfo, appId) }.onFailure { Log.w(TAG, "appinfo: ${it.javaClass.simpleName}") }.getOrNull()
val entry = JSONObject().put("at", now).put("mtime", mtime)
// When Steam was last asked anonymously ([lookup]), kept across re-reads of the file.
known?.optLong("picsAt")?.takeIf { it > 0 }?.let { entry.put("picsAt", it) }
if (read == null) entry.put("miss", true) else entry.put("name", read.name).put("depots", JSONArray(read.sharedDepots))
cache.put(appId.toString(), entry)
val out = cacheFile.startWrite()
try { out.write(cache.toString().toByteArray()); cacheFile.finishWrite(out) } catch (e: Exception) { cacheFile.failWrite(out) }
return read
}
/** Steam asked anonymously ([SteamPics]); a test stands in for it. */
@androidx.annotation.VisibleForTesting
internal var pics: (List<Int>) -> Map<Int, Info?>? = { SteamPics.lookup(it) }
/**
* Of [appIds], those neither the client's cache (appinfo.vdf, read first: free) nor an earlier
* answer has, and that Steam was not asked about anonymously in the past week.
*/
fun needLookup(context: Context, appIds: List<Int>): List<Int> {
val wanted = appIds.distinct().filter { it > 0 && info(context, it) == null }
if (wanted.isEmpty()) return emptyList()
val cache = cache(context)
val now = System.currentTimeMillis()
return wanted.filter { now - (cache.optJSONObject(it.toString())?.optLong("picsAt") ?: 0L) >= RETRY_AFTER_MS }
}
/**
* [appIds] the client never saw, asked of Steam anonymously in one connection ([SteamPics], in
* batches) and every answer kept like the client's: an app's depots and name, or a miss tried
* again in a week. False when nothing needed asking or Steam could not be reached. Blocking.
*/
@Synchronized
fun lookup(context: Context, appIds: List<Int>): Boolean {
val need = needLookup(context, appIds)
if (need.isEmpty()) return false
val answers = pics(need) ?: return false
val cache = cache(context)
val now = System.currentTimeMillis()
val mtime = appinfoFile(context).lastModified()
for (id in need) {
val info = answers[id]
val entry = JSONObject().put("at", now).put("mtime", mtime).put("picsAt", now)
if (info == null) entry.put("miss", true) else entry.put("name", info.name).put("depots", JSONArray(info.sharedDepots)).put("source", "pics")
cache.put(id.toString(), entry)
}
val file = AtomicFile(File(context.filesDir, CACHE))
val out = file.startWrite()
try { out.write(cache.toString().toByteArray()); file.finishWrite(out) } catch (e: Exception) { file.failWrite(out) }
return true
}
/** What is kept for [appId]: "hit", "miss" or "none" (part of a game's list fingerprint). */
fun state(context: Context, appId: Int): String = cache(context).optJSONObject(appId.toString())?.let { if (it.optBoolean("miss")) "miss" else "hit" } ?: "none"
private fun cache(context: Context): JSONObject =
runCatching { JSONObject(AtomicFile(File(context.filesDir, CACHE)).readFully().toString(Charsets.UTF_8)) }.getOrDefault(JSONObject())
private fun JSONArray?.strings(): List<String> = if (this == null) emptyList() else (0 until length()).map { optString(it) }
/**
* [appId]'s entry in an appinfo.vdf (versions 27, 28 and 29, the last with its keys in a string
* table), or null when the file does not have it.
*/
internal fun read(file: File, appId: Int): Info? {
if (!file.isFile) return null
RandomAccessFile(file, "r").use { raf ->
val head = ByteBuffer.allocate(16).order(ByteOrder.LITTLE_ENDIAN)
raf.readFully(head.array(), 0, 8)
val magic = head.getInt(0)
val version = magic and 0xFF
if (magic ushr 8 != 0x075644 || version !in 0x27..0x29) return null
var strings: List<String>? = null
var pos = 8L
if (version >= 0x29) {
raf.readFully(head.array(), 8, 8)
val tableAt = head.getLong(8)
strings = readStrings(raf, tableAt)
pos = 16L
}
val fixed = if (version >= 0x28) 4 + 4 + 8 + 20 + 4 + 20 else 4 + 4 + 8 + 20 + 4
val entry = ByteBuffer.allocate(8).order(ByteOrder.LITTLE_ENDIAN)
while (pos + 8 <= raf.length()) {
raf.seek(pos)
raf.readFully(entry.array(), 0, 8)
val id = entry.getInt(0)
val size = entry.getInt(4).toLong() and 0xFFFFFFFFL
if (id == 0) return null
if (id == appId) {
if (size > 64L * 1024 * 1024) return null
val body = ByteArray(size.toInt())
raf.readFully(body)
val kv = ByteBuffer.wrap(body, fixed, body.size - fixed).order(ByteOrder.LITTLE_ENDIAN)
val tree = readMap(kv, strings)
@Suppress("UNCHECKED_CAST")
val app = (tree["appinfo"] as? Map<String, Any>) ?: tree
return infoOf(app)
}
pos += 8 + size
}
return null
}
}
@Suppress("UNCHECKED_CAST")
internal fun infoOf(app: Map<String, Any>): Info {
val name = (app["common"] as? Map<String, Any>)?.get("name") as? String ?: ""
val depots = (app["depots"] as? Map<String, Any>).orEmpty()
val shared = depots.mapNotNull { (key, value) ->
val depot = value as? Map<String, Any> ?: return@mapNotNull null
key.takeIf { (depot["depotfromapp"] as? String) == SHARED_APP || (key in SteamRedists.DEPOTS && depot["sharedinstall"] != null) }
}
return Info(name, shared)
}
private fun readStrings(raf: RandomAccessFile, at: Long): List<String> {
raf.seek(at)
val count = Integer.reverseBytes(raf.readInt())
val rest = ByteArray((raf.length() - at - 4).toInt().coerceAtLeast(0))
raf.readFully(rest)
val out = ArrayList<String>(count)
var start = 0
for (i in rest.indices) {
if (out.size == count) break
if (rest[i] == 0.toByte()) { out.add(String(rest, start, i - start, Charsets.UTF_8)); start = i + 1 }
}
return out
}
private fun cstring(b: ByteBuffer): String {
val start = b.position()
while (b.get() != 0.toByte()) { }
val bytes = ByteArray(b.position() - start - 1)
b.position(start); b.get(bytes); b.get()
return String(bytes, Charsets.UTF_8)
}
/** One binary key-value map, to its end marker; keys by name, or by index into [strings]. */
private fun readMap(b: ByteBuffer, strings: List<String>?): Map<String, Any> {
val out = LinkedHashMap<String, Any>()
while (b.hasRemaining()) {
val type = b.get().toInt() and 0xFF
if (type == 0x08) return out
val key = if (strings != null) strings.getOrElse(b.int) { "" } else cstring(b)
when (type) {
0x00 -> out[key] = readMap(b, strings)
0x01 -> out[key] = cstring(b)
0x02 -> out[key] = b.int.toString()
0x03 -> out[key] = b.float.toString()
0x07, 0x0A -> out[key] = b.long.toString()
else -> return out
}
}
return out
}
}
@@ -0,0 +1,77 @@
package com.droiddeck.launcher.frontend
import android.content.Context
import android.util.AtomicFile
import org.json.JSONObject
import java.io.File
/**
* Which Steam app a game added in DroidDeck is, kept once per game (by its folder) and used by
* everything that asks: its art ([AddedGameArt]) and its Windows components ([WinCompSources]),
* so the two never disagree.
*
* [Certainty.FILES]: the game's own files name the appid (steam_appid.txt, also under
* steam_settings/ or a parent folder; an emulator ini; Steam's appmanifest) - certain.
* [Certainty.NAME]: Steam's store search returned a game of the same name - a guess.
* [Certainty.NONE]: neither; a miss is tried again after a week.
*/
object SteamMatch {
enum class Certainty { FILES, NAME, NONE }
/** [source]: what gave the appid (GameIdentifier's source, or "store search"). */
class Match(val appId: Int?, val certainty: Certainty, val source: String, val checkedAt: Long)
private const val FILE = "steam-matches.json"
private const val RETRY_AFTER_MS = 7L * 24 * 3600 * 1000
private fun store(context: Context) = AtomicFile(File(context.filesDir, FILE))
@Synchronized
private fun all(context: Context): JSONObject =
runCatching { JSONObject(store(context).readFully().toString(Charsets.UTF_8)) }.getOrDefault(JSONObject())
@Synchronized
fun get(context: Context, key: String): Match? = all(context).optJSONObject(key)?.let { o ->
Match(
o.optInt("appId", 0).takeIf { it > 0 },
runCatching { Certainty.valueOf(o.optString("certainty")) }.getOrDefault(Certainty.NONE),
o.optString("source"), o.optLong("at"),
)
}
@Synchronized
fun put(context: Context, key: String, match: Match) {
val json = all(context).put(
key, JSONObject().put("certainty", match.certainty.name).put("source", match.source).put("at", match.checkedAt)
.apply { match.appId?.let { put("appId", it) } },
)
val atomic = store(context)
val out = atomic.startWrite()
try { out.write(json.toString().toByteArray()); atomic.finishWrite(out) } catch (e: Exception) { atomic.failWrite(out) }
}
/**
* The match for the game in [key] (its folder), [exe] its exe and [name] its title: its files
* first, always read again; else the one kept (a name match stays, a miss for a week); else
* [legacy], an appid an earlier build's art lookup kept; else [search] on the name, when given.
* Blocking ([search] asks the network).
*/
fun resolve(context: Context, key: String, exe: File, name: String, legacy: File? = null, search: ((String) -> Int?)? = null): Match {
val now = System.currentTimeMillis()
val identity = runCatching { GameIdentifier.identify(exe) }.getOrNull()
identity?.appId?.let { id ->
return Match(id, Certainty.FILES, identity.source.name.lowercase(), now).also { put(context, key, it) }
}
val known = get(context, key)
if (known != null && known.certainty == Certainty.NAME) return known
if (known != null && known.certainty == Certainty.NONE && now - known.checkedAt < RETRY_AFTER_MS) return known
legacy?.takeIf { it.isFile }?.let { runCatching { it.readText().trim().toInt() }.getOrNull() }?.takeIf { it > 0 }?.let { id ->
return Match(id, Certainty.NAME, "store search", now).also { put(context, key, it) }
}
if (search == null) return known?.takeIf { it.certainty != Certainty.FILES } ?: Match(null, Certainty.NONE, "", 0)
val id = runCatching { search(name) }.getOrNull()
val match = if (id != null && id > 0) Match(id, Certainty.NAME, "store search", now) else Match(null, Certainty.NONE, "", now)
put(context, key, match)
return match
}
}
@@ -0,0 +1,334 @@
package com.droiddeck.launcher.frontend
import android.util.Log
import androidx.annotation.VisibleForTesting
import org.json.JSONObject
import java.io.ByteArrayInputStream
import java.io.ByteArrayOutputStream
import java.io.DataInputStream
import java.io.IOException
import java.io.InputStream
import java.io.OutputStream
import java.net.HttpURLConnection
import java.net.URL
import java.nio.ByteBuffer
import java.nio.ByteOrder
import java.security.SecureRandom
import java.util.zip.GZIPInputStream
import javax.net.ssl.SSLSocket
import javax.net.ssl.SSLSocketFactory
/**
* Steam's own app info (PICS) for apps the client in the runtime has never seen, asked of Steam
* anonymously: a connection to one of Steam's connection managers (the list from Steam's public
* ISteamDirectory/GetCMListForConnect, the WebSocket transport Steam's web clients use), an
* anonymous logon - no account, nothing of the user's Steam sign-in - a request for the apps'
* access tokens and then their product info, the app KeyValues read for their Steamworks Shared
* depots and name, and the connection closed. What steamcmd's anonymous `app_info_print` sees.
*/
object SteamPics {
private const val TAG = "SteamPics"
private const val CM_LIST = "https://api.steampowered.com/ISteamDirectory/GetCMListForConnect/v1/?cellid=0&format=json"
/** At most this many apps per request. */
const val BATCH = 25
private const val PROTO = 0x80000000.toInt()
private const val EMSG_MULTI = 1
private const val EMSG_LOGON = 5514
private const val EMSG_LOGON_RESPONSE = 751
private const val EMSG_LOGOFF = 706
private const val EMSG_TOKENS_REQUEST = 8905
private const val EMSG_TOKENS_RESPONSE = 8906
private const val EMSG_INFO_REQUEST = 8903
private const val EMSG_INFO_RESPONSE = 8904
private const val PROTOCOL_VERSION = 65580
/** An anonymous user's SteamID: universe Public, account type AnonUser, instance 0. */
private const val ANON_STEAMID = (1L shl 56) or (10L shl 52)
/**
* [appIds]' info, one connection for all of them in batches of [BATCH]: each known app's
* info, null for an app Steam does not know. Null when Steam could not be reached. Blocking.
*/
fun lookup(appIds: List<Int>): Map<Int, SteamAppInfo.Info?>? {
if (appIds.isEmpty()) return emptyMap()
val host = runCatching { cmHost() }.getOrNull() ?: return null
return try {
Wss(host, 20_000).use { ws ->
ws.send(message(EMSG_LOGON, header(ANON_STEAMID, 0), Proto().varint(1, PROTOCOL_VERSION.toLong()).varint(7, 16).string(6, "english").bytes()))
var steamId = 0L
var session = 0
// The logon's answer (other messages before it are passed over).
loop@ while (true) for ((emsg, hdr, body) in messages(ws.receive() ?: return null)) if (emsg == EMSG_LOGON_RESPONSE) {
val h = fields(hdr); val b = fields(body)
if ((b[1]?.firstOrNull() as? Long ?: 0L) != 1L) { Log.w(TAG, "anonymous logon refused: ${b[1]}"); return null }
steamId = h[1]?.firstOrNull() as? Long ?: 0L
session = (h[2]?.firstOrNull() as? Long ?: 0L).toInt()
break@loop
}
val out = HashMap<Int, SteamAppInfo.Info?>()
for (batch in batches(appIds)) {
val tokens = HashMap<Int, Long>()
ws.send(message(EMSG_TOKENS_REQUEST, header(steamId, session), Proto().apply { batch.forEach { varint(2, it.toLong()) } }.bytes()))
tokens@ while (true) for ((emsg, _, body) in messages(ws.receive() ?: return null)) if (emsg == EMSG_TOKENS_RESPONSE) {
for (t in fields(body)[3].orEmpty()) {
val f = fields(t as ByteArray)
val id = (f[1]?.firstOrNull() as? Long)?.toInt() ?: continue
tokens[id] = f[2]?.firstOrNull() as? Long ?: 0L
}
break@tokens
}
ws.send(message(EMSG_INFO_REQUEST, header(steamId, session), Proto().apply {
batch.forEach { id -> bytes(2, Proto().varint(1, id.toLong()).apply { tokens[id]?.takeIf { it != 0L }?.let { varint(2, it) } }.bytes()) }
varint(3, 0)
}.bytes()))
var pending = true
while (pending) for ((emsg, _, body) in messages(ws.receive() ?: return null)) if (emsg == EMSG_INFO_RESPONSE) {
val r = parseInfoResponse(body)
out.putAll(r.apps)
r.unknown.forEach { out[it] = null }
pending = r.pending
}
batch.forEach { if (it !in out) out[it] = null }
}
runCatching { ws.send(message(EMSG_LOGOFF, header(steamId, session), ByteArray(0))) }
Log.i(TAG, "steam pics: ${out.size} apps, ${out.values.count { it?.sharedDepots?.isNotEmpty() == true }} with shared redists")
out
}
} catch (e: Exception) {
Log.w(TAG, "steam pics: ${e.javaClass.simpleName}: ${e.message}")
null
}
}
/** [appIds] in requests of at most [BATCH], each once. */
fun batches(appIds: List<Int>): List<List<Int>> = appIds.distinct().chunked(BATCH)
/** A connection manager for the WebSocket transport, least loaded first as Steam lists them. */
private fun cmHost(): String? {
val c = URL(CM_LIST).openConnection() as HttpURLConnection
c.connectTimeout = 10_000; c.readTimeout = 10_000
val body = try { c.inputStream.bufferedReader().readText() } finally { c.disconnect() }
val list = JSONObject(body).optJSONObject("response")?.optJSONArray("serverlist") ?: return null
return (0 until list.length()).mapNotNull { list.optJSONObject(it) }
.firstOrNull { it.optString("type") == "websockets" }?.optString("endpoint")?.substringBefore(':')
}
// ---- the product info answer ----
class InfoResponse(val apps: Map<Int, SteamAppInfo.Info>, val unknown: List<Int>, val pending: Boolean)
/** A CMsgClientPICSProductInfoResponse: each app's KeyValues read, the unknown ones, whether more follows. */
@VisibleForTesting
internal fun parseInfoResponse(body: ByteArray): InfoResponse {
val f = fields(body)
val apps = HashMap<Int, SteamAppInfo.Info>()
for (a in f[1].orEmpty()) {
val app = fields(a as ByteArray)
val id = (app[1]?.firstOrNull() as? Long)?.toInt() ?: continue
val buffer = app[5]?.firstOrNull() as? ByteArray ?: continue
val text = String(buffer, Charsets.UTF_8).trimEnd('\u0000')
infoFromKeyValues(text)?.let { apps[id] = it }
}
val unknown = f[2].orEmpty().mapNotNull { (it as? Long)?.toInt() } + f[2].orEmpty().filterIsInstance<ByteArray>().flatMap { packed(it) }
return InfoResponse(apps, unknown, (f[6]?.firstOrNull() as? Long ?: 0L) != 0L)
}
/** An app's KeyValues text ("appinfo" { "common" {...} "depots" {...} }) to its name and shared depots. */
@VisibleForTesting
internal fun infoFromKeyValues(text: String): SteamAppInfo.Info? {
// The buffer ends in a NUL, kept or not by whoever handed it over.
val tree = runCatching { KeyValues(text.trimEnd('\u0000', ' ', '\n', '\r', '\t')).read() }.getOrNull() ?: return null
@Suppress("UNCHECKED_CAST")
val app = tree["appinfo"] as? Map<String, Any> ?: return null
return SteamAppInfo.infoOf(app)
}
/** Valve's text KeyValues: quoted keys and values, nested braces. */
private class KeyValues(private val s: String) {
private var i = 0
fun read(): Map<String, Any> = map(top = true)
private fun map(top: Boolean): Map<String, Any> {
val out = LinkedHashMap<String, Any>()
while (true) {
skip()
if (i >= s.length) { if (top) return out else throw IOException("unclosed") }
if (s[i] == '}') { i++; return out }
val key = token()
skip()
if (i < s.length && s[i] == '{') { i++; out[key] = map(top = false) } else out[key] = token()
}
}
private fun skip() {
while (i < s.length) {
if (s[i].isWhitespace() || s[i] == '\u0000') i++
else if (s.startsWith("//", i)) { while (i < s.length && s[i] != '\n') i++ }
else break
}
}
private fun token(): String {
if (i < s.length && s[i] == '"') {
i++
val b = StringBuilder()
while (i < s.length && s[i] != '"') {
if (s[i] == '\\' && i + 1 < s.length) { i++; b.append(when (s[i]) { 'n' -> '\n'; 't' -> '\t'; else -> s[i] }) } else b.append(s[i])
i++
}
i++
return b.toString()
}
val start = i
while (i < s.length && !s[i].isWhitespace() && s[i] != '{' && s[i] != '}') i++
if (start == i) throw IOException("token expected at $i")
return s.substring(start, i)
}
}
// ---- messages ----
private fun header(steamId: Long, session: Int) = Proto().fixed64(1, steamId).varint(2, session.toLong()).bytes()
private fun message(emsg: Int, header: ByteArray, body: ByteArray): ByteArray =
ByteBuffer.allocate(8 + header.size + body.size).order(ByteOrder.LITTLE_ENDIAN)
.putInt(emsg or PROTO).putInt(header.size).put(header).put(body).array()
/** One message off the wire as (emsg, header, body); a Multi unpacked (gunzipped when it says so) into the ones inside. */
@VisibleForTesting
internal fun messages(data: ByteArray): List<Triple<Int, ByteArray, ByteArray>> {
if (data.size < 8) return emptyList()
val b = ByteBuffer.wrap(data).order(ByteOrder.LITTLE_ENDIAN)
val raw = b.int
if (raw and PROTO == 0) return emptyList()
val emsg = raw and PROTO.inv()
val hl = b.int
if (hl < 0 || 8 + hl > data.size) return emptyList()
val header = data.copyOfRange(8, 8 + hl)
val body = data.copyOfRange(8 + hl, data.size)
if (emsg != EMSG_MULTI) return listOf(Triple(emsg, header, body))
val f = fields(body)
var inner = f[2]?.firstOrNull() as? ByteArray ?: return emptyList()
if ((f[1]?.firstOrNull() as? Long ?: 0L) > 0) inner = GZIPInputStream(ByteArrayInputStream(inner)).use { it.readBytes() }
val out = ArrayList<Triple<Int, ByteArray, ByteArray>>()
val ib = ByteBuffer.wrap(inner).order(ByteOrder.LITTLE_ENDIAN)
while (ib.remaining() >= 4) {
val len = ib.int
if (len < 0 || len > ib.remaining()) break
val one = ByteArray(len).also { ib.get(it) }
out.addAll(messages(one))
}
return out
}
// ---- protobuf, the little needed ----
/** Writes protobuf fields. */
@VisibleForTesting
internal class Proto {
private val out = ByteArrayOutputStream()
private fun raw(v: Long) { var x = v; while (true) { val b = (x and 0x7F).toInt(); x = x ushr 7; if (x == 0L) { out.write(b); return }; out.write(b or 0x80) } }
fun varint(field: Int, v: Long) = apply { raw((field shl 3).toLong()); raw(v) }
fun fixed64(field: Int, v: Long) = apply { raw(((field shl 3) or 1).toLong()); for (k in 0 until 8) out.write(((v ushr (8 * k)) and 0xFF).toInt()) }
fun bytes(field: Int, b: ByteArray) = apply { raw(((field shl 3) or 2).toLong()); raw(b.size.toLong()); out.write(b) }
fun string(field: Int, s: String) = bytes(field, s.toByteArray())
fun bytes(): ByteArray = out.toByteArray()
}
/** Reads protobuf fields: number to values (Long for varint and fixed, ByteArray for length-delimited). */
@VisibleForTesting
internal fun fields(b: ByteArray): Map<Int, List<Any>> {
val out = HashMap<Int, MutableList<Any>>()
var i = 0
fun varint(): Long { var v = 0L; var s = 0; while (true) { val c = b[i++].toInt() and 0xFF; v = v or ((c and 0x7F).toLong() shl s); s += 7; if (c < 0x80) return v } }
while (i < b.size) {
val key = varint()
val field = (key ushr 3).toInt()
val value: Any = when ((key and 7).toInt()) {
0 -> varint()
1 -> { var v = 0L; for (k in 0 until 8) v = v or ((b[i + k].toLong() and 0xFF) shl (8 * k)); i += 8; v }
5 -> { var v = 0L; for (k in 0 until 4) v = v or ((b[i + k].toLong() and 0xFF) shl (8 * k)); i += 4; v }
2 -> { val n = varint().toInt(); val v = b.copyOfRange(i, i + n); i += n; v }
else -> throw IOException("wire type ${key and 7}")
}
out.getOrPut(field) { ArrayList() }.add(value)
}
return out
}
private fun packed(b: ByteArray): List<Int> {
val out = ArrayList<Int>(); var i = 0
while (i < b.size) { var v = 0L; var s = 0; while (true) { val c = b[i++].toInt() and 0xFF; v = v or ((c and 0x7F).toLong() shl s); s += 7; if (c < 0x80) break }; out.add(v.toInt()) }
return out
}
// ---- WebSocket over TLS, binary frames ----
private class Wss(host: String, timeoutMs: Int) : AutoCloseable {
// Made with the host name, so it goes out as SNI.
private val socket: SSLSocket = (SSLSocketFactory.getDefault().createSocket(host, 443) as SSLSocket).apply { soTimeout = timeoutMs }
private val out: OutputStream
private val input: DataInputStream
init {
// Host name checked against the certificate, as HTTPS does.
socket.sslParameters = socket.sslParameters.apply { endpointIdentificationAlgorithm = "HTTPS" }
socket.startHandshake()
out = socket.outputStream
input = DataInputStream(socket.inputStream)
val key = ByteArray(16).also { SecureRandom().nextBytes(it) }
out.write(("GET /cmsocket/ HTTP/1.1\r\nHost: $host\r\nUpgrade: websocket\r\nConnection: Upgrade\r\n" +
"Sec-WebSocket-Key: ${android.util.Base64.encodeToString(key, android.util.Base64.NO_WRAP)}\r\nSec-WebSocket-Version: 13\r\n\r\n").toByteArray())
out.flush()
val status = line(input) ?: throw IOException("no upgrade response")
if (!status.contains(" 101 ")) throw IOException("upgrade refused: $status")
while (true) { val l = line(input) ?: break; if (l.isEmpty()) break }
}
private fun line(s: InputStream): String? {
val buf = ByteArrayOutputStream()
while (true) {
val b = s.read()
if (b < 0) return if (buf.size() == 0) null else buf.toString("UTF-8")
if (b == '\n'.code) return buf.toString("UTF-8").trimEnd('\r')
buf.write(b)
}
}
fun send(payload: ByteArray) {
val mask = ByteArray(4).also { SecureRandom().nextBytes(it) }
val frame = ByteArrayOutputStream()
frame.write(0x82)
when {
payload.size < 126 -> frame.write(0x80 or payload.size)
payload.size < 65536 -> { frame.write(0x80 or 126); frame.write(payload.size ushr 8); frame.write(payload.size and 0xFF) }
else -> { frame.write(0x80 or 127); for (k in 7 downTo 0) frame.write(((payload.size.toLong() ushr (8 * k)) and 0xFF).toInt()) }
}
frame.write(mask)
for (k in payload.indices) frame.write((payload[k].toInt() xor mask[k % 4].toInt()) and 0xFF)
out.write(frame.toByteArray()); out.flush()
}
/** The next binary message (fragments joined); null on close. */
fun receive(): ByteArray? {
val message = ByteArrayOutputStream()
while (true) {
val b0 = input.read(); if (b0 < 0) return null
val b1 = input.read(); if (b1 < 0) return null
val fin = b0 and 0x80 != 0
val opcode = b0 and 0x0F
var len = (b1 and 0x7F).toLong()
if (len == 126L) len = input.readUnsignedShort().toLong() else if (len == 127L) len = input.readLong()
if (len > 32L * 1024 * 1024) throw IOException("frame too large")
val data = ByteArray(len.toInt()).also { input.readFully(it) }
when (opcode) {
0x8 -> return null
0x9 -> { out.write(byteArrayOf(0x8A.toByte(), 0x80.toByte(), 0, 0, 0, 0)); out.flush() }
0x2, 0x0 -> { message.write(data); if (fin) return message.toByteArray() }
else -> {}
}
}
}
override fun close() {
runCatching { out.write(byteArrayOf(0x88.toByte(), 0x80.toByte(), 0, 0, 0, 0)); out.flush() }
runCatching { socket.close() }
}
}
}
@@ -0,0 +1,98 @@
package com.droiddeck.launcher.frontend
import android.content.Context
import android.util.AtomicFile
import android.util.Log
import androidx.annotation.VisibleForTesting
import com.droiddeck.launcher.stores.Store
import com.droiddeck.launcher.stores.StoreGameSidecar
import com.droiddeck.launcher.stores.epic.EpicApiClient
import com.droiddeck.launcher.stores.epic.EpicCredentialStore
import com.droiddeck.launcher.stores.epic.EpicDownloadManager
import com.droiddeck.launcher.stores.gog.GogAuth
import com.droiddeck.launcher.stores.gog.GogDownloadManager
import org.json.JSONObject
import java.io.File
/**
* For a GOG or Epic game installed before its sidecar kept the store's list (GOG's build
* `dependencies`, Epic's manifest prerequisite): the list read once from the store's build
* manifest with the saved sign-in - the manifest alone, never a game file - and kept in the sidecar
* the way an install keeps it. Runs on the components' worker ([AutoComponents]). Without a
* sign-in or a network it is skipped quietly and tried again a week later.
*/
object StoreListBackfill {
private const val TAG = "StoreListBackfill"
private const val FILE = "store-list-backfill.json"
private const val RETRY_AFTER_MS = 7L * 24 * 3600 * 1000
/** The store's list for the game in a sidecar: GOG's dependency ids, or Epic's prerequisite {name, path}; null when it could not be had. */
fun interface Fetcher {
fun fetch(context: Context, sidecar: StoreGameSidecar): Map<String, String>?
}
/** The store itself, with the saved sign-in. */
internal val storeFetcher = Fetcher { context, sidecar -> fromStore(context, sidecar) }
@VisibleForTesting internal var fetcher: Fetcher = storeFetcher
/** Whether [sidecar] was written before the list was kept. */
/**
* Whether [sidecar] has no list read whole: written before the list was kept, or by a build
* that could have written an empty one for a manifest it did not read right.
*/
fun needs(sidecar: StoreGameSidecar): Boolean =
(sidecar.store == Store.GOG || sidecar.store == Store.EPIC) && WinCompSources.LIST_READ !in sidecar.extra
/**
* The list for the game in [folder], fetched and kept when its sidecar lacks one and it was not
* tried in the past week. True when the sidecar changed. Blocking (network).
*/
@Synchronized
fun fill(context: Context, folder: File): Boolean {
val sidecar = StoreGameSidecar.read(folder)?.takeIf { it.isInstalled && needs(it) } ?: return false
val tries = runCatching { JSONObject(AtomicFile(File(context.filesDir, FILE)).readFully().toString(Charsets.UTF_8)) }.getOrDefault(JSONObject())
val now = System.currentTimeMillis()
if (now - tries.optLong(folder.path, 0L) < RETRY_AFTER_MS) return false
// Offline is no try: it waits for a network rather than a week.
if (!AutoComponents.online(context)) return false
val list = runCatching { fetcher.fetch(context, sidecar) }.getOrNull()
if (list == null) {
tries.put(folder.path, now)
val atomic = AtomicFile(File(context.filesDir, FILE))
val out = atomic.startWrite()
try { out.write(tries.toString().toByteArray()); atomic.finishWrite(out) } catch (e: Exception) { atomic.failWrite(out) }
Log.i(TAG, "${sidecar.title}: the ${sidecar.store.label} list could not be had; again in a week")
return false
}
// Read fresh and written whole, as an install writes it: only the list is added.
val current = StoreGameSidecar.read(folder) ?: return false
current.copy(extra = current.extra + list + (WinCompSources.LIST_READ to "1")).write(folder)
Log.i(TAG, "backfill: ${sidecar.title} " + when (sidecar.store) {
Store.GOG -> "gog deps=[${list[WinCompSources.GOG_DEPENDENCIES].orEmpty()}]"
else -> "epic prereq=" + (list[WinCompSources.EPIC_PREREQ_NAME].orEmpty().ifEmpty { list[WinCompSources.EPIC_PREREQ_PATH].orEmpty() }.ifEmpty { "none" })
})
return true
}
private fun fromStore(context: Context, sidecar: StoreGameSidecar): Map<String, String>? {
return when (sidecar.store) {
Store.GOG -> {
val token = GogAuth.validToken(context) ?: return null
GogDownloadManager.fetchDependencies(sidecar.id, sidecar.installVersion, token)
?.let { mapOf(WinCompSources.GOG_DEPENDENCIES to it.joinToString(",")) }
}
Store.EPIC -> {
val token = EpicCredentialStore.getValidAccessToken(context) ?: return null
val namespace = sidecar.extra["namespace"].orEmpty()
val catalogItemId = sidecar.extra["catalogItemId"].orEmpty()
if (namespace.isEmpty() || catalogItemId.isEmpty()) return null
val json = EpicApiClient.getManifestApiJson(token, namespace, catalogItemId, sidecar.extra["appName"] ?: sidecar.id) ?: return null
EpicDownloadManager.fetchPrereq(json)?.let { (name, path) ->
mapOf(WinCompSources.EPIC_PREREQ_NAME to name, WinCompSources.EPIC_PREREQ_PATH to path)
}
}
else -> null
}
}
}
@@ -0,0 +1,160 @@
package com.droiddeck.launcher.frontend
import android.content.Context
import com.droiddeck.launcher.stores.Store
import com.droiddeck.launcher.stores.StoreGameSidecar
import org.json.JSONObject
import java.io.File
import java.util.Locale
/**
* Which Windows components a game needs, from the list its source keeps - the source badge decides
* which list is trusted:
*
* - **Steam**: the Steamworks Shared redistributables Steam installs with the game (its
* appmanifest's SharedDepots, else the client's app info) - turned on by themselves.
* - **GOG**: the build manifest's `dependencies`, kept in the sidecar at install - on by themselves.
* - **Epic Games**: the manifest's prerequisite (usually UE4/UE5's prerequisite setup: VC++ and the
* DirectX June 2010 runtime), kept in the sidecar at install - on by themselves.
* - **Amazon Games**: fuel.json's PostInstall installers - on by themselves.
* - **Custom** whose files name a Steam appid ([SteamMatch], certain): Steam's list for that app -
* on by themselves. Matched by name only: the same list as Recommended. No match: the folder scan
* ([DependencyDetector]) as Recommended.
*
* Every store entry is mapped to catalog names here, in one place; one that maps to nothing is a
* suggestion under its own name. The folder scan is offered as Recommended for every game.
*/
object WinCompSources {
/** Whose list a finding came from; the reason line says it. */
enum class Origin { STEAM, GOG, EPIC, AMAZON, STEAM_NAME, FOLDER }
/**
* One thing a list names: [component] the catalog name it maps to (null: none, a suggestion),
* [original] what the list called it, [auto] whether it is turned on by itself, [args] what
* the reason line names (Steam: the label, the game, the appid).
*/
data class Finding(val component: String?, val original: String, val origin: Origin, val auto: Boolean, val args: List<String> = emptyList())
// ---- mapping tables: a store's names -> catalog components ----
private val VC_YEAR = mapOf(
"2005" to "vcredist2005", "2008" to "vcredist2008", "2010" to "vcredist2010", "2012" to "vcredist2012",
"2013" to "vcredist2013", "2015" to "vcredist2015", "2017" to "vcredist2015", "2019" to "vcredist2019", "2022" to "vcredist2022",
)
private val DOTNET = mapOf(
"35" to "dotnet35", "40" to "dotnet40", "45" to "dotnet45", "451" to "dotnet452", "452" to "dotnet452",
"46" to "dotnet46", "461" to "dotnet46", "462" to "dotnet46", "47" to "dotnet472", "471" to "dotnet472",
"472" to "dotnet472", "48" to "dotnet48", "481" to "dotnet48",
)
/**
* A GOG dependency id ("MSVC2017_x64", "DirectX", "DOTNET48", "PhysX", "OpenAL", "XNA4") to its
* components; empty for one that is none of ours (ISI scripts, GOG's own helpers).
*/
fun gog(id: String): List<String> {
val key = id.uppercase(Locale.US).replace(Regex("[^A-Z0-9]"), "")
Regex("""^(?:MSVC|VCREDIST|VC)(\d{4})""").find(key)?.let { m -> return listOfNotNull(VC_YEAR[m.groupValues[1]]) }
Regex("""^(?:DOTNET|NETFRAMEWORK|DOTNETFRAMEWORK)(\d{2,3})""").find(key)?.let { m -> return listOfNotNull(DOTNET[m.groupValues[1]]) }
return when {
key.startsWith("DIRECTX") || key.startsWith("DX9") || key == "DXSETUP" || key.startsWith("D3DX") -> listOf("d3dx9")
key.startsWith("PHYSX") -> listOf("physx")
key.startsWith("OPENAL") -> listOf("oalinst")
key == "XNA31" || key == "XNA3" -> listOf("xna31")
key.startsWith("XNA") -> listOf("xna40")
key.startsWith("XLIVE") || key.startsWith("GFWL") -> listOf("XLiveRedist")
key.startsWith("XACT") -> listOf("xact")
else -> emptyList()
}
}
/** Unreal's prerequisite setup (UE4PrereqSetup_x64.exe, UEPrereqSetup_x64.exe): VC++ 2015-2022 and DirectX June 2010. */
private val UE_PREREQ = Regex("""(?i)ue\d?prereqsetup""")
/** An Epic prerequisite ([path] the installer it runs, [name] what it is called) to its components. */
fun epic(path: String, name: String): List<String> = when {
UE_PREREQ.containsMatchIn(path) || name.contains("UE4 Prerequisites", true) || name.contains("UE5 Prerequisites", true) ||
name.contains("Unreal Engine Prerequisites", true) -> listOf("vcredist2022", "d3dx9")
else -> DependencyDetector.componentsFor(path).ifEmpty { if (path.isEmpty()) gog(name) else emptyList() }
}
/** An installer a store runs after an install (Amazon's PostInstall "Command") to its components. */
fun installer(path: String): List<String> = if (UE_PREREQ.containsMatchIn(path)) listOf("vcredist2022", "d3dx9") else DependencyDetector.componentsFor(path)
/** Steamworks Shared depot ids to (component, Steam's label). */
fun steamDepots(ids: List<String>): List<Pair<String, String>> = ids.mapNotNull { SteamRedists.DEPOTS[it] }.distinctBy { it.first }
// ---- what each store left at install ----
/** Sidecar keys the store installs fill. */
const val GOG_DEPENDENCIES = "dependencies"
const val EPIC_PREREQ_PATH = "prereqPath"
const val EPIC_PREREQ_NAME = "prereqName"
/** Set beside the list when it was read whole (an empty list then means the store lists none). */
const val LIST_READ = "listRead"
/** fuel.json's PostInstall commands. */
fun amazonPostInstall(folder: File): List<String> = runCatching {
val arr = JSONObject(File(folder, "fuel.json").readText()).optJSONArray("PostInstall") ?: return emptyList()
(0 until arr.length()).mapNotNull { arr.optJSONObject(it)?.optString("Command")?.takeIf { c -> c.isNotEmpty() } }
}.getOrDefault(emptyList())
// ---- per game ----
/** Steam's list for [appId]: the local appmanifest's SharedDepots, else the client's app info. */
fun steamList(context: Context, gameDir: File?, appId: Int, gameName: String, auto: Boolean): List<Finding> {
val local = SteamRedists.detect(gameDir, appId)
val pairs = if (local.isNotEmpty()) local.map { it.componentName to it.label }
else SteamAppInfo.info(context, appId)?.let { steamDepots(it.sharedDepots) }.orEmpty()
return pairs.map { (component, label) ->
Finding(component, label, if (auto) Origin.STEAM else Origin.STEAM_NAME, auto, listOf(label, gameName, appId.toString()))
}
}
/** Everything known for [game], blocking (files; the client's app info; no network). */
fun forGame(context: Context, game: Library.SteamGame): List<Finding> {
val folder = game.gameFiles
val out = ArrayList<Finding>()
val store = Store.byId(game.source)
val sidecar = folder?.let { StoreGameSidecar.read(it) }
when {
game.library != Library.ADDED -> if (game.appId > 0) out += steamList(context, folder, game.appId, game.name, auto = true)
store == Store.GOG -> sidecar?.extra?.get(GOG_DEPENDENCIES)?.split(',')?.map { it.trim() }?.filter { it.isNotEmpty() }?.forEach { id ->
val parts = gog(id)
if (parts.isEmpty()) out += Finding(null, id, Origin.GOG, false)
parts.forEach { out += Finding(it, id, Origin.GOG, true) }
}
store == Store.EPIC -> {
val path = sidecar?.extra?.get(EPIC_PREREQ_PATH).orEmpty()
val name = sidecar?.extra?.get(EPIC_PREREQ_NAME).orEmpty()
if (path.isNotEmpty() || name.isNotEmpty()) {
val parts = epic(path, name)
val original = name.ifEmpty { path.substringAfterLast('/') }
if (parts.isEmpty()) out += Finding(null, original, Origin.EPIC, false)
parts.forEach { out += Finding(it, original, Origin.EPIC, true) }
}
}
store == Store.AMAZON -> folder?.let { amazonPostInstall(it) }?.forEach { command ->
val parts = installer(command)
val original = command.replace('\\', '/').substringAfterLast('/')
if (parts.isEmpty()) out += Finding(null, original, Origin.AMAZON, false)
parts.forEach { out += Finding(it, original, Origin.AMAZON, true) }
}
else -> {}
}
// A game added in DroidDeck (Custom, or a store's) that is a Steam app: Steam's list for it,
// on by itself when its files say so, else only recommended.
if (game.library == Library.ADDED && folder != null) SteamMatch.get(context, folder.path)?.let { match ->
val id = match.appId ?: return@let
when (match.certainty) {
SteamMatch.Certainty.FILES -> out += steamList(context, folder, id, game.name, auto = true)
SteamMatch.Certainty.NAME -> out += steamList(context, folder, id, game.name, auto = false)
else -> {}
}
}
// The folder scan, for every game: what it bundles, as Recommended.
folder?.let { DependencyDetector.detect(it) }?.forEach { rec ->
out += Finding(rec.componentName, rec.reason, Origin.FOLDER, false, listOf(rec.reason, rec.kind.name))
}
return out
}
}
@@ -168,7 +168,7 @@ object Lossless {
val libraries = listOfNotNull(
File(LinuxRuntime.rootDir(context), "root/.local/share/Steam"),
GameStorage.effective(context)?.path?.let(::File),
) + SessionPrefs.addedGamesDirs(context).map(::File)
) + com.droiddeck.launcher.frontend.AddedGames.roots(context).map { it.host }
return libraries.map { File(it, STEAM_DLL) }.filter { it.isFile }.maxByOrNull { it.lastModified() }
}
@@ -47,6 +47,7 @@ class ControllerEditorActivity : ComponentActivity() {
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
requestedOrientation = com.droiddeck.launcher.session.SessionPrefs.orientation(this)
immersive()
val root = FrameLayout(this)
root.setBackgroundColor(0xFF0B0F14.toInt())
@@ -46,6 +46,7 @@ object ControllerPrefs {
val steamButton: Boolean,
val qamButton: Boolean,
val keyboardButton: Boolean,
val selectSteam: Boolean,
val snap: Boolean,
val customLayout: Boolean,
val mapping: Map<String, String>,
@@ -65,6 +66,7 @@ object ControllerPrefs {
steamButton = p.getBoolean("steamButton", true),
qamButton = p.getBoolean("qamButton", true),
keyboardButton = p.getBoolean("keyboardButton", true),
selectSteam = p.getBoolean("selectSteam", false),
snap = p.getBoolean("snap", true),
customLayout = p.all.keys.any { it.startsWith("layout.") },
mapping = mappable.associate { (id, _) -> id to target(context, id) },
@@ -121,6 +123,11 @@ object ControllerPrefs {
prefs(context).edit().putBoolean("keyboardButton", on).apply()
}
/** The physical Select button opens the Steam menu instead of View/Select. */
fun setSelectSteam(context: Context, on: Boolean) {
prefs(context).edit().putBoolean("selectSteam", on).apply()
}
fun setSnap(context: Context, on: Boolean) {
prefs(context).edit().putBoolean("snap", on).apply()
}
@@ -94,18 +94,52 @@ public class FakeInputWriter {
private static final int RING_SNAPSHOT_SEQ_OFFSET = 32;
private static final int RING_SNAPSHOT_BUTTONS_OFFSET = 40;
private static final int RING_SNAPSHOT_AXES_OFFSET = 44; // short[8]
// After the events - MUST match the block in fakeinput_steam.cpp (DeckImu): what a Deck has and
// an Xbox pad does not - motion, back grips, trackpads - in the Steam Deck controller's axes and
// units, under a seqlock like the snapshot's. Only libfakeinput's Deck controller reads it.
// After the events - MUST match the blocks in fakeinput_steam.cpp. DeckImu carries the
// Deck-only motion, grips and trackpads. TritonShared carries the paired Steam Controller's
// native BLE reports and output reports, so the Steam client can see it as its own device.
private static final int IMU_OFFSET = RING_SIZE;
private static final int IMU_MAGIC = 0x31554D49; // IMU1
// Written by libfakeinput, not here: the client's SETTING_IMU_MODE | 0x80000000 (readImuMode).
private static final int IMU_MODE_OFFSET = IMU_OFFSET + 4;
private static final int IMU_SEQ_OFFSET = IMU_OFFSET + 8;
private static final int IMU_ACCEL_OFFSET = IMU_OFFSET + 16; // short[3]
private static final int IMU_GYRO_OFFSET = IMU_OFFSET + 22; // short[3]
private static final int DECK_PADS_OFFSET = IMU_OFFSET + 28; // short[4]: left X, Y, right X, Y
private static final int DECK_PRESSURE_OFFSET = IMU_OFFSET + 36; // short[2]: left, right
private static final int DECK_CONTROLS_OFFSET = IMU_OFFSET + 40; // int: DeckControls bits
private static final int RING_FILE_SIZE = RING_SIZE + 64;
// Slot 0 only: what the pad is to the Steam client (libfakeinput's PAD_TYPE_*), | PAD_TYPE_SET.
private static final int PAD_TYPE_OFFSET = IMU_OFFSET + 44;
private static final int PAD_TYPE_SET = 0x80000000;
public static final int PAD_TYPE_DECK = 0;
public static final int PAD_TYPE_STEAM = 1;
public static final int PAD_TYPE_XBOX = 2;
public static final int PAD_TYPE_DS4 = 3;
public static final int PAD_TYPE_DUALSENSE = 4;
/** The type chosen this process, written into a ring made after the choice too; -1 for none. */
private static volatile int padType = -1;
/** The paired controller's state, likewise written into a ring made after it connected. */
private static volatile boolean tritonConnected = false;
private static final int TRITON_OFFSET = RING_SIZE + 64;
private static final int TRITON_MAGIC = 0x4e525454; // TTRN
// Whether the paired controller is connected: libfakeinput offers its device only then.
private static final int TRITON_CONNECTED_OFFSET = TRITON_OFFSET + 4;
private static final int TRITON_INPUT_SEQ_OFFSET = TRITON_OFFSET + 8;
private static final int TRITON_REPORT_ID_OFFSET = TRITON_OFFSET + 16;
private static final int TRITON_REPORT_LENGTH_OFFSET = TRITON_OFFSET + 20;
private static final int TRITON_REPORT_OFFSET = TRITON_OFFSET + 24;
private static final int TRITON_OUTPUT_SEQ_OFFSET = TRITON_OFFSET + 88;
private static final int TRITON_OUTPUT_FEATURE_OFFSET = TRITON_OFFSET + 96;
private static final int TRITON_OUTPUT_LENGTH_OFFSET = TRITON_OFFSET + 100;
private static final int TRITON_OUTPUT_OFFSET = TRITON_OFFSET + 104;
// A feature read: libfakeinput bumps the request, the reply names the request it answers.
private static final int TRITON_GET_REQUEST_OFFSET = TRITON_OFFSET + 168;
private static final int TRITON_GET_REPLY_OFFSET = TRITON_OFFSET + 176;
private static final int TRITON_GET_REPORT_ID_OFFSET = TRITON_OFFSET + 184;
private static final int TRITON_GET_LENGTH_OFFSET = TRITON_OFFSET + 188;
private static final int TRITON_GET_OFFSET = TRITON_OFFSET + 192;
private static final int TRITON_REPORT_MAX = 64;
private static final int TRITON_BLOCK_SIZE = 256;
private static final int RING_FILE_SIZE = TRITON_OFFSET + TRITON_BLOCK_SIZE;
private static final Object RING_LOCK = new Object();
private static final RingSlot[] RING_SLOTS = new RingSlot[MAX_FAKE_INPUT_SLOTS];
@@ -215,11 +249,24 @@ public class FakeInputWriter {
data.putShort(RING_SNAPSHOT_AXES_OFFSET + (i * 2), (short) 0);
}
data.putInt(IMU_OFFSET, 0);
data.putInt(IMU_MODE_OFFSET, 0);
data.putLong(IMU_SEQ_OFFSET, 0L);
for (int i = 0; i < 12; i++) {
data.putShort(IMU_ACCEL_OFFSET + (i * 2), (short) 0);
}
data.putInt(DECK_CONTROLS_OFFSET, 0);
int type = padType;
data.putInt(PAD_TYPE_OFFSET, type >= 0 ? PAD_TYPE_SET | type : 0);
data.putInt(TRITON_OFFSET, TRITON_MAGIC);
data.putInt(TRITON_CONNECTED_OFFSET, tritonConnected ? 1 : 0);
data.putLong(TRITON_INPUT_SEQ_OFFSET, 0L);
data.putInt(TRITON_REPORT_ID_OFFSET, 0);
data.putInt(TRITON_REPORT_LENGTH_OFFSET, 0);
data.putLong(TRITON_OUTPUT_SEQ_OFFSET, 0L);
data.putInt(TRITON_OUTPUT_FEATURE_OFFSET, 0);
data.putInt(TRITON_OUTPUT_LENGTH_OFFSET, 0);
data.putLong(TRITON_GET_REQUEST_OFFSET, 0L);
data.putLong(TRITON_GET_REPLY_OFFSET, 0L);
}
// Lock order: RING_LOCK, then the slot. Every writer holds the slot while it touches data, and
@@ -476,6 +523,23 @@ public class FakeInputWriter {
}
}
/**
* The motion the Steam client asked the Deck controller of slot {@code slot} for: its
* SETTING_IMU_MODE (0 = off), or -1 until it has said. Safe from any thread.
*/
public static int readImuMode(int slot) {
RingSlot ringSlot;
synchronized (RING_LOCK) {
ringSlot = slot >= 0 && slot < RING_SLOTS.length ? RING_SLOTS[slot] : null;
}
if (ringSlot == null) return -1;
synchronized (ringSlot) {
ByteBuffer ring = ringSlot.data;
int value = ring == null ? 0 : ring.getInt(IMU_MODE_OFFSET);
return value < 0 ? value & 0xffff : -1;
}
}
/**
* Publishes the Deck's back grips and trackpads for slot {@code slot} (see DeckControls):
* {@code controls} is the DeckControls bit set, {@code pads} left X, Y, right X, Y and
@@ -502,6 +566,121 @@ public class FakeInputWriter {
}
}
/**
* Switches what the pad is to the Steam client while the session runs ([PAD_TYPE_DECK],
* [PAD_TYPE_STEAM] or [PAD_TYPE_XBOX]); libfakeinput unplugs the old device and plugs in the new.
*/
public static void setPadType(int type) {
padType = type;
RingSlot ringSlot;
synchronized (RING_LOCK) {
ringSlot = RING_SLOTS[0];
}
if (ringSlot == null) return;
synchronized (ringSlot) {
ByteBuffer ring = ringSlot.data;
if (ring == null) return;
nativeStoreFence();
ring.putInt(PAD_TYPE_OFFSET, PAD_TYPE_SET | type);
}
}
/** The paired Steam Controller came or went (SteamControllerBle): its device is plugged in or out. */
public static void setTritonConnected(int slot, boolean connected) {
tritonConnected = connected;
RingSlot ringSlot = tritonSlot(slot);
if (ringSlot == null) return;
synchronized (ringSlot) {
ByteBuffer ring = ringSlot.data;
if (ring == null || ring.getInt(TRITON_OFFSET) != TRITON_MAGIC) return;
nativeStoreFence();
ring.putInt(TRITON_CONNECTED_OFFSET, connected ? 1 : 0);
}
}
/** Publishes a native Triton input report (without the HID report ID byte). */
public static void writeTritonState(int slot, int reportId, byte[] report) {
if (report == null || report.length == 0 || report.length > TRITON_REPORT_MAX) return;
RingSlot ringSlot;
synchronized (RING_LOCK) {
ringSlot = slot >= 0 && slot < RING_SLOTS.length ? RING_SLOTS[slot] : null;
}
if (ringSlot == null) return;
synchronized (ringSlot) {
ByteBuffer ring = ringSlot.data;
if (ring == null || ring.getInt(TRITON_OFFSET) != TRITON_MAGIC) return;
long seq = ring.getLong(TRITON_INPUT_SEQ_OFFSET);
if ((seq & 1) != 0) seq++;
ring.putLong(TRITON_INPUT_SEQ_OFFSET, seq + 1);
nativeStoreFence();
ring.putInt(TRITON_REPORT_ID_OFFSET, reportId);
ring.putInt(TRITON_REPORT_LENGTH_OFFSET, report.length);
for (int i = 0; i < TRITON_REPORT_MAX; i++) {
ring.put(TRITON_REPORT_OFFSET + i, i < report.length ? report[i] : 0);
}
nativeStoreFence();
ring.putLong(TRITON_INPUT_SEQ_OFFSET, seq + 2);
}
}
/** Returns the newest Steam-client output report after {@code lastSequence}, or null. */
public static byte[] readTritonOutput(int slot, long lastSequence) {
RingSlot ringSlot;
synchronized (RING_LOCK) {
ringSlot = slot >= 0 && slot < RING_SLOTS.length ? RING_SLOTS[slot] : null;
}
if (ringSlot == null) return null;
synchronized (ringSlot) {
ByteBuffer ring = ringSlot.data;
if (ring == null || ring.getInt(TRITON_OFFSET) != TRITON_MAGIC) return null;
nativeStoreFence();
long seq = ring.getLong(TRITON_OUTPUT_SEQ_OFFSET);
if ((seq & 1) != 0 || seq <= lastSequence) return null;
int length = ring.getInt(TRITON_OUTPUT_LENGTH_OFFSET);
if (length <= 0 || length > TRITON_REPORT_MAX) return null;
byte[] output = new byte[9 + length];
ByteBuffer.wrap(output).order(ByteOrder.LITTLE_ENDIAN).putLong(seq);
output[8] = (byte) (ring.getInt(TRITON_OUTPUT_FEATURE_OFFSET) != 0 ? 1 : 0);
for (int i = 0; i < length; i++) output[9 + i] = ring.get(TRITON_OUTPUT_OFFSET + i);
nativeStoreFence();
return seq == ring.getLong(TRITON_OUTPUT_SEQ_OFFSET) ? output : null;
}
}
/** The Steam client's newest unanswered feature read, or 0: its request number. */
public static long readTritonFeatureRequest(int slot) {
RingSlot ringSlot = tritonSlot(slot);
if (ringSlot == null) return 0L;
synchronized (ringSlot) {
ByteBuffer ring = ringSlot.data;
if (ring == null || ring.getInt(TRITON_OFFSET) != TRITON_MAGIC) return 0L;
nativeStoreFence();
long request = ring.getLong(TRITON_GET_REQUEST_OFFSET);
return request != ring.getLong(TRITON_GET_REPLY_OFFSET) ? request : 0L;
}
}
/** Answers feature read {@code request} with the controller's report, or a failure when null. */
public static void writeTritonFeatureReply(int slot, long request, byte[] report) {
RingSlot ringSlot = tritonSlot(slot);
if (ringSlot == null) return;
synchronized (ringSlot) {
ByteBuffer ring = ringSlot.data;
if (ring == null || ring.getInt(TRITON_OFFSET) != TRITON_MAGIC) return;
int length = report == null ? 0 : Math.min(report.length, TRITON_REPORT_MAX);
for (int i = 0; i < TRITON_REPORT_MAX; i++) ring.put(TRITON_GET_OFFSET + i, i < length ? report[i] : 0);
ring.putInt(TRITON_GET_LENGTH_OFFSET, length);
nativeStoreFence();
ring.putLong(TRITON_GET_REPLY_OFFSET, request);
}
}
private static RingSlot tritonSlot(int slot) {
synchronized (RING_LOCK) {
return slot >= 0 && slot < RING_SLOTS.length ? RING_SLOTS[slot] : null;
}
}
private static short clampShort(int value) {
if (value > Short.MAX_VALUE) {
return Short.MAX_VALUE;
@@ -29,13 +29,27 @@ import java.util.concurrent.atomic.AtomicIntegerArray;
* Guide-then-A chord the client itself answers.
*
* <p>Each further controller is another player: slots 1 to 3, an Xbox 360 pad each, which in a
* Steam session only the client reads (libfakeinput) and hands games through Steam Input.
* Steam session only the client reads (libfakeinput) and hands games through Steam Input. Slot 0
* is the device's own controls wherever it has them: a Bluetooth pad pressed first used to take
* it, and played as the handheld while its built-in pad became player 2.
*/
public final class PadBridge {
private static final String TAG = "PadBridge";
/** Player slots: the session prepares a ring for each (SessionService). */
public static final int SLOTS = 4;
private static final float DEAD_ZONE = 0.12f;
private static final int[] JOYSTICK_AXES = {
MotionEvent.AXIS_X, MotionEvent.AXIS_Y, MotionEvent.AXIS_Z, MotionEvent.AXIS_RZ,
MotionEvent.AXIS_HAT_X, MotionEvent.AXIS_HAT_Y, MotionEvent.AXIS_LTRIGGER,
MotionEvent.AXIS_RTRIGGER, MotionEvent.AXIS_BRAKE, MotionEvent.AXIS_GAS
};
private static final int[] CONTROLLER_BUTTONS = {
KeyEvent.KEYCODE_BUTTON_A, KeyEvent.KEYCODE_BUTTON_B, KeyEvent.KEYCODE_BUTTON_X,
KeyEvent.KEYCODE_BUTTON_Y, KeyEvent.KEYCODE_BUTTON_L1, KeyEvent.KEYCODE_BUTTON_R1,
KeyEvent.KEYCODE_BUTTON_SELECT, KeyEvent.KEYCODE_BUTTON_START,
KeyEvent.KEYCODE_BUTTON_THUMBL, KeyEvent.KEYCODE_BUTTON_THUMBR,
KeyEvent.KEYCODE_BUTTON_MODE, KeyEvent.KEYCODE_BUTTON_L2, KeyEvent.KEYCODE_BUTTON_R2
};
// The client takes A as part of the chord only once it has had Guide held for a while, and a
// client starved of CPU needs longer. Too short, and it acts on A as well, selecting whatever it
// had focused before opening QAM: with 80 ms of lead that was 3 times in 20 at rest, and 250 ms
@@ -65,6 +79,7 @@ public final class PadBridge {
private final PadState effectiveState = new PadState();
private final Handler mainHandler = new Handler(Looper.getMainLooper());
private boolean open;
private volatile boolean selectSteam;
private boolean systemGuidePressed;
private boolean systemQamPressed;
private boolean qamChordActive;
@@ -88,6 +103,17 @@ public final class PadBridge {
/** No physical controller is driving the pad: nothing yet, or the on-screen controls were last. */
public static final int NO_CONTROLLER = -1;
// AYN's handhelds (the Thor) present their own pad as a USB device Android calls external,
// beside the 2020:0111 it re-presents each Bluetooth Xbox pad as.
private static final int AYN_VENDOR = 0x2020;
private static final int AYN_BUILT_IN_PAD = 0x0112;
/** Whether this controller is the device's own controls rather than a pad connected to it. */
static boolean isBuiltIn(InputDevice device) {
return !device.isExternal()
|| (device.getVendorId() == AYN_VENDOR && device.getProductId() == AYN_BUILT_IN_PAD);
}
/**
* The Android input device id of the physical controller that last drove any slot, or
* {@link #NO_CONTROLLER}: where rumble goes when it names no slot of ours (RumbleComponent).
@@ -113,6 +139,11 @@ public final class PadBridge {
for (int slot = 1; slot < SLOTS; slot++) playerStates[slot] = new PadState();
}
/** The physical Select button opens the Steam menu instead of View/Select (Setup → Controller). */
public void setSelectSteam(boolean on) {
selectSteam = on;
}
/** Opens the ring; safe to call more than once. */
public synchronized boolean start() {
if (!open) {
@@ -154,8 +185,58 @@ public final class PadBridge {
// counting it would hide the on-screen controls with nothing to replace them.
if (device.isVirtual()) return false;
int sources = device.getSources();
return (sources & InputDevice.SOURCE_GAMEPAD) == InputDevice.SOURCE_GAMEPAD
|| (sources & InputDevice.SOURCE_JOYSTICK) == InputDevice.SOURCE_JOYSTICK;
if ((sources & InputDevice.SOURCE_GAMEPAD) == InputDevice.SOURCE_GAMEPAD) return true;
if ((sources & InputDevice.SOURCE_JOYSTICK) != InputDevice.SOURCE_JOYSTICK) return false;
if (device.getKeyboardType() != InputDevice.KEYBOARD_TYPE_ALPHABETIC) return true;
// Some composite keyboards advertise joystick input without any sticks or pad buttons.
// A keyboard's mouse ranges do not establish a joystick; a real hybrid's joystick ranges
// or physical controller buttons do. GAMEPAD above remains authoritative even with keys.
for (int axis : JOYSTICK_AXES) {
InputDevice.MotionRange range = device.getMotionRange(axis, InputDevice.SOURCE_JOYSTICK);
if (range != null && range.getMax() > range.getMin()) return true;
}
for (boolean present : device.hasKeys(CONTROLLER_BUTTONS)) {
if (present) return true;
}
return false;
}
/** Whether this key belongs to the pad, rather than a keyboard on the same input device. */
public static boolean isControllerKeyEvent(KeyEvent event) {
InputDevice device = event.getDevice();
if (device == null || device.isVirtual()) return false;
// An explicit pad button is unambiguous even when a hybrid omits its capabilities.
if (KeyEvent.isGamepadButton(event.getKeyCode())) {
return device.supportsSource(InputDevice.SOURCE_GAMEPAD)
|| device.supportsSource(InputDevice.SOURCE_JOYSTICK);
}
switch (event.getKeyCode()) {
case KeyEvent.KEYCODE_DPAD_UP:
case KeyEvent.KEYCODE_DPAD_RIGHT:
case KeyEvent.KEYCODE_DPAD_DOWN:
case KeyEvent.KEYCODE_DPAD_LEFT:
case KeyEvent.KEYCODE_BACK:
case KeyEvent.KEYCODE_MENU:
case KeyEvent.KEYCODE_HOME:
if (!isFromController(device)) return false;
// Without GAMEPAD, an alphabetic hybrid's keyboard arrows remain keyboard keys.
// Its ambiguous navigation keys need explicit pad/joystick event provenance;
// DPAD alone also describes ordinary keyboard arrows.
return device.supportsSource(InputDevice.SOURCE_GAMEPAD)
|| device.getKeyboardType() != InputDevice.KEYBOARD_TYPE_ALPHABETIC
|| event.isFromSource(InputDevice.SOURCE_GAMEPAD)
|| event.isFromSource(InputDevice.SOURCE_JOYSTICK);
default: return false;
}
}
/** Mouse motion on a composite controller still belongs to the pointer, not its sticks. */
public static boolean isControllerMotionEvent(MotionEvent event) {
InputDevice device = event.getDevice();
// An actual joystick event, like a pad button, survives a hybrid's missing capabilities.
return event.isFromSource(InputDevice.SOURCE_JOYSTICK) && device != null && !device.isVirtual()
&& (device.supportsSource(InputDevice.SOURCE_GAMEPAD)
|| device.supportsSource(InputDevice.SOURCE_JOYSTICK));
}
/** A Bluetooth keyboard can share a device with a joystick. Classify the key, not just
@@ -209,10 +290,9 @@ public final class PadBridge {
/** @return true when the event was a pad button and has been consumed. */
public synchronized boolean onKeyEvent(KeyEvent event) {
if (!isControllerKey(event)) return false;
if (!isControllerKeyEvent(event)) return false;
int slot = slotFor(event.getDevice());
PadState pad = stateFor(slot);
noteDevice(event.getDevice(), slot);
boolean pressed = event.getAction() == KeyEvent.ACTION_DOWN;
switch (event.getKeyCode()) {
case KeyEvent.KEYCODE_BUTTON_A: pad.press(0, pressed); break;
@@ -222,6 +302,11 @@ public final class PadBridge {
case KeyEvent.KEYCODE_BUTTON_L1: pad.press(4, pressed); break;
case KeyEvent.KEYCODE_BUTTON_R1: pad.press(5, pressed); break;
case KeyEvent.KEYCODE_BUTTON_SELECT:
// Optionally (Setup → Controller) the physical Select button opens the Steam menu
// instead of View/Select: on some handhelds it sits next to the Steam button.
if (selectSteam) pad.press(PadState.GUIDE, pressed);
else pad.press(6, pressed);
break;
case KeyEvent.KEYCODE_BACK: pad.press(6, pressed); break;
case KeyEvent.KEYCODE_BUTTON_START:
case KeyEvent.KEYCODE_MENU: pad.press(7, pressed); break;
@@ -239,6 +324,7 @@ public final class PadBridge {
case KeyEvent.KEYCODE_DPAD_LEFT: pad.left = pressed; break;
default: return false;
}
noteDevice(event.getDevice(), slot);
if (pressed) notePlayerInput();
statButtons++;
scheduleStats();
@@ -248,7 +334,7 @@ public final class PadBridge {
/** @return true when the event was a pad's sticks/triggers and has been consumed. */
public synchronized boolean onMotionEvent(MotionEvent event) {
if (!isFromController(event.getDevice())) return false;
if (!isControllerMotionEvent(event)) return false;
if (event.getAction() != MotionEvent.ACTION_MOVE) return false;
int slot = slotFor(event.getDevice());
PadState pad = stateFor(slot);
@@ -296,6 +382,23 @@ public final class PadBridge {
publish();
}
/**
* A controller the app reads itself rather than through Android's input (the Steam Controller
* over Bluetooth, SteamControllerBle): its whole state, merged into the one exported slot as a
* physical pad's events are. Android has no device for it, so a game's rumble cannot reach it.
* It streams its state whether or not anything moved; {@code changed} is whether this one did.
*/
public synchronized void applyExternal(java.util.function.Consumer<PadState> mutation, boolean changed) {
mutation.accept(state);
if (changed) {
activeControllerId = NO_CONTROLLER;
notePlayerInput();
statButtons++;
scheduleStats();
}
publish();
}
/** Touch-only Steam and QAM buttons, merged with physical input without changing its state. */
public synchronized void setSystemButtons(boolean guidePressed, boolean qamPressed) {
if (systemGuidePressed == guidePressed && systemQamPressed == qamPressed) return;
@@ -321,6 +424,19 @@ public final class PadBridge {
}
}
/**
* Gives every connected controller its slot now, so the client lists each one as it is
* connected instead of on its first press: a further player's pad is plugged in, at rest.
*/
public synchronized void claimConnected() {
for (int id : InputDevice.getDeviceIds()) {
InputDevice device = InputDevice.getDevice(id);
if (!isFromController(device)) continue;
int slot = slotFor(device);
if (slot > 0 && playerWriters[slot] == null) publish(slot);
}
}
/** A controller went away: its player's buttons are let go, and a further player's pad is unplugged. */
public synchronized void onDeviceRemoved(int deviceId) {
for (int slot = 0; slot < SLOTS; slot++) {
@@ -381,23 +497,55 @@ public final class PadBridge {
publish();
}
/** The slot this controller plays in: the one it has, else the first free one, else player 1's. */
/**
* The slot this controller plays in: the one it has, else slot 0 for the device's own controls
* and a further player's slot for a connected pad (slot 0 too while the device has no controls
* of its own), else player 1's.
*/
private int slotFor(InputDevice device) {
int id = device.getId();
if (owners[0] == id) return 0;
for (int slot = 1; slot < SLOTS; slot++) {
for (int slot = 0; slot < SLOTS; slot++) {
if (owners[slot] == id) return slot;
}
for (int slot = 0; slot < SLOTS; slot++) {
if (owners[slot] != NO_CONTROLLER && InputDevice.getDevice(owners[slot]) == null) free(slot);
}
for (int slot = 0; slot < SLOTS; slot++) {
if (isBuiltIn(device)) {
// A connected pad playing as the device's own controls moves to a player's slot of its own.
int was = owners[0];
if (was != NO_CONTROLLER) {
free(0);
InputDevice other = InputDevice.getDevice(was);
if (other != null && !isBuiltIn(other)) {
int to = claim(other, 1);
if (to > 0) publish(to);
}
}
owners[0] = id;
Log.i(TAG, "\"" + device.getName() + "\" (id " + id + ") is player 1, the device's own controls");
return 0;
}
int slot = claim(device, builtInConnected() ? 1 : 0);
return slot >= 0 ? slot : 0;
}
/** Takes the first free slot from {@code from} for this controller, or -1 if none is free. */
private int claim(InputDevice device, int from) {
for (int slot = from; slot < SLOTS; slot++) {
if (owners[slot] != NO_CONTROLLER) continue;
owners[slot] = id;
Log.i(TAG, "\"" + device.getName() + "\" (id " + id + ") is player " + (slot + 1));
owners[slot] = device.getId();
Log.i(TAG, "\"" + device.getName() + "\" (id " + device.getId() + ") is player " + (slot + 1));
return slot;
}
return 0;
return -1;
}
private static boolean builtInConnected() {
for (int id : InputDevice.getDeviceIds()) {
InputDevice device = InputDevice.getDevice(id);
if (isFromController(device) && isBuiltIn(device)) return true;
}
return false;
}
private PadState stateFor(int slot) {
@@ -20,9 +20,10 @@ import kotlin.math.roundToInt
* and units as SDL's Deck driver undoes them (SDL_hidapi_steamdeck.c): X right, Y away from the
* player, Z up; 1 g = 16384, 2000 °/s = 32768. The Steam client calibrates the gyro itself.
*
* Sensors run only while the session is on screen with the pad a Deck controller, sampled as often
* as the Deck reports (4 ms); each reading goes straight into the pad's ring
* ([FakeInputWriter.writeMotion]), where libfakeinput's 4 ms report picks up the latest.
* Sensors run only while the session is on screen with the pad a Deck controller, and the client
* wants motion (see [follow]), sampled as often as the Deck reports (4 ms); each reading goes
* straight into the pad's ring ([FakeInputWriter.writeMotion]), where libfakeinput's 4 ms report
* picks up the latest.
*/
class PadMotion(private val context: Context, private val rotation: () -> Int) : SensorEventListener {
private val sensors = context.getSystemService(SensorManager::class.java)
@@ -32,6 +33,11 @@ class PadMotion(private val context: Context, private val rotation: () -> Int) :
// Touched only on the worker thread.
private val accel = ShortArray(3)
private val gyro = ShortArray(3)
private var reading = false
private var idlePolls = 0
// Asking the display costs more than the rest of a reading, which arrives every 2 ms.
private var turn = Surface.ROTATION_0
private var turnAt = 0L
fun start() {
if (thread != null || sensors == null) return
@@ -45,8 +51,13 @@ class PadMotion(private val context: Context, private val rotation: () -> Int) :
thread = worker
val handler = Handler(worker.looper)
active = true
gyroscope?.let { sensors.registerListener(this, it, SAMPLING_US, handler) }
accelerometer?.let { sensors.registerListener(this, it, SAMPLING_US, handler) }
handler.post(object : Runnable {
override fun run() {
if (!active) return
follow(handler, gyroscope, accelerometer)
handler.postDelayed(this, POLL_MS)
}
})
Log.i(TAG, "pad motion: gyro ${gyroscope?.name ?: "none"}, accelerometer ${accelerometer?.name ?: "none"}")
}
@@ -54,31 +65,71 @@ class PadMotion(private val context: Context, private val rotation: () -> Int) :
val worker = thread ?: return
thread = null
active = false
sensors?.unregisterListener(this)
// A still controller, not one frozen mid-turn - published on the worker, after any reading
// already queued there, so nothing can overwrite it.
// On the worker, after any poll or reading already queued there, so nothing registers again
// or overwrites the still controller.
Handler(worker.looper).post {
gyro.fill(0)
FakeInputWriter.writeMotion(SLOT, accel, gyro)
SteamTouchDevice.current?.setMotion(accel, gyro)
sensors?.unregisterListener(this)
reading = false
idlePolls = 0
still()
}
worker.quitSafely()
}
/**
* Reads the sensors while the client wants motion: the Deck's IMU mode (unknown counts as
* wanted), or Steam's touch controls asking for gyro. A game whose layout has no gyro turns the
* mode off; Steam's own menus keep it on. The client also turns it off and on again for a few
* ms whenever it re-applies a layout, so the sensors stop only after two polls without it.
*/
private fun follow(handler: Handler, gyroscope: Sensor?, accelerometer: Sensor?) {
val touch = SteamTouchDevice.current
val wanted = FakeInputWriter.readImuMode(SLOT) != 0 ||
touch != null && touch.plugged && touch.gyroRequested && touch.motionEnabled
idlePolls = if (wanted) 0 else idlePolls + 1
if (wanted && !reading) {
reading = true
turnAt = 0L
gyroscope?.let { sensors?.registerListener(this, it, SAMPLING_US, handler) }
accelerometer?.let { sensors?.registerListener(this, it, SAMPLING_US, handler) }
Log.i(TAG, "pad motion: reading")
} else if (reading && idlePolls >= 2) {
reading = false
sensors?.unregisterListener(this)
still()
Log.i(TAG, "pad motion: paused, the client wants none")
}
}
/** A still controller, not one frozen mid-turn. */
private fun still() {
gyro.fill(0)
FakeInputWriter.writeMotion(SLOT, accel, gyro)
SteamTouchDevice.current?.setMotion(accel, gyro)
}
override fun onSensorChanged(event: SensorEvent) {
if (!active) return
val values = event.values
if (event.timestamp - turnAt > ROTATION_REFRESH_NS) {
turn = rotation()
turnAt = event.timestamp
}
// Natural orientation to the screen's: Android's display-rotation transform.
val (x, y) = when (rotation()) {
Surface.ROTATION_90 -> -values[1] to values[0]
Surface.ROTATION_180 -> -values[0] to -values[1]
Surface.ROTATION_270 -> values[1] to -values[0]
else -> values[0] to values[1]
val x: Float
val y: Float
when (turn) {
Surface.ROTATION_90 -> { x = -values[1]; y = values[0] }
Surface.ROTATION_180 -> { x = -values[0]; y = -values[1] }
Surface.ROTATION_270 -> { x = values[1]; y = -values[0] }
else -> { x = values[0]; y = values[1] }
}
val z = values[2]
val (target, scale) = when (event.sensor.type) {
Sensor.TYPE_GYROSCOPE -> gyro to GYRO_COUNTS_PER_RAD_S
Sensor.TYPE_ACCELEROMETER -> accel to ACCEL_COUNTS_PER_M_S2
val target: ShortArray
val scale: Float
when (event.sensor.type) {
Sensor.TYPE_GYROSCOPE -> { target = gyro; scale = GYRO_COUNTS_PER_RAD_S }
Sensor.TYPE_ACCELEROMETER -> { target = accel; scale = ACCEL_COUNTS_PER_M_S2 }
else -> return
}
// Screen frame (right, up, towards the player) to the Deck's (right, away, up).
@@ -99,6 +150,9 @@ class PadMotion(private val context: Context, private val rotation: () -> Int) :
private const val SLOT = 0
/** The Deck controller's report interval (libfakeinput: DECK_REPORT_INTERVAL_US). */
private const val SAMPLING_US = 4000
/** How often [follow] checks whether the client wants motion. */
private const val POLL_MS = 500L
private const val ROTATION_REFRESH_NS = 250_000_000L
private const val ACCEL_COUNTS_PER_M_S2 = 16384f / SensorManager.GRAVITY_EARTH
private const val GYRO_COUNTS_PER_RAD_S = (32768f / 2000f) * (180f / Math.PI.toFloat())
}
@@ -0,0 +1,362 @@
package com.droiddeck.launcher.input
import android.annotation.SuppressLint
import android.bluetooth.BluetoothAdapter
import android.bluetooth.BluetoothDevice
import android.bluetooth.BluetoothGatt
import android.bluetooth.BluetoothGattCallback
import android.bluetooth.BluetoothGattCharacteristic
import android.bluetooth.BluetoothGattDescriptor
import android.bluetooth.BluetoothManager
import android.bluetooth.BluetoothProfile
import android.content.Context
import android.os.Handler
import android.os.HandlerThread
import android.util.Log
import java.nio.ByteBuffer
import java.nio.ByteOrder
import java.util.ArrayDeque
import java.util.UUID
/**
* Reads the Steam Controller (2025, Triton) through its Valve BLE service. State reports remain
* native Triton packets and are published to the session's separate 28DE:1303 HID device. Steam's
* output reports travel back to their matching BLE characteristics, preserving grip capacitance,
* trackpads, sensors, and controller haptics without folding the controller into the Deck target.
*
* Lizard mode is refreshed while connected; when a session ends, the controller restores it itself.
*/
@SuppressLint("MissingPermission") // BLUETOOTH is granted at install below targetSdk 31
class SteamControllerBle(private val context: Context) {
private val thread = HandlerThread("steam-controller-ble").apply { start() }
private val handler = Handler(thread.looper)
private val outputReports = mutableMapOf<Int, UUID>()
/** GATT allows one operation at a time: writes carry a value, a feature read none. */
private val queuedWrites = ArrayDeque<Pair<UUID, ByteArray?>>()
private var gatt: BluetoothGatt? = null
private var running = false
private var input: UUID? = null
private var reportId = 0
private var lizardOffSent = false
private var reportedNone = false
private var discoveries = 0
private var outputSequence = 0L
private var outputWritePending = false
/** The Steam client's feature read being answered, so the poll queues each one once. */
private var featureRead = 0L
/** Looks for a paired Steam Controller and keeps a connection to it until [stop]. */
fun start() = handler.post {
if (running) return@post
running = true
Log.i(TAG, "looking for a paired Steam Controller")
connect()
}
/** Lets the controller go (it returns to lizard mode by itself); this object is done after. */
fun stop() = handler.post {
running = false
handler.removeCallbacksAndMessages(null)
disconnect("session ended")
thread.quitSafely()
}
private fun connect() {
if (!running || gatt != null) return
val adapter = (context.getSystemService(Context.BLUETOOTH_SERVICE) as? BluetoothManager)?.adapter
val bonded = runCatching { adapter?.takeIf(BluetoothAdapter::isEnabled)?.bondedDevices }
.onFailure { Log.w(TAG, "paired devices unreadable", it) }
.getOrNull()
val device = bonded?.firstOrNull(::isSteamController)
if (device == null) {
if (!reportedNone) {
reportedNone = true
Log.i(TAG, "no paired Steam Controller (Bluetooth ${if (adapter?.isEnabled == true) "on" else "off"}; paired: " +
(bonded?.joinToString { "\"${it.name}\" type ${it.type}" } ?: "unreadable") + "); looking again every ${RETRY_MS / 1000} s")
}
handler.postDelayed(::connect, RETRY_MS)
return
}
Log.i(TAG, "connecting to \"${device.name}\"")
gatt = device.connectGatt(context, false, callback, BluetoothDevice.TRANSPORT_LE, BluetoothDevice.PHY_LE_1M_MASK, handler)
}
private fun disconnect(why: String) {
val g = gatt ?: return
gatt = null
discoveries = 0
input = null
lizardOffSent = false
outputWritePending = false
featureRead = 0L
queuedWrites.clear()
outputReports.clear()
connected = false
FakeInputWriter.setTritonConnected(SLOT, false)
g.disconnect()
g.close()
release()
Log.i(TAG, "disconnected: $why")
listener?.invoke()
}
private val callback = object : BluetoothGattCallback() {
override fun onConnectionStateChange(g: BluetoothGatt, status: Int, newState: Int) {
if (g !== gatt) return
if (newState == BluetoothProfile.STATE_CONNECTED) {
g.discoverServices()
} else if (newState == BluetoothProfile.STATE_DISCONNECTED) {
disconnect("link lost (status $status)")
if (running) handler.postDelayed(::connect, RETRY_MS)
}
}
override fun onServicesDiscovered(g: BluetoothGatt, status: Int) {
if (g !== gatt) return
if (status != BluetoothGatt.GATT_SUCCESS) {
disconnect("service discovery failed (status $status)")
if (running) handler.postDelayed(::connect, RETRY_MS)
return
}
// The Triton needs data length extensions, which Android enables for a large MTU.
if (!g.requestMtu(517)) subscribe(g)
}
override fun onMtuChanged(g: BluetoothGatt, mtu: Int, status: Int) {
if (g === gatt) subscribe(g)
}
override fun onDescriptorWrite(g: BluetoothGatt, descriptor: BluetoothGattDescriptor, status: Int) {
if (g !== gatt || descriptor.characteristic.uuid != input) return
Log.i(TAG, "subscribed to report 0x%02x (status %d)".format(reportId, status))
connected = status == BluetoothGatt.GATT_SUCCESS
FakeInputWriter.setTritonConnected(SLOT, connected)
listener?.invoke()
if (connected) {
refreshLizardMode()
pollOutput()
}
}
override fun onCharacteristicWrite(g: BluetoothGatt, characteristic: BluetoothGattCharacteristic, status: Int) {
if (g !== gatt) return
if (characteristic.uuid == REPORT && !lizardOffSent) {
lizardOffSent = true
Log.i(TAG, "lizard mode off (status $status)")
} else if (status != BluetoothGatt.GATT_SUCCESS) {
Log.w(TAG, "output report write failed for ${characteristic.uuid} (status $status)")
}
outputWritePending = false
writeNextOutput()
}
override fun onCharacteristicRead(g: BluetoothGatt, characteristic: BluetoothGattCharacteristic, value: ByteArray, status: Int) {
if (g === gatt) onFeatureRead(value.takeIf { status == BluetoothGatt.GATT_SUCCESS })
}
@Deprecated("Below Android 13 only", ReplaceWith(""))
override fun onCharacteristicRead(g: BluetoothGatt, characteristic: BluetoothGattCharacteristic, status: Int) {
@Suppress("DEPRECATION")
if (g === gatt) onFeatureRead(characteristic.value.takeIf { status == BluetoothGatt.GATT_SUCCESS })
}
// Android 13 hands the value over with the call; older ones only through the characteristic.
override fun onCharacteristicChanged(g: BluetoothGatt, characteristic: BluetoothGattCharacteristic, value: ByteArray) {
if (g === gatt && characteristic.uuid == input) onState(value)
}
@Deprecated("Below Android 13 only", ReplaceWith(""))
override fun onCharacteristicChanged(g: BluetoothGatt, characteristic: BluetoothGattCharacteristic) {
@Suppress("DEPRECATION")
if (g === gatt && characteristic.uuid == input) onState(characteristic.value ?: return)
}
}
private fun subscribe(g: BluetoothGatt) {
val service = g.getService(SERVICE)
if (service == null) {
Log.w(TAG, "no Valve service on \"${g.device.name}\" (attempt ${discoveries + 1}); services: " +
g.services.joinToString { it.uuid.toString().take(8) })
// Android may answer from the service list its HID host cached, which can lack the
// vendor service: ask the controller again before giving up on it.
if (++discoveries < 3) {
refreshCache(g)
handler.postDelayed({ if (g === gatt) g.discoverServices() }, 500)
} else {
disconnect("no Valve service")
if (running) handler.postDelayed(::connect, RETRY_MS)
}
return
}
val chr = service.getCharacteristic(INPUT_0X47)?.also { reportId = 0x47 }
?: service.getCharacteristic(INPUT_0X45)?.also { reportId = 0x45 }
if (chr == null) {
disconnect("no state report characteristic")
return
}
outputReports.clear()
service.characteristics.forEach { characteristic ->
val uuid = characteristic.uuid.toString().lowercase()
if (uuid.startsWith(OUTPUT_UUID_PREFIX)) {
val characteristicId = uuid.substring(6, 8).toIntOrNull(16) ?: return@forEach
val outputId = characteristicId - OUTPUT_UUID_OFFSET
if (outputId in 0x80..0x85) outputReports[outputId] = characteristic.uuid
}
}
Log.i(TAG, "found Triton haptic reports: ${outputReports.keys.sorted().joinToString { "0x%02x".format(it) }}")
input = chr.uuid
g.setCharacteristicNotification(chr, true)
val cccd = chr.getDescriptor(CCCD) ?: return disconnect("state report cannot notify")
@Suppress("DEPRECATION")
cccd.value = BluetoothGattDescriptor.ENABLE_NOTIFICATION_VALUE
@Suppress("DEPRECATION")
g.writeDescriptor(cccd)
}
/** BluetoothGatt.refresh(): hidden, but used to drop stale service caches. */
private fun refreshCache(g: BluetoothGatt) {
runCatching { g.javaClass.getMethod("refresh").invoke(g) }
.onFailure { Log.w(TAG, "could not refresh the service cache", it) }
}
/** SDL's DisableSteamTritonLizardMode: ID_SET_SETTINGS_VALUES (0x87), setting 9 = off. */
private fun refreshLizardMode() {
if (!connected) return
val msg = ByteArray(FEATURE_BYTES)
msg[0] = 0x87.toByte(); msg[1] = 3; msg[2] = 9
enqueueOutput(REPORT, msg)
handler.postDelayed(::refreshLizardMode, LIZARD_REFRESH_MS)
}
/** Preserve the full 0x45 or 0x47 packet so Triton-only bits reach Steam's native driver. */
private fun onState(data: ByteArray) {
if (data.size < STATE_BYTES) return
FakeInputWriter.writeTritonState(SLOT, reportId, data)
}
/** A neutral report prevents a link lost mid-press from leaving controls held. */
private fun release() {
FakeInputWriter.writeTritonState(SLOT, if (reportId == 0x47) 0x47 else 0x45, ByteArray(STATE_BYTES))
}
/** Drain Steam HID output reports and write each to Triton's matching BLE characteristic. */
private fun pollOutput() {
if (!running || !connected || gatt == null) return
FakeInputWriter.readTritonOutput(SLOT, outputSequence)?.let { output ->
outputSequence = ByteBuffer.wrap(output, 0, 8).order(ByteOrder.LITTLE_ENDIAN).long
sendOutputReport(output[8] != 0.toByte(), output.copyOfRange(9, output.size))
}
val request = FakeInputWriter.readTritonFeatureRequest(SLOT)
if (request != 0L && request != featureRead) {
featureRead = request
// Behind any queued write, so a read answers the feature report written before it.
queuedWrites.addLast(REPORT to null)
writeNextOutput()
}
handler.postDelayed(::pollOutput, OUTPUT_POLL_MS)
}
/** The controller's answer to a feature read (its info, for one), or null when the read failed. */
private fun onFeatureRead(value: ByteArray?) {
outputWritePending = false
if (featureRead != 0L) {
if (value == null) Log.w(TAG, "feature read failed")
FakeInputWriter.writeTritonFeatureReply(SLOT, featureRead, value)
}
writeNextOutput()
}
private fun sendOutputReport(feature: Boolean, report: ByteArray) {
if (report.size < 2) return
val characteristic = if (feature) {
REPORT
} else {
val id = report[0].toInt() and 0xff
outputReports[id] ?: run {
Log.w(TAG, "no BLE output characteristic for report 0x%02x".format(id))
return
}
}
// SDL's BLE driver removes the HID report ID and trailing padding byte before transmission.
enqueueOutput(characteristic, report.copyOfRange(1, report.size - 1))
}
private fun enqueueOutput(characteristic: UUID, value: ByteArray?) {
queuedWrites.addLast(characteristic to value)
writeNextOutput()
}
private fun writeNextOutput() {
if (outputWritePending) return
val g = gatt ?: return
val (uuid, value) = queuedWrites.pollFirst() ?: return
val characteristic = g.getService(SERVICE)?.getCharacteristic(uuid)
if (characteristic == null) {
Log.w(TAG, "BLE output characteristic $uuid disappeared")
return
}
if (value == null) {
if (g.readCharacteristic(characteristic)) {
outputWritePending = true
} else {
queuedWrites.addFirst(uuid to null)
handler.postDelayed(::writeNextOutput, OUTPUT_RETRY_MS)
}
return
}
@Suppress("DEPRECATION")
characteristic.writeType = BluetoothGattCharacteristic.WRITE_TYPE_DEFAULT
@Suppress("DEPRECATION")
characteristic.value = value
@Suppress("DEPRECATION")
if (!g.writeCharacteristic(characteristic)) {
queuedWrites.addFirst(uuid to value)
handler.postDelayed(::writeNextOutput, OUTPUT_RETRY_MS)
} else {
outputWritePending = true
}
}
companion object {
private const val TAG = "SteamControllerBle"
/** The paired controller's native Triton reports live in slot 0's extended ring. */
private const val SLOT = 0
private const val RETRY_MS = 5_000L
private const val LIZARD_REFRESH_MS = 3_000L
private const val OUTPUT_POLL_MS = 4L
private const val OUTPUT_RETRY_MS = 10L
/** SDL's 64-byte feature report less the report ID and the trailing byte. */
private const val FEATURE_BYTES = 62
private const val STATE_BYTES = 45
private const val OUTPUT_UUID_PREFIX = "100f6c"
private const val OUTPUT_UUID_OFFSET = 0x35
private val SERVICE = UUID.fromString("100F6C32-1735-4313-B402-38567131E5F3")
private val INPUT_0X45 = UUID.fromString("100F6C7A-1735-4313-B402-38567131E5F3")
private val INPUT_0X47 = UUID.fromString("100F6C7C-1735-4313-B402-38567131E5F3")
private val REPORT = UUID.fromString("100F6C34-1735-4313-B402-38567131E5F3")
private val CCCD = UUID.fromString("00002902-0000-1000-8000-00805f9b34fb")
private fun isSteamController(device: BluetoothDevice): Boolean =
device.type and BluetoothDevice.DEVICE_TYPE_LE != 0 &&
(device.name ?: "").let { it.startsWith("Steam Ctrl") || it.startsWith("SteamController") }
/** Whether a session should expose a Triton HID node for a paired controller. */
@JvmStatic
fun hasPairedController(context: Context): Boolean {
val adapter = (context.getSystemService(Context.BLUETOOTH_SERVICE) as? BluetoothManager)?.adapter ?: return false
return runCatching { adapter.isEnabled && adapter.bondedDevices.any(::isSteamController) }
.onFailure { Log.w(TAG, "paired devices unreadable", it) }
.getOrDefault(false)
}
/** A Steam Controller is connected and feeding its native HID reports. */
@Volatile @JvmStatic
var connected = false
private set
/** Notifies the activity when [connected] changes. */
@Volatile @JvmStatic
var listener: (() -> Unit)? = null
}
}
@@ -256,6 +256,20 @@ object AppImageManager {
"Terminal=false\nCategories=$categories\nX-DroidDeck-AppImage=$id\n")
}
/** The desktop's import of [key]; older ones of the image at [path], since replaced, are removed. */
fun desktopImport(context: Context, key: String, path: String): String? {
var found: String? = null
root(context).listFiles()?.forEach { dir ->
val asked = FileUtils.readString(File(dir, UserApps.DESKTOP_IMPORT))?.lines() ?: return@forEach
if (asked.firstOrNull() == key) {
if (File(dir, "app").isDirectory) found = dir.name
} else if (asked.getOrNull(1) == path) {
remove(context, dir.name)
}
}
return found
}
fun remove(context: Context, id: String) {
if (id.isEmpty() || '/' in id || id.startsWith(".")) return
FileUtils.delete(File(root(context), id))
@@ -20,12 +20,12 @@ import java.io.File
*/
object DesktopCatalog {
private const val TAG = "DesktopCatalog"
const val CATALOG_URL = "https://raw.githubusercontent.com/The412Banner/winlator-contents/main/desktop.json"
const val CATALOG_URL = "https://raw.githubusercontent.com/Droid-Deck/DroidDeck-Components/main/desktop.json"
/**
* Valve's Proton Experimental (ARM64) with its appmanifest, laid out as the client keeps it.
* In a catalog of its own so it never shows among the packages a user picks from.
*/
const val STEAM_SEED_URL = "https://raw.githubusercontent.com/The412Banner/winlator-contents/main/steam-seed.json"
const val STEAM_SEED_URL = "https://raw.githubusercontent.com/Droid-Deck/DroidDeck-Components/main/steam-seed.json"
const val PROTON_SEED_ID = "proton-arm64"
class Entry(
@@ -41,7 +41,7 @@ public final class LinuxRuntimeInstaller {
/** Catalog row, beside the other component catalogs in winlator-contents. */
public static final String CATALOG_URL =
"https://raw.githubusercontent.com/The412Banner/winlator-contents/main/linuxfs.json";
"https://raw.githubusercontent.com/Droid-Deck/DroidDeck-Components/main/linuxfs.json";
private static final String VERSION_FILE = ".version";
@@ -32,6 +32,8 @@ object UserApps {
private const val TAG = "UserApps"
const val GUEST_DIR = "/opt/droiddeck-apps"
const val SCRIPT_LAUNCHER = "/usr/local/bin/droiddeck-script-run"
/** An app the desktop asked for (droiddeck-open): its key, then the path it was started from there. */
const val DESKTOP_IMPORT = "desktop-import"
private const val FLATPAK_OVERRIDES = ".flatpak"
private const val ICON_SIZE = 256
private const val ELF_SCAN_LIMIT = 4000
@@ -72,8 +74,8 @@ object UserApps {
class FlatpakBundle(val file: File) : Source()
}
/** [icon]: a picked file's path or a suggested icon's URL. */
class Request(val source: Source, val name: String?, val icon: String?)
/** [icon]: a picked file's path or a suggested icon's URL. [meta]: files written into the added app's folder. */
class Request(val source: Source, val name: String?, val icon: String?, val meta: Map<String, String> = emptyMap())
/** How a script's folder reaches the session: linked in place, or copied in ([bytes] to copy). */
class ScriptPlan(val folder: File, val copy: Boolean, val bytes: Long)
@@ -121,8 +123,8 @@ object UserApps {
val name = request.name?.trim()?.takeIf { it.isNotEmpty() }
return withIcon(context, request.icon) { icon ->
when (val s = request.source) {
is Source.Script -> addScript(context, s.file, name, icon, onProgress)
is Source.AppImage -> AppImageManager.import(context, s.file, name, icon) { onProgress(it, -1) }
is Source.Script -> addScript(context, s.file, name, icon, request.meta, onProgress)
is Source.AppImage -> AppImageManager.import(context, s.file, name, icon, request.meta) { onProgress(it, -1) }
is Source.GitHub -> addFromGitHub(context, s.repo, name, icon, onProgress)
is Source.Flatpak -> addFlatpak(context, s.id, name, icon, onProgress)
is Source.FlatpakBundle -> addFlatpakBundle(context, s.file, name, icon, onProgress)
@@ -197,6 +199,14 @@ object UserApps {
}.distinct()
}
/** The desktop's import of [key] as the session sees it: an added script's folder, or an AppImage's. */
fun desktopImport(context: Context, key: String, path: String): String? {
root(context).listFiles()?.firstOrNull { dir ->
File(dir, "entry").isFile && FileUtils.readString(File(dir, DESKTOP_IMPORT))?.lineSequence()?.firstOrNull() == key
}?.let { return "$GUEST_DIR/${it.name}" }
return AppImageManager.desktopImport(context, key, path)?.let { "${AppImageManager.GUEST_DIR}/$it" }
}
fun planScript(script: File): ScriptPlan {
val folder = script.canonicalFile.parentFile ?: script.parentFile!!
var scanned = 0
@@ -209,7 +219,9 @@ object UserApps {
RandomAccessFile(f, "r").use { val b = ByteArray(4); it.read(b) == 4 && b[0] == 0x7f.toByte() && b[1] == 'E'.code.toByte() && b[2] == 'L'.code.toByte() && b[3] == 'F'.code.toByte() }
} catch (e: Exception) { false }
private fun addScript(context: Context, script: File, name: String?, icon: File?, onProgress: (String, Int) -> Unit): String? {
private fun addScript(
context: Context, script: File, name: String?, icon: File?, meta: Map<String, String>, onProgress: (String, Int) -> Unit,
): String? {
if (!script.isFile) return context.getString(R.string.user_apps_file_gone)
val plan = planScript(script)
val base = root(context).apply { mkdirs() }
@@ -232,6 +244,7 @@ object UserApps {
FileUtils.writeString(File(dir, "name"), title)
FileUtils.writeString(File(dir, "source"), script.canonicalPath)
if (icon != null && !saveIcon(icon, File(dir, "icon.png"))) Log.w(TAG, "icon ${icon.path} could not be read")
meta.forEach { (k, v) -> FileUtils.writeString(File(dir, k), v) }
writeScriptEntry(context, dir)
return null
} catch (e: Exception) {
@@ -68,12 +68,19 @@ object GameStorage {
class Library(val host: File, val guest: String, val label: String)
/**
* Games folders an earlier build registered as Steam libraries that Steam has installed games
* into (an appmanifest in their steamapps/): those stay libraries, so the games are not lost.
* Any other Games folder is no longer one; the session's library list leaves it out and the
* runtime's droiddeck-steam-library forgets it.
*/
fun gamesFolderLibraries(context: Context): List<Library> {
if (SessionPrefs.gameStorage(context).isNotEmpty()) return emptyList()
val card = effective(context)?.let { canonical(File(it.path)) }
val sm = context.getSystemService(StorageManager::class.java)
return AddedGames.roots(context).mapNotNull { root ->
val host = canonical(root.host)
if (!hasInstalledGames(root.host)) return@mapNotNull null
if ('"' in root.guest || '\\' in root.guest || !root.host.isDirectory || !root.host.canWrite()) return@mapNotNull null
if (card != null && (host == card || host.startsWith("$card/") || card.startsWith("$host/"))) return@mapNotNull null
val volume = try { sm?.getStorageVolume(root.host)?.getDescription(context) } catch (e: Exception) { null }
@@ -83,6 +90,10 @@ object GameStorage {
}
}
/** Steam has installed a game into [root] as a library: a manifest in its steamapps/. */
internal fun hasInstalledGames(root: File): Boolean =
File(root, "steamapps").listFiles { f -> f.isFile && f.name.startsWith("appmanifest_") && f.name.endsWith(".acf") }?.isNotEmpty() == true
private fun canonical(file: File): String = runCatching { file.canonicalPath }.getOrDefault(file.absolutePath)
/** The label the client shows for a chosen folder: its last name, or the volume's. */
@@ -4,6 +4,7 @@ import android.net.LocalServerSocket
import android.content.SharedPreferences
import android.os.Build
import android.os.CombinedVibration
import android.os.SystemClock
import android.os.VibrationEffect
import android.os.Vibrator
import android.os.VibratorManager
@@ -33,6 +34,8 @@ class RumbleComponent : SessionPart() {
private var phone: Motors? = null
/** Per slot, where its last effect went, so the next one (or a stop) can end it there. */
private val playing = HashMap<Int, Motors>()
/** Per slot, the last effect handed to the motors and when, to drop the repeats games stream. */
private val sent = HashMap<Int, Sent>()
/** Each controller's motors, looked up once; null when it has none. */
private val controllers = HashMap<Int, Motors?>()
private var preferences: SharedPreferences? = null
@@ -95,13 +98,21 @@ class RumbleComponent : SessionPart() {
val previous = playing[slot]
if (target !== previous) {
previous?.cancel()
sent.remove(slot)
if (target == null) playing.remove(slot) else playing[slot] = target
target?.let { Log.i(TAG, "rumble: slot $slot playing on ${it.name}") }
}
if (target == null) return
if ((strong == 0 && weak == 0) || ms == 0) { target.cancel(); return }
if ((strong == 0 && weak == 0) || ms == 0) { target.cancel(); sent.remove(slot); return }
// Games re-send their effect every frame; restarting the motor that often floods the haptics
// driver. ponytail: a change inside MIN_GAP_MS is dropped, the game's next packet brings it.
val now = SystemClock.uptimeMillis()
val last = sent[slot]
if (last != null && (now - last.at < MIN_GAP_MS || (last.strong == strong && last.weak == weak && now < last.until))) return
val duration = ms.toLong().coerceIn(1L, 5000L)
try {
target.play(strong, weak, ms.toLong().coerceIn(1L, 5000L))
target.play(strong, weak, duration)
sent[slot] = Sent(strong, weak, now, now + duration)
} catch (e: Exception) {
Log.w(TAG, "rumble: ${target.name}: $e")
}
@@ -118,8 +129,11 @@ class RumbleComponent : SessionPart() {
private fun cancelAll() {
playing.values.toSet().forEach { it.cancel() }
playing.clear()
sent.clear()
}
private data class Sent(val strong: Int, val weak: Int, val at: Long, val until: Long)
/** One place an effect can play. Strengths are the evdev 0..65535 magnitudes. */
internal interface Motors {
val name: String
@@ -160,6 +174,8 @@ class RumbleComponent : SessionPart() {
private const val TAG = "SessionService"
/** Must match the fake evdev layer (fakeinput_steam.cpp). */
const val NAME = "droiddeck-rumble"
/** The shortest time between two effects started on one slot's motors. */
const val MIN_GAP_MS = 50L
private fun oneShot(vibrator: Vibrator, strength: Int, ms: Long): VibrationEffect {
val amplitude = if (vibrator.hasAmplitudeControl()) (strength * 255L / 65535L).toInt().coerceIn(1, 255)
@@ -52,6 +52,26 @@ object SessionDisplay {
fun followSize(width: Int, height: Int): Pair<Int, Int>? =
if (minOf(width, height) < FOLLOW_MIN_SIDE) null else (width and 1.inv()) to (height and 1.inv())
/** Unfolded controls: the most of the window the game may take, so a window near 16:9 itself
* still leaves the Deck controls room. */
const val UNFOLDED_MAX_GAME_SHARE = 0.8f
/** A foldable: Android 11's hinge angle sensor, which foldables report and slates do not. Unfolded
* controls are offered only there - untested on tablets, whose 16:10 landscape leaves them
* little room under a 16:9 game. */
fun foldable(context: Context): Boolean =
context.packageManager.hasSystemFeature("android.hardware.sensor.hinge_angle")
/** A panel at least this wide on its shortest side (dp) is a foldable's unfolded inner screen. */
const val UNFOLDED_MIN_DP = 600
/** Unfolded controls: the game's height in a window of this size - 16:9 across the full width,
* held either way, with even sides - and the Deck controls take the rest below it, laid out to
* whatever that leaves (DeckControlsPanel's GamepadLayout). */
fun unfoldedGameHeight(width: Int, height: Int): Int =
minOf(width * 9 / 16, (height * UNFOLDED_MAX_GAME_SHARE).toInt()) and 1.inv()
fun panelSize(context: Context): Pair<Int, Int> {
val manager = context.getSystemService(Context.WINDOW_SERVICE) as WindowManager
@Suppress("DEPRECATION")
@@ -94,6 +94,10 @@ object SessionFiles {
// The user's own AppImages, extracted at import (AppImageManager).
"usr/local/bin/droiddeck-appimage-run" to "usr/local/bin/droiddeck-appimage-run",
"usr/local/bin/droiddeck-script-run" to "usr/local/bin/droiddeck-script-run",
// The desktop's Run entries and its exec fallback: Windows programs through Steam's Proton,
// x86 Linux programs through FEX.
"usr/local/bin/droiddeck-open" to "usr/local/bin/droiddeck-open",
"usr/local/bin/droiddeck-proton-run" to "usr/local/bin/droiddeck-proton-run",
// The SteamOS helpers the client calls in Deck mode: the two Armada found it needs, plus
// the three under /usr/bin, all no-ops that answer "nothing to do" (see each file).
"usr/bin/steamos-update" to "usr/bin/steamos-update",
@@ -129,8 +133,8 @@ object SessionFiles {
.map { "usr/local/lib/droiddeck-msitools/$it" } +
listOf("NOTICE", "GPL-2", "GPL-3", "LGPL-2.1", "7zip-License", "7zip-unRarLicense").map { "usr/local/share/licenses/droiddeck-msitools/$it" }
val fexPreloads = listOf("x86_64", "i386").flatMap { arch ->
listOf("libblsession.so", "libfakeinput.so").map { "$arch/$it" to "usr/local/lib/droiddeck-fex/$arch/$it" }
} + listOf("libfaultreport.so", "libthunkaudit.so", "libvulkan-thunk.so").map { "x86_64/$it" to "usr/local/lib/droiddeck-fex/x86_64/$it" }
listOf("libblsession.so", "libfakeinput.so", "libfaultreport.so").map { "$arch/$it" to "usr/local/lib/droiddeck-fex/$arch/$it" }
} + listOf("libthunkaudit.so", "libvulkan-thunk.so").map { "x86_64/$it" to "usr/local/lib/droiddeck-fex/x86_64/$it" }
val optional = (arrayOf(
"usr/local/bin/gamescope" to "usr/local/bin/gamescope",
"usr/local/lib/droiddeck/uruntime" to "usr/local/lib/droiddeck/uruntime",
@@ -1,6 +1,7 @@
package com.droiddeck.launcher.session
import android.content.Context
import android.content.pm.ActivityInfo
import androidx.annotation.StringRes
import com.droiddeck.launcher.R
import com.droiddeck.launcher.core.TextureFiltering
@@ -18,6 +19,9 @@ object SessionPrefs {
const val CONTROLLER_DECK = "deck"
const val CONTROLLER_XBOX360 = "xbox360"
const val CONTROLLER_STEAM = "steamcontroller"
const val CONTROLLER_DS4 = "dualshock4"
const val CONTROLLER_DUALSENSE = "dualsense"
const val OSC_AUTO = "auto"
const val OSC_ALWAYS = "always"
const val OSC_STEAM_QAM = "steam-qam"
@@ -35,6 +39,22 @@ object SessionPrefs {
private fun prefs(context: Context) = context.getSharedPreferences("session", Context.MODE_PRIVATE)
val orientationOptions = listOf(
ActivityInfo.SCREEN_ORIENTATION_SENSOR_LANDSCAPE to R.string.setup_orientation_auto,
ActivityInfo.SCREEN_ORIENTATION_LANDSCAPE to R.string.setup_orientation_landscape,
ActivityInfo.SCREEN_ORIENTATION_REVERSE_LANDSCAPE to R.string.setup_orientation_reverse,
)
fun orientation(context: Context): Int =
prefs(context).getInt("orientation", ActivityInfo.SCREEN_ORIENTATION_SENSOR_LANDSCAPE)
.takeIf { value -> orientationOptions.any { it.first == value } }
?: ActivityInfo.SCREEN_ORIENTATION_SENSOR_LANDSCAPE
fun setOrientation(context: Context, orientation: Int) {
require(orientationOptions.any { it.first == orientation })
prefs(context).edit().putInt("orientation", orientation).apply()
}
/** Whether the launcher hides Android's status and navigation bars. */
fun launcherFullscreen(context: Context): Boolean = prefs(context).getBoolean("launcherFullscreen", true)
@@ -114,9 +134,9 @@ object SessionPrefs {
private fun hudKey(context: Context) = if (mangoappSession(context)) "hudDeck" else "hud"
/** A Steam session that runs Deck mode with its performance overlay (mangoapp). */
/** A Steam session with Deck mode's performance overlay (mangoapp). */
private fun mangoappSession(context: Context) =
SessionState.mode == SessionService.MODE_STEAM && steamDeckMode(context) && mangoapp(context)
SessionState.mode == SessionService.MODE_STEAM && mangoapp(context)
/** When enabled, a single Back opens Steam QAM and a double Back opens the session menu. */
fun backActionsInverted(context: Context): Boolean = prefs(context).getBoolean("backActionsInverted", false)
@@ -432,27 +452,21 @@ object SessionPrefs {
/**
* What the pad is to the Steam client: [CONTROLLER_DECK], a Steam Deck controller (Quick Access
* button, gyro, Steam Input's full treatment - SteamDeckPad), or [CONTROLLER_XBOX360], the plain
* Xbox 360 pad of earlier versions (QAM by the Guide+A chord).
* button, gyro, Steam Input's full treatment - SteamDeckPad), [CONTROLLER_STEAM], a 2025 Steam
* Controller (the same, plus grip sense and its own haptics), [CONTROLLER_DS4] or
* [CONTROLLER_DUALSENSE], a PlayStation pad with its touchpad fed by the trackpads, or
* [CONTROLLER_XBOX360], the plain Xbox 360 pad of earlier versions (QAM by the Guide+A chord).
*/
fun steamController(context: Context): String =
prefs(context).getString("steamController", CONTROLLER_DECK) ?: CONTROLLER_DECK
fun setSteamController(context: Context, id: String) { prefs(context).edit().putString("steamController", id).apply() }
/** Runs the SteamOS gamepad client with its Quick Access performance controls. On by default (settleDeckModeDefault). */
fun steamDeckMode(context: Context): Boolean = prefs(context).getBoolean("steamDeckMode", true)
fun setSteamDeckMode(context: Context, on: Boolean) { prefs(context).edit().putBoolean("steamDeckMode", on).apply() }
/**
* Deck mode is the default: every install, new or from before, is moved to it once. An install
* from before 0.3.0 had off written down for it whether or not anyone chose it, so the move
* can't tell a choice from that default and moves everyone; turning it off afterwards sticks.
* Run once at process start, before anything reads or writes these prefs.
*/
fun settleDeckModeDefault(context: Context) {
val p = prefs(context)
if (p.getBoolean("deckModeMoved", false)) return
p.edit().putBoolean("steamDeckMode", true).putBoolean("deckModeMoved", true).apply()
/** [steamController]'s choice as libfakeinput's pad type (FakeInputWriter.PAD_TYPE_*). */
fun padType(id: String): Int = when (id) {
CONTROLLER_STEAM -> com.droiddeck.launcher.input.FakeInputWriter.PAD_TYPE_STEAM
CONTROLLER_XBOX360 -> com.droiddeck.launcher.input.FakeInputWriter.PAD_TYPE_XBOX
CONTROLLER_DS4 -> com.droiddeck.launcher.input.FakeInputWriter.PAD_TYPE_DS4
CONTROLLER_DUALSENSE -> com.droiddeck.launcher.input.FakeInputWriter.PAD_TYPE_DUALSENSE
else -> com.droiddeck.launcher.input.FakeInputWriter.PAD_TYPE_DECK
}
/**
@@ -586,24 +600,6 @@ object SessionPrefs {
.onFailure { android.util.Log.e("GameEnvironment", "Could not update game environment", it) }
}
/**
* The Steam client branch used for the first download and forced on the command line:
* "steamdeck_publicbeta" by default, or "publicbeta" when chosen with Deck mode off.
* Deck mode always takes the Deck branch, whatever was chosen:
* with -steamos3 the client picks its own branch as SteamOS does, and on publicbeta it settled
* on steamdeck_stable - an older client it then offered as a "Software Update" in every session,
* which applying turns into the exit-42 restart loop (seen on device 2026-09-30). Earlier, Deck
* mode on publicbeta also reinstalled the same client at every start (2026-09-23). On
* steamdeck_publicbeta the client finds no update. The choice applies with Deck mode off.
*/
fun steamChannel(context: Context): String =
if (steamDeckMode(context)) "steamdeck_publicbeta"
else prefs(context).getString("steamChannel", null) ?: "steamdeck_publicbeta"
fun setSteamChannel(context: Context, id: String) {
prefs(context).edit().putString("steamChannel", id).apply()
}
/** Whether opening DroidDeck starts a Steam session instead of showing the front end. */
fun runSteamAtStartup(context: Context): Boolean = prefs(context).getBoolean("runSteamAtStartup", false)
@@ -612,8 +608,9 @@ object SessionPrefs {
}
/**
* The folders of the user's own Windows games (one game per subfolder), any number of them
* from anywhere on the device. The single folder an earlier build kept is carried in.
* The Games folders earlier builds let the user add (one game per subfolder); read only by
* GamesFolderMigration, which turns them into entries and clears them. The single folder an
* even earlier build kept is carried in.
*/
fun addedGamesDirs(context: Context): List<String> {
val p = prefs(context)
@@ -627,6 +624,26 @@ object SessionPrefs {
prefs(context).edit().putString("addedGamesDirs", dirs.distinct().joinToString("\n")).remove("addedGamesDir").apply()
}
/**
* The Games folders earlier builds had (host path, guest path), still bound into the session so
* their games keep their paths; written once by GamesFolderMigration.
*/
fun gameFolderBinds(context: Context): List<Pair<String, String>> =
(prefs(context).getString("gameFolderBinds", "") ?: "").split('\n').mapNotNull { line ->
line.split('\t').takeIf { it.size == 2 && it[0].isNotEmpty() && it[1].isNotEmpty() }?.let { it[0] to it[1] }
}
fun setGameFolderBinds(context: Context, binds: List<Pair<String, String>>) {
prefs(context).edit().putString("gameFolderBinds", binds.distinctBy { it.first }.joinToString("\n") { "${it.first}\t${it.second}" }).apply()
}
/** The Games folders were turned into entries (GamesFolderMigration); a record, not a gate. */
fun gamesFoldersMigrated(context: Context): Boolean = prefs(context).getBoolean("gamesFoldersMigrated", false)
fun setGamesFoldersMigrated(context: Context) {
prefs(context).edit().putBoolean("gamesFoldersMigrated", true).apply()
}
/**
* Game folders (by path) the user removed from the added games: a scan skips them, so they
* stay off the Games tab and out of Steam until restored. Nothing on disk is touched.
@@ -883,6 +900,26 @@ object SessionPrefs {
prefs(context).edit().putBoolean("pipAutoEnter", enabled).apply()
}
/** A foldable's unfolded panel shows the game above the Deck controls (SessionDisplay.unfoldedGameHeight). */
fun unfoldedControls(context: Context): Boolean = prefs(context).getBoolean("unfoldedControls", false)
fun setUnfoldedControls(context: Context, enabled: Boolean) {
prefs(context).edit().putBoolean("unfoldedControls", enabled).apply()
}
const val SURFACE_STICK = "stick"
const val SURFACE_TRACKPAD = "trackpad"
/** The Deck controls' gamepad tab: what each side is - an analog stick or a Steam trackpad.
* Left a stick and right a trackpad until changed, as a Deck is mostly played. */
fun gamepadSurface(context: Context, right: Boolean): String =
prefs(context).getString(if (right) "gamepadRight" else "gamepadLeft", null)
?: if (right) SURFACE_TRACKPAD else SURFACE_STICK
fun setGamepadSurface(context: Context, right: Boolean, surface: String) {
prefs(context).edit().putString(if (right) "gamepadRight" else "gamepadLeft", surface).apply()
}
fun suspendPolicy(context: Context, mode: String): String =
prefs(context).getString("suspendPolicy.${prefMode(mode)}", SUSPEND_MANUAL)
?.let { if (it == LEGACY_SUSPEND_DOWNLOADS) SUSPEND_AUTO else it }
@@ -37,9 +37,12 @@ import com.droiddeck.launcher.core.FileUtils
import com.droiddeck.launcher.core.HostProcess
import com.droiddeck.launcher.frontend.GameLaunchLink
import com.droiddeck.launcher.input.FakeInputWriter
import com.droiddeck.launcher.input.SteamControllerBle
import com.droiddeck.launcher.runtime.BwrapSpawner
import com.droiddeck.launcher.runtime.LinuxNetworkLinkComponent
import com.droiddeck.launcher.runtime.LinuxRuntime
import com.droiddeck.launcher.runtime.ProotFastPath
import com.droiddeck.launcher.runtime.UserApps
import com.droiddeck.launcher.wayland.WaylandCompositor
import java.io.File
import java.text.SimpleDateFormat
@@ -407,6 +410,8 @@ class SessionService : Service() {
guest.add(it)
Log.i(TAG, "steam: handing the client $it")
}
// A game whose download finishes in this session gets its Windows components readied in it.
if (SessionState.mode == MODE_STEAM) com.droiddeck.launcher.frontend.SessionInstalls.watch(this)
// A program under gamescope: the script's run mode takes the path (an AppImage, a script
// or a binary inside the runtime). This is how an emulator gets the GPU - the desktop's
// KWin composites in software and offers no dma-buf, so a Vulkan swapchain cannot exist
@@ -620,8 +625,7 @@ class SessionService : Service() {
guest.add("ZINK_DEBUG=compact")
}
// The rest of the client-interface switches (SessionPrefs): GL marshalled off the calling
// thread, no GL error checks, and the client run as SteamOS runs it (the script reads
// BL_STEAMDECK; it is the one that builds the command line).
// thread and no GL error checks.
if (SessionPrefs.glThread(this)) guest.add("mesa_glthread=true")
if (SessionPrefs.noGlError(this)) guest.add("MESA_NO_ERROR=1")
// Mesa's shader cache as one database instead of a file per entry. Every lookup in the
@@ -634,9 +638,7 @@ class SessionService : Service() {
// every thread start paid a SIGSYS that proot answers; the malloc top pad grows the heap
// 16 MB at a time instead of 128 KB, and every brk(2) is a proot stop too.
guest.add("GLIBC_TUNABLES=glibc.pthread.rseq=0:glibc.malloc.top_pad=16777216")
if (SessionState.mode == MODE_STEAM) guest.add("BL_STEAMDECK=" + (if (SessionPrefs.steamDeckMode(this)) "1" else "0"))
if (SessionState.mode == MODE_STEAM) guest.add("BL_MANGOAPP=" + (if (SessionPrefs.mangoapp(this)) "1" else "0"))
if (steamHere) guest.add("BL_STEAM_CHANNEL=" + SessionPrefs.steamChannel(this))
if (SessionState.mode == MODE_STEAM) {
guest.add("BL_GAMESCOPE_STRETCH_16X9=" + (if (SessionPrefs.stretch16x9(this)) "1" else "0"))
}
@@ -680,7 +682,9 @@ class SessionService : Service() {
// changes what games do and leaves the client alone. The microphone is its own opt-in on
// top, and the helper only opens an input stream when asked - so a user who wants game
// sound but no recording gets exactly that, and Android's recording indicator stays off.
val wantsDirectAudio = SessionState.mode == MODE_STEAM && SessionPrefs.directAudio(this)
// The desktop starts Windows programs through the same Proton wrappers (droiddeck-proton-run).
val wantsDirectAudio = (SessionState.mode == MODE_STEAM || SessionState.mode == MODE_DESKTOP) &&
SessionPrefs.directAudio(this)
val wantsMic = SessionPrefs.micEnabled(this) &&
checkSelfPermission(android.Manifest.permission.RECORD_AUDIO) == PackageManager.PERMISSION_GRANTED
// Both paths sit under the app's files directory, which the session binds at its own path,
@@ -738,13 +742,18 @@ class SessionService : Service() {
// hidraw (SteamDeckPad), which only works with Steam Input's virtual pad for games to read.
// Decided only once its sysfs is in place: without it the client would find no Deck, and
// with the Deck asked for the pad's own nodes are withdrawn - no controller at all.
// Set up whatever the pad is to be, so the drawer can switch it between a Deck, a Steam
// Controller and an Xbox 360 pad while the session runs (FakeInputWriter.setPadType).
val wantsDeck = uinput && SessionState.mode == MODE_STEAM &&
SessionState.steamUi != "desktop" &&
SessionPrefs.steamController(this) == SessionPrefs.CONTROLLER_DECK &&
!File(Environment.getExternalStorageDirectory(), NO_DECK_PAD_SWITCH).exists()
// Steam's touch controller rides on the Deck's sysfs and is offered only beside it.
val wantsTouch = wantsDeck && !File(Environment.getExternalStorageDirectory(), NO_STEAM_TOUCH_SWITCH).exists()
deckBinds = if (wantsDeck) SteamDeckPad.prepare(this, fakeInputDir.parentFile!!.parentFile!!, wantsTouch) else emptyList()
// A paired Triton is exposed as a second native Steam Controller HID, with its original
// report IDs and output path, beside whatever the handheld's own pad is.
val wantsTriton = wantsDeck && SteamControllerBle.hasPairedController(this)
deckBinds = if (wantsDeck) SteamDeckPad.prepare(this, fakeInputDir.parentFile!!.parentFile!!, wantsTouch, wantsTriton,
SessionPrefs.padType(SessionPrefs.steamController(this))) else emptyList()
SessionState.deckPad = deckBinds.isNotEmpty()
if (wantsDeck && !SessionState.deckPad) Log.w(TAG, "deck pad: not available this session; the pad stays an Xbox 360 controller")
logControllersAtStart()
@@ -754,6 +763,11 @@ class SessionService : Service() {
guest.add("FAKE_EVDEV_UINPUT=1")
if (SessionState.deckPad) {
guest.add("FAKE_EVDEV_DECK=1")
if (wantsTriton) guest.add("FAKE_EVDEV_TRITON=1")
// What the pad starts as; the drawer's changes reach libfakeinput through the ring.
val padType = SessionPrefs.steamController(this)
guest.add("FAKE_EVDEV_PAD_TYPE=$padType")
com.droiddeck.launcher.input.FakeInputWriter.setPadType(SessionPrefs.padType(padType))
// Steam's touch controller (SteamTouchDevice): the file the app and libfakeinput share.
if (wantsTouch) com.droiddeck.launcher.input.SteamTouchDevice.prepare(fakeInputDir)?.let {
guest.add("FAKE_TOUCHCTL_RING=" + it.path)
@@ -1214,6 +1228,13 @@ class SessionService : Service() {
}
return
}
if (path != null && path.startsWith("desktop-import-") && path.endsWith(".request")) {
val request = File(dir, path)
val text = runCatching { request.readText() }.getOrNull() ?: return
request.delete()
importForDesktop(dir, text)
return
}
if (path != "steam-launch") return
val file = File(dir, path)
val text = try { file.readText() } catch (e: Exception) { return }
@@ -1243,6 +1264,48 @@ class SessionService : Service() {
launchWatcher = watcher
}
private val desktopImports = java.util.concurrent.ConcurrentHashMap<String, Any>()
/**
* What the desktop cannot run where it lies (droiddeck-open), added as Add an app adds it, menu entry
* included: an AppImage of sharun's, whose hard links Android denies apps and only the app's own proot
* (link2symlink) makes, or a script or program whose folder of Linux programs is on noexec shared
* storage, copied in. The answer goes beside the request, "ok <folder>" or "error <why>".
*/
private fun importForDesktop(dir: File, text: String) {
val fields = text.lineSequence().mapNotNull { line ->
line.split('=', limit = 2).takeIf { it.size == 2 }?.let { it[0] to it[1].trim() }
}.toMap()
val key = fields["id"]?.takeIf { it.matches(Regex("[0-9a-f]{16}")) } ?: return
val guestPath = fields["path"]?.takeIf { it.startsWith("/") && !it.contains("/../") } ?: return
val folder = fields["kind"] == "folder"
val binds = LinuxRuntime.lastBinds(this).map { BwrapSpawner.Bind.parse(it) }
val file = File(BwrapSpawner.hostPath(binds, LinuxRuntime.rootDir(this).path, guestPath))
val context = applicationContext
// A request for what is already being added waits for that, then finds it.
val lock = desktopImports.computeIfAbsent(key) { Any() }
Thread({
fun imported() = UserApps.desktopImport(context, key, guestPath)?.let { "ok $it" }
val request = UserApps.Request(
if (folder) UserApps.Source.Script(file) else UserApps.Source.AppImage(file),
if (folder) file.parentFile?.name else null, null, mapOf(UserApps.DESKTOP_IMPORT to "$key\n$guestPath"),
)
val answer = synchronized(lock) {
try {
imported() ?: UserApps.add(context, request) { _, _ -> }?.let { "error $it" }
?: imported() ?: "error " + context.getString(R.string.user_apps_failed)
} catch (e: Exception) {
Log.e(TAG, "Desktop import of $guestPath", e)
"error " + (e.message ?: context.getString(R.string.user_apps_failed))
}
}
Log.i(TAG, "Desktop import of $guestPath: $answer")
val result = File(dir, "desktop-import-$key.result")
val partial = File(dir, result.name + ".tmp")
runCatching { partial.writeText(answer.replace('\n', ' ') + "\n"); partial.renameTo(result) }
}, "desktop-import").start()
}
@Volatile private var syncWatcher: android.os.FileObserver? = null
private fun watchSyncWanted(root: File) {
@@ -1719,7 +1782,7 @@ class SessionService : Service() {
pads.add(String.format(java.util.Locale.ROOT, "\"%s\" (%04x:%04x)", d.name, d.vendorId, d.productId))
}
Log.i(TAG, "controllers at start: " + (if (pads.isEmpty()) "none" else pads.joinToString(", ")) +
"; presented to the guest as " + if (SessionState.deckPad) "a Steam Deck controller" else "an Xbox 360 controller")
"; presented to the guest as " + if (SessionState.deckPad) "switchable, starting as " + SessionPrefs.steamController(this) else "an Xbox 360 controller")
}
companion object {
@@ -44,6 +44,36 @@ object SteamDeckPad {
private const val HIDRAW = "$HID/hidraw/$NODE"
private const val SERIAL = "DROIDDECK0001"
// The paired Bluetooth Steam Controller is a separate Triton HID device. It must not inherit
// the Deck's identity or report format. Must match fakeinput_steam.cpp (TRITON_HIDRAW_*).
private const val TRITON_MINOR = 18
private const val TRITON_HID = "$GUEST_DEVICES/bluetooth/0005:28DE:1303.0003"
private const val TRITON_SERIAL = "DROIDDECKSC01"
// The handheld's own pad as the Steam Controller (SessionPrefs.CONTROLLER_STEAM): the wired
// controller (28de:1302) on a USB device of its own, since it is built in.
private const val TRITON_USB = "$GUEST_DEVICES/usb3"
private const val TRITON_INTERFACE = "$TRITON_USB/3-1:1.0"
private const val TRITON_WIRED_HID = "$TRITON_INTERFACE/0003:28DE:1302.0004"
/** Must match libfakeinput (DS4_HIDRAW_MINOR, DUALSENSE_HIDRAW_MINOR). */
private const val SONY_DS4_MINOR = 20
private const val SONY_DUALSENSE_MINOR = 21
/** Must match libfakeinput's kDs4ReportDescriptor and kDualSenseReportDescriptor. */
private val DS4_REPORT_DESCRIPTOR = intArrayOf(
0x05, 0x01, 0x09, 0x05, 0xa1, 0x01, 0x06, 0x00, 0xff, 0x15, 0x00, 0x26, 0xff, 0x00, 0x75, 0x08,
0x85, 0x01, 0x09, 0x20, 0x95, 0x3f, 0x81, 0x02, 0x85, 0x05, 0x09, 0x21, 0x95, 0x1f, 0x91, 0x02,
0x85, 0x02, 0x09, 0x24, 0x95, 0x24, 0xb1, 0x02, 0x85, 0x12, 0x09, 0x25, 0x95, 0x0f, 0xb1, 0x02,
0x85, 0xa3, 0x09, 0x26, 0x95, 0x30, 0xb1, 0x02, 0x85, 0x81, 0x09, 0x27, 0x95, 0x3f, 0xb1, 0x02,
0xc0,
).map { it.toByte() }.toByteArray()
private val DUALSENSE_REPORT_DESCRIPTOR = intArrayOf(
0x05, 0x01, 0x09, 0x05, 0xa1, 0x01, 0x06, 0x00, 0xff, 0x15, 0x00, 0x26, 0xff, 0x00, 0x75, 0x08,
0x85, 0x01, 0x09, 0x20, 0x95, 0x3f, 0x81, 0x02, 0x85, 0x02, 0x09, 0x21, 0x95, 0x2f, 0x91, 0x02,
0x85, 0x05, 0x09, 0x22, 0x95, 0x28, 0xb1, 0x02, 0x85, 0x09, 0x09, 0x23, 0x95, 0x13, 0xb1, 0x02,
0x85, 0x20, 0x09, 0x24, 0x95, 0x3f, 0xb1, 0x02, 0xc0,
).map { it.toByte() }.toByteArray()
/** Must match libfakeinput (TRITON_PAD_HIDRAW_*): its own node, so a paired controller keeps 18. */
private const val TRITON_PAD_MINOR = 19
// Steam's touch controller (SteamTouchDevice), a second device beside the Deck's: 0000:11fb,
// "Mobile Touch Control", on a USB device of its own. Must match libfakeinput (TOUCH_*).
private const val TOUCH_MINOR = 17
@@ -69,11 +99,65 @@ object SteamDeckPad {
0x15, 0x00, 0x26, 0xff, 0x00, 0x75, 0x08, 0x95, 0x40, 0xb1, 0x02, 0xc0,
).map { it.toByte() }.toByteArray()
/** Triton's native 0x45/0x47 input and 0x80-0x85 haptic output report IDs. */
private val TRITON_REPORT_DESCRIPTOR = intArrayOf(
0x06, 0x00, 0xff, 0x09, 0x01, 0xa1, 0x01,
0x15, 0x00, 0x26, 0xff, 0x00, 0x75, 0x08,
0x85, 0x45, 0x09, 0x02, 0x95, 0x2d, 0x81, 0x02,
0x85, 0x47, 0x09, 0x03, 0x95, 0x2d, 0x81, 0x02,
0x85, 0x80, 0x09, 0x04, 0x95, 0x09, 0x91, 0x02,
0x85, 0x81, 0x09, 0x05, 0x95, 0x07, 0x91, 0x02,
0x85, 0x82, 0x09, 0x06, 0x95, 0x03, 0x91, 0x02,
0x85, 0x83, 0x09, 0x07, 0x95, 0x09, 0x91, 0x02,
0x85, 0x84, 0x09, 0x08, 0x95, 0x08, 0x91, 0x02,
0x85, 0x85, 0x09, 0x09, 0x95, 0x03, 0x91, 0x02,
0x85, 0x01, 0x09, 0x0a, 0x95, 0x3f, 0xb1, 0x02,
0xc0,
).map { it.toByte() }.toByteArray()
/** The wired controller's: its 0x42 state report in place of the Bluetooth 0x45. */
private val TRITON_WIRED_REPORT_DESCRIPTOR = TRITON_REPORT_DESCRIPTOR.copyOf().also { it[15] = 0x42 }
/** Where [prepare] puts the stand-in listings; bound into the guest at the same path. */
fun listingDir(sessionRoot: File) = File(sessionRoot, "sys/deck/listing")
/** Writes the tree and returns the `host:guest` binds for it, or none if it could not be made. */
fun prepare(context: Context, sessionRoot: File, touch: Boolean = false): List<String> {
/** The pad's own devices by minor (the Deck, its Steam Controller, the PlayStation pads): guest paths. */
private val padNodes = mutableMapOf<Int, String>()
@Volatile private var hidrawClassDir: File? = null
/**
* Lists only the device the pad is now ([padType], FakeInputWriter.PAD_TYPE_*) in
* /sys/class/hidraw, none for an Xbox 360 pad. Device scans then find what is plugged in: SDL's
* (the PlayStation pads' driver) keeps a device it found and could not open, and never tries it
* again, while one that disappears from the listing is let go. Before the shim is told.
*/
@Synchronized
fun showPad(padType: Int) {
val dir = hidrawClassDir ?: return
val shown = when (padType) {
com.droiddeck.launcher.input.FakeInputWriter.PAD_TYPE_DECK -> MINOR
com.droiddeck.launcher.input.FakeInputWriter.PAD_TYPE_STEAM -> TRITON_PAD_MINOR
com.droiddeck.launcher.input.FakeInputWriter.PAD_TYPE_DS4 -> SONY_DS4_MINOR
com.droiddeck.launcher.input.FakeInputWriter.PAD_TYPE_DUALSENSE -> SONY_DUALSENSE_MINOR
else -> -1
}
for ((minor, target) in padNodes) {
val link = File(dir, "hidraw$minor")
try {
if (minor == shown) {
if (!link.exists()) { link.delete(); Os.symlink(target, link.path) }
} else {
link.delete()
}
} catch (e: Exception) {
Log.w(TAG, "pad: could not list /dev/hidraw$minor: $e")
}
}
}
fun prepare(context: Context, sessionRoot: File, touch: Boolean = false, triton: Boolean = false,
padType: Int = com.droiddeck.launcher.input.FakeInputWriter.PAD_TYPE_DECK): List<String> {
val base = File(sessionRoot, "sys/deck")
val devices = File(base, "devices")
val hidrawClass = File(base, "class-hidraw")
@@ -113,7 +197,8 @@ object SteamDeckPad {
link(File(dir(HIDRAW), "device"), HID)
hidrawClass.mkdirs()
link(File(hidrawClass, NODE), HIDRAW)
padNodes.clear()
padNodes[MINOR] = HIDRAW
// Only the names matter: libudev reads a listing for the subsystems to descend into and
// then opens /sys/class/hidraw itself, which is the bind above.
@@ -153,6 +238,88 @@ object SteamDeckPad {
Log.i(TAG, "deck pad: /dev/$TOUCH_NODE described as Steam's touch controller (0000:11fb)")
}
// A Steam Controller's HID device: the handheld's own pad as the wired one, or the paired
// one on Bluetooth. Both are always described; libfakeinput offers whichever is in use.
fun describeTriton(wired: Boolean) {
val hid = if (wired) TRITON_WIRED_HID else TRITON_HID
val product = if (wired) "1302" else "1303"
val bus = if (wired) "0003" else "0005"
val minor = if (wired) TRITON_PAD_MINOR else TRITON_MINOR
val node = "hidraw$minor"
if (wired) {
write(TRITON_USB, "uevent", "DEVTYPE=usb_device\nPRODUCT=28de/1302/100\nTYPE=0/0/0\nBUSNUM=003\nDEVNUM=002\n")
write(TRITON_USB, "idVendor", "28de\n")
write(TRITON_USB, "idProduct", "1302\n")
write(TRITON_USB, "bcdDevice", "0100\n")
write(TRITON_USB, "manufacturer", "Valve Software\n")
write(TRITON_USB, "product", "Steam Controller\n")
write(TRITON_USB, "serial", "$TRITON_SERIAL\n")
link(File(dir(TRITON_USB), "subsystem"), "/sys/bus/usb")
write(TRITON_INTERFACE, "uevent", "DEVTYPE=usb_interface\nPRODUCT=28de/1302/100\nINTERFACE=3/0/0\n")
write(TRITON_INTERFACE, "bInterfaceNumber", "00\n")
write(TRITON_INTERFACE, "bInterfaceClass", "03\n")
link(File(dir(TRITON_INTERFACE), "subsystem"), "/sys/bus/usb")
}
write(hid, "uevent", "DRIVER=hid-generic\nHID_ID=$bus:000028DE:0000$product\n" +
"HID_NAME=Valve Software Steam Controller\n" +
"HID_PHYS=${if (wired) "usb-droiddeck-3/input0" else "bluetooth-droiddeck-1"}\n" +
"HID_UNIQ=$TRITON_SERIAL\nMODALIAS=hid:b${bus}g0001v000028DEp0000$product\n")
File(dir(hid), "report_descriptor").writeBytes(if (wired) TRITON_WIRED_REPORT_DESCRIPTOR else TRITON_REPORT_DESCRIPTOR)
link(File(dir(hid), "subsystem"), "/sys/bus/hid")
val hidraw = "$hid/hidraw/$node"
write(hidraw, "uevent", "MAJOR=$MAJOR\nMINOR=$minor\nDEVNAME=$node\n")
write(hidraw, "dev", "$MAJOR:$minor\n")
link(File(dir(hidraw), "subsystem"), "/sys/class/hidraw")
link(File(dir(hidraw), "device"), hid)
if (wired) padNodes[minor] = hidraw else link(File(hidrawClass, node), hidraw)
link(File(context.cacheDir, "drm/sys/$MAJOR:$minor"), hidraw)
File(udevData, "c$MAJOR:$minor").writeText("I:1\nE:ID_INPUT=1\nE:ID_INPUT_JOYSTICK=1\n")
Log.i(TAG, "steam controller: /dev/$node described as Triton (28de:$product)")
}
// The handheld's own pad as a PlayStation controller, each a USB device of its own with
// the report descriptor libfakeinput serves (kDs4/kDualSenseReportDescriptor).
fun describeSony(dualSense: Boolean) {
val bus = if (dualSense) 5 else 4
val product = if (dualSense) "0ce6" else "09cc"
val minor = if (dualSense) SONY_DUALSENSE_MINOR else SONY_DS4_MINOR
val node = "hidraw$minor"
val name = if (dualSense) "DualSense Wireless Controller" else "Wireless Controller"
val usb = "$GUEST_DEVICES/usb$bus"
val iface = "$usb/$bus-1:1.3"
val hid = "$iface/0003:054C:${product.uppercase()}.000${bus + 1}"
write(usb, "uevent", "DEVTYPE=usb_device\nPRODUCT=54c/$product/100\nTYPE=0/0/0\nBUSNUM=00$bus\nDEVNUM=002\n")
write(usb, "idVendor", "054c\n")
write(usb, "idProduct", "$product\n")
write(usb, "bcdDevice", "0100\n")
write(usb, "manufacturer", "Sony Interactive Entertainment\n")
write(usb, "product", "$name\n")
link(File(dir(usb), "subsystem"), "/sys/bus/usb")
write(iface, "uevent", "DEVTYPE=usb_interface\nPRODUCT=54c/$product/100\nINTERFACE=3/0/0\n")
write(iface, "bInterfaceNumber", "03\n")
write(iface, "bInterfaceClass", "03\n")
link(File(dir(iface), "subsystem"), "/sys/bus/usb")
write(hid, "uevent", "DRIVER=hid-generic\nHID_ID=0003:0000054C:0000${product.uppercase()}\n" +
"HID_NAME=Sony Interactive Entertainment $name\nHID_PHYS=usb-droiddeck-$bus/input3\n" +
"HID_UNIQ=5e:43:4b:44:44:02\nMODALIAS=hid:b0003g0001v0000054Cp0000${product.uppercase()}\n")
File(dir(hid), "report_descriptor").writeBytes(if (dualSense) DUALSENSE_REPORT_DESCRIPTOR else DS4_REPORT_DESCRIPTOR)
link(File(dir(hid), "subsystem"), "/sys/bus/hid")
val hidraw = "$hid/hidraw/$node"
write(hidraw, "uevent", "MAJOR=$MAJOR\nMINOR=$minor\nDEVNAME=$node\n")
write(hidraw, "dev", "$MAJOR:$minor\n")
link(File(dir(hidraw), "subsystem"), "/sys/class/hidraw")
link(File(dir(hidraw), "device"), hid)
padNodes[minor] = hidraw
link(File(context.cacheDir, "drm/sys/$MAJOR:$minor"), hidraw)
File(udevData, "c$MAJOR:$minor").writeText("I:1\nE:ID_INPUT=1\nE:ID_INPUT_JOYSTICK=1\n")
Log.i(TAG, "playstation pad: /dev/$node described as 054c:$product")
}
describeSony(dualSense = false)
describeSony(dualSense = true)
describeTriton(wired = true)
if (triton) describeTriton(wired = false)
hidrawClassDir = hidrawClass
showPad(padType)
Log.i(TAG, "deck pad: /dev/$NODE described as a Steam Deck controller (28de:1205)")
listOf(devices.path + ":" + GUEST_DEVICES, hidrawClass.path + ":/sys/class/hidraw")
} catch (e: Exception) {
@@ -38,7 +38,7 @@ import java.util.zip.ZipInputStream
*/
object WinComponents {
private const val TAG = "WinComponents"
const val CATALOG_URL = "https://raw.githubusercontent.com/The412Banner/winlator-contents/main/components.json"
const val CATALOG_URL = "https://raw.githubusercontent.com/Droid-Deck/DroidDeck-Components/main/components.json"
private const val SELECTION = "wincomponents.json"
private const val GUEST_SELECTION = "root/.config/droiddeck/wincomponents.json"
private const val STORE = "opt/droiddeck/wincomponents"
@@ -77,6 +77,21 @@ object WinComponents {
fun validId(id: String): Boolean = ID.matches(id) && ".." !in id
private val BACKGROUND = ThreadLocal<Boolean>()
/**
* Runs [block] as background work: the runtime helpers an install starts on this thread run
* at the lowest CPU and I/O priority (nice 19, ionice idle where the runtime has it).
*/
fun <T> background(block: () -> T): T {
BACKGROUND.set(true)
try { return block() } finally { BACKGROUND.remove() }
}
private fun guest(argv: List<String>): List<String> = if (BACKGROUND.get() != true) argv else listOf(
"/bin/sh", "-c", "if command -v ionice >/dev/null 2>&1; then exec nice -n 19 ionice -c3 \"\$@\"; else exec nice -n 19 \"\$@\"; fi", "sh",
) + argv
class Step(val action: String, val json: JSONObject) {
fun str(key: String): String = json.optString(key, "").ifEmpty {
if (key in NESTED_KEYS) json.optJSONObject("environment")?.optString(key, "").orEmpty() else ""
@@ -118,8 +133,13 @@ object WinComponents {
(step.action == "install_msi" || listOf(step.str("file_name"), step.str("url").substringBefore('?'))
.any { it.endsWith(".msi", ignoreCase = true) || it.endsWith(".exe", ignoreCase = true) })
/** Catalog entries nothing here can use: the IE8 security update patches a browser no prefix has. */
private val POINTLESS = setOf("ie8_kb2936068")
/**
* Catalog entries nothing here can use: the IE8 security update patches a browser no prefix
* has; the OpenAL setup program is a twin of oalinst_dll, which copies the same DLLs; and
* WebView2 can only be installed by running Microsoft's installer in the prefix (its files
* sit in a container nothing opens and may not be hosted), which no runtime here does.
*/
private val POINTLESS = setOf("ie8_kb2936068", "oalinst", "webview2")
private fun protonProvides(name: String): Boolean = name in PROTON_PROVIDES || name in POINTLESS || name.startsWith("mono-")
@@ -128,8 +148,24 @@ object WinComponents {
/** The steps a recording makes redundant: everything that places files or edits the registry. */
private val SNAPSHOT_COVERS = INSTALLER_STEPS + setOf("download_archive", "archive_extract", "copy_dll", "copy_file") + OFFLINE_STEPS
fun fetch(): List<Component>? {
val body = Downloader.downloadString(CATALOG_URL) ?: return null
fun fetch(): List<Component>? = Downloader.downloadString(CATALOG_URL)?.let { parse(it) }
private const val CATALOG_CACHE = "wincomponents-catalog.json"
/** The catalog from the network, kept for later; the copy kept last time when offline. */
fun fetch(context: Context): List<Component>? {
val cache = File(context.filesDir, CATALOG_CACHE)
Downloader.downloadString(CATALOG_URL)?.let { body ->
parse(body)?.let { runCatching { write(cache, body) }; return it }
}
return runCatching { parse(cache.readText()) }.getOrNull()
}
/** The copy of the catalog kept by [fetch], without asking the network; null before the first fetch. */
fun cached(context: Context): List<Component>? =
runCatching { parse(File(context.filesDir, CATALOG_CACHE).readText()) }.getOrNull()
private fun parse(body: String): List<Component>? {
return try {
val arr = JSONObject(body).getJSONArray("components")
(0 until arr.length()).mapNotNull { i ->
@@ -150,6 +186,16 @@ object WinComponents {
}
}
/**
* The catalog entry that installs [name] here: the detector names installers
* (vcredist2022, oalinst), and their DLL-copy twin (vcredist2022_dll, oalinst_dll) is the one
* that installs without running anything; [name] itself when there is no such twin.
*/
fun installable(name: String, all: Map<String, Component>): String {
val twin = all[if (name == "oalinst") "oalinst_dll" else "${name}_dll"]
return if (twin != null && support(twin, all) == Support.READY) twin.name else name
}
/** Whether [c] can be installed here, its bundled components included. */
fun support(c: Component, all: Map<String, Component>, depth: Int = 0): Support {
if (c.status != "ready" || depth > 8 || protonProvides(c.name)) return Support.UNSUPPORTED
@@ -501,7 +547,7 @@ object WinComponents {
val inside = archive.relativeTo(root).path
var problem: String? = null
var ok = false
val status = GuestCommand.run(context, listOf(MSI_INSTALL, "--unpack", "/$inside", "/$MSI_CACHE/${dir.name}"),
val status = GuestCommand.run(context, guest(listOf(MSI_INSTALL, "--unpack", "/$inside", "/$MSI_CACHE/${dir.name}")),
logName = "wincomponents-${c.name}") { line ->
when {
line.startsWith("progress ") -> engineLine(line.removePrefix("progress ")).let { (text, _) -> onProgress(Progress(c.name, text, Phase.INSTALL, -1)) }
@@ -540,7 +586,7 @@ object WinComponents {
onProgress(Progress(c.name, "installing", Phase.INSTALL, 0))
var result: JSONObject? = null
var problem: String? = null
val status = GuestCommand.run(context, listOf(MSI_INSTALL, "/$MSI_CACHE/${file.name}", "/$STORE/${staging.name}"),
val status = GuestCommand.run(context, guest(listOf(MSI_INSTALL, "/$MSI_CACHE/${file.name}", "/$STORE/${staging.name}")),
logName = "wincomponents-${c.name}") { line ->
when {
line.startsWith("progress ") -> engineLine(line.removePrefix("progress ")).let { (text, pct) -> onProgress(Progress(c.name, text, Phase.INSTALL, pct)) }
@@ -617,36 +663,101 @@ object WinComponents {
fun uninstall(context: Context, id: String) {
require(validId(id))
FileUtils.delete(File(LinuxRuntime.rootDir(context), "$STORE/$id"))
save(context, read(context).mapValues { (_, ids) -> ids - id }.filterValues { it.isNotEmpty() })
save(context, selections(context).mapValues { (_, sel) -> Selection(sel.auto - id, sel.user - id) })
}
/**
* Why a component was turned on for a game by itself ([setAuto]): [kind] says whose list it came
* from (see AutoComponents), [args] what the reason line names. Kept beside the picks.
*/
class AutoReason(val kind: String, val args: List<String>)
/**
* Everything kept per game, by the appid its prefix is named after: [auto] - turned on by
* itself, with its reason; [user] - a switch the user pressed (true on, false off), which wins
* over [auto] either way. What the launch reads is [effective].
*/
class Selection(val auto: Map<String, AutoReason>, val user: Map<String, Boolean>) {
val effective: List<String>
get() = (auto.keys.filter { user[it] != false } + user.filterValues { it }.keys).distinct()
}
/** Each game's picks, by the appid its prefix is named after. */
@Synchronized
fun read(context: Context): Map<String, List<String>> = try {
fun selections(context: Context): Map<String, Selection> = try {
val json = JSONObject(AtomicFile(File(context.filesDir, SELECTION)).readFully().toString(Charsets.UTF_8))
val games = json.optJSONObject("games") ?: JSONObject()
games.keys().asSequence().associateWith { app ->
val ids = games.getJSONArray(app)
(0 until ids.length()).map { ids.getString(it) }.filter { validId(it) }
val autos = json.optJSONObject("auto") ?: JSONObject()
val users = json.optJSONObject("user")
val keys = games.keys().asSequence().toSet() + autos.keys().asSequence().toSet() + (users?.keys()?.asSequence()?.toSet() ?: emptySet())
keys.associateWith { app ->
val auto = autos.optJSONObject(app)?.let { o ->
o.keys().asSequence().filter { validId(it) }.associateWith { id ->
val r = o.optJSONObject(id) ?: JSONObject()
val a = r.optJSONArray("args") ?: JSONArray()
AutoReason(r.optString("kind"), (0 until a.length()).map { a.optString(it) })
}
}.orEmpty()
// Before there were overrides, every pick was the user's own.
val user = if (users == null) games.optJSONArray(app)?.let { ids -> (0 until ids.length()).map { ids.getString(it) }.filter { validId(it) }.associateWith { true } }.orEmpty()
else users.optJSONObject(app)?.let { o -> o.keys().asSequence().filter { validId(it) }.associateWith { o.optBoolean(it) } }.orEmpty()
Selection(auto, user)
}
} catch (_: java.io.FileNotFoundException) {
emptyMap()
}
fun picks(context: Context, appKey: String): List<String> = read(context)[appKey].orEmpty()
/** Each game's picks as the launch takes them, by the appid its prefix is named after. */
fun read(context: Context): Map<String, List<String>> =
selections(context).mapValues { it.value.effective }.filterValues { it.isNotEmpty() }
fun picks(context: Context, appKey: String): List<String> = selections(context)[appKey]?.effective.orEmpty()
fun selection(context: Context, appKey: String): Selection = selections(context)[appKey] ?: Selection(emptyMap(), emptyMap())
/** The user's own switch for [id] in [appKey]: on or off, over whatever was picked by itself. */
@Synchronized
fun setPicks(context: Context, appKey: String, ids: List<String>) {
require(ids.all { validId(it) })
val all = read(context).toMutableMap()
if (ids.isEmpty()) all.remove(appKey) else all[appKey] = ids.distinct()
fun setUser(context: Context, appKey: String, id: String, on: Boolean) {
require(validId(id))
val all = selections(context).toMutableMap()
val now = all[appKey] ?: Selection(emptyMap(), emptyMap())
all[appKey] = Selection(now.auto, now.user + (id to on))
save(context, all)
}
private fun save(context: Context, games: Map<String, List<String>>) {
val text = JSONObject().put("version", 1).put("games", JSONObject().apply {
games.forEach { (app, ids) -> put(app, JSONArray(ids)) }
}).toString()
/** The components picked by themselves for [appKey], replacing the earlier ones; the user's switches stay. */
@Synchronized
fun setAuto(context: Context, appKey: String, auto: Map<String, AutoReason>) {
require(auto.keys.all { validId(it) })
val all = selections(context).toMutableMap()
val now = all[appKey] ?: Selection(emptyMap(), emptyMap())
all[appKey] = Selection(auto, now.user)
save(context, all)
}
/** The user's picks for [appKey] set outright (each listed on, every other user switch dropped). */
@Synchronized
fun setPicks(context: Context, appKey: String, ids: List<String>) {
require(ids.all { validId(it) })
val all = selections(context).toMutableMap()
val now = all[appKey] ?: Selection(emptyMap(), emptyMap())
all[appKey] = Selection(now.auto, ids.distinct().associateWith { true } + now.auto.keys.filter { it !in ids }.associateWith { false })
save(context, all)
}
private fun save(context: Context, games: Map<String, Selection>) {
val kept = games.filterValues { it.auto.isNotEmpty() || it.user.isNotEmpty() }
// Version 1 still: droiddeck-wincomponents reads "games" and ignores the rest.
val text = JSONObject().put("version", 1)
.put("games", JSONObject().apply { kept.forEach { (app, sel) -> sel.effective.takeIf { it.isNotEmpty() }?.let { put(app, JSONArray(it)) } } })
.put("auto", JSONObject().apply {
kept.forEach { (app, sel) ->
if (sel.auto.isNotEmpty()) put(app, JSONObject().apply {
sel.auto.forEach { (id, r) -> put(id, JSONObject().put("kind", r.kind).put("args", JSONArray(r.args))) }
})
}
})
.put("user", JSONObject().apply { kept.forEach { (app, sel) -> if (sel.user.isNotEmpty()) put(app, JSONObject(sel.user as Map<*, *>)) } })
.toString()
write(File(context.filesDir, SELECTION), text)
publish(context, text)
}
@@ -52,6 +52,9 @@ object StoreInstalls {
try { StoreArt.fetchInto(folder, finished) } catch (e: Exception) { Log.w(TAG, "art for ${folder.name}: ${e.message}") }
onStep?.invoke(2, STEPS)
register(app)
// Its Windows components from the store's list (GOG's dependencies, Epic's prerequisite,
// Amazon's install steps), on a thread of their own.
AddedGames.single(app, folder.path)?.let { com.droiddeck.launcher.frontend.AutoComponents.refreshLater(app, Library.addedGame(app, it)) }
onStep?.invoke(STEPS, STEPS)
}
@@ -156,7 +156,11 @@ object EpicBackend : StoreBackend, EpicLaunchSupport {
Store.EPIC, item.id, item.title, exe = exe.ifEmpty { "game.exe" }, args = args,
installVersion = result.buildVersion, installedAt = System.currentTimeMillis(),
cover = item.tallImageUrl ?: item.imageUrl, hero = item.imageUrl,
extra = EpicLaunchData.extras(item.id, namespace, catalogItemId, deploymentId),
// Both kept even when empty: the manifest was read, and names no prerequisite.
extra = EpicLaunchData.extras(item.id, namespace, catalogItemId, deploymentId) + mapOf(
com.droiddeck.launcher.frontend.WinCompSources.EPIC_PREREQ_PATH to result.prereqPath,
com.droiddeck.launcher.frontend.WinCompSources.EPIC_PREREQ_NAME to result.prereqName,
) + (if (result.prereqRead) mapOf(com.droiddeck.launcher.frontend.WinCompSources.LIST_READ to "1") else emptyMap()),
)
StoreInstalls.complete(app, folder, sidecar)
return folder.path
@@ -107,6 +107,11 @@ public final class EpicDownloadManager {
public String launchExe = "";
public String launchCommand = "";
public String buildVersion = "";
/** The prerequisite the launcher runs first (UE4PrereqSetup_x64.exe...): what it is called and its path in the install. */
public String prereqName = "";
public String prereqPath = "";
/** The prerequisite fields were read (an empty name and path then mean the build names none). */
public boolean prereqRead = false;
public List<CdnUrl> cdnUrls = new ArrayList<>();
}
@@ -115,7 +120,12 @@ public final class EpicDownloadManager {
public final String launchExe;
public final String buildVersion;
public final long bytes;
/** The manifest's prerequisite, for Windows components; "" when it names none. */
public String prereqName = "";
public String prereqPath = "";
Result(String launchExe, String buildVersion, long bytes) { this.launchExe = launchExe; this.buildVersion = buildVersion; this.bytes = bytes; }
public boolean prereqRead = false;
Result withPrereq(Manifest m) { prereqName = m.prereqName; prereqPath = m.prereqPath; prereqRead = m.prereqRead; return this; }
}
public static final class InstallException extends Exception {
@@ -178,7 +188,7 @@ public final class EpicDownloadManager {
if (pending.isEmpty()) {
deleteDir(chunkCacheDir);
cb.onProgress("Complete", 100);
return new Result(manifest.launchExe, manifest.buildVersion, planned);
return new Result(manifest.launchExe, manifest.buildVersion, planned).withPrereq(manifest);
}
List<ChunkInfo> needed = uniqueChunksForFiles(manifest, pending);
// The cache holds whole chunk windows (~1 MiB each), and a window is shared with files
@@ -265,7 +275,7 @@ public final class EpicDownloadManager {
if (r.cancelled) return null;
if (!r.success) throw new InstallException(r.error.isEmpty() ? "assembly failed" : r.error);
cb.onProgress("Complete", 100);
return new Result(manifest.launchExe, manifest.buildVersion, planned);
return new Result(manifest.launchExe, manifest.buildVersion, planned).withPrereq(manifest);
}
cb.onLog("epic: assembler not started (" + r.error + "); writing the files here");
}
@@ -293,7 +303,7 @@ public final class EpicDownloadManager {
}
deleteDir(chunkCacheDir);
cb.onProgress("Complete", 100);
return new Result(manifest.launchExe, manifest.buildVersion, planned);
return new Result(manifest.launchExe, manifest.buildVersion, planned).withPrereq(manifest);
} catch (InstallException e) {
throw e;
} catch (Exception e) {
@@ -470,6 +480,13 @@ public final class EpicDownloadManager {
m.buildVersion = readFString(b);
m.launchExe = readFString(b).replace('\\', '/');
m.launchCommand = readFString(b);
// PrereqIds (an array), PrereqName, PrereqPath, PrereqArgs.
int prereqIds = b.getInt();
for (int i = 0; i < prereqIds && i < 64; i++) readFString(b);
m.prereqName = readFString(b);
m.prereqPath = readFString(b).replace('\\', '/');
readFString(b); // PrereqArgs
m.prereqRead = true;
} catch (Exception e) {
Log.w(TAG, "manifest meta: " + e.getClass().getSimpleName());
}
@@ -537,6 +554,9 @@ public final class EpicDownloadManager {
m.launchExe = root.optString("LaunchExe", "").replace('\\', '/');
m.launchCommand = root.optString("LaunchCommand", "");
m.buildVersion = root.optString("BuildVersionString", "");
m.prereqName = root.optString("PrereqName", "");
m.prereqPath = root.optString("PrereqPath", "").replace('\\', '/');
m.prereqRead = true;
JSONObject chunkHashList = root.optJSONObject("ChunkHashList");
JSONObject dataGroupList = root.optJSONObject("DataGroupList");
JSONObject chunkFilesizeList = root.optJSONObject("ChunkFilesizeList");
@@ -753,6 +773,22 @@ public final class EpicDownloadManager {
}
}
/** The manifest's prerequisite as {name, path}, from the manifest alone; null on failure. Blocking. */
public static String[] fetchPrereq(String manifestApiJson) {
try {
List<CdnUrl> cdnUrls = parseCdnUrls(manifestApiJson);
if (cdnUrls.isEmpty()) return null;
byte[] bytes = downloadManifest(manifestApiJson, cdnUrls);
if (bytes == null) return null;
Manifest m = parseManifest(bytes);
if (m == null || !m.prereqRead) { Log.w(TAG, "manifest prerequisite not read"); return null; }
Log.i(TAG, "manifest " + m.buildVersion + ": " + m.files.size() + " files, prerequisite " + (m.prereqName.isEmpty() && m.prereqPath.isEmpty() ? "none" : m.prereqName + " (" + m.prereqPath + ")"));
return new String[] { m.prereqName, m.prereqPath };
} catch (Exception e) {
return null;
}
}
/** The install size for the tags (null = every file), from the manifest alone; -1 on failure. Blocking. */
public static long fetchInstallSizeBytes(String manifestApiJson, List<String> installTags) {
try {
@@ -111,6 +111,9 @@ object GogBackend : StoreBackend {
Store.GOG, game.gameId, game.title, exe = result.exeRelative.ifEmpty { "" },
installVersion = result.buildId, installedAt = System.currentTimeMillis(),
cover = item.tallImageUrl ?: item.imageUrl, hero = item.imageUrl,
// Kept even when empty: the build was read, and lists none.
extra = mapOf(com.droiddeck.launcher.frontend.WinCompSources.GOG_DEPENDENCIES to result.dependencies.joinToString(","))
+ (if (result.dependenciesRead) mapOf(com.droiddeck.launcher.frontend.WinCompSources.LIST_READ to "1") else emptyMap()),
)
if (sidecar.exe.isEmpty()) handle.log("gog: no exe found in ${folder.name}; pick one in Steam settings › Added games")
StoreInstalls.complete(app, folder, onStep = { done, steps -> handle.stageProgress(done.toLong(), steps.toLong()) }, sidecar = if (sidecar.exe.isEmpty()) sidecar.copy(exe = "game.exe") else sidecar)
@@ -79,6 +79,10 @@ public final class GogDownloadManager {
public final String exeRelative;
public final String buildId;
public final long bytes;
/** The build manifest's "dependencies" (GOG's redistributable ids: MSVC2017_x64, DirectX...), for Windows components. */
public List<String> dependencies = new ArrayList<>();
/** The dependencies come from a gen2 build manifest read whole (gen1 builds and installers have none). */
public boolean dependenciesRead = false;
Result(String exeRelative, String buildId, long bytes) { this.exeRelative = exeRelative; this.buildId = buildId; this.bytes = bytes; }
}
@@ -150,10 +154,12 @@ public final class GogDownloadManager {
if ((baseProductId == null || baseProductId.isEmpty()) && products != null && products.length() > 0)
baseProductId = products.getJSONObject(0).optString("productId", null);
if (baseProductId == null || baseProductId.isEmpty()) baseProductId = game.gameId;
// The build's required redistributables (MSVC, DirectX, ...) are recorded for the engine
// log; Steam's shared redistributables run in the prefix on first launch as for any shortcut.
// The build's required redistributables (MSVC, DirectX, ...): logged, and kept in the
// sidecar, where Windows components take them as this game's list (WinCompSources).
JSONArray deps = manifest.optJSONArray("dependencies");
if (deps != null && deps.length() > 0) cb.onLog("gog: build lists dependencies " + deps);
List<String> dependencies = new ArrayList<>();
if (deps != null) for (int i = 0; i < deps.length(); i++) { String d = deps.optString(i, ""); if (!d.isEmpty()) dependencies.add(d); }
if (!dependencies.isEmpty()) cb.onLog("gog: build lists dependencies " + deps);
cb.onProgress("Reading depot manifests…", 10);
List<DepotFile> files = new ArrayList<>();
@@ -262,6 +268,8 @@ public final class GogDownloadManager {
String clientSecret = manifest.optString("clientSecret", null);
if (clientSecret != null && !clientSecret.isEmpty()) GogPrefs.get(ctx).edit().putString("client_secret_" + game.gameId, clientSecret).apply();
out.result = new Result(pickExe(installPath, tempExe, game.title), gm.buildId, planned);
out.result.dependencies = dependencies;
out.result.dependenciesRead = true;
return out;
} catch (Exception e) {
out.error = "exception: " + e.getClass().getSimpleName() + (e.getMessage() != null ? ": " + e.getMessage() : "");
@@ -1031,6 +1039,48 @@ public final class GogDownloadManager {
}
/** The install size of the Windows build (English / all-language depots), or -1. Blocking. */
/**
* The "dependencies" of the build [buildId] of [gameId] (else the newest Windows build), from
* its build manifest alone: no game file is fetched. Null when the builds or the manifest
* could not be had; empty when the build lists none. Blocking.
*/
public static List<String> fetchDependencies(String gameId, String buildId, String token) {
try {
String buildsUrl = "https://content-system.gog.com/products/" + gameId + "/os/windows/builds?generation=2";
String buildsJson = httpGet(buildsUrl, null);
if (buildsJson == null) buildsJson = httpGet(buildsUrl, token);
if (buildsJson == null) return null;
JSONArray items = new JSONObject(buildsJson).optJSONArray("items");
if (items == null || items.length() == 0) return null;
String manifestUrl = null;
for (int i = 0; i < items.length(); i++) {
JSONObject item = items.getJSONObject(i);
if (!"windows".equals(item.optString("os"))) continue;
String link = item.optString("link");
if (link == null || link.isEmpty()) link = item.optString("meta_url");
if (manifestUrl == null) manifestUrl = link;
if (buildId != null && !buildId.isEmpty() && buildId.equals(item.optString("build_id"))) { manifestUrl = link; break; }
}
if (manifestUrl == null || manifestUrl.isEmpty()) return null;
byte[] raw = fetchBytes(manifestUrl, token);
if (raw == null) return null;
String manifestStr = decompressBytes(raw);
if (manifestStr == null) return null;
JSONObject manifest = new JSONObject(manifestStr);
// A manifest without depots is not one this reads right: no list rather than an empty one.
JSONArray depots = manifest.optJSONArray("depots");
if (depots == null) { Log.w(TAG, "gog: build manifest for " + gameId + " has no depots; keys " + manifest.names()); return null; }
JSONArray deps = manifest.optJSONArray("dependencies");
List<String> out = new ArrayList<>();
if (deps != null) for (int i = 0; i < deps.length(); i++) { String d = deps.optString(i, ""); if (!d.isEmpty()) out.add(d); }
Log.i(TAG, "gog: build " + manifest.optString("buildId", buildId) + " of " + gameId + ": " + depots.length() + " depots, dependencies "
+ (manifest.has("dependencies") ? String.valueOf(deps) : "(none listed)"));
return out;
} catch (Exception e) {
return null;
}
}
public static long fetchInstallSizeBytes(String gameId, String token) {
try {
String buildsUrl = "https://content-system.gog.com/products/" + gameId + "/os/windows/builds?generation=2";
@@ -28,6 +28,7 @@ class ControllerActions(
val onSteamButton: (Boolean) -> Unit,
val onQamButton: (Boolean) -> Unit,
val onKeyboardButton: (Boolean) -> Unit,
val onSelectSteam: (Boolean) -> Unit,
val onEditLayout: () -> Unit,
val onResetLayout: () -> Unit,
val onMapping: () -> Unit,
@@ -70,6 +71,7 @@ fun ColumnScope.ControllerRows(host: MenuHost, oscMode: String, c: ControllerPre
ToggleRow(host, "controller-steam", stringResource(R.string.ctrl_steam_button), null, c.steamButton, onChange = a.onSteamButton)
ToggleRow(host, "controller-qam", stringResource(R.string.ctrl_qam_button), null, c.qamButton, onChange = a.onQamButton)
ToggleRow(host, "controller-keyboard", stringResource(R.string.ctrl_keyboard_button), null, c.keyboardButton, onChange = a.onKeyboardButton)
ToggleRow(host, "controller-select-steam", stringResource(R.string.ctrl_select_steam), stringResource(R.string.ctrl_select_steam_hint), c.selectSteam, onChange = a.onSelectSteam)
SettingsRow(stringResource(R.string.ctrl_layout), if (c.customLayout) stringResource(R.string.ctrl_layout_custom) else stringResource(R.string.ctrl_layout_default)) {
Row(horizontalArrangement = Arrangement.spacedBy(6.dp)) {
SecondaryButton(stringResource(R.string.ctrl_edit)) { a.onEditLayout() }
@@ -15,10 +15,16 @@ import android.view.Gravity
import android.view.HapticFeedbackConstants
import android.view.MotionEvent
import android.view.View
import android.view.ViewGroup
import android.widget.FrameLayout
import android.widget.LinearLayout
import android.widget.TextView
import com.droiddeck.launcher.input.DeckControls
import com.droiddeck.launcher.input.PadBridge
import com.droiddeck.launcher.input.PadState
import com.droiddeck.launcher.session.SessionPrefs
import kotlin.math.atan2
import kotlin.math.hypot
import kotlin.math.min
/**
@@ -26,7 +32,8 @@ import kotlin.math.min
*
* Four tabs: the grips as a 2x2 grid (left grips on the left, upper above lower, as they sit on a
* Deck); either trackpad on its own with a bar to click it; and both trackpads with the grips in a
* row beneath. A trackpad clicks from its bar or from a second finger while the first is down.
* row beneath. Given the [pad] (the unfolded screen's panel), a fifth: a whole gamepad, each side
* an analog stick or a trackpad as the player picks. A trackpad clicks from its bar or from a second finger while the first is down.
* Drawn for OLED: true black, with outlines rather than filled surfaces, and the Steam blue only
* where something is being touched.
*/
@@ -36,18 +43,29 @@ class DeckControlsPanel(
private val onClose: () -> Unit,
private val onSteamMenu: () -> Unit,
private val onQam: () -> Unit,
private val pad: PadBridge? = null,
) : LinearLayout(context) {
private enum class Tab(val label: Int) { GRIPS(R.string.deck_tab_grips), LEFT(R.string.deck_tab_left), RIGHT(R.string.deck_tab_right), BOTH(R.string.deck_tab_both) }
private enum class Tab(val label: Int) { GAMEPAD(R.string.deck_tab_gamepad), GRIPS(R.string.deck_tab_grips), LEFT(R.string.deck_tab_left), RIGHT(R.string.deck_tab_right), BOTH(R.string.deck_tab_both) }
private val content = FrameLayout(context)
private val tabViews = HashMap<Tab, TextView>()
private val topBar: View by lazy { topBar() }
init {
orientation = VERTICAL
setBackgroundColor(Color.BLACK)
addView(topBar(), LayoutParams(LayoutParams.MATCH_PARENT, dp(64)))
addView(topBar, LayoutParams(LayoutParams.MATCH_PARENT, dp(64)))
addView(content, LayoutParams(LayoutParams.MATCH_PARENT, 0, 1f))
select(selected)
select(if (pad != null) selectedWithPad else selected)
}
override fun onSizeChanged(w: Int, h: Int, oldw: Int, oldh: Int) {
super.onSizeChanged(w, h, oldw, oldh)
// Held flat the gamepad has no Panels button, so another tab would have no way back:
// turning the phone from upright onto its side returns to the gamepad.
if (pad != null && selectedWithPad != Tab.GAMEPAD && !GamepadGeometry.tall(w.toFloat(), h.toFloat())) {
post { if (selectedWithPad != Tab.GAMEPAD) select(Tab.GAMEPAD) }
}
}
override fun onDetachedFromWindow() {
@@ -65,6 +83,7 @@ class DeckControlsPanel(
background = outline(CORNER_PILL, SURFACE)
setPadding(dp(4), dp(4), dp(4), dp(4))
for (tab in Tab.values()) {
if (tab == Tab.GAMEPAD && pad == null) continue
val view = TextView(context).apply {
text = context.getString(tab.label)
textSize = 14f
@@ -88,7 +107,7 @@ class DeckControlsPanel(
}
private fun select(tab: Tab) {
selected = tab
if (pad != null) selectedWithPad = tab else selected = tab
DeckControls.releaseAll()
for ((each, view) in tabViews) {
val on = each == tab
@@ -96,12 +115,15 @@ class DeckControlsPanel(
view.setTextColor(if (on) ON_ACCENT else TEXT_DIM)
}
content.removeAllViews()
// The gamepad needs every row of the panel; held upright its Panels button brings the tabs back.
topBar.visibility = if (tab == Tab.GAMEPAD) View.GONE else View.VISIBLE
content.addView(
when (tab) {
Tab.GRIPS -> gripsGrid()
Tab.LEFT -> singlePad(right = false)
Tab.RIGHT -> singlePad(right = true)
Tab.BOTH -> bothPads()
Tab.GAMEPAD -> gamepad(pad ?: return)
},
FrameLayout.LayoutParams(FrameLayout.LayoutParams.MATCH_PARENT, FrameLayout.LayoutParams.MATCH_PARENT),
)
@@ -151,6 +173,79 @@ class DeckControlsPanel(
addView(grips, LayoutParams(LayoutParams.MATCH_PARENT, dp(84)))
}
/**
* A whole pad, placed for two thumbs rather than in columns (see [GamepadGeometry]): on each side
* a surface where the thumb rests - most of play is on the sticks or trackpads - and a short
* slide inward along its arc, the d-pad on the left and the face buttons on the right; the bumpers and triggers above, at the top of the arc; View, Steam, QAM and Menu in the
* middle, which a thumb only reaches on purpose. Each surface is an analog stick or a Steam
* trackpad, switched from its corner and remembered (SessionPrefs.gamepadSurface). Its tab bar
* is hidden; held upright, a Panels button brings the other tabs back, and held flat there is no
* room for one.
*/
private fun gamepad(pad: PadBridge): View = GamepadLayout(context).apply {
fun pill(label: String, write: (PadState, Boolean) -> Unit) = PadButton(context, pad, label, write)
place(surfaceSlot(pad, right = false), GamepadGeometry.Slot.LEFT_MAIN)
place(DirectionPad(context, pad), GamepadGeometry.Slot.LEFT_SECOND)
place(surfaceSlot(pad, right = true), GamepadGeometry.Slot.RIGHT_MAIN)
place(FaceButtons(context, pad), GamepadGeometry.Slot.RIGHT_SECOND)
place(pill("LT") { st, d -> st.leftTrigger = if (d) 1f else 0f }, GamepadGeometry.Slot.LEFT_OUTER_SHOULDER)
place(pill("LB") { st, d -> st.press(PadState.LB, d) }, GamepadGeometry.Slot.LEFT_INNER_SHOULDER)
place(pill("RB") { st, d -> st.press(PadState.RB, d) }, GamepadGeometry.Slot.RIGHT_INNER_SHOULDER)
place(pill("RT") { st, d -> st.rightTrigger = if (d) 1f else 0f }, GamepadGeometry.Slot.RIGHT_OUTER_SHOULDER)
place(pill("VIEW") { st, d -> st.press(PadState.SELECT, d) }, GamepadGeometry.Slot.VIEW)
place(centreTap("STEAM", context.getString(R.string.deck_open_steam_menu), onSteamMenu), GamepadGeometry.Slot.STEAM)
place(centreTap("\u2022\u2022\u2022", context.getString(R.string.drawer_open_qam), onQam), GamepadGeometry.Slot.QAM)
place(pill("MENU") { st, d -> st.press(PadState.START, d) }, GamepadGeometry.Slot.MENU)
place(centreTap("\u25a6 " + context.getString(R.string.deck_panels), context.getString(R.string.deck_more_controls)) { select(Tab.BOTH) },
GamepadGeometry.Slot.PANELS)
}
private fun centreTap(label: String, description: String, onClick: () -> Unit) = TextView(context).apply {
text = label
contentDescription = description
textSize = 13f
typeface = Typeface.create(Typeface.DEFAULT, Typeface.BOLD)
letterSpacing = 0.08f
gravity = Gravity.CENTER
setTextColor(TEXT)
background = outline(CORNER_PILL, SURFACE)
setOnClickListener {
performHapticFeedback(HapticFeedbackConstants.VIRTUAL_KEY)
onClick()
}
}
/** One side's stick or trackpad, with a chip in its outer corner that swaps the two. */
private fun surfaceSlot(pad: PadBridge, right: Boolean): View = FrameLayout(context).apply {
val slot = this
fun show() {
slot.removeAllViews()
val stick = SessionPrefs.gamepadSurface(context, right) == SessionPrefs.SURFACE_STICK
// No caption: the switch in its corner says which it is.
slot.addView(if (stick) StickView(context, pad, right) else TrackpadView(context, right, captioned = false),
FrameLayout.LayoutParams(FrameLayout.LayoutParams.MATCH_PARENT, FrameLayout.LayoutParams.MATCH_PARENT))
val chip = TextView(context).apply {
text = "\u21c4"
contentDescription = context.getString(R.string.deck_surface_switch)
textSize = 14f
typeface = Typeface.create(Typeface.DEFAULT, Typeface.BOLD)
gravity = Gravity.CENTER
setTextColor(TEXT_DIM)
background = outline(CORNER_PILL, SURFACE)
setOnClickListener {
performHapticFeedback(HapticFeedbackConstants.VIRTUAL_KEY)
SessionPrefs.setGamepadSurface(context, right,
if (stick) SessionPrefs.SURFACE_TRACKPAD else SessionPrefs.SURFACE_STICK)
show()
}
}
// In the corner away from the thumb, so play does not swap it by accident.
slot.addView(chip, FrameLayout.LayoutParams(dp(30), dp(30),
(if (right) Gravity.START else Gravity.END) or Gravity.TOP).apply { setMargins(dp(6), dp(6), dp(6), dp(6)) })
}
show()
}
private fun roundButton(label: String, description: String, onClick: () -> Unit, wide: Boolean = false) =
TextView(context).apply {
text = label
@@ -262,7 +357,7 @@ class DeckControlsPanel(
* One trackpad: where the first finger is, as the Deck reports it (-1..1, y up), and a click
* while a second finger is down.
*/
private class TrackpadView(context: Context, private val right: Boolean) : Surface(context) {
private class TrackpadView(context: Context, private val right: Boolean, private val captioned: Boolean = true) : Surface(context) {
private var touching = false
private var clicked = false
private var fingerX = 0f
@@ -283,7 +378,7 @@ class DeckControlsPanel(
canvas.drawLine(cx, cy - reach, cx, cy + reach, guide)
captionPaint.textSize = 12 * density
captionPaint.color = if (touching) ACCENT else TEXT_DIM
canvas.drawText((if (right) context.getString(R.string.deck_right_trackpad) else context.getString(R.string.deck_left_trackpad)).uppercase(), cx, 28 * density, captionPaint)
if (captioned) canvas.drawText((if (right) context.getString(R.string.deck_right_trackpad) else context.getString(R.string.deck_left_trackpad)).uppercase(), cx, 28 * density, captionPaint)
if (touching) {
val radius = 56 * density
glow.shader = RadialGradient(fingerX, fingerY, radius, ACCENT_GLOW, Color.TRANSPARENT, Shader.TileMode.CLAMP)
@@ -333,9 +428,271 @@ class DeckControlsPanel(
}
}
/** A pad button held while a finger is on it, written into the shared pad as the on-screen pad does. */
private class PadButton(
context: Context,
private val pad: PadBridge,
label: String,
private val write: (PadState, Boolean) -> Unit,
) : HoldButton(context, label, null, { down -> pad.applyTouch { write(it, down) } }) {
override fun onDetachedFromWindow() {
pad.applyTouch { write(it, false) }
super.onDetachedFromWindow()
}
}
/**
* An analog stick that centres where the thumb lands, so it is never missed: the knob follows
* the finger from there out to the ring. Tapping it again and holding (within [CLICK_MS]) is
* the stick's click - L3 or R3 - for as long as the thumb stays down.
*/
private class StickView(context: Context, private val pad: PadBridge, private val right: Boolean) : Surface(context) {
private var touching = false
private var clicking = false
private var lastUp = 0L
private var originX = 0f
private var originY = 0f
private var knobX = 0f
private var knobY = 0f
private val ring = Paint(Paint.ANTI_ALIAS_FLAG).apply { style = Paint.Style.STROKE }
private val knob = Paint(Paint.ANTI_ALIAS_FLAG)
private fun reach() = min(width, height) * 0.30f
override fun onDraw(canvas: Canvas) {
drawSurface(canvas, clicking)
val cx = if (touching) originX else width / 2f
val cy = if (touching) originY else height / 2f
ring.strokeWidth = 2 * density
ring.color = if (touching) ACCENT else LINE
canvas.drawCircle(cx, cy, reach(), ring)
knob.color = if (touching) ACCENT else KNOB
canvas.drawCircle(cx + knobX, cy + knobY, reach() * 0.45f, knob)
}
@SuppressLint("ClickableViewAccessibility")
override fun onTouchEvent(event: MotionEvent): Boolean {
val r = reach().coerceAtLeast(1f)
when (event.actionMasked) {
MotionEvent.ACTION_DOWN -> {
// Centred where the thumb lands, but kept far enough in for the ring to fit.
originX = event.x.coerceIn(r, (width - r).coerceAtLeast(r))
originY = event.y.coerceIn(r, (height - r).coerceAtLeast(r))
touching = true
performHapticFeedback(HapticFeedbackConstants.VIRTUAL_KEY)
if (event.eventTime - lastUp < CLICK_MS) setClick(true)
follow(event.x, event.y, r)
}
MotionEvent.ACTION_MOVE -> follow(event.getX(0), event.getY(0), r)
MotionEvent.ACTION_UP, MotionEvent.ACTION_CANCEL -> {
touching = false
lastUp = event.eventTime
setClick(false)
move(0f, 0f, 1f)
}
}
return true
}
private fun follow(x: Float, y: Float, r: Float) {
var dx = x - originX
var dy = y - originY
val length = hypot(dx, dy)
if (length > r) { dx *= r / length; dy *= r / length }
move(dx, dy, r)
}
private fun move(dx: Float, dy: Float, r: Float) {
knobX = dx
knobY = dy
pad.applyTouch { s ->
if (right) { s.rightX = dx / r; s.rightY = dy / r } else { s.leftX = dx / r; s.leftY = dy / r }
}
invalidate()
}
private fun setClick(down: Boolean) {
if (clicking == down) return
clicking = down
if (down) performHapticFeedback(HapticFeedbackConstants.VIRTUAL_KEY)
pad.applyTouch { s -> s.press(if (right) PadState.R3 else PadState.L3, down) }
}
override fun onDetachedFromWindow() {
setClick(false)
if (knobX != 0f || knobY != 0f) move(0f, 0f, 1f)
super.onDetachedFromWindow()
}
}
/** The d-pad: the direction is the finger's angle from the centre, diagonals included. */
private class DirectionPad(context: Context, private val pad: PadBridge) : View(context) {
private val density = resources.displayMetrics.density
private var held = 0 // bits: 1 up, 2 right, 4 down, 8 left
private val arm = Paint(Paint.ANTI_ALIAS_FLAG)
private val rect = RectF()
override fun onDraw(canvas: Canvas) {
val size = min(width, height) * 0.86f
val cx = width / 2f
val cy = height / 2f
val half = size / 2f
val thick = size / 3f / 2f
fun armRect(bit: Int, l: Float, t: Float, r: Float, b: Float) {
arm.color = if (held and bit != 0) ACCENT else KNOB
rect.set(l, t, r, b)
canvas.drawRoundRect(rect, 8 * density, 8 * density, arm)
}
armRect(1, cx - thick, cy - half, cx + thick, cy - thick)
armRect(4, cx - thick, cy + thick, cx + thick, cy + half)
armRect(8, cx - half, cy - thick, cx - thick, cy + thick)
armRect(2, cx + thick, cy - thick, cx + half, cy + thick)
arm.color = KNOB
canvas.drawRect(cx - thick, cy - thick, cx + thick, cy + thick, arm)
}
@SuppressLint("ClickableViewAccessibility")
override fun onTouchEvent(event: MotionEvent): Boolean {
val bits = when (event.actionMasked) {
MotionEvent.ACTION_DOWN, MotionEvent.ACTION_MOVE -> {
val dx = event.getX(0) - width / 2f
val dy = event.getY(0) - height / 2f
if (hypot(dx, dy) < min(width, height) * 0.08f) 0 else {
// Eight sectors of 45 degrees, starting with right and going clockwise.
val sector = ((Math.toDegrees(atan2(dy.toDouble(), dx.toDouble())) + 360 + 22.5) % 360 / 45).toInt()
intArrayOf(2, 2 or 4, 4, 4 or 8, 8, 8 or 1, 1, 1 or 2)[sector]
}
}
else -> 0
}
set(bits)
return true
}
private fun set(bits: Int) {
if (bits == held) return
if (bits and held.inv() != 0) performHapticFeedback(HapticFeedbackConstants.VIRTUAL_KEY)
held = bits
pad.applyTouch { s ->
s.up = bits and 1 != 0; s.right = bits and 2 != 0; s.down = bits and 4 != 0; s.left = bits and 8 != 0
}
invalidate()
}
override fun onDetachedFromWindow() {
set(0)
super.onDetachedFromWindow()
}
}
/** A, B, X and Y in a diamond, as on a Deck; several fingers can hold several at once. */
private class FaceButtons(context: Context, private val pad: PadBridge) : View(context) {
private val buttons = listOf(PadState.Y to "Y", PadState.B to "B", PadState.A to "A", PadState.X to "X")
private var held = emptySet<Int>()
private val fill = Paint(Paint.ANTI_ALIAS_FLAG)
private val text = Paint(Paint.ANTI_ALIAS_FLAG).apply {
textAlign = Paint.Align.CENTER
typeface = Typeface.create(Typeface.DEFAULT, Typeface.BOLD)
}
/** Top, right, bottom, left - the centre of each, and the radius of all. */
private fun layout(): Pair<List<Pair<Float, Float>>, Float> {
val cx = width / 2f
val cy = height / 2f
val spread = min(width, height) * 0.30f
val radius = min(width, height) * 0.16f
return listOf(cx to cy - spread, cx + spread to cy, cx to cy + spread, cx - spread to cy) to radius
}
override fun onDraw(canvas: Canvas) {
val (centres, radius) = layout()
text.textSize = radius * 0.9f
buttons.forEachIndexed { i, (button, label) ->
val (x, y) = centres[i]
val down = button in held
fill.color = if (down) ACCENT else KNOB
canvas.drawCircle(x, y, radius, fill)
text.color = if (down) ON_ACCENT else TEXT
canvas.drawText(label, x, y + text.textSize * 0.35f, text)
}
}
@SuppressLint("ClickableViewAccessibility")
override fun onTouchEvent(event: MotionEvent): Boolean {
val (centres, radius) = layout()
val now = HashSet<Int>()
val ending = event.actionMasked == MotionEvent.ACTION_UP || event.actionMasked == MotionEvent.ACTION_CANCEL
if (!ending) for (p in 0 until event.pointerCount) {
if (event.actionMasked == MotionEvent.ACTION_POINTER_UP && p == event.actionIndex) continue
val x = event.getX(p)
val y = event.getY(p)
// The nearest button within reach, so a thumb between two still lands on one.
val nearest = centres.indices.minByOrNull { hypot(centres[it].first - x, centres[it].second - y) } ?: continue
if (hypot(centres[nearest].first - x, centres[nearest].second - y) <= radius * 1.6f) now.add(buttons[nearest].first)
}
set(now)
return true
}
private fun set(now: Set<Int>) {
if (now == held) return
if ((now - held).isNotEmpty()) performHapticFeedback(HapticFeedbackConstants.VIRTUAL_KEY)
val before = held
held = now
pad.applyTouch { s -> for (b in before + now) s.press(b, b in now) }
invalidate()
}
override fun onDetachedFromWindow() {
set(emptySet())
super.onDetachedFromWindow()
}
}
/** Lays its controls out where [GamepadGeometry] puts them for the panel's size. */
private class GamepadLayout(context: Context) : ViewGroup(context) {
private val slots = LinkedHashMap<View, GamepadGeometry.Slot>()
fun place(view: View, slot: GamepadGeometry.Slot) {
slots[view] = slot
addView(view)
}
private fun boxes(w: Int, h: Int) = GamepadGeometry.layout(w.toFloat(), h.toFloat(), resources.displayMetrics.density)
override fun onMeasure(widthMeasureSpec: Int, heightMeasureSpec: Int) {
val w = MeasureSpec.getSize(widthMeasureSpec)
val h = MeasureSpec.getSize(heightMeasureSpec)
setMeasuredDimension(w, h)
val boxes = boxes(w, h)
for ((view, slot) in slots) {
val box = boxes[slot]
view.visibility = if (box == null) View.GONE else View.VISIBLE
if (box == null) continue
view.measure(MeasureSpec.makeMeasureSpec(box.width.toInt().coerceAtLeast(1), MeasureSpec.EXACTLY),
MeasureSpec.makeMeasureSpec(box.height.toInt().coerceAtLeast(1), MeasureSpec.EXACTLY))
}
}
override fun onLayout(changed: Boolean, l: Int, t: Int, r: Int, b: Int) {
val boxes = boxes(r - l, b - t)
for ((view, slot) in slots) {
val box = boxes[slot] ?: continue
val left = box.left.toInt()
val top = box.top.toInt()
view.layout(left, top, left + box.width.toInt().coerceAtLeast(1), top + box.height.toInt().coerceAtLeast(1))
}
}
}
companion object {
/** A second press on a stick this soon after the last is its click (L3, R3). */
private const val CLICK_MS = 300L
/** The tab last shown, kept for the session so reopening the panel lands where it was. */
private var selected = Tab.BOTH
/** The same for the unfolded screen's panel, which opens on the gamepad. */
private var selectedWithPad = Tab.GAMEPAD
private const val CORNER = 20f
private const val CORNER_PILL = 22f
@@ -348,5 +705,6 @@ class DeckControlsPanel(
private val GUIDE = Color.rgb(0x16, 0x18, 0x1B)
private val TEXT = Color.rgb(0xF2, 0xF4, 0xF7)
private val TEXT_DIM = Color.rgb(0x7A, 0x80, 0x8A)
private val KNOB = Color.rgb(0x2A, 0x2D, 0x33)
}
}
@@ -322,8 +322,18 @@ private fun GameList(
modifier = modifier.verticalScroll(rememberScrollState()).padding(start = 12.dp, end = 10.dp, top = 16.dp, bottom = 16.dp),
) {
GamesHeader(games.size, onAdd, Modifier.padding(start = 6.dp, bottom = 10.dp))
// A game that arrives while the list is up (Steam installed it in the session that just
// ended, or it was added) eases in; the ones there when the page opened are simply there.
val seen = remember { HashSet<Int>() }
var settled by remember { androidx.compose.runtime.mutableStateOf(false) }
for (g in games) key(g.appId) {
GameRow(g, g.appId == current.appId, onSelect = { onSelect(g) }, onLaunch = { onLaunch(g) })
Box(Modifier.arriveIn(settled && g.appId !in seen)) {
GameRow(g, g.appId == current.appId, onSelect = { onSelect(g) }, onLaunch = { onLaunch(g) })
}
}
androidx.compose.runtime.SideEffect {
seen.addAll(games.map { it.appId })
if (games.isNotEmpty()) settled = true
}
}
}
@@ -442,6 +452,14 @@ private fun AddGameButton(onClick: () -> Unit) {
}
}
/** Fades and slides a row in once when [arriving]; nothing for one already there (or with animations off). */
@Composable
internal fun Modifier.arriveIn(arriving: Boolean): Modifier {
val t = remember { androidx.compose.animation.core.Animatable(if (arriving) 0f else 1f) }
LaunchedEffect(Unit) { if (t.value < 1f) t.animateTo(1f, Motion.tw(280)) }
return graphicsLayer { alpha = t.value; translationY = (1f - t.value) * 12.dp.toPx() }
}
/** One game in the list. Moving onto it with the pad shows it; A launches it, a tap only shows it. */
@Composable
private fun GameRow(g: Library.SteamGame, selected: Boolean, onSelect: () -> Unit, onLaunch: () -> Unit) {
@@ -33,13 +33,16 @@ import androidx.compose.foundation.layout.Box
import androidx.compose.runtime.key
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.WindowInsets
import androidx.compose.foundation.layout.displayCutout
import androidx.compose.foundation.layout.fillMaxHeight
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.systemBarsPadding
import androidx.compose.foundation.layout.systemBars
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.layout.windowInsetsPadding
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material.icons.filled.Settings
import androidx.compose.material3.MaterialTheme
@@ -126,6 +129,7 @@ class FrontEndState(
val controller: com.droiddeck.launcher.input.ControllerPrefs.Settings? = null,
val phantomProcessStatus: PhantomProcessStatus = PhantomProcessStatus.NOT_APPLICABLE,
val showPhantomGate: Boolean = false,
val orientation: Int = android.content.pm.ActivityInfo.SCREEN_ORIENTATION_SENSOR_LANDSCAPE,
val launcherFullscreen: Boolean = true,
val animationsEnabled: Boolean = true,
/** The Flathub Store, a beta the user turns on in Setup. */
@@ -201,6 +205,7 @@ class FrontEndActions(
val onTheme: (String) -> Unit = {},
val onLanguage: (String) -> Unit = {},
val onAppScale: (Int) -> Unit = {},
val onOrientation: (Int) -> Unit = {},
val onLauncherFullscreen: (Boolean) -> Unit = {},
val onAnimationsEnabled: (Boolean) -> Unit = {},
val onStoreEnabled: (Boolean) -> Unit = {},
@@ -468,7 +473,7 @@ private fun FrontEndScreenBody(s: FrontEndState, a: FrontEndActions, page: (@Com
var anyFocused by remember { mutableStateOf(false) }
Box(
modifier = Modifier.fillMaxSize().background(colors.background)
.then(if (s.launcherFullscreen) Modifier else Modifier.systemBarsPadding())
.windowInsetsPadding(if (s.launcherFullscreen) WindowInsets.displayCutout else WindowInsets.systemBars)
.onFocusChanged { anyFocused = it.hasFocus },
) {
// Start controllers on the current page's main action, else on the rail.
@@ -351,6 +351,11 @@ internal fun SetupPanel(
if (s.homeScreenEnabled) stringResource(R.string.setup_home_on) else stringResource(R.string.setup_home_off),
s.homeScreenEnabled,
) { a.onHomeScreen(it) }
ChoiceRow(
host, "orientation", stringResource(R.string.setup_orientation), null,
SessionPrefs.orientationOptions.map { (value, label) -> value to stringResource(label) },
s.orientation, onPick = a.onOrientation,
)
ToggleRow(
host, "launcher-fullscreen", stringResource(R.string.setup_fullscreen),
if (s.launcherFullscreen) stringResource(R.string.setup_fullscreen_on) else stringResource(R.string.setup_fullscreen_off),
@@ -505,6 +510,7 @@ internal fun LaunchSettings(s: FrontEndState, a: FrontEndActions, host: MenuHost
if (opened != null && wincompKey != null && game != null) WinComponentsDialog(
wincompKey, game.name, game.gameFiles, opened, compat = game.protonPrefix,
steamAppId = game.appId.takeIf { game.library != com.droiddeck.launcher.frontend.Library.ADDED },
game = game,
) { wincompOpen = null }
Column(verticalArrangement = Arrangement.spacedBy(10.dp), modifier = Modifier.fillMaxWidth().padding(bottom = 16.dp)) {
for (row in cards.chunked(columns)) {
@@ -0,0 +1,116 @@
package com.droiddeck.launcher.ui
import kotlin.math.min
/**
* Where the unfolded screen's gamepad puts each control, for a panel of a given size: plain
* numbers, so the layout is tested without a device (GamepadGeometryTest).
*
* Each thumb pivots at its bottom corner; its main control (a stick or trackpad) sits where it
* rests, diagonally in from the corner, and the right side mirrors the left.
*
* A wide strip (a foldable held flat as a tablet): the second control - the d-pad, the face
* buttons - a slide inward along the arc, the shoulders above, and View, Steam, QAM and Menu
* between the arcs. The reach R is the strip's height, or a third of its width.
*
* A tall panel (held upright): the second control a slide up the arc instead, the shoulders above
* that, View, Steam, QAM and Menu between the two sticks, and Panels - the Deck controls' other
* tabs - between the shoulders. Each side's column S is half the width, or as much as the height
* holds. A wide strip has no room for Panels and leaves it out.
*/
object GamepadGeometry {
enum class Slot {
LEFT_MAIN, LEFT_SECOND, RIGHT_MAIN, RIGHT_SECOND,
LEFT_OUTER_SHOULDER, LEFT_INNER_SHOULDER, RIGHT_INNER_SHOULDER, RIGHT_OUTER_SHOULDER,
VIEW, STEAM, QAM, MENU,
/** The Deck controls' other tabs: on a tall panel only. */
PANELS,
}
/** A control's box: its centre and size, in pixels. */
data class Box(val cx: Float, val cy: Float, val width: Float, val height: Float) {
val left get() = cx - width / 2f
val top get() = cy - height / 2f
val right get() = cx + width / 2f
val bottom get() = cy + height / 2f
fun overlaps(other: Box): Boolean =
left < other.right && other.left < right && top < other.bottom && other.top < bottom
}
/** The middle buttons, in a row in this order; two by two as on a Deck, View and Menu above Steam and QAM. */
private val middle = listOf(Slot.VIEW, Slot.STEAM, Slot.QAM, Slot.MENU)
private val twoByTwo = mapOf(Slot.VIEW to 0, Slot.MENU to 1, Slot.STEAM to 2, Slot.QAM to 3)
/** A panel this shape is laid out as a tall panel (held upright), which has room for Panels;
* anything flatter is a wide strip. */
fun tall(w: Float, h: Float): Boolean = h >= w * 0.45f
fun layout(w: Float, h: Float, density: Float): Map<Slot, Box> {
if (w <= 0f || h <= 0f) return emptyMap()
val pad = 8 * density
val buttonH = 40 * density
// A row needs its buttons comfortable; two by two will take them a little narrower.
val rowButton = 64 * density
val gridButton = 48 * density
val gutter = 8 * density
val boxes = LinkedHashMap<Slot, Box>()
fun mirror(x: Float, right: Boolean) = if (right) w - x else x
fun pair(left: Slot, right: Slot, x: Float, cy: Float, bw: Float, bh: Float) {
boxes[left] = Box(mirror(x, false), cy, bw, bh)
boxes[right] = Box(mirror(x, true), cy, bw, bh)
}
fun middleGrid(columns: Int, span: Float, top: Float) {
val each = (span / columns).coerceAtMost(110 * density)
for ((order, slot) in middle.withIndex()) {
val index = if (columns == 2) twoByTwo.getValue(slot) else order
val x = w / 2f + (index % columns - (columns - 1) / 2f) * each
boxes[slot] = Box(x, top + buttonH / 2f + (index / columns) * (buttonH + 10 * density), each - gutter, buttonH)
}
}
if (!tall(w, h)) {
val reach = min(h - pad, w * 0.33f)
val main = reach * 0.66f
val second = reach * 0.54f
val mainTop = h - reach * 0.40f - main / 2f
val shoulderW = reach * 0.36f
val shoulderH = (reach * 0.16f).coerceAtLeast(buttonH)
// Above the main controls, never over them, and inside the panel.
val shoulderY = min(h - reach * 0.94f + shoulderH / 2f, mainTop - gutter - shoulderH / 2f)
.coerceAtLeast(pad + shoulderH / 2f)
pair(Slot.LEFT_MAIN, Slot.RIGHT_MAIN, reach * 0.40f, h - reach * 0.40f, main, main)
pair(Slot.LEFT_SECOND, Slot.RIGHT_SECOND, reach * 1.03f, h - reach * 0.30f, second, second)
pair(Slot.LEFT_OUTER_SHOULDER, Slot.RIGHT_OUTER_SHOULDER, reach * 0.22f, shoulderY, shoulderW, shoulderH)
pair(Slot.LEFT_INNER_SHOULDER, Slot.RIGHT_INNER_SHOULDER, reach * 0.62f, shoulderY, shoulderW, shoulderH)
// Between the arcs: in a row if they fit, else two by two, else one above another.
val gap = w - 2 * reach * 1.32f
val columns = when {
gap >= middle.size * rowButton -> middle.size
gap >= 2 * gridButton -> 2
else -> 1
}
middleGrid(columns, gap - 2 * gutter, shoulderY - buttonH / 2f)
} else {
val side = min(w * 0.5f, h * 0.70f)
val main = side * 0.62f
val second = side * 0.46f
val secondCy = h - side * 0.97f
val shoulderW = side * 0.34f
val shoulderH = (side * 0.13f).coerceAtLeast(buttonH)
val shoulderY = (secondCy - second / 2f - gutter - shoulderH / 2f).coerceAtLeast(pad + shoulderH / 2f)
pair(Slot.LEFT_MAIN, Slot.RIGHT_MAIN, side * 0.40f, h - side * 0.40f, main, main)
pair(Slot.LEFT_SECOND, Slot.RIGHT_SECOND, side * 0.74f, secondCy, second, second)
pair(Slot.LEFT_OUTER_SHOULDER, Slot.RIGHT_OUTER_SHOULDER, side * 0.20f, shoulderY, shoulderW, shoulderH)
pair(Slot.LEFT_INNER_SHOULDER, Slot.RIGHT_INNER_SHOULDER, side * 0.57f, shoulderY, shoulderW, shoulderH)
// Between the two sticks at the bottom: two by two, or one above another.
val gap = w - 2 * (side * 0.40f + main / 2f)
val columns = if (gap >= 2 * gridButton) 2 else 1
val rows = (middle.size + columns - 1) / columns
middleGrid(columns, gap - 2 * gutter, h - pad - rows * buttonH - (rows - 1) * 10 * density)
// Between the inner shoulders, where it fits.
val panelsW = min(112 * density, w - 2 * (side * 0.57f + shoulderW / 2f) - 2 * gutter)
if (panelsW >= buttonH) boxes[Slot.PANELS] = Box(w / 2f, shoulderY, panelsW, buttonH)
}
return boxes
}
}
@@ -86,15 +86,13 @@ class ModeSettings(
/** Steam only: SessionPrefs.SYNC_* chosen for Proton games; null outside Steam. */
val syncBackend: String? = null,
val stretch16x9: Boolean? = null,
/** Steam only: the client branch forced on the command line. */
val steamChannel: String? = null,
/** Steam only: enable the SteamOS client interface and its performance controls. */
val steamDeckMode: Boolean = false,
val steamRepairQueued: Boolean = false,
/** Steam, Deck mode: the QAM's performance overlay (mangoapp) is started. */
/** Steam: the QAM's performance overlay (mangoapp) is started. */
val mangoapp: Boolean = true,
/** Steam only: what the pad is to the client (SessionPrefs.CONTROLLER_*); null outside Steam. */
val steamController: String? = null,
/** Unfolded controls (Steam only; null hides the row). */
val unfoldedControls: Boolean? = null,
/** Steam only: start a Steam session when DroidDeck opens. */
val runSteamAtStartup: Boolean = false,
/** Null outside Steam; the saved choice is separate from Android's access and device switch. */
@@ -139,11 +137,10 @@ class ModeSettingsActions(
val onForceSsbs: (Boolean) -> Unit = {},
val onSyncBackend: (String) -> Unit = {},
val onStretch16x9: (Boolean) -> Unit = {},
val onSteamChannel: (String) -> Unit = {},
val onSteamRepair: () -> Unit = {},
val onSteamDeckMode: (Boolean) -> Unit = {},
val onMangoapp: (Boolean) -> Unit = {},
val onSteamController: (String) -> Unit = {},
val onUnfoldedControls: (Boolean) -> Unit = {},
val onRunSteamAtStartup: (Boolean) -> Unit = {},
val onWifiDiscovery: (Boolean) -> Unit = {},
val onWifiDiscoverySettings: () -> Unit = {},
@@ -266,11 +263,19 @@ fun ModeSettingsPage(s: ModeSettings, a: ModeSettingsActions) {
host, "controller", stringResource(R.string.mode_controller), stringResource(R.string.mode_controller_hint),
listOf(
SessionPrefs.CONTROLLER_DECK to stringResource(R.string.mode_controller_deck),
SessionPrefs.CONTROLLER_STEAM to stringResource(R.string.mode_controller_steam),
SessionPrefs.CONTROLLER_DS4 to stringResource(R.string.mode_controller_ds4),
SessionPrefs.CONTROLLER_DUALSENSE to stringResource(R.string.mode_controller_dualsense),
SessionPrefs.CONTROLLER_XBOX360 to stringResource(R.string.mode_controller_x360),
), s.steamController,
note = stringResource(R.string.mode_controller_note),
onPick = a.onSteamController,
)
if (steam && s.unfoldedControls != null) ToggleRow(
host, "unfolded-controls", stringResource(R.string.unfolded_controls),
stringResource(R.string.unfolded_controls_hint),
s.unfoldedControls, onChange = a.onUnfoldedControls,
)
if (steam) ChoiceRow(
host, "back-actions", stringResource(R.string.mode_back), stringResource(SessionPrefs.backActionsOrder(s.backActionsInverted)),
listOf(
@@ -373,25 +378,12 @@ fun ModeSettingsPage(s: ModeSettings, a: ModeSettingsActions) {
)
}
}
if (steam && tab == ModeSettingsTab.STEAM && s.steamChannel != null) SettingsGroup(stringResource(R.string.mode_client)) {
if (steam && tab == ModeSettingsTab.STEAM) SettingsGroup(stringResource(R.string.mode_client)) {
ToggleRow(
host, "steamdeck", stringResource(R.string.mode_deck_mode),
stringResource(R.string.mode_deck_mode_hint),
s.steamDeckMode, onChange = a.onSteamDeckMode,
)
if (s.steamDeckMode) ToggleRow(
host, "mangoapp", stringResource(R.string.mode_mangoapp),
stringResource(R.string.mode_mangoapp_hint),
s.mangoapp, onChange = a.onMangoapp,
)
// Deck mode fixes the branch (SessionPrefs.steamChannel); the choice is for Deck mode off.
if (s.steamDeckMode) SettingsRow(stringResource(R.string.mode_branch), stringResource(R.string.mode_branch_deck)) {}
else ChoiceRow(
host, "channel", stringResource(R.string.mode_branch), stringResource(R.string.mode_branch_hint),
listOf("publicbeta" to stringResource(R.string.mode_branch_public), "steamdeck_publicbeta" to stringResource(R.string.mode_branch_deck_beta)), s.steamChannel,
note = stringResource(R.string.mode_branch_note),
onPick = a.onSteamChannel,
)
SettingsRow(
stringResource(R.string.mode_steam_repair),
stringResource(if (s.steamRepairQueued) R.string.mode_steam_repair_queued else R.string.mode_steam_repair_hint),
@@ -194,6 +194,9 @@ class DrawerActions(
val suspendPolicy: String,
val steamDownloadsInBackground: Boolean = false,
val backActionsInverted: Boolean,
/** What the pad is to Steam (SessionPrefs.CONTROLLER_*), switched live; null hides the row. */
val padType: String? = null,
val onPadType: (String) -> Unit = {},
val touchMode: String,
val touchAuto: String,
val fexPreset: String,
@@ -242,6 +245,9 @@ class DrawerActions(
val pipAutoEnter: Boolean = false,
val onPip: () -> Unit = {},
val onPipAutoEnter: (Boolean) -> Unit = {},
/** Unfolded controls (on a foldable; null hides the row). */
val unfoldedControls: Boolean? = null,
val onUnfoldedControls: (Boolean) -> Unit = {},
val onShareLogs: () -> Unit,
val onStop: () -> Unit,
/** Stop with the dialog's button's bounds on screen, for the flood to grow out of; null falls back to [onStop]. */
@@ -416,6 +422,15 @@ internal fun SessionDrawer(open: Boolean, requestedPage: SessionDrawerPage, cont
}
SessionDrawerPage.CONTROLLER -> {
SettingsGroup(stringResource(R.string.drawer_page_controller)) {
a.padType?.let { type ->
ChoiceRow(host, "pad-type", stringResource(R.string.mode_controller), null,
listOf(SessionPrefs.CONTROLLER_DECK to stringResource(R.string.mode_controller_deck),
SessionPrefs.CONTROLLER_STEAM to stringResource(R.string.mode_controller_steam),
SessionPrefs.CONTROLLER_DS4 to stringResource(R.string.mode_controller_ds4),
SessionPrefs.CONTROLLER_DUALSENSE to stringResource(R.string.mode_controller_dualsense),
SessionPrefs.CONTROLLER_XBOX360 to stringResource(R.string.mode_controller_x360)),
type, chipModifier = focus.track(page, "pad-type"), onPick = a.onPadType)
}
ChoiceRow(host, "touch", stringResource(R.string.mode_touch), null,
listOf(SessionPrefs.TOUCH_AUTO to stringResource(R.string.drawer_touch_auto, a.touchAuto), SessionPrefs.TOUCH_PAD to stringResource(R.string.mode_touch_touchpad), SessionPrefs.TOUCH_DIRECT to stringResource(R.string.mode_touch_direct), SessionPrefs.TOUCH_OFF to stringResource(R.string.widgets_off)),
a.touchMode, chipModifier = focus.track(page, "touch"), onPick = a.onTouch)
@@ -445,6 +460,9 @@ internal fun SessionDrawer(open: Boolean, requestedPage: SessionDrawerPage, cont
if (a.steam) ChoiceRow(host, "back-actions", stringResource(R.string.mode_back), null,
listOf(false to stringResource(SessionPrefs.BACK_MENU_THEN_QAM), true to stringResource(SessionPrefs.BACK_QAM_THEN_MENU)),
a.backActionsInverted, chipModifier = focus.track(page, "back-actions"), onPick = a.onBackActionsInverted)
if (a.steam && a.unfoldedControls != null) ToggleRow(host, "unfolded-controls", stringResource(R.string.unfolded_controls),
stringResource(R.string.unfolded_controls_hint), a.unfoldedControls,
chipModifier = focus.track(page, "unfolded-controls"), onChange = a.onUnfoldedControls)
}
SettingsGroup(stringResource(R.string.drawer_keyboard)) {
Row(horizontalArrangement = Arrangement.spacedBy(8.dp), modifier = Modifier.fillMaxWidth().padding(8.dp)) {
@@ -4,7 +4,6 @@ import androidx.compose.ui.platform.testTag
import com.droiddeck.launcher.R
import androidx.compose.ui.res.stringResource
import androidx.compose.animation.core.FastOutSlowInEasing
import androidx.compose.animation.core.LinearEasing
import androidx.compose.animation.core.RepeatMode
import androidx.compose.runtime.State
import androidx.compose.animation.core.animateFloat
@@ -12,7 +11,6 @@ import androidx.compose.animation.core.infiniteRepeatable
import androidx.compose.animation.core.rememberInfiniteTransition
import androidx.compose.animation.core.tween
import androidx.compose.foundation.background
import androidx.compose.foundation.border
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.BoxWithConstraints
@@ -41,21 +39,38 @@ import androidx.compose.ui.layout.boundsInParent
import androidx.compose.ui.layout.onPlaced
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.draw.clipToBounds
import androidx.compose.ui.draw.drawBehind
import androidx.compose.ui.geometry.Offset
import androidx.compose.ui.graphics.Brush
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.graphicsLayer
import androidx.compose.ui.layout.ContentScale
import androidx.compose.ui.unit.dp
import coil.compose.AsyncImage
import com.droiddeck.launcher.frontend.Library
import androidx.compose.foundation.layout.width
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.key
import androidx.compose.runtime.mutableFloatStateOf
import androidx.compose.runtime.withFrameNanos
import androidx.compose.ui.geometry.CornerRadius
import androidx.compose.ui.geometry.Size
import androidx.compose.ui.graphics.drawscope.DrawScope
import androidx.compose.ui.graphics.drawscope.Stroke
import androidx.compose.ui.graphics.drawscope.inset
import androidx.compose.ui.graphics.painter.Painter
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.platform.LocalDensity
import coil.compose.rememberAsyncImagePainter
import coil.request.ImageRequest
import coil.size.Scale
import coil.transform.RoundedCornersTransformation
import kotlin.math.roundToInt
/** From this many games the Steam tab is a wall; fewer would only repeat the same covers. */
private const val WALL_MIN_GAMES = 4
private const val DRIFT_STEP_NS = 15_000_000L
/**
* The Steam tab: the library as a slowly drifting, tilted wall of capsules (a few games instead
* lean on their own; none, the DroidDeck mark wandering a blank wall), and over it the wordmark and the one thing to do here -
@@ -126,32 +141,49 @@ internal fun CapsuleWall(
games: List<Library.SteamGame>,
driftMs: Int = if (games.isEmpty()) 75_000 else 40_000,
modifier: Modifier = Modifier,
) = TiltedWall(games.size, driftMs, modifier) { i, m ->
Capsule(if (games.isEmpty()) null else games[i], RoundedCornerShape(10.dp), m)
) {
val pal = LocalPalette.current
val blank = remember(pal) { WallCapsule(Brush.linearGradient(listOf(pal.surface, pal.background)), edge = pal.line) }
TiltedWall(games.size, driftMs, modifier) { i -> if (games.isEmpty()) blank else WallCapsule(artBrush(hueOf(games[i].name)), games[i].art) }
}
/** One capsule of a [TiltedWall]: its ground, the art laid over it once loaded, and an outline. */
internal class WallCapsule(val ground: Brush, val art: Any? = null, val edge: Color? = null)
/**
* [CapsuleWall]'s tilted, drifting columns for any [count] of capsules; [capsule] draws number i
* (0 until [count], or 0 for each blank one when [count] is 0) at the modifier it is given.
* [CapsuleWall]'s tilted, drifting columns for any [count] of capsules; [capsule] describes number i
* (0 until [count], or 0 for each blank one when [count] is 0). Each column is one drawing with the
* covers' corners cut into their bitmaps: a rounded clip under the tilt is rasterised on the CPU,
* for every capsule on every frame.
*/
@Composable
internal fun TiltedWall(
count: Int,
driftMs: Int,
modifier: Modifier = Modifier,
capsule: @Composable (index: Int, modifier: Modifier) -> Unit,
capsule: (index: Int) -> WallCapsule,
) {
val capW = 112.dp
val capH = 168.dp
val gap = 14.dp
val transition = rememberInfiniteTransition(label = "wall")
val drift = transition.animateFloat(
0f, 1f,
infiniteRepeatable(tween(driftMs, easing = LinearEasing)),
label = "drift",
)
// Animations off in the system settings: a still wall.
val still = Motion.scale == 0f
// The wall moves a few pixels a step. Stepped on frames at most every 15 ms it looks the same as
// stepped on every frame, which on a 120 Hz screen drew it twice as often.
val drift = remember { mutableFloatStateOf(0f) }
if (!still) LaunchedEffect(driftMs) {
val period = driftMs * 1_000_000.0 * Motion.scale
var start = 0L
var shown = 0L
while (true) withFrameNanos { now ->
if (start == 0L) start = now
if (shown == 0L || now - shown >= DRIFT_STEP_NS) {
shown = now
drift.floatValue = ((now - start) % period / period).toFloat()
}
}
}
val density = LocalDensity.current
BoxWithConstraints(modifier.fillMaxSize()) {
// Centred on the page and big enough that, once tilted, it still covers every corner.
val tilt = Math.toRadians(13.0)
@@ -162,6 +194,17 @@ internal fun TiltedWall(
val columns = ((wallW + gap) / (capW + gap)).toInt() + 1
val perRun = ((wallH + gap) / (capH + gap)).toInt() + 1
val run = (capH + gap) * perRun
val cap = with(density) { Size(capW.toPx(), capH.toPx()) }
val step = with(density) { (capH + gap).toPx() }
val radius = with(density) { 10.dp.toPx() }
val line = with(density) { 1.dp.toPx() }
val indexOf = { c: Int, i: Int -> if (count == 0) 0 else (c * 5 + (i % perRun) * 3) % count }
val shown = remember(count, columns, perRun) { (0 until columns).flatMap { c -> (0 until perRun).map { indexOf(c, it) } }.distinct() }
val looks = HashMap<Int, Pair<WallCapsule, Painter?>>(shown.size * 2)
for (index in shown) key(index) {
val look = capsule(index)
looks[index] = look to look.art?.let { rememberCapsuleArt(it, cap, radius) }
}
Row(
horizontalArrangement = Arrangement.spacedBy(gap),
modifier = Modifier
@@ -171,26 +214,52 @@ internal fun TiltedWall(
.graphicsLayer { rotationZ = -13f },
) {
repeat(columns) { c ->
Column(
verticalArrangement = Arrangement.spacedBy(gap),
modifier = Modifier.wrapContentHeight(Alignment.Top, unbounded = true).graphicsLayer {
val shift = if (still) 0f else run.toPx() * drift.value
translationY = if (c % 2 == 0) -shift else shift - run.toPx()
},
) {
repeat(perRun * 2) { i ->
capsule(if (count == 0) 0 else (c * 5 + (i % perRun) * 3) % count, Modifier.size(capW, capH))
}
}
Spacer(
Modifier.width(capW).wrapContentHeight(Alignment.Top, unbounded = true).height(run * 2 - gap)
.graphicsLayer {
val shift = if (still) 0f else run.toPx() * drift.floatValue
translationY = if (c % 2 == 0) -shift else shift - run.toPx()
}
.drawBehind {
for (i in 0 until perRun * 2) {
val (look, art) = looks[indexOf(c, i)] ?: continue
val top = i * step
inset(0f, top, 0f, size.height - top - cap.height) { drawCapsule(look, art, radius, line) }
}
},
)
}
}
}
}
/** A cover sized for a capsule, its corners already cut, so it draws without a clip. */
@Composable
private fun rememberCapsuleArt(model: Any, size: Size, radius: Float): Painter {
val context = LocalContext.current
val w = size.width.roundToInt()
val h = size.height.roundToInt()
return rememberAsyncImagePainter(
remember(model, w, h, radius) {
ImageRequest.Builder(context).data(model).size(w, h).scale(Scale.FILL)
.transformations(RoundedCornersTransformation(radius)).build()
},
)
}
private fun DrawScope.drawCapsule(look: WallCapsule, art: Painter?, radius: Float, line: Float) {
drawRoundRect(look.ground, size = size, cornerRadius = CornerRadius(radius))
if (art != null) with(art) { draw(size) }
look.edge?.let {
drawRoundRect(it, Offset(line / 2, line / 2), Size(size.width - line, size.height - line), CornerRadius(radius - line / 2), style = Stroke(line))
}
}
/** One to three games, each a large capsule leaning on the others and floating a little. */
@Composable
private fun CapsuleFan(games: List<Library.SteamGame>) {
val pal = LocalPalette.current
val density = LocalDensity.current
val transition = rememberInfiniteTransition(label = "fan")
val floats = listOf(9_000, 11_000, 13_000).map { ms ->
transition.animateFloat(0f, 1f, infiniteRepeatable(tween(ms, easing = FastOutSlowInEasing), RepeatMode.Reverse), label = "float")
@@ -212,12 +281,14 @@ private fun CapsuleFan(games: List<Library.SteamGame>) {
)
},
) {
val radius = with(density) { 14.dp.toPx() }
for (i in games.indices.reversed()) {
val slot = slots[i]
val h = maxHeight * slot.height
val v: State<Float> = floats[i]
Capsule(
games[i], RoundedCornerShape(14.dp),
val look = WallCapsule(artBrush(hueOf(games[i].name)), games[i].art)
val art = look.art?.let { rememberCapsuleArt(it, with(density) { Size((h * 2f / 3f).toPx(), h.toPx()) }, radius) }
Spacer(
Modifier
.offset(x = maxWidth * slot.x, y = maxHeight * slot.y)
.size(h * 2f / 3f, h)
@@ -228,23 +299,11 @@ private fun CapsuleFan(games: List<Library.SteamGame>) {
shadowElevation = 24.dp.toPx()
shape = RoundedCornerShape(14.dp)
clip = false
},
}
.drawBehind { drawCapsule(look, art, radius, 0f) },
)
}
}
}
private class FanSlot(val x: Float, val y: Float, val height: Float, val lean: Float)
/** A game's portrait capsule, or a blank one standing in for a game yet to be installed. */
@Composable
private fun Capsule(g: Library.SteamGame?, shape: RoundedCornerShape, modifier: Modifier) {
val pal = LocalPalette.current
if (g == null) {
Box(modifier.clip(shape).background(Brush.linearGradient(listOf(pal.surface, pal.background))).border(1.dp, pal.line, shape))
return
}
Box(modifier.clip(shape).background(artBrush(hueOf(g.name)))) {
if (g.art != null) AsyncImage(model = g.art, contentDescription = null, contentScale = ContentScale.Crop, modifier = Modifier.matchParentSize())
}
}
@@ -36,6 +36,7 @@ import androidx.compose.ui.layout.onSizeChanged
import androidx.compose.ui.draw.drawWithContent
import androidx.compose.ui.draw.drawBehind
import androidx.compose.ui.graphics.Brush
import androidx.compose.ui.graphics.SolidColor
import androidx.compose.ui.draw.clipToBounds
import androidx.compose.ui.draw.blur
import androidx.compose.foundation.layout.wrapContentHeight
@@ -774,14 +775,12 @@ private fun SignInCard(store: Store, height: Dp) {
.takeIf { it.size >= 8 }.orEmpty()
}
val shape = RoundedCornerShape(20.dp)
val capsule = RoundedCornerShape(10.dp)
val blank = WallCapsule(Brush.linearGradient(listOf(c.fill, colors.background)), edge = c.dot.copy(alpha = 0.22f))
val ground = SolidColor(c.fill)
Box(Modifier.fillMaxWidth().height(height.coerceAtLeast(320.dp)).clip(shape).background(colors.background).border(1.dp, pal.line, shape)) {
// The storefront arrives a moment after the page: its wall fades in over the blank one.
androidx.compose.animation.Crossfade(art, animationSpec = Motion.tw(600), label = "signInWall") { tiles ->
TiltedWall(tiles.size, if (tiles.isEmpty()) 75_000 else 50_000) { i, m ->
if (tiles.isEmpty()) Box(m.clip(capsule).background(Brush.linearGradient(listOf(c.fill, colors.background))).border(1.dp, c.dot.copy(alpha = 0.22f), capsule))
else Box(m.clip(capsule).background(c.fill)) { AsyncImage(model = tiles[i], contentDescription = null, contentScale = ContentScale.Crop, modifier = Modifier.matchParentSize()) }
}
TiltedWall(tiles.size, if (tiles.isEmpty()) 75_000 else 50_000) { i -> if (tiles.isEmpty()) blank else WallCapsule(ground, tiles[i]) }
}
// The wall gives way to the words: dark at the left and the foot, and the store's glow under them.
Box(
@@ -15,6 +15,10 @@ import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.key
import androidx.compose.ui.res.pluralStringResource
import androidx.compose.foundation.layout.size
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
@@ -38,6 +42,7 @@ import com.droiddeck.launcher.R
import com.droiddeck.launcher.frontend.DependencyDetector
import com.droiddeck.launcher.frontend.PrefixInstalledDetector
import com.droiddeck.launcher.frontend.SteamRedists
import com.droiddeck.launcher.frontend.WinCompSources
import com.droiddeck.launcher.session.WinComponentNames
import com.droiddeck.launcher.session.WinComponents
import com.droiddeck.launcher.session.WinComponents.Support
@@ -53,13 +58,38 @@ import java.io.File
// picked ones into the game's prefix (droiddeck-wincomponents).
/** Bannerlator's detector names installers; here the DLL-copy twin is the one that installs. */
private fun installable(name: String, all: Map<String, WinComponents.Component>): String {
val dll = when (name) {
"oalinst" -> "oalinst_dll"
else -> "${name}_dll"
}
val twin = all[dll]
return if (twin != null && WinComponents.support(twin, all) == Support.READY) dll else name
private fun installable(name: String, all: Map<String, WinComponents.Component>): String = WinComponents.installable(name, all)
/** A store as a list's owner names it in a reason line. */
@Composable
private fun originName(origin: String): String = stringResource(
when (origin) {
WinCompSources.Origin.GOG.name -> R.string.wincomp_src_gog
WinCompSources.Origin.EPIC.name -> R.string.wincomp_src_epic
WinCompSources.Origin.AMAZON.name -> R.string.wincomp_src_amazon
else -> R.string.wincomp_src_steam
},
)
/** Why a component was turned on by itself, from [WinComponents.AutoReason] (args: what the list called it, then the origin's own). */
@Composable
private fun autoReason(r: WinComponents.AutoReason): String = when (r.kind) {
WinCompSources.Origin.STEAM.name -> stringResource(R.string.wincomp_auto_steam, r.args.getOrElse(1) { "" }, r.args.getOrElse(2) { "" }, r.args.getOrElse(3) { "" })
WinCompSources.Origin.GOG.name -> stringResource(R.string.wincomp_auto_gog)
WinCompSources.Origin.EPIC.name -> stringResource(R.string.wincomp_auto_epic)
WinCompSources.Origin.AMAZON.name -> stringResource(R.string.wincomp_auto_amazon)
else -> ""
}
/** The reason line of a finding that is only recommended. */
@Composable
private fun findingReason(f: WinCompSources.Finding): String = when (f.origin) {
WinCompSources.Origin.FOLDER -> stringResource(
if (f.args.getOrNull(1) == DependencyDetector.Kind.SHIPPED.name) R.string.wincomp_found_shipped else R.string.wincomp_found_bundled,
f.args.getOrElse(0) { f.original },
)
WinCompSources.Origin.STEAM_NAME -> stringResource(R.string.wincomp_steam_by_name, f.args.getOrElse(0) { f.original }, f.args.getOrElse(2) { "" })
else -> autoReason(WinComponents.AutoReason(f.origin.name, listOf(f.original) + f.args))
}
@OptIn(androidx.compose.ui.ExperimentalComposeUiApi::class)
@@ -68,6 +98,8 @@ internal fun WinComponentsDialog(
appKey: String, gameName: String, gameDir: File?, byPad: Boolean,
/** compatdata/<id>: what the prefix already has. [steamAppId]: a Steam title, whose appmanifest lists Steam's own redists. */
compat: File? = null, steamAppId: Int? = null,
/** The game as the Games tab has it: its source decides whose list is trusted ([WinCompSources]). */
game: com.droiddeck.launcher.frontend.Library.SteamGame? = null,
onClose: () -> Unit,
) {
val context = LocalContext.current
@@ -77,11 +109,17 @@ internal fun WinComponentsDialog(
// null while the catalog loads; empty when it could not be had.
var catalog by remember { mutableStateOf<Map<String, WinComponents.Component>?>(null) }
var offline by remember { mutableStateOf(false) }
var recommended by remember { mutableStateOf(emptyList<DependencyDetector.Recommendation>()) }
var findings by remember { mutableStateOf(emptyList<WinCompSources.Finding>()) }
// Whether the game's list has been worked out at least once (kept from an earlier time counts).
var findingsKnown by remember { mutableStateOf(game == null) }
// "Loading the components" only when the catalog takes a while.
var slowCatalog by remember { mutableStateOf(false) }
LaunchedEffect(Unit) { kotlinx.coroutines.delay(300); slowCatalog = true }
var installed by remember { mutableStateOf(emptySet<String>()) }
// Detector names (oalinst, physx...) already in the prefix from elsewhere.
var present by remember { mutableStateOf(emptySet<String>()) }
var picks by remember { mutableStateOf(WinComponents.picks(context, appKey)) }
var selection by remember { mutableStateOf(WinComponents.selection(context, appKey)) }
val picks = selection.effective
// The row being worked on: its id, the latest progress (a dependency's while that installs
// first), and whether it is turning off rather than on. The row greys out and shows the bar
// the moment the switch is pressed, and only that row; the rest of the page stays as it is.
@@ -96,6 +134,7 @@ internal fun WinComponentsDialog(
// pad, so the d-pad always has somewhere to start; LB and RB jump between the sections, since
// the full list runs past sixty switches.
val recFocus = remember { FocusRequester() }
val recFocusAfterAuto = remember { FocusRequester() }
val activeFocus = remember { FocusRequester() }
val allFocus = remember { FocusRequester() }
val waitFocus = remember { FocusRequester() }
@@ -105,20 +144,46 @@ internal fun WinComponentsDialog(
LaunchedEffect(Unit) {
val (entries, found) = withContext(Dispatchers.IO) {
// The folder's own installers first, then what Steam installs with the game.
val own = gameDir?.let { DependencyDetector.detect(it) } ?: emptyList()
val steam = steamAppId?.let { SteamRedists.detect(gameDir, it) } ?: emptyList()
WinComponents.fetch() to (own + steam)
val list = WinComponents.fetch(context)
// The source's list first (and what it turns on by itself, already installed), then the folder scan.
if (game != null) {
// The list kept from last time, at once; it is worked out again (and its components
// queued) on the background worker, which this page then follows.
com.droiddeck.launcher.frontend.AutoComponents.queueGame(context, game, asked = true)
val kept = com.droiddeck.launcher.frontend.AutoComponents.cachedFindings(context, appKey)
if (kept != null) findingsKnown = true
list to kept.orEmpty()
} else {
val own = gameDir?.let { DependencyDetector.detect(it) } ?: emptyList()
val steam = steamAppId?.let { SteamRedists.detect(gameDir, it) } ?: emptyList()
list to (own + steam).map { WinCompSources.Finding(it.componentName, it.reason, WinCompSources.Origin.FOLDER, false, listOf(it.reason, it.kind.name)) }
}
}
offline = entries == null
catalog = entries.orEmpty().associateBy { it.name }
recommended = found
findings = found
selection = withContext(Dispatchers.IO) { WinComponents.selection(context, appKey) }
present = withContext(Dispatchers.IO) { PrefixInstalledDetector.detect(compat) }
installed = withContext(Dispatchers.IO) { WinComponents.installedIds(context).toSet() }
}
fun setPicks(next: List<String>) {
picks = next
coroutine.launch(Dispatchers.IO) { WinComponents.setPicks(context, appKey, next) }
/** The user's own switch for [id]: kept apart from what was turned on by itself, and over it. */
// Automatic picks still downloading show on their rows; when one lands the page reads the picks again.
val autoDownloading by com.droiddeck.launcher.frontend.AutoComponents.downloading.collectAsState()
// Without a network a component not downloaded yet cannot be turned on; the rows say so and
// come back the moment the network does.
val online = rememberOnline()
val autoChanges by com.droiddeck.launcher.frontend.AutoComponents.changes.collectAsState()
val pendingGames by com.droiddeck.launcher.frontend.AutoComponents.pendingGames.collectAsState()
val pendingComponents by com.droiddeck.launcher.frontend.AutoComponents.pendingComponents.collectAsState()
LaunchedEffect(autoChanges) {
if (game != null) withContext(Dispatchers.IO) { com.droiddeck.launcher.frontend.AutoComponents.cachedFindings(context, appKey) }
?.let { findings = it; if (appKey !in pendingGames) findingsKnown = true }
selection = withContext(Dispatchers.IO) { WinComponents.selection(context, appKey) }
installed = withContext(Dispatchers.IO) { WinComponents.installedIds(context).toSet() }
}
fun setUser(id: String, on: Boolean) {
selection = WinComponents.Selection(selection.auto, selection.user + (id to on))
coroutine.launch(Dispatchers.IO) { WinComponents.setUser(context, appKey, id, on) }
}
fun toggle(id: String, on: Boolean) {
// Switches stay enabled during a download - a disabled one drops the pad's focus - so a press
@@ -130,17 +195,16 @@ internal fun WinComponentsDialog(
// Turning off is a saved pick, quick, but the row greys out until it is written so the
// press is seen to land; the files leave the prefix at the game's next launch.
busyId = id; reverting = true; progress = null
val next = picks - id
picks = next
selection = WinComponents.Selection(selection.auto, selection.user + (id to false))
coroutine.launch {
withContext(Dispatchers.IO) { WinComponents.setPicks(context, appKey, next) }
withContext(Dispatchers.IO) { WinComponents.setUser(context, appKey, id, false) }
busyId = null; reverting = false
}
return
}
val all = catalog.orEmpty()
val c = all[id]
if (id in installed || c == null) { setPicks(picks + id); return }
if (id in installed || c == null) { setUser(id, true); return }
busyId = id; reverting = false
progress = WinComponents.Progress(id, "", WinComponents.Phase.DOWNLOAD, -1)
coroutine.launch {
@@ -149,28 +213,48 @@ internal fun WinComponentsDialog(
}
progress = null
installed = withContext(Dispatchers.IO) { WinComponents.installedIds(context).toSet() }
if (problem != null) { error = problem; failed = id } else setPicks(picks + id)
if (problem != null) { error = problem; failed = id } else setUser(id, true)
busyId = null
}
}
val all = catalog
// The first data is in: rows that show up from now on ease in.
var arrived by remember { mutableStateOf(false) }
LaunchedEffect(all != null && findingsKnown) { if (all != null && findingsKnown) { withFrameNanos { }; arrived = true } }
val here = if (all == null) emptySet() else present.map { installable(it, all) }.toSet()
fun supportOf(id: String): Support =
all?.get(id)?.let { WinComponents.support(it, all) } ?: if (id in installed) Support.READY else Support.UNSUPPORTED
val recIds = if (all == null) emptyList() else recommended.distinctBy { installable(it.componentName, all) }
// Turned on by itself for this game, each with its reason; then what is only recommended (the
// folder scan, a name match, an auto pick that cannot install here); then what the lists name
// that is no component here, by its own name.
// Still being worked out on the background worker: the page says so instead of "nothing found".
// Only before the game's list has ever been worked out: a list kept from before shows at once and
// is checked again quietly, its changes easing in.
val checking = game != null && !findingsKnown
// Automatic picks on their way (queued or downloading) sit in their section already, with
// their progress, and settle there when they land.
val turningOn = if (all == null) emptyMap() else findings.filter { it.auto && it.component != null }
.associateBy { installable(it.component!!, all) }
.filterKeys { it !in selection.auto && selection.user[it] != false && (it in pendingComponents || it in autoDownloading) }
val autoIds = selection.auto.keys.toList() + turningOn.keys
val recIds = if (all == null) emptyList() else findings.filter { it.component != null }
.filter { installable(it.component!!, all).let { id -> id !in selection.auto && id !in turningOn } }
.distinctBy { installable(it.component!!, all) }
val suggestions = findings.filter { it.component == null }.distinctBy { it.origin to it.original }
val ready = all.orEmpty().values.filter { WinComponents.support(it, all.orEmpty()) == Support.READY }.map { it.name }
val extra = (installed + picks).filter { all?.containsKey(it) != true }
// What is turned on for this game gets its own section under the recommendations, which stay
// where they are; the rest of the list is everything else, so a component turned off goes back
// to its place in the alphabet.
val recSet = recIds.map { installable(it.componentName, all.orEmpty()) }.toSet()
// to its place in the alphabet. A component is listed once: the recommendations are left out
// of the full list too.
val recSet = recIds.map { installable(it.component!!, all.orEmpty()) }.toSet() + autoIds
val active = picks.filter { it !in recSet && (all?.containsKey(it) == true || it in installed) }.distinct()
.sortedBy { WinComponentNames.of(it).lowercase() }
val list = (ready + extra).distinct().filter { it !in active }.sortedBy { WinComponentNames.of(it).lowercase() }
val list = (ready + extra).distinct().filter { it !in active && it !in recSet }.sortedBy { WinComponentNames.of(it).lowercase() }
val waiting = all.orEmpty().values.filter { WinComponents.support(it, all.orEmpty()) == Support.NEEDS_INSTALLER }
.map { it.name }.sortedBy { WinComponentNames.of(it).lowercase() }
val hasRec = all != null && recIds.any { supportOf(installable(it.componentName, all)) == Support.READY }
val hasRec = all != null && (autoIds.isNotEmpty() || recIds.any { supportOf(installable(it.component!!, all)) == Support.READY })
val sections = listOfNotNull(recFocus.takeIf { hasRec }, activeFocus.takeIf { active.isNotEmpty() },
allFocus.takeIf { list.isNotEmpty() }, waitFocus.takeIf { waiting.isNotEmpty() }, doneFocus)
var section by remember { mutableStateOf(0) }
@@ -197,13 +281,14 @@ internal fun WinComponentsDialog(
DialogHeader(gameName, stringResource(R.string.wincomp_title))
Small(stringResource(R.string.wincomp_applies))
if (byPad) Small(stringResource(R.string.wincomp_pad_hint))
if (all == null) Small(stringResource(R.string.wincomp_loading))
if (all == null && slowCatalog) Small(stringResource(R.string.wincomp_loading))
if (offline) Small(stringResource(R.string.wincomp_offline), error = true)
@Composable
fun Item(id: String, reason: String?, focus: FocusRequester?) {
val c = all?.get(id)
val support = supportOf(id)
val status = when {
!online && id !in installed && support == Support.READY -> stringResource(R.string.wincomp_when_online)
id == failed && error != null -> stringResource(R.string.wincomp_failed, error.orEmpty())
id in here && id !in picks -> stringResource(R.string.wincomp_already_here)
id in installed -> stringResource(R.string.wincomp_downloaded)
@@ -213,12 +298,15 @@ internal fun WinComponentsDialog(
}
// The catalog key stays in the detail: it is what a log or a bug report names.
val detail = listOfNotNull(reason, c?.description?.takeIf { it.isNotEmpty() }, status, id).joinToString(" · ")
val usable = support == Support.READY
val busy = id == busyId
val offlineHere = !online && id !in installed
val usable = support == Support.READY && !offlineHere
val autoProgress = autoDownloading[id]?.takeIf { !offlineHere }
val busy = id == busyId || autoProgress != null
val shownProgress = if (id == busyId) progress else autoProgress
val caption = when {
!busy -> null
reverting -> stringResource(R.string.wincomp_reverting)
else -> progress?.let { p ->
id == busyId && reverting -> stringResource(R.string.wincomp_reverting)
else -> shownProgress?.let { p ->
val stage = if (p.component != id && p.stage.isNotEmpty()) "${WinComponentNames.of(p.component)}: ${p.stage}" else p.stage
val text = when (p.phase) {
WinComponents.Phase.DOWNLOAD -> stringResource(R.string.wincomp_downloading, stage)
@@ -228,9 +316,9 @@ internal fun WinComponentsDialog(
}
}
ComponentRow(
WinComponentNames.of(id), detail, checked = id in picks, enabled = usable || id in picks,
WinComponentNames.of(id), detail, checked = id in picks, enabled = usable || (id in picks && id in installed),
dim = !usable || busy, busy = busy, caption = caption,
percent = if (busy && !reverting) progress?.percent ?: -1 else -1,
percent = if (busy && !reverting) shownProgress?.percent ?: -1 else -1,
modifier = (if (usable && focus != null) Modifier.focusRequester(focus) else Modifier)
.onFocusChanged { if (it.isFocused) switchFocused = true },
) { on -> toggle(id, on) }
@@ -241,48 +329,97 @@ internal fun WinComponentsDialog(
return { id -> focus.takeIf { id == first } }
}
if (all != null && recIds.isNotEmpty()) {
Section(stringResource(R.string.wincomp_recommended))
val focusOf = firstUsable(recIds.map { installable(it.componentName, all) }, recFocus)
Panel {
recIds.forEachIndexed { i, rec ->
if (i > 0) Divider()
val reason = stringResource(
when (rec.kind) {
DependencyDetector.Kind.BUNDLED -> R.string.wincomp_found_bundled
DependencyDetector.Kind.SHIPPED -> R.string.wincomp_found_shipped
DependencyDetector.Kind.STEAM -> R.string.wincomp_found_steam
}, rec.reason,
)
val id = installable(rec.componentName, all)
Item(id, reason, focusOf(id))
// The lists only once the catalog is in, faded in once: no half list counted before it.
androidx.compose.animation.AnimatedVisibility(
visible = all != null,
enter = androidx.compose.animation.fadeIn(Motion.tw(220)),
exit = androidx.compose.animation.fadeOut(Motion.tw(0)),
label = "lists",
) {
Column(verticalArrangement = Arrangement.spacedBy(14.dp), modifier = Modifier.fillMaxWidth()) {
if (checking) Row(verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(8.dp)) {
androidx.compose.material3.CircularProgressIndicator(Modifier.padding(start = 2.dp).size(12.dp), strokeWidth = 1.5.dp)
Small(stringResource(R.string.wincomp_checking))
}
androidx.compose.animation.AnimatedVisibility(
visible = all != null && autoIds.isNotEmpty(),
enter = androidx.compose.animation.fadeIn(Motion.tw(260)) + androidx.compose.animation.expandVertically(Motion.tw(260)),
exit = androidx.compose.animation.fadeOut(Motion.tw(180)) + androidx.compose.animation.shrinkVertically(Motion.tw(200)),
label = "autoSection",
) {
Column(verticalArrangement = Arrangement.spacedBy(8.dp)) {
Section(stringResource(R.string.wincomp_auto_section))
if (turningOn.isNotEmpty()) Small(pluralStringResource(R.plurals.wincomp_turning_on, turningOn.size, turningOn.size))
val focusOf = firstUsable(autoIds, recFocus)
Panel {
autoIds.forEachIndexed { i, id ->
key(id) {
Arrive(arrived) {
Column {
if (i > 0) Divider()
val reason = selection.auto[id] ?: turningOn[id]?.let { f -> WinComponents.AutoReason(f.origin.name, listOf(f.original) + f.args) }
Item(id, reason?.let { autoReason(it) }, focusOf(id))
}
}
}
}
}
}
}
if (all != null && recIds.isNotEmpty()) {
Section(stringResource(R.string.wincomp_recommended))
val focusOf = firstUsable(recIds.map { installable(it.component!!, all) }, if (autoIds.isEmpty()) recFocus else recFocusAfterAuto)
Panel {
recIds.forEachIndexed { i, rec ->
val id = installable(rec.component!!, all)
key(id) {
Arrive(arrived) {
Column {
if (i > 0) Divider()
Item(id, findingReason(rec), focusOf(id))
}
}
}
}
}
} else if (all != null && gameDir != null && autoIds.isEmpty() && !checking) Small(stringResource(R.string.wincomp_no_recommendation))
if (suggestions.isNotEmpty()) {
Section(stringResource(R.string.wincomp_suggestions))
Panel {
suggestions.forEachIndexed { i, f ->
if (i > 0) Divider()
Column(Modifier.fillMaxWidth().padding(horizontal = 14.dp, vertical = 10.dp)) {
Text(f.original, fontSize = 15.sp, fontWeight = FontWeight.SemiBold, color = MaterialTheme.colorScheme.onBackground)
Text(stringResource(R.string.wincomp_suggestion_reason, originName(f.origin.name)), fontSize = 12.sp, color = MaterialTheme.colorScheme.onSurfaceVariant)
}
}
}
}
if (active.isNotEmpty()) {
Section(stringResource(R.string.wincomp_active, active.size))
val focusOf = firstUsable(active, activeFocus)
Panel { active.forEachIndexed { i, id -> if (i > 0) Divider(); Item(id, null, focusOf(id)) } }
}
if (list.isNotEmpty()) {
Section(stringResource(R.string.wincomp_all, list.size))
val focusOf = firstUsable(list, allFocus)
Panel { list.forEachIndexed { i, id -> if (i > 0) Divider(); Item(id, null, focusOf(id)) } }
}
if (waiting.isNotEmpty()) {
Row(verticalAlignment = Alignment.CenterVertically) {
Section(stringResource(R.string.wincomp_installers, waiting.size))
Spacer(Modifier.weight(1f))
SecondaryButton(
stringResource(if (showWaiting) R.string.wincomp_hide else R.string.wincomp_show), compact = true,
modifier = Modifier.focusRequester(waitFocus),
) { showWaiting = !showWaiting }
}
Small(stringResource(R.string.wincomp_installers_note))
if (showWaiting) Panel { waiting.forEachIndexed { i, id -> if (i > 0) Divider(); Item(id, null, null) } }
}
}
} else if (all != null && gameDir != null) Small(stringResource(R.string.wincomp_no_recommendation))
if (active.isNotEmpty()) {
Section(stringResource(R.string.wincomp_active, active.size))
val focusOf = firstUsable(active, activeFocus)
Panel { active.forEachIndexed { i, id -> if (i > 0) Divider(); Item(id, null, focusOf(id)) } }
}
if (list.isNotEmpty()) {
Section(stringResource(R.string.wincomp_all, list.size))
val focusOf = firstUsable(list, allFocus)
Panel { list.forEachIndexed { i, id -> if (i > 0) Divider(); Item(id, null, focusOf(id)) } }
}
if (waiting.isNotEmpty()) {
Row(verticalAlignment = Alignment.CenterVertically) {
Section(stringResource(R.string.wincomp_installers, waiting.size))
Spacer(Modifier.weight(1f))
SecondaryButton(
stringResource(if (showWaiting) R.string.wincomp_hide else R.string.wincomp_show), compact = true,
modifier = Modifier.focusRequester(waitFocus),
) { showWaiting = !showWaiting }
}
Small(stringResource(R.string.wincomp_installers_note))
if (showWaiting) Panel { waiting.forEachIndexed { i, id -> if (i > 0) Divider(); Item(id, null, null) } }
}
error?.let { Small(it, error = true) }
Row(verticalAlignment = Alignment.CenterVertically, modifier = Modifier.padding(top = 4.dp)) {
Small(stringResource(R.string.wincomp_next_launch))
@@ -292,6 +429,39 @@ internal fun WinComponentsDialog(
}
}
/** Once the page has its first data ([arrived]), a row that shows up eases in; the first ones are simply there. */
@Composable
private fun Arrive(arrived: Boolean, content: @Composable () -> Unit) {
val state = remember { androidx.compose.animation.core.MutableTransitionState(!arrived) }.apply { targetState = true }
androidx.compose.animation.AnimatedVisibility(
visibleState = state,
enter = androidx.compose.animation.fadeIn(Motion.tw(240)) + androidx.compose.animation.expandVertically(Motion.tw(240)) +
androidx.compose.animation.slideInVertically(Motion.tw(240)) { -it / 4 },
exit = androidx.compose.animation.fadeOut(Motion.tw(160)),
label = "arrive",
) { content() }
}
/** Whether the default network is there and validated, followed live. */
@Composable
private fun rememberOnline(): Boolean {
val context = LocalContext.current
var online by remember { mutableStateOf(com.droiddeck.launcher.frontend.AutoComponents.isOnline(context)) }
androidx.compose.runtime.DisposableEffect(Unit) {
val cm = context.getSystemService(android.net.ConnectivityManager::class.java)
val main = android.os.Handler(android.os.Looper.getMainLooper())
val callback = object : android.net.ConnectivityManager.NetworkCallback() {
private fun update() = main.post { online = com.droiddeck.launcher.frontend.AutoComponents.isOnline(context) }
override fun onAvailable(network: android.net.Network) { update() }
override fun onLost(network: android.net.Network) { update() }
override fun onCapabilitiesChanged(network: android.net.Network, caps: android.net.NetworkCapabilities) { update() }
}
runCatching { cm?.registerDefaultNetworkCallback(callback) }
onDispose { runCatching { cm?.unregisterNetworkCallback(callback) } }
}
return online
}
@Composable
private fun Section(title: String) =
Text(title.uppercase(), fontSize = 12.sp, fontWeight = FontWeight.SemiBold, letterSpacing = 1.5.sp, color = MaterialTheme.colorScheme.onSurfaceVariant)
+25 -10
View File
@@ -254,6 +254,9 @@
<string name="mode_controller">Mando</string>
<string name="mode_controller_hint">Cómo ve Steam tu mando. Se aplica en la próxima sesión.</string>
<string name="mode_controller_deck">Mando de Steam Deck</string>
<string name="mode_controller_steam">Steam Controller</string>
<string name="mode_controller_ds4">DualShock 4</string>
<string name="mode_controller_dualsense">DualSense</string>
<string name="mode_controller_x360">Mando Xbox 360</string>
<string name="mode_controller_note">Deck: botón de Quick Access y giroscopio. Xbox 360: abre Quick Access con Guide+A.</string>
<string name="mode_back">Atrás</string>
@@ -283,16 +286,8 @@
<string name="mode_decky_remove_title">¿Desinstalar Decky Loader?</string>
<string name="mode_decky_remove_text">Elimina el loader. Tus plugins y ajustes se conservan.</string>
<string name="mode_client">Cliente</string>
<string name="mode_deck_mode">Modo Steam Deck</string>
<string name="mode_deck_mode_hint">La interfaz Deck de Steam y los controles de rendimiento de Quick Access. Se aplica en la próxima sesión.</string>
<string name="mode_mangoapp">Superposición de rendimiento</string>
<string name="mode_mangoapp_hint">Desactívalo si los juegos fallan o se quedan en negro en modo Deck. Se aplica en la próxima sesión.</string>
<string name="mode_branch">Rama del cliente</string>
<string name="mode_branch_deck">Beta pública de Steam Deck (requerida por el modo Deck).</string>
<string name="mode_branch_hint">Se aplica en la próxima sesión. Steam puede actualizarse una vez.</string>
<string name="mode_branch_public">Beta pública</string>
<string name="mode_branch_deck_beta">Beta pública de Steam Deck</string>
<string name="mode_branch_note">El modo Deck siempre usa la beta pública de Steam Deck.</string>
<string name="mode_steam_repair">Reparar el cliente de Steam</string>
<string name="mode_steam_repair_hint">Vuelve a descargar el cliente de Steam la próxima vez que se inicie Steam. Úsalo si Steam se queda en la pantalla de carga. Se conservan los juegos, la sesión iniciada y los ajustes.</string>
<string name="mode_steam_repair_queued">El cliente de Steam se volverá a descargar la próxima vez que se inicie Steam.</string>
@@ -312,12 +307,30 @@
<string name="wincomp_title">Componentes de Windows</string>
<string name="wincomp_pad_hint">LB / RB saltan entre las secciones.</string>
<string name="wincomp_card_none">Ninguno</string>
<string name="wincomp_applies">DLL de Windows que Proton no incluye, de la lista de componentes de Bannerlator. Activa las que necesita este juego: van a la carpeta de Windows propia del juego, nunca a sus archivos, y se mantienen con cualquier Proton que use.</string>
<string name="wincomp_applies">DLL de Windows que Proton no incluye, de la lista de componentes. Activa las que necesita este juego: van a la carpeta de Windows propia del juego, nunca a sus archivos, y se mantienen con cualquier Proton que use.</string>
<string name="wincomp_offline">No se pudo acceder a la lista de componentes. Los componentes ya descargados se pueden seguir activando o desactivando.</string>
<string name="wincomp_recommended">Recomendados para este juego</string>
<string name="wincomp_checking">Comprobando lo que necesita este juego…</string>
<string name="wincomp_when_online">Disponible con conexión</string>
<plurals name="wincomp_turning_on">
<item quantity="one">Activando %1$d componente…</item>
<item quantity="many">Activando %1$d componentes…</item>
<item quantity="other">Activando %1$d componentes…</item>
</plurals>
<string name="wincomp_auto_section">Activados para este juego</string>
<string name="wincomp_auto_steam">Steam instala %1$s con %2$s (app %3$s)</string>
<string name="wincomp_auto_gog">De la lista de GOG para este juego</string>
<string name="wincomp_auto_epic">De los requisitos previos de Epic para este juego</string>
<string name="wincomp_auto_amazon">De los pasos de instalación de Amazon para este juego</string>
<string name="wincomp_steam_by_name">Steam instala %1$s con la app %2$s, encontrada por nombre</string>
<string name="wincomp_suggestions">También en la lista</string>
<string name="wincomp_suggestion_reason">En la lista de %1$s; no es un componente aquí</string>
<string name="wincomp_src_steam">Steam</string>
<string name="wincomp_src_gog">GOG</string>
<string name="wincomp_src_epic">Epic Games</string>
<string name="wincomp_src_amazon">Amazon Games</string>
<string name="wincomp_found_bundled">El juego incluye %1$s</string>
<string name="wincomp_found_shipped">El juego tiene %1$s (suele funcionar tal cual)</string>
<string name="wincomp_found_steam">Steam instala %1$s con este juego</string>
<string name="wincomp_already_here">Ya está en este juego</string>
<string name="wincomp_no_recommendation">Nada en la carpeta del juego apunta a un componente.</string>
<string name="wincomp_all">Todos los componentes (%1$d)</string>
@@ -839,6 +852,8 @@
<string name="ctrl_steam_button">Botón Steam</string>
<string name="ctrl_qam_button">Botón QAM</string>
<string name="ctrl_keyboard_button">Botón de teclado</string>
<string name="ctrl_select_steam">Pulsa Select para abrir el menú de Steam</string>
<string name="ctrl_select_steam_hint">En lugar de View/Select.</string>
<string name="setup_animations">Animaciones</string>
<string name="ctrl_adaptive_hint">Los sticks aparecen donde tocas y se ocultan al soltar</string>
<string name="ctrl_layout">Disposición</string>
+5 -8
View File
@@ -232,6 +232,9 @@
<string name="mode_controller">Manette</string>
<string name="mode_controller_hint">Comment Steam reconnaît votre manette. Prendra effet à la prochaine session.</string>
<string name="mode_controller_deck">Manette Steam Deck</string>
<string name="mode_controller_steam">Steam Controller</string>
<string name="mode_controller_ds4">DualShock 4</string>
<string name="mode_controller_dualsense">DualSense</string>
<string name="mode_controller_x360">Manette Xbox 360</string>
<string name="mode_controller_note">Deck : fournit un bouton d\'accès rapide et un gyroscope. Xbox 360 : utilisez Guide+A pour ouvrir l\'accès rapide.</string>
<string name="mode_back">Retour</string>
@@ -257,16 +260,8 @@
<string name="mode_decky_remove_title">Désinstaller Decky Loader ?</string>
<string name="mode_decky_remove_text">Supprime le chargeur. Vos plugins et paramètres seront conservés.</string>
<string name="mode_client">Client</string>
<string name="mode_deck_mode">Mode Steam Deck</string>
<string name="mode_deck_mode_hint">Interface Steam Deck et contrôles de performance à accès rapide. Prendra effet à la prochaine session.</string>
<string name="mode_mangoapp">Superposition de performance</string>
<string name="mode_mangoapp_hint">Désactivez cette option si les jeux plantent ou affichent un écran noir en mode Deck. Prendra effet à la prochaine session.</string>
<string name="mode_branch">Branche du client</string>
<string name="mode_branch_deck">Bêta Steam Deck (requis pour le mode Deck).</string>
<string name="mode_branch_hint">Prendra effet à la prochaine session. Steam peut se mettre à jour une fois de lui-même.</string>
<string name="mode_branch_public">Bêta</string>
<string name="mode_branch_deck_beta">Bêta Steam Deck</string>
<string name="mode_branch_note">Le mode Deck utilise toujours la version bêta de Steam Deck.</string>
<string name="mode_added_art">Illustrations</string>
<string name="mode_sgdb_key">Clé API SteamGridDB</string>
<string name="mode_sgdb_key_yours">Votre clé</string>
@@ -730,6 +725,8 @@
<string name="ctrl_steam_button">Bouton Steam</string>
<string name="ctrl_qam_button">Bouton QAM</string>
<string name="ctrl_keyboard_button">Bouton clavier</string>
<string name="ctrl_select_steam">Appuyez sur Select pour ouvrir le menu Steam</string>
<string name="ctrl_select_steam_hint">À la place de View/Select.</string>
<string name="setup_animations">Animations</string>
<string name="ctrl_adaptive_hint">Le joystick apparaîtra là où vous touchez et se masquera une fois relâché</string>
<string name="ctrl_layout">Disposition</string>
+23 -10
View File
@@ -254,6 +254,9 @@
<string name="mode_controller">コントローラー</string>
<string name="mode_controller_hint">Steamがコントローラーを認識する方法。次回のセッションで有効になります。</string>
<string name="mode_controller_deck">Steam Deckコントローラー</string>
<string name="mode_controller_steam">Steam Controller</string>
<string name="mode_controller_ds4">DualShock 4</string>
<string name="mode_controller_dualsense">DualSense</string>
<string name="mode_controller_x360">Xbox 360コントローラー</string>
<string name="mode_controller_note">Deck:クイックアクセスボタンとジャイロを提供します。Xbox 360:Guide+Aでクイックアクセスを開きます。</string>
<string name="mode_back">戻る</string>
@@ -283,16 +286,8 @@
<string name="mode_decky_remove_title">Decky Loaderをアンインストールしますか?</string>
<string name="mode_decky_remove_text">Loaderを削除します。プラグインと設定は保持されます。</string>
<string name="mode_client">クライアント</string>
<string name="mode_deck_mode">Steam Deckモード</string>
<string name="mode_deck_mode_hint">Steam DeckのUIとクイックアクセスパフォーマンスコントロール。次回のセッションで有効になります。</string>
<string name="mode_mangoapp">パフォーマンスオーバーレイ</string>
<string name="mode_mangoapp_hint">Deckモードでゲームがクラッシュしたり画面が真っ暗になる場合は、これをオフにしてください。次回のセッションで有効になります。</string>
<string name="mode_branch">クライアントブランチ</string>
<string name="mode_branch_deck">Steam Deckベータ版(Deckモードに必要)。</string>
<string name="mode_branch_hint">次回のセッションで有効になります。Steamが自動的に一度アップデートされる場合があります。</string>
<string name="mode_branch_public">ベータ版</string>
<string name="mode_branch_deck_beta">Steam Deckベータ版</string>
<string name="mode_branch_note">Deckモードは常にSteam Deckベータ版を使用します。</string>
<string name="mode_steam_repair">Steamクライアントを修復</string>
<string name="mode_steam_repair_hint">次回Steamの起動時にSteamクライアントを再ダウンロードします。Steamが読み込み画面のまま進まない場合に使用してください。ゲーム、ログイン情報、設定は保持されます。</string>
<string name="mode_steam_repair_queued">次回Steamの起動時にSteamクライアントを再ダウンロードします。</string>
@@ -312,12 +307,28 @@
<string name="wincomp_title">Windowsコンポーネント</string>
<string name="wincomp_pad_hint">LB / RBでセクション間を移動します。</string>
<string name="wincomp_card_none">なし</string>
<string name="wincomp_applies">Protonに含まれないWindows DLLです(Bannerlatorのコンポーネント一覧から)。このゲームに必要なものをオンにしてください。ゲーム専用のWindowsフォルダーに入り、ゲームのファイルには触れず、どのProtonで実行しても維持されます。</string>
<string name="wincomp_applies">Protonに含まれないWindows DLLです(コンポーネント一覧から)。このゲームに必要なものをオンにしてください。ゲーム専用のWindowsフォルダーに入り、ゲームのファイルには触れず、どのProtonで実行しても維持されます。</string>
<string name="wincomp_offline">コンポーネント一覧に接続できませんでした。ダウンロード済みのコンポーネントは引き続き切り替えられます。</string>
<string name="wincomp_recommended">このゲームにおすすめ</string>
<string name="wincomp_checking">このゲームに必要なものを確認しています…</string>
<string name="wincomp_when_online">オンライン時に利用可能</string>
<plurals name="wincomp_turning_on">
<item quantity="other">%1$d 個のコンポーネントを有効にしています…</item>
</plurals>
<string name="wincomp_auto_section">このゲームで有効</string>
<string name="wincomp_auto_steam">Steamが%2$s(アプリ %3$s)と一緒に%1$sをインストールします</string>
<string name="wincomp_auto_gog">このゲームのGOGのリストから</string>
<string name="wincomp_auto_epic">このゲームのEpicの前提条件から</string>
<string name="wincomp_auto_amazon">このゲームのAmazonのインストール手順から</string>
<string name="wincomp_steam_by_name">Steamがアプリ %2$sと一緒に%1$sをインストールします(名前で一致)</string>
<string name="wincomp_suggestions">その他のリスト</string>
<string name="wincomp_suggestion_reason">%1$sのリストにあります。ここではコンポーネントではありません</string>
<string name="wincomp_src_steam">Steam</string>
<string name="wincomp_src_gog">GOG</string>
<string name="wincomp_src_epic">Epic Games</string>
<string name="wincomp_src_amazon">Amazon Games</string>
<string name="wincomp_found_bundled">ゲームに%1$sが同梱されています</string>
<string name="wincomp_found_shipped">ゲームに%1$sがあります(そのままで動くことが多いです)</string>
<string name="wincomp_found_steam">Steamがこのゲームと一緒に%1$sをインストールします</string>
<string name="wincomp_already_here">このゲームに導入済み</string>
<string name="wincomp_no_recommendation">ゲームのフォルダーには、必要なコンポーネントを示すものがありません。</string>
<string name="wincomp_all">すべてのコンポーネント(%1$d)</string>
@@ -831,6 +842,8 @@
<string name="ctrl_steam_button">Steamボタン</string>
<string name="ctrl_qam_button">QAMボタン</string>
<string name="ctrl_keyboard_button">キーボードボタン</string>
<string name="ctrl_select_steam">Selectを押してSteamメニューを開く</string>
<string name="ctrl_select_steam_hint">View/Selectの代わり。</string>
<string name="setup_animations">アニメーション</string>
<string name="ctrl_adaptive_hint">スティックは触れた位置に表示され、離すと非表示になります</string>
<string name="ctrl_layout">レイアウト</string>
+23 -10
View File
@@ -254,6 +254,9 @@
<string name="mode_controller">컨트롤러</string>
<string name="mode_controller_hint">Steam이 컨트롤러를 인식하는 방식입니다. 다음 세션에 적용됩니다.</string>
<string name="mode_controller_deck">Steam Deck 컨트롤러</string>
<string name="mode_controller_steam">Steam Controller</string>
<string name="mode_controller_ds4">DualShock 4</string>
<string name="mode_controller_dualsense">DualSense</string>
<string name="mode_controller_x360">Xbox 360 컨트롤러</string>
<string name="mode_controller_note">Deck: 빠른 액세스 버튼과 자이로스코프를 제공합니다. Xbox 360: Guide+A로 빠른 액세스를 엽니다.</string>
<string name="mode_back">뒤로</string>
@@ -283,16 +286,8 @@
<string name="mode_decky_remove_title">Decky Loader를 제거하시겠습니까?</string>
<string name="mode_decky_remove_text">로더를 제거합니다. 플러그인과 설정은 유지됩니다.</string>
<string name="mode_client">클라이언트</string>
<string name="mode_deck_mode">Steam Deck 모드</string>
<string name="mode_deck_mode_hint">Steam Deck UI 및 빠른 액세스 성능 제어. 다음 세션에 적용됩니다.</string>
<string name="mode_mangoapp">성능 오버레이</string>
<string name="mode_mangoapp_hint">Deck 모드에서 게임이 충돌하거나 화면이 검게 변하면 이 옵션을 끄세요. 다음 세션에 적용됩니다.</string>
<string name="mode_branch">클라이언트 브랜치</string>
<string name="mode_branch_deck">Steam Deck 베타(Deck 모드에 필요).</string>
<string name="mode_branch_hint">다음 세션에 적용됩니다. Steam이 자체적으로 한 번 업데이트될 수 있습니다.</string>
<string name="mode_branch_public">베타</string>
<string name="mode_branch_deck_beta">Steam Deck 베타</string>
<string name="mode_branch_note">Deck 모드는 항상 Steam Deck 베타를 사용합니다.</string>
<string name="mode_steam_repair">Steam 클라이언트 복구</string>
<string name="mode_steam_repair_hint">다음에 Steam을 시작할 때 Steam 클라이언트를 다시 다운로드합니다. Steam이 로딩 화면에서 넘어가지 않을 때 사용하세요. 게임, 로그인 정보 및 설정은 유지됩니다.</string>
<string name="mode_steam_repair_queued">다음에 Steam을 시작할 때 Steam 클라이언트를 다시 다운로드합니다.</string>
@@ -312,12 +307,28 @@
<string name="wincomp_title">Windows 구성 요소</string>
<string name="wincomp_pad_hint">LB / RB로 섹션 사이를 이동합니다.</string>
<string name="wincomp_card_none">없음</string>
<string name="wincomp_applies">Proton에 포함되지 않은 Windows DLL로, Bannerlator의 구성 요소 목록에서 가져옵니다. 이 게임에 필요한 것을 켜세요. 게임 전용 Windows 폴더에 들어가며 게임 파일은 건드리지 않고, 어떤 Proton으로 실행해도 유지됩니다.</string>
<string name="wincomp_applies">Proton에 포함되지 않은 Windows DLL로, 구성 요소 목록에서 가져옵니다. 이 게임에 필요한 것을 켜세요. 게임 전용 Windows 폴더에 들어가며 게임 파일은 건드리지 않고, 어떤 Proton으로 실행해도 유지됩니다.</string>
<string name="wincomp_offline">구성 요소 목록에 연결할 수 없습니다. 이미 다운로드한 구성 요소는 계속 켜고 끌 수 있습니다.</string>
<string name="wincomp_recommended">이 게임에 권장</string>
<string name="wincomp_checking">이 게임에 필요한 것을 확인하는 중…</string>
<string name="wincomp_when_online">온라인일 때 사용 가능</string>
<plurals name="wincomp_turning_on">
<item quantity="other">구성 요소 %1$d개를 켜는 중…</item>
</plurals>
<string name="wincomp_auto_section">이 게임에 켜짐</string>
<string name="wincomp_auto_steam">Steam이 %2$s(앱 %3$s)과(와) 함께 %1$s을(를) 설치합니다</string>
<string name="wincomp_auto_gog">이 게임의 GOG 목록에서</string>
<string name="wincomp_auto_epic">이 게임의 Epic 사전 요구 사항에서</string>
<string name="wincomp_auto_amazon">이 게임의 Amazon 설치 단계에서</string>
<string name="wincomp_steam_by_name">Steam이 앱 %2$s과(와) 함께 %1$s을(를) 설치합니다(이름으로 일치)</string>
<string name="wincomp_suggestions">함께 나열됨</string>
<string name="wincomp_suggestion_reason">%1$s 목록에 있음; 여기서는 구성 요소가 아님</string>
<string name="wincomp_src_steam">Steam</string>
<string name="wincomp_src_gog">GOG</string>
<string name="wincomp_src_epic">Epic Games</string>
<string name="wincomp_src_amazon">Amazon Games</string>
<string name="wincomp_found_bundled">게임에 %1$s이(가) 포함되어 있습니다</string>
<string name="wincomp_found_shipped">게임에 %1$s이(가) 있습니다 (대개 그대로 작동)</string>
<string name="wincomp_found_steam">Steam이 이 게임과 함께 %1$s을(를) 설치합니다</string>
<string name="wincomp_already_here">이 게임에 이미 있음</string>
<string name="wincomp_no_recommendation">게임 폴더에서 필요한 구성 요소를 가리키는 것이 없습니다.</string>
<string name="wincomp_all">모든 구성 요소 (%1$d)</string>
@@ -831,6 +842,8 @@
<string name="ctrl_steam_button">Steam 버튼</string>
<string name="ctrl_qam_button">QAM 버튼</string>
<string name="ctrl_keyboard_button">키보드 버튼</string>
<string name="ctrl_select_steam">Select를 눌러 Steam 메뉴 열기</string>
<string name="ctrl_select_steam_hint">View/Select 대신.</string>
<string name="setup_animations">애니메이션</string>
<string name="ctrl_adaptive_hint">스틱이 터치한 위치에 나타나며 손을 떼면 숨겨집니다</string>
<string name="ctrl_layout">레이아웃</string>
+5 -8
View File
@@ -254,6 +254,9 @@
<string name="mode_controller">Контроллер</string>
<string name="mode_controller_hint">Как Steam распознает ваш контроллер. Вступит в силу в следующей сессии.</string>
<string name="mode_controller_deck">Контроллер Steam Deck</string>
<string name="mode_controller_steam">Steam Controller</string>
<string name="mode_controller_ds4">DualShock 4</string>
<string name="mode_controller_dualsense">DualSense</string>
<string name="mode_controller_x360">Контроллер Xbox 360</string>
<string name="mode_controller_note">Deck: кнопка быстрого доступа и гироскоп. Xbox 360: используйте Guide+A для быстрого доступа.</string>
<string name="mode_back">Назад</string>
@@ -281,16 +284,8 @@
<string name="mode_decky_remove_title">Удалить Decky Loader?</string>
<string name="mode_decky_remove_text">Удаляет загрузчик. Ваши плагины и настройки сохранятся.</string>
<string name="mode_client">Клиент</string>
<string name="mode_deck_mode">Режим Steam Deck</string>
<string name="mode_deck_mode_hint">Интерфейс Steam Deck и управление производительностью через быстрый доступ. Вступит в силу в следующей сессии.</string>
<string name="mode_mangoapp">Оверлей производительности</string>
<string name="mode_mangoapp_hint">Отключите, если игры вылетают или показывают черный экран в режиме Deck. Вступит в силу в следующей сессии.</string>
<string name="mode_branch">Ветка клиента</string>
<string name="mode_branch_deck">Публичная бета Steam Deck (требуется для режима Deck).</string>
<string name="mode_branch_hint">Вступит в силу в следующей сессии. Steam может обновиться самостоятельно один раз.</string>
<string name="mode_branch_public">Публичная бета</string>
<string name="mode_branch_deck_beta">Публичная бета Steam Deck</string>
<string name="mode_branch_note">Режим Deck всегда использует публичную бету Steam Deck.</string>
<string name="mode_steam_repair">Восстановить клиент Steam</string>
<string name="mode_steam_repair_hint">При следующем запуске Steam клиент будет скачан заново. Используйте, если Steam зависает на экране загрузки. Игры, вход и настройки сохраняются.</string>
<string name="mode_steam_repair_queued">Клиент Steam будет скачан заново при следующем запуске Steam.</string>
@@ -814,6 +809,8 @@
<string name="ctrl_steam_button">Кнопка Steam</string>
<string name="ctrl_qam_button">Кнопка QAM</string>
<string name="ctrl_keyboard_button">Кнопка клавиатуры</string>
<string name="ctrl_select_steam">Нажмите Select, чтобы открыть меню Steam</string>
<string name="ctrl_select_steam_hint">Вместо View/Select.</string>
<string name="setup_animations">Анимации</string>
<string name="ctrl_adaptive_hint">Стики появляются в месте касания и исчезают после отпускания</string>
<string name="ctrl_layout">Раскладка</string>
+23 -10
View File
@@ -254,6 +254,9 @@
<string name="mode_controller">手柄</string>
<string name="mode_controller_hint">决定 Steam 把你的手柄识别为哪种类型,将在下次会话生效。</string>
<string name="mode_controller_deck">Steam Deck 手柄</string>
<string name="mode_controller_steam">Steam Controller</string>
<string name="mode_controller_ds4">DualShock 4</string>
<string name="mode_controller_dualsense">DualSense</string>
<string name="mode_controller_x360">Xbox 360 手柄</string>
<string name="mode_controller_note">Deck:快捷访问按钮与陀螺仪。Xbox 360:用 Guide+A 打开快捷访问。</string>
<string name="mode_back">返回</string>
@@ -283,16 +286,8 @@
<string name="mode_decky_remove_title">卸载 Decky Loader?</string>
<string name="mode_decky_remove_text">移除加载器。你的插件和设置会保留。</string>
<string name="mode_client">客户端</string>
<string name="mode_deck_mode">Steam Deck 模式</string>
<string name="mode_deck_mode_hint">Steam 的 Deck 界面和快捷访问性能控制。将在下次会话生效。</string>
<string name="mode_mangoapp">性能覆盖层</string>
<string name="mode_mangoapp_hint">若游戏在 Deck 模式下崩溃或黑屏,请关闭性能覆盖层。将在下次会话生效。</string>
<string name="mode_branch">客户端分支</string>
<string name="mode_branch_deck">Steam Deck 公开测试版(Deck 模式所必需)。</string>
<string name="mode_branch_hint">将在下次会话生效。Steam 可能会自行更新一次。</string>
<string name="mode_branch_public">公开测试版</string>
<string name="mode_branch_deck_beta">Steam Deck 公开测试版</string>
<string name="mode_branch_note">Deck 模式始终使用 Steam Deck 公开测试版。</string>
<string name="mode_steam_repair">修复 Steam 客户端</string>
<string name="mode_steam_repair_hint">下次启动 Steam 时重新下载 Steam 客户端。如果 Steam 一直停在加载画面,请使用此项。游戏、登录信息和设置都会保留。</string>
<string name="mode_steam_repair_queued">下次启动 Steam 时将重新下载 Steam 客户端。</string>
@@ -312,12 +307,28 @@
<string name="wincomp_title">Windows 组件</string>
<string name="wincomp_pad_hint">按 LB / RB 在各部分之间跳转。</string>
<string name="wincomp_card_none">无</string>
<string name="wincomp_applies">Proton 未包含的 Windows DLL,来自 Bannerlator 的组件列表。开启此游戏需要的组件:它们会放入游戏自己的 Windows 文件夹,绝不改动游戏文件,并且无论游戏使用哪个 Proton 都会保留。</string>
<string name="wincomp_applies">Proton 未包含的 Windows DLL,来自组件列表。开启此游戏需要的组件:它们会放入游戏自己的 Windows 文件夹,绝不改动游戏文件,并且无论游戏使用哪个 Proton 都会保留。</string>
<string name="wincomp_offline">无法访问组件列表。已下载的组件仍可开启或关闭。</string>
<string name="wincomp_recommended">此游戏推荐</string>
<string name="wincomp_checking">正在检查此游戏需要什么…</string>
<string name="wincomp_when_online">联网后可用</string>
<plurals name="wincomp_turning_on">
<item quantity="other">正在开启 %1$d 个组件…</item>
</plurals>
<string name="wincomp_auto_section">此游戏已开启</string>
<string name="wincomp_auto_steam">Steam 会随 %2$s(应用 %3$s)安装 %1$s</string>
<string name="wincomp_auto_gog">来自此游戏的 GOG 列表</string>
<string name="wincomp_auto_epic">来自此游戏的 Epic 先决条件</string>
<string name="wincomp_auto_amazon">来自此游戏的 Amazon 安装步骤</string>
<string name="wincomp_steam_by_name">Steam 会随应用 %2$s 安装 %1$s(按名称匹配)</string>
<string name="wincomp_suggestions">列表中的其他项</string>
<string name="wincomp_suggestion_reason">%1$s 列出;此处不是组件</string>
<string name="wincomp_src_steam">Steam</string>
<string name="wincomp_src_gog">GOG</string>
<string name="wincomp_src_epic">Epic Games</string>
<string name="wincomp_src_amazon">Amazon Games</string>
<string name="wincomp_found_bundled">游戏自带 %1$s</string>
<string name="wincomp_found_shipped">游戏含有 %1$s(通常可直接使用)</string>
<string name="wincomp_found_steam">Steam 会随此游戏安装 %1$s</string>
<string name="wincomp_already_here">此游戏中已有</string>
<string name="wincomp_no_recommendation">游戏文件夹中没有指向任何组件的内容。</string>
<string name="wincomp_all">全部组件(%1$d)</string>
@@ -831,6 +842,8 @@
<string name="ctrl_steam_button">Steam 按钮</string>
<string name="ctrl_qam_button">QAM 按钮</string>
<string name="ctrl_keyboard_button">键盘按钮</string>
<string name="ctrl_select_steam">按 Select 打开 Steam 菜单</string>
<string name="ctrl_select_steam_hint">代替 View/Select。</string>
<string name="setup_animations">动画</string>
<string name="ctrl_adaptive_hint">摇杆出现在触摸处,松手后隐藏</string>
<string name="ctrl_layout">布局</string>
+23 -10
View File
@@ -254,6 +254,9 @@
<string name="mode_controller">控制器</string>
<string name="mode_controller_hint">Steam 如何識別你的控制器。下次會話生效。</string>
<string name="mode_controller_deck">Steam Deck 控制器</string>
<string name="mode_controller_steam">Steam Controller</string>
<string name="mode_controller_ds4">DualShock 4</string>
<string name="mode_controller_dualsense">DualSense</string>
<string name="mode_controller_x360">Xbox 360 控制器</string>
<string name="mode_controller_note">Deck:提供快速存取按鈕和陀螺儀。Xbox 360:按 Guide+A 開啟快速存取。</string>
<string name="mode_back">返回</string>
@@ -283,16 +286,8 @@
<string name="mode_decky_remove_title">解除安裝 Decky Loader?</string>
<string name="mode_decky_remove_text">移除載入器,你的插件和設定會保留。</string>
<string name="mode_client">用戶端</string>
<string name="mode_deck_mode">Steam Deck 模式</string>
<string name="mode_deck_mode_hint">Steam 的 Deck 介面及快速存取效能控制。下次會話生效。</string>
<string name="mode_mangoapp">效能疊加層</string>
<string name="mode_mangoapp_hint">如果在 Deck 模式下遊戲當機或黑畫面,請關閉此項。下次會話生效。</string>
<string name="mode_branch">用戶端分支</string>
<string name="mode_branch_deck">Steam Deck 公開測試版(Deck 模式需要)。</string>
<string name="mode_branch_hint">下次會話生效。Steam 可能會自行更新一次。</string>
<string name="mode_branch_public">公開測試版</string>
<string name="mode_branch_deck_beta">Steam Deck 公開測試版</string>
<string name="mode_branch_note">Deck 模式一律使用 Steam Deck 公開測試版。</string>
<string name="mode_steam_repair">修復 Steam 用戶端</string>
<string name="mode_steam_repair_hint">下次啟動 Steam 時重新下載 Steam 用戶端。如果 Steam 一直停留在載入畫面,請使用此功能。遊戲、登入資料和設定都會保留。</string>
<string name="mode_steam_repair_queued">下次啟動 Steam 時會重新下載 Steam 用戶端。</string>
@@ -312,12 +307,28 @@
<string name="wincomp_title">Windows 組件</string>
<string name="wincomp_pad_hint">按 LB / RB 在各部分之間切換。</string>
<string name="wincomp_card_none">無</string>
<string name="wincomp_applies">Proton 未有包含的 Windows DLL,取自 Bannerlator 的組件清單。開啟這款遊戲需要的組件:它們會放進遊戲自己的 Windows 資料夾,絕不改動遊戲檔案,而且無論遊戲用哪個 Proton 都會保留。</string>
<string name="wincomp_applies">Proton 未有包含的 Windows DLL,取自組件清單。開啟這款遊戲需要的組件:它們會放進遊戲自己的 Windows 資料夾,絕不改動遊戲檔案,而且無論遊戲用哪個 Proton 都會保留。</string>
<string name="wincomp_offline">無法連接組件清單。已下載的組件仍可開啟或關閉。</string>
<string name="wincomp_recommended">建議用於這款遊戲</string>
<string name="wincomp_checking">正在檢查這款遊戲需要什麼…</string>
<string name="wincomp_when_online">連線後可用</string>
<plurals name="wincomp_turning_on">
<item quantity="other">正在開啟 %1$d 個元件…</item>
</plurals>
<string name="wincomp_auto_section">這款遊戲已開啟</string>
<string name="wincomp_auto_steam">Steam 會隨 %2$s(應用程式 %3$s)安裝 %1$s</string>
<string name="wincomp_auto_gog">來自這款遊戲的 GOG 清單</string>
<string name="wincomp_auto_epic">來自這款遊戲的 Epic 必要元件</string>
<string name="wincomp_auto_amazon">來自這款遊戲的 Amazon 安裝步驟</string>
<string name="wincomp_steam_by_name">Steam 會隨應用程式 %2$s 安裝 %1$s(依名稱比對)</string>
<string name="wincomp_suggestions">清單中的其他項目</string>
<string name="wincomp_suggestion_reason">%1$s 列出;這裡不是元件</string>
<string name="wincomp_src_steam">Steam</string>
<string name="wincomp_src_gog">GOG</string>
<string name="wincomp_src_epic">Epic Games</string>
<string name="wincomp_src_amazon">Amazon Games</string>
<string name="wincomp_found_bundled">遊戲附帶 %1$s</string>
<string name="wincomp_found_shipped">遊戲含有 %1$s(通常可直接使用)</string>
<string name="wincomp_found_steam">Steam 會隨這款遊戲安裝 %1$s</string>
<string name="wincomp_already_here">這款遊戲中已有</string>
<string name="wincomp_no_recommendation">遊戲資料夾內沒有指向任何組件的內容。</string>
<string name="wincomp_all">所有組件(%1$d)</string>
@@ -831,6 +842,8 @@
<string name="ctrl_steam_button">Steam 按鈕</string>
<string name="ctrl_qam_button">QAM 按鈕</string>
<string name="ctrl_keyboard_button">鍵盤按鈕</string>
<string name="ctrl_select_steam">按 Select 開啟 Steam 選單</string>
<string name="ctrl_select_steam_hint">代替 View/Select。</string>
<string name="setup_animations">動畫</string>
<string name="ctrl_adaptive_hint">搖桿會在你觸控的位置出現,放開後隱藏</string>
<string name="ctrl_layout">佈局</string>
+23 -10
View File
@@ -254,6 +254,9 @@
<string name="mode_controller">控制器</string>
<string name="mode_controller_hint">Steam 辨識你的控制器的方式。下次工作階段生效。</string>
<string name="mode_controller_deck">Steam Deck 控制器</string>
<string name="mode_controller_steam">Steam Controller</string>
<string name="mode_controller_ds4">DualShock 4</string>
<string name="mode_controller_dualsense">DualSense</string>
<string name="mode_controller_x360">Xbox 360 控制器</string>
<string name="mode_controller_note">Deck:提供快速存取按鈕和陀螺儀。Xbox 360:使用 Guide+A 開啟快速存取。</string>
<string name="mode_back">返回</string>
@@ -283,16 +286,8 @@
<string name="mode_decky_remove_title">解除安裝 Decky Loader?</string>
<string name="mode_decky_remove_text">移除載入器。你的外掛程式和設定會保留。</string>
<string name="mode_client">用戶端</string>
<string name="mode_deck_mode">Steam Deck 模式</string>
<string name="mode_deck_mode_hint">Steam Deck 介面和快速存取效能控制。下次工作階段生效。</string>
<string name="mode_mangoapp">效能重疊層</string>
<string name="mode_mangoapp_hint">如果 Deck 模式下遊戲崩潰或黑畫面,請關閉此項。下次工作階段生效。</string>
<string name="mode_branch">用戶端分支</string>
<string name="mode_branch_deck">Steam Deck 公開測試版(Deck 模式所需)。</string>
<string name="mode_branch_hint">下次工作階段生效。Steam 可能會自行更新一次。</string>
<string name="mode_branch_public">公開測試版</string>
<string name="mode_branch_deck_beta">Steam Deck 公開測試版</string>
<string name="mode_branch_note">Deck 模式一律使用 Steam Deck 公開測試版。</string>
<string name="mode_steam_repair">修復 Steam 用戶端</string>
<string name="mode_steam_repair_hint">下次啟動 Steam 時重新下載 Steam 用戶端。如果 Steam 一直停在載入畫面,請使用此功能。遊戲、登入狀態和設定都會保留。</string>
<string name="mode_steam_repair_queued">下次啟動 Steam 時會重新下載 Steam 用戶端。</string>
@@ -312,12 +307,28 @@
<string name="wincomp_title">Windows 元件</string>
<string name="wincomp_pad_hint">按 LB / RB 在各區段之間切換。</string>
<string name="wincomp_card_none">無</string>
<string name="wincomp_applies">Proton 未內含的 Windows DLL,取自 Bannerlator 的元件清單。開啟這款遊戲需要的元件:它們會放進遊戲自己的 Windows 資料夾,絕不更動遊戲檔案,且不論遊戲使用哪個 Proton 都會保留。</string>
<string name="wincomp_applies">Proton 未內含的 Windows DLL,取自元件清單。開啟這款遊戲需要的元件:它們會放進遊戲自己的 Windows 資料夾,絕不更動遊戲檔案,且不論遊戲使用哪個 Proton 都會保留。</string>
<string name="wincomp_offline">無法連上元件清單。已下載的元件仍可開啟或關閉。</string>
<string name="wincomp_recommended">建議用於這款遊戲</string>
<string name="wincomp_checking">正在檢查這款遊戲需要什麼…</string>
<string name="wincomp_when_online">連線後可用</string>
<plurals name="wincomp_turning_on">
<item quantity="other">正在開啟 %1$d 個元件…</item>
</plurals>
<string name="wincomp_auto_section">這款遊戲已開啟</string>
<string name="wincomp_auto_steam">Steam 會隨 %2$s(應用程式 %3$s)安裝 %1$s</string>
<string name="wincomp_auto_gog">來自這款遊戲的 GOG 清單</string>
<string name="wincomp_auto_epic">來自這款遊戲的 Epic 必要元件</string>
<string name="wincomp_auto_amazon">來自這款遊戲的 Amazon 安裝步驟</string>
<string name="wincomp_steam_by_name">Steam 會隨應用程式 %2$s 安裝 %1$s(依名稱比對)</string>
<string name="wincomp_suggestions">清單中的其他項目</string>
<string name="wincomp_suggestion_reason">%1$s 列出;這裡不是元件</string>
<string name="wincomp_src_steam">Steam</string>
<string name="wincomp_src_gog">GOG</string>
<string name="wincomp_src_epic">Epic Games</string>
<string name="wincomp_src_amazon">Amazon Games</string>
<string name="wincomp_found_bundled">遊戲隨附 %1$s</string>
<string name="wincomp_found_shipped">遊戲含有 %1$s(通常可直接使用)</string>
<string name="wincomp_found_steam">Steam 會隨這款遊戲安裝 %1$s</string>
<string name="wincomp_already_here">這款遊戲中已有</string>
<string name="wincomp_no_recommendation">遊戲資料夾中沒有指向任何元件的內容。</string>
<string name="wincomp_all">所有元件(%1$d)</string>
@@ -831,6 +842,8 @@
<string name="ctrl_steam_button">Steam 按鈕</string>
<string name="ctrl_qam_button">QAM 按鈕</string>
<string name="ctrl_keyboard_button">鍵盤按鈕</string>
<string name="ctrl_select_steam">按 Select 開啟 Steam 功能表</string>
<string name="ctrl_select_steam_hint">代替 View/Select。</string>
<string name="setup_animations">動畫</string>
<string name="ctrl_adaptive_hint">搖桿會出現在你觸控的位置,並在放開後隱藏</string>
<string name="ctrl_layout">版面配置</string>
+37 -11
View File
@@ -262,8 +262,11 @@
<string name="mode_controller">Controller</string>
<string name="mode_controller_hint">How Steam sees your controller. Applies next session.</string>
<string name="mode_controller_deck">Steam Deck controller</string>
<string name="mode_controller_steam">Steam Controller</string>
<string name="mode_controller_ds4">DualShock 4</string>
<string name="mode_controller_dualsense">DualSense</string>
<string name="mode_controller_x360">Xbox 360 controller</string>
<string name="mode_controller_note">Deck: Quick Access button and gyro. Xbox 360: open Quick Access with Guide+A.</string>
<string name="mode_controller_note">Deck: Quick Access button and gyro. Steam Controller: the same, with grip sense on the back buttons. DualShock 4 and DualSense: gyro, and the trackpads as their touchpad. Xbox 360: open Quick Access with Guide+A.</string>
<string name="mode_back">Back</string>
<string name="back_menu_then_qam">Menu → QAM</string>
<string name="back_qam_then_menu">QAM → Menu</string>
@@ -293,16 +296,8 @@
<string name="mode_decky_remove_title">Uninstall Decky Loader?</string>
<string name="mode_decky_remove_text">Removes the loader. Your plugins and settings are kept.</string>
<string name="mode_client">Client</string>
<string name="mode_deck_mode">Steam Deck mode</string>
<string name="mode_deck_mode_hint">Steam\'s Deck interface and Quick Access performance controls. Applies next session.</string>
<string name="mode_mangoapp">Performance overlay</string>
<string name="mode_mangoapp_hint">Turn off if games crash or go black in Deck mode. Applies next session.</string>
<string name="mode_branch">Client branch</string>
<string name="mode_branch_deck">Steam Deck public beta (required by Deck mode).</string>
<string name="mode_branch_hint">Applies next session. Steam may update itself once.</string>
<string name="mode_branch_public">Public beta</string>
<string name="mode_branch_deck_beta">Steam Deck public beta</string>
<string name="mode_branch_note">Deck mode always uses Steam Deck public beta.</string>
<string name="mode_steam_repair">Repair Steam client</string>
<string name="mode_steam_repair_hint">Downloads the Steam client again the next time Steam starts. Use it if Steam stays on its loading screen. Games, sign-in and settings are kept.</string>
<string name="mode_steam_repair_queued">The Steam client will be downloaded again the next time Steam starts.</string>
@@ -322,12 +317,29 @@
<string name="wincomp_title">Windows components</string>
<string name="wincomp_pad_hint">LB / RB jump between the sections.</string>
<string name="wincomp_card_none">None</string>
<string name="wincomp_applies">Windows DLLs Proton does not include, from Bannerlator\'s component list. Turn on the ones this game needs: they go into the game\'s own Windows folder, never its files, and stay whichever Proton it runs on.</string>
<string name="wincomp_applies">Windows DLLs Proton does not include, from the component list. Turn on the ones this game needs: they go into the game\'s own Windows folder, never its files, and stay whichever Proton it runs on.</string>
<string name="wincomp_offline">Could not reach the component list. Components already downloaded can still be switched.</string>
<string name="wincomp_recommended">Recommended for this game</string>
<string name="wincomp_checking">Checking what this game needs…</string>
<string name="wincomp_when_online">Available when online</string>
<plurals name="wincomp_turning_on">
<item quantity="one">Turning on %1$d component…</item>
<item quantity="other">Turning on %1$d components…</item>
</plurals>
<string name="wincomp_auto_section">On for this game</string>
<string name="wincomp_auto_steam">Steam installs %1$s with %2$s (app %3$s)</string>
<string name="wincomp_auto_gog">From GOG\'s list for this game</string>
<string name="wincomp_auto_epic">From Epic\'s prerequisites for this game</string>
<string name="wincomp_auto_amazon">From Amazon\'s install steps for this game</string>
<string name="wincomp_steam_by_name">Steam installs %1$s with app %2$s, matched by name</string>
<string name="wincomp_suggestions">Also listed</string>
<string name="wincomp_suggestion_reason">Listed by %1$s; not a component here</string>
<string name="wincomp_src_steam">Steam</string>
<string name="wincomp_src_gog">GOG</string>
<string name="wincomp_src_epic">Epic Games</string>
<string name="wincomp_src_amazon">Amazon Games</string>
<string name="wincomp_found_bundled">The game ships %1$s</string>
<string name="wincomp_found_shipped">The game has %1$s (often works as is)</string>
<string name="wincomp_found_steam">Steam installs %1$s with this game</string>
<string name="wincomp_already_here">Already in this game</string>
<string name="wincomp_no_recommendation">Nothing in the game\'s folder points at a component.</string>
<string name="wincomp_active">Installed for this game (%1$d)</string>
@@ -635,6 +647,10 @@
<string name="setup_home">Use as Home screen</string>
<string name="setup_home_on">DroidDeck can be the Home app</string>
<string name="setup_home_off">DroidDeck is never offered as a Home app</string>
<string name="setup_orientation">Orientation</string>
<string name="setup_orientation_auto">Automatic landscape</string>
<string name="setup_orientation_landscape">Landscape</string>
<string name="setup_orientation_reverse">Reverse landscape</string>
<string name="setup_fullscreen">Fullscreen</string>
<string name="setup_fullscreen_on">Hides the Android status and navigation bars</string>
<string name="setup_fullscreen_off">Shows the Android status and navigation bars</string>
@@ -849,6 +865,8 @@
<string name="ctrl_steam_button">Steam button</string>
<string name="ctrl_qam_button">QAM button</string>
<string name="ctrl_keyboard_button">Keyboard button</string>
<string name="ctrl_select_steam">Press Select to open the Steam menu</string>
<string name="ctrl_select_steam_hint">Instead of View/Select.</string>
<string name="setup_animations">Animations</string>
<string name="ctrl_adaptive_hint">Sticks appear where you touch and hide on release</string>
<string name="ctrl_layout">Layout</string>
@@ -973,6 +991,12 @@
<string name="deck_tab_left">Left pad</string>
<string name="deck_tab_right">Right pad</string>
<string name="deck_tab_both">Both</string>
<string name="deck_tab_gamepad">Gamepad</string>
<string name="deck_surface_stick">Stick</string>
<string name="deck_surface_trackpad">Trackpad</string>
<string name="deck_surface_switch">Switch between stick and trackpad</string>
<string name="deck_more_controls">More Deck controls</string>
<string name="deck_panels">PANELS</string>
<string name="deck_close">Close second-screen controls</string>
<string name="deck_open_steam_menu">Open Steam menu</string>
<string name="deck_left_upper">Left · upper</string>
@@ -1036,6 +1060,8 @@
<string name="game_env_eyebrow">Proton games</string>
<string name="pip_title">Picture in picture</string>
<string name="pip_auto">Automatically enter PiP</string>
<string name="unfolded_controls">Controls on the unfolded screen</string>
<string name="unfolded_controls_hint">Unfolded, the game sits above the Steam Deck trackpads and back grips. Folded, the game is full screen.</string>
<string name="pip_suspended">Suspended</string>
<string name="pip_not_ready">Picture in picture is available once the session is ready.</string>
<string name="pip_permission">Allow picture in picture for DroidDeck in Android settings.</string>
@@ -46,10 +46,26 @@ class GameEnvironmentTest {
@Test fun dotnetRuntimeFitsAndroidAddressSpaceForEveryGame() {
val values = GameEnvironment.effective(GameEnvironment.Config(), "PERFORMANCE", "43")
assertEquals("0x800000000", values["DOTNET_GCRegionRange"])
assertEquals("800000000", values["DOTNET_GCRegionRange"])
assertEquals("0", values["DOTNET_EnableWriteXorExecute"])
}
@Test fun dotnetHeapRangeIsBareHexForNativeAot() {
for (preset in FexPreset.all) {
val values = GameEnvironment.effective(GameEnvironment.Config(), preset.id, "3478050")
assertEquals(32L * 1024 * 1024 * 1024, values.getValue("DOTNET_GCRegionRange")!!.toLong(16))
}
}
@Test fun dotnetHeapRangeRespectsSharedAndGameOverrides() {
val config = GameEnvironment.Config(
shared = mapOf("DOTNET_GCRegionRange" to "400000000"),
games = mapOf("3478050" to mapOf("DOTNET_GCRegionRange" to "800000000")),
)
assertEquals("800000000", GameEnvironment.effective(config, "", "3478050")["DOTNET_GCRegionRange"])
assertEquals("400000000", GameEnvironment.effective(config, "", "43")["DOTNET_GCRegionRange"])
}
@Test fun multiSelectionPreservesCustomTokensAndRemovesToggledValues() {
assertEquals("fps,custom_token,frametimes", GameEnvironmentOptions.toggle("fps,custom_token", "frametimes"))
assertEquals("custom_token", GameEnvironmentOptions.toggle("fps,custom_token", "fps"))
@@ -83,7 +83,8 @@ class AddedExesTest {
val result = AddedGames.addExe(app, deep)
assertTrue(result is AddedGames.AddResult.Switched)
assertEquals(deep.path, AddedGames.scan(app).single().exe.path)
assertEquals(0, AddedExes.list(app).size)
// The Games folder's game is an entry of its own (GamesFolderMigration); no second one.
assertEquals(listOf(File(games, "Example").path), AddedExes.list(app).map { it.folder })
}
@Test fun anExeTheSessionCannotSeeIsNotAdded() {
@@ -33,27 +33,40 @@ class AddedGameArtLookupTest {
}
@Test fun anAppidFromTheGamesFilesIsUsedWithoutASearch() {
val lookup = File(tmp.newFolder(), "steam-appid")
val id = AddedGameArt.steamAppId("Palworld", 1623730, lookup) { fail("searched"); null }
assertEquals("1623730", id)
assertEquals("1623730", lookup.readText())
// An earlier "none" from a search does not stand in its way.
lookup.writeText("none")
assertEquals("1623730", AddedGameArt.steamAppId("Palworld", 1623730, lookup) { fail("searched"); null })
val dir = tmp.newFolder("Palworld")
File(dir, "steam_appid.txt").writeText("1623730")
val exe = File(dir, "Palworld.exe").apply { writeText("x") }
val match = SteamMatch.resolve(app, dir.path, exe, "Palworld") { fail("searched"); null }
assertEquals(1623730, match.appId)
assertEquals(SteamMatch.Certainty.FILES, match.certainty)
// The same record for everyone who asks.
assertEquals(1623730, SteamMatch.get(app, dir.path)!!.appId)
}
@Test fun withoutAnAppidTheTitleIsSearchedOnceAWeek() {
val lookup = File(tmp.newFolder(), "steam-appid")
val dir = tmp.newFolder("Nothing Like It")
val exe = File(dir, "game.exe").apply { writeText("x") }
var searches = 0
assertNull(AddedGameArt.steamAppId("Nothing Like It", null, lookup) { searches++; null })
assertEquals("none", lookup.readText())
assertNull(AddedGameArt.steamAppId("Nothing Like It", null, lookup) { searches++; "1" })
assertEquals(null, SteamMatch.resolve(app, dir.path, exe, "Nothing Like It") { searches++; null }.appId)
assertEquals(null, SteamMatch.resolve(app, dir.path, exe, "Nothing Like It") { searches++; 1 }.appId)
assertEquals(1, searches)
lookup.setLastModified(System.currentTimeMillis() - 8L * 24 * 3600 * 1000)
assertEquals("42", AddedGameArt.steamAppId("Nothing Like It", null, lookup) { searches++; "42" })
val old = SteamMatch.get(app, dir.path)!!
SteamMatch.put(app, dir.path, SteamMatch.Match(null, SteamMatch.Certainty.NONE, "", old.checkedAt - 8L * 24 * 3600 * 1000))
val found = SteamMatch.resolve(app, dir.path, exe, "Nothing Like It") { searches++; 42 }
assertEquals(42, found.appId)
assertEquals(SteamMatch.Certainty.NAME, found.certainty)
assertEquals(2, searches)
}
@Test fun anEarlierBuildsArtLookupIsKeptAsANameMatch() {
val dir = tmp.newFolder("Hades")
val exe = File(dir, "Hades.exe").apply { writeText("x") }
val legacy = File(tmp.newFolder(), "steam-appid").apply { writeText("1145360") }
val match = SteamMatch.resolve(app, dir.path, exe, "Hades", legacy = legacy) { fail("searched"); null }
assertEquals(1145360, match.appId)
assertEquals(SteamMatch.Certainty.NAME, match.certainty)
}
@Test fun aStoreResultIsTakenOnlyWhenItsNameMatches() {
// The first result is no longer taken for being first.
assertNull(AddedGameArt.pickStoreResult("My Obscure Game", listOf("10" to "Totally Different", "11" to "Another One")))
@@ -27,4 +27,45 @@ class AddedGamesScanTest {
SessionPrefs.setAddedGamesDirs(app, listOf(games.path))
assertEquals(listOf("Example"), AddedGames.scan(app).map { it.name })
}
@Test fun aStorePackageLaunchesTheExecutableItDeclares() {
val game = tmp.newFolder("Minecraft Dungeons")
File(game, "Dungeons.exe").writeText("unreal bootstrap")
File(game, "Dungeons/Binaries/Win64").mkdirs()
File(game, "Dungeons/Binaries/Win64/Dungeons.exe").writeText("the game, renamed from Dungeons-Win64-Shipping.exe")
File(game, "MicrosoftGame.config").writeText(
"<Game><ExecutableList>\n <Executable Name=\"Dungeons\\Binaries\\Win64\\Dungeons.exe\"\n\n Id=\"Game\" />\n</ExecutableList></Game>",
)
assertEquals(File(game, "Dungeons/Binaries/Win64/Dungeons.exe").path, AddedGames.candidates(game).first().path)
assertEquals(2, AddedGames.candidates(game).size)
}
@Test fun anAppxManifestNamesTheGameWhenThereIsNoConfig() {
val game = tmp.newFolder("Store Game")
File(game, "Launcher.exe").writeText("bootstrap")
File(game, "Game/Binaries/Win64").mkdirs()
File(game, "Game/Binaries/Win64/Game.exe").writeText("game")
File(game, "appxmanifest.xml").writeText("<Package><Applications><Application Id=\"Game\" Executable=\"Game\\Binaries\\Win64\\Game.exe\" /></Applications></Package>")
assertEquals(File(game, "Game/Binaries/Win64/Game.exe").path, AddedGames.candidates(game).first().path)
}
@Test fun aDeclaredTopLevelExecutableKeepsItsPlace() {
val game = tmp.newFolder("Minecraft Dungeons II")
File(game, "Dungeons.exe").writeText("bootstrap")
File(game, "Other.exe").writeText("a much longer other executable")
File(game, "MicrosoftGame.config").writeText("<Executable Name=\"Dungeons.exe\" Id=\"App\" />")
assertEquals(listOf("Dungeons.exe", "Other.exe"), AddedGames.candidates(game).map { it.name })
}
@Test fun declaredHelpersMissingFilesAndPathsOutsideTheFolderAreIgnored() {
val game = tmp.newFolder("Pass Game")
File(game, "PassGame.exe").writeText("game")
File(game, "gamelaunchhelper.exe").writeText("needs gaming services")
tmp.newFile("Outside.exe").writeText("not this game")
File(game, "MicrosoftGame.config").writeText(
"<Executable Name=\"gamelaunchhelper.exe\" /><Executable Name=\"Missing.exe\" /><Executable Name=\"..\\Outside.exe\" />",
)
assertEquals(emptyList<File>(), AddedGames.declaredExes(game))
assertEquals(listOf("PassGame.exe"), AddedGames.candidates(game).map { it.name })
}
}
@@ -0,0 +1,102 @@
package com.droiddeck.launcher.frontend
import com.droiddeck.launcher.session.GameStorage
import com.droiddeck.launcher.session.SessionPrefs
import com.droiddeck.launcher.stores.StoreGameSidecar
import java.io.File
import org.junit.Assert.assertEquals
import org.junit.Assert.assertFalse
import org.junit.Assert.assertTrue
import org.junit.Before
import org.junit.Rule
import org.junit.Test
import org.junit.rules.TemporaryFolder
import org.junit.runner.RunWith
import org.robolectric.RobolectricTestRunner
import org.robolectric.RuntimeEnvironment
import org.robolectric.annotation.Config
/** Games folders from earlier builds become entries, every game keeping what it had. */
@RunWith(RobolectricTestRunner::class)
@Config(sdk = [33])
class GamesFolderMigrationTest {
private val app get() = RuntimeEnvironment.getApplication()
@get:Rule val tmp = TemporaryFolder()
private fun exe(root: File, rel: String) = File(root, rel).apply { parentFile!!.mkdirs(); writeBytes(ByteArray(16)) }
@Before fun setUp() {
SessionPrefs.setGameStorage(app, "", "")
AddedGamesPublisher.now = true
}
@Test fun eachGameBecomesAnEntryKeepingItsExeAppidNameAndRemoval() {
val pc = tmp.newFolder("PC")
val alpha = File(pc, "Alpha"); exe(pc, "Alpha/Alpha.exe")
val beta = File(pc, "Beta"); exe(pc, "Beta/Beta.exe"); val pick = exe(pc, "Beta/bin/Beta64.exe")
val gone = File(pc, "Gone"); exe(pc, "Gone/Gone.exe")
exe(pc, "steamapps/common/Installed/Installed.exe")
val store = File(pc, "Store"); exe(pc, "Store/Store.exe")
StoreGameSidecar.file(store).writeText("""{"store":"gog","id":"1","title":"Store Game","exe":"Store.exe"}""")
File(pc, "Notes").mkdirs()
SessionPrefs.setAddedGamesDirs(app, listOf(pc.path))
// What earlier scans had kept, by folder.
SessionPrefs.setAddedGameExe(app, beta.path, pick.path)
SessionPrefs.addedGameAppId(app, alpha.path, 0x8123_4567L)
SessionPrefs.addedGameAppId(app, beta.path, 0x8765_4321L)
SessionPrefs.setAddedGameName(app, alpha.path, "Alpha GOTY")
SessionPrefs.setAddedGameRemoved(app, gone.path, true)
GamesFolderMigration.run(app)
assertEquals(emptyList<String>(), SessionPrefs.addedGamesDirs(app))
assertTrue(SessionPrefs.gamesFoldersMigrated(app))
val entries = AddedExes.list(app).associateBy { it.folder }
assertEquals(setOf(alpha.path, beta.path, gone.path), entries.keys)
assertTrue(entries.getValue(beta.path).picked)
assertEquals(pick.path, entries.getValue(beta.path).exe)
assertFalse(entries.getValue(alpha.path).picked)
val games = AddedGames.scan(app).associateBy { it.folder.path }
assertEquals(setOf(alpha.path, beta.path), games.keys)
assertEquals(0x8123_4567L, games.getValue(alpha.path).appId)
assertEquals(0x8765_4321L, games.getValue(beta.path).appId)
assertEquals("Alpha GOTY", games.getValue(alpha.path).name)
assertEquals(pick.path, games.getValue(beta.path).exe.path)
// The same path inside the session as before: the shortcut keeps its target.
assertEquals(AddedGames.GUEST_DIR + "/PC/Alpha/Alpha.exe", games.getValue(alpha.path).guestExe)
assertEquals(AddedGames.GUEST_DIR + "/PC/Beta/bin/Beta64.exe", games.getValue(beta.path).guestExe)
assertTrue(SessionPrefs.removedAddedGames(app).contains(gone.path))
// Not a library any more.
assertTrue(GameStorage.gamesFolderLibraries(app).isEmpty())
}
@Test fun runningAgainChangesNothing() {
val pc = tmp.newFolder("PC")
exe(pc, "Alpha/Alpha.exe")
SessionPrefs.setAddedGamesDirs(app, listOf(pc.path))
GamesFolderMigration.run(app)
val entries = AddedExes.list(app).map { it.folder to it.exe }
val binds = SessionPrefs.gameFolderBinds(app)
val before = AddedGames.scan(app).map { it.appId to it.guestExe }
GamesFolderMigration.run(app)
GamesFolderMigration.run(app)
assertEquals(entries, AddedExes.list(app).map { it.folder to it.exe })
assertEquals(binds, SessionPrefs.gameFolderBinds(app))
assertEquals(before, AddedGames.scan(app).map { it.appId to it.guestExe })
assertEquals(listOf(pc.path to AddedGames.GUEST_DIR + "/PC"), binds)
}
@Test fun twoFoldersOfOneNameKeepTheirOwnGuestPaths() {
val a = File(tmp.newFolder("a"), "Games").apply { mkdirs() }
val b = File(tmp.newFolder("b"), "Games").apply { mkdirs() }
exe(a, "One/One.exe")
exe(b, "Two/Two.exe")
SessionPrefs.setAddedGamesDirs(app, listOf(a.path, b.path))
val guests = GamesFolderMigration.legacyRoots(listOf(a.path, b.path)).map { it.guest }
val games = AddedGames.scan(app).associateBy { it.name }
assertEquals(guests[0] + "/One/One.exe", games.getValue("One").guestExe)
assertEquals(guests[1] + "/Two/Two.exe", games.getValue("Two").guestExe)
assertTrue(guests[0] != guests[1])
}
}
@@ -0,0 +1,95 @@
package com.droiddeck.launcher.frontend
import java.io.ByteArrayOutputStream
import java.io.File
import java.nio.ByteBuffer
import java.nio.ByteOrder
import java.util.zip.GZIPOutputStream
import org.junit.After
import org.junit.Assert.assertEquals
import org.junit.Assert.assertFalse
import org.junit.Assert.assertNull
import org.junit.Assert.assertTrue
import org.junit.Assert.fail
import org.junit.Test
import org.junit.runner.RunWith
import org.robolectric.RobolectricTestRunner
import org.robolectric.RuntimeEnvironment
import org.robolectric.annotation.Config
/** Steam's app info asked anonymously: the answer read, batched, kept, and asked only when needed. */
@RunWith(RobolectricTestRunner::class)
@Config(sdk = [33])
class SteamPicsTest {
private val app get() = RuntimeEnvironment.getApplication()
/** Dragon's Dogma 2's app info as Steam's anonymous PICS answer carried it (recorded 2026-10-10). */
private val recorded: String get() = javaClass.classLoader!!.getResource("steam-pics/app_2054970.vdf")!!.readText()
@After fun tearDown() { SteamAppInfo.pics = { SteamPics.lookup(it) } }
@Test fun theRecordedAnswerGivesTheSharedRedistAndTheName() {
val info = SteamPics.infoFromKeyValues(recorded)!!
assertEquals("Dragon's Dogma 2", info.name)
assertEquals(listOf("228988"), info.sharedDepots)
// VC++ 2019 for the Windows components.
assertEquals(listOf("vcredist2019_dll" to "VC++ 2019"), WinCompSources.steamDepots(info.sharedDepots))
}
private fun message(emsg: Int, body: ByteArray): ByteArray =
ByteBuffer.allocate(8 + body.size).order(ByteOrder.LITTLE_ENDIAN).putInt(emsg or 0x80000000.toInt()).putInt(0).put(body).array()
@Test fun aProductInfoResponseInsideAGzippedMultiIsRead() {
val appEntry = SteamPics.Proto().varint(1, 2054970).varint(2, 123).bytes(5, recorded.toByteArray() + byteArrayOf(0)).bytes()
val response = SteamPics.Proto().bytes(1, appEntry).varint(2, 999).bytes()
val inner = message(8904, response)
val packed = ByteBuffer.allocate(4 + inner.size).order(ByteOrder.LITTLE_ENDIAN).putInt(inner.size).put(inner).array()
val gz = ByteArrayOutputStream().also { o -> GZIPOutputStream(o).use { it.write(packed) } }.toByteArray()
val multi = message(1, SteamPics.Proto().varint(1, packed.size.toLong()).bytes(2, gz).bytes())
val msgs = SteamPics.messages(multi)
assertEquals(listOf(8904), msgs.map { it.first })
val r = SteamPics.parseInfoResponse(msgs.single().third)
assertEquals(listOf("228988"), r.apps.getValue(2054970).sharedDepots)
assertEquals(listOf(999), r.unknown)
assertFalse(r.pending)
}
@Test fun requestsAreBatchedByTwentyFive() {
val ids = (1..60).toList() + listOf(5, 6)
val batches = SteamPics.batches(ids)
assertEquals(listOf(25, 25, 10), batches.map { it.size })
assertEquals((1..60).toList(), batches.flatten())
}
@Test fun answersAreKeptAndAMissIsAskedAgainOnlyAfterAWeek() {
val calls = ArrayList<List<Int>>()
SteamAppInfo.pics = { ids -> calls.add(ids); mapOf(2054970 to SteamPics.infoFromKeyValues(recorded), 7 to null) }
assertTrue(SteamAppInfo.lookup(app, listOf(2054970, 7)))
// One connection for both.
assertEquals(listOf(listOf(2054970, 7)), calls)
assertEquals(listOf("228988"), SteamAppInfo.info(app, 2054970)!!.sharedDepots)
assertNull(SteamAppInfo.info(app, 7))
// Kept: nothing asked again (the miss waits a week).
assertFalse(SteamAppInfo.lookup(app, listOf(2054970, 7)))
assertEquals(1, calls.size)
assertEquals("hit", SteamAppInfo.state(app, 2054970))
assertEquals("miss", SteamAppInfo.state(app, 7))
}
@Test fun noNetworkWhenTheClientsCacheHasTheApp() {
// An appinfo.vdf with the app in it (built as in WinCompSourcesTest).
val file = SteamAppInfo.appinfoFile(app)
WinCompSourcesTest.writeAppinfo(file, 1145360)
SteamAppInfo.pics = { fail("asked Steam"); null }
assertTrue(SteamAppInfo.needLookup(app, listOf(1145360)).isEmpty())
assertFalse(SteamAppInfo.lookup(app, listOf(1145360)))
assertEquals(listOf("228986", "228990"), SteamAppInfo.info(app, 1145360)!!.sharedDepots)
}
@Test fun anUnreachableSteamKeepsNothingAndTriesAgain() {
SteamAppInfo.pics = { null }
assertFalse(SteamAppInfo.lookup(app, listOf(42)))
assertEquals(listOf(42), SteamAppInfo.needLookup(app, listOf(42)))
}
}
@@ -0,0 +1,465 @@
package com.droiddeck.launcher.frontend
import com.droiddeck.launcher.runtime.LinuxRuntime
import com.droiddeck.launcher.session.WinComponents
import com.droiddeck.launcher.stores.Store
import com.droiddeck.launcher.stores.StoreGameSidecar
import java.io.ByteArrayOutputStream
import java.io.File
import java.nio.ByteBuffer
import java.nio.ByteOrder
import org.json.JSONObject
import org.junit.Assert.assertEquals
import org.junit.Assert.assertFalse
import org.junit.Assert.assertTrue
import org.junit.Rule
import org.junit.Test
import org.junit.rules.TemporaryFolder
import org.junit.runner.RunWith
import org.robolectric.RobolectricTestRunner
import org.robolectric.RuntimeEnvironment
import org.robolectric.annotation.Config
/** Each source's list of Windows components, how it maps to the catalog, and what turns on by itself. */
@RunWith(RobolectricTestRunner::class)
@Config(sdk = [33])
class WinCompSourcesTest {
private val app get() = RuntimeEnvironment.getApplication()
@get:Rule val tmp = TemporaryFolder()
private fun game(folder: File?, source: String, appId: Int = 0x8123_4567.toInt(), library: String = Library.ADDED, name: String = "Game") =
Library.SteamGame(appId, name, null, library, gameFiles = folder, source = source)
private fun sidecar(folder: File, store: Store, extra: Map<String, String>) =
StoreGameSidecar(store, "1", "Game", exe = "Game.exe", extra = extra).write(folder)
private fun auto(findings: List<WinCompSources.Finding>) = findings.filter { it.auto }.map { it.component to it.origin }
// ---- mapping tables ----
@Test fun gogDependencyIdsMapToComponents() {
assertEquals(listOf("vcredist2015"), WinCompSources.gog("MSVC2017_x64"))
assertEquals(listOf("vcredist2022"), WinCompSources.gog("MSVC2022"))
assertEquals(listOf("vcredist2013"), WinCompSources.gog("msvc2013"))
assertEquals(listOf("d3dx9"), WinCompSources.gog("DirectX"))
assertEquals(listOf("dotnet48"), WinCompSources.gog("DOTNET48"))
assertEquals(listOf("dotnet472"), WinCompSources.gog("DotNet471"))
assertEquals(listOf("physx"), WinCompSources.gog("PhysX"))
assertEquals(listOf("oalinst"), WinCompSources.gog("OpenAL"))
assertEquals(listOf("xna40"), WinCompSources.gog("XNA4"))
assertEquals(emptyList<String>(), WinCompSources.gog("ISI"))
}
@Test fun epicPrerequisitesMapToComponents() {
assertEquals(listOf("vcredist2022", "d3dx9"), WinCompSources.epic("Engine/Extras/Redist/en-us/UEPrereqSetup_x64.exe", ""))
assertEquals(listOf("vcredist2022", "d3dx9"), WinCompSources.epic("Engine/Extras/Redist/en-us/UE4PrereqSetup_x64.exe", "UE4 Prerequisites (x64)"))
assertEquals(listOf("vcredist2022", "d3dx9"), WinCompSources.epic("", "UE4 Prerequisites (x64)"))
assertEquals(listOf("vcredist2013"), WinCompSources.epic("Redist/vcredist/2013/vcredist_x64.exe", "Visual C++"))
assertEquals(emptyList<String>(), WinCompSources.epic("Installers/LauncherHelper.exe", "Launcher Helper"))
}
@Test fun steamworksSharedDepotsMapToComponents() {
assertEquals(listOf("vcredist2015_dll" to "VC++ 2015", "d3dx9" to "DirectX (June 2010)"), WinCompSources.steamDepots(listOf("228986", "228990", "999")))
}
// ---- per source ----
@Test fun steamTakesItsSharedDepotsAndTurnsThemOn() {
val steamapps = tmp.newFolder("steamapps")
val folder = File(steamapps, "common/Game").apply { mkdirs() }
File(steamapps, "appmanifest_570.acf").writeText(
"\"AppState\"\n{\n\t\"appid\"\t\t\"570\"\n\t\"SharedDepots\"\n\t{\n\t\t\"228986\"\t\t\"228980\"\n\t\t\"228990\"\t\t\"228980\"\n\t}\n}\n",
)
val findings = WinCompSources.forGame(app, game(folder, Library.SOURCE_STEAM, 570, library = "steam", name = "Dota"))
assertEquals(listOf("vcredist2015_dll" to WinCompSources.Origin.STEAM, "d3dx9" to WinCompSources.Origin.STEAM), auto(findings))
assertEquals(listOf("VC++ 2015", "Dota", "570"), findings.first().args)
}
@Test fun gogTakesTheBuildsDependencies() {
val folder = tmp.newFolder("GogGame")
sidecar(folder, Store.GOG, mapOf(WinCompSources.GOG_DEPENDENCIES to "MSVC2017_x64,DirectX,ISI"))
val findings = WinCompSources.forGame(app, game(folder, "gog"))
assertEquals(listOf("vcredist2015" to WinCompSources.Origin.GOG, "d3dx9" to WinCompSources.Origin.GOG), auto(findings))
// ISI is none of ours: a suggestion under its own name.
assertEquals(listOf("ISI"), findings.filter { it.component == null }.map { it.original })
}
@Test fun epicTakesTheManifestsPrerequisite() {
val folder = tmp.newFolder("EpicGame")
sidecar(folder, Store.EPIC, mapOf(WinCompSources.EPIC_PREREQ_PATH to "Engine/Extras/Redist/en-us/UEPrereqSetup_x64.exe", WinCompSources.EPIC_PREREQ_NAME to "UE Prerequisites"))
assertEquals(listOf("vcredist2022" to WinCompSources.Origin.EPIC, "d3dx9" to WinCompSources.Origin.EPIC), auto(WinCompSources.forGame(app, game(folder, "epic"))))
}
@Test fun amazonTakesFuelJsonsPostInstall() {
val folder = tmp.newFolder("AmazonGame")
sidecar(folder, Store.AMAZON, emptyMap())
File(folder, "fuel.json").writeText(JSONObject()
.put("Main", JSONObject().put("Command", "Game.exe"))
.put("PostInstall", org.json.JSONArray()
.put(JSONObject().put("Command", "_CommonRedist\\vcredist\\2015\\vc_redist.x64.exe").put("Args", org.json.JSONArray().put("/q")))
.put(JSONObject().put("Command", "_CommonRedist\\DirectX\\Jun2010\\DXSETUP.exe"))
.put(JSONObject().put("Command", "Tools\\Activate.exe")))
.toString())
val findings = WinCompSources.forGame(app, game(folder, "amazon"))
assertEquals(listOf("vcredist2015" to WinCompSources.Origin.AMAZON, "d3dx9" to WinCompSources.Origin.AMAZON), auto(findings))
assertEquals(listOf("Activate.exe"), findings.filter { it.component == null }.map { it.original })
}
// ---- Custom: certainty decides ----
private fun appinfo(file: File, appId: Int) = writeAppinfo(file, appId)
companion object {
/** A version 29 appinfo.vdf with one app, whose depots point at two Steamworks Shared ones. */
fun writeAppinfo(file: File, appId: Int) {
val strings = listOf("appinfo", "common", "name", "depots", "228986", "depotfromapp", "228990", "1001", "maxsize")
fun key(s: String) = ByteBuffer.allocate(4).order(ByteOrder.LITTLE_ENDIAN).putInt(strings.indexOf(s)).array()
val kv = ByteArrayOutputStream().apply {
fun map(k: String) { write(0x00); write(key(k)) }
fun str(k: String, v: String) { write(0x01); write(key(k)); write(v.toByteArray()); write(0) }
fun end() = write(0x08)
map("appinfo"); map("common"); str("name", "Some Game"); end()
map("depots"); map("228986"); str("depotfromapp", "228980"); end(); map("228990"); str("depotfromapp", "228980"); end()
map("1001"); str("maxsize", "1"); end(); end(); end(); end()
}.toByteArray()
val body = ByteArray(60) + kv
val table = ByteArrayOutputStream().apply {
write(ByteBuffer.allocate(4).order(ByteOrder.LITTLE_ENDIAN).putInt(strings.size).array())
strings.forEach { write(it.toByteArray()); write(0) }
}.toByteArray()
val head = ByteBuffer.allocate(16).order(ByteOrder.LITTLE_ENDIAN).putInt(0x07564429).putInt(1)
val entries = ByteBuffer.allocate(8 + body.size + 4).order(ByteOrder.LITTLE_ENDIAN).putInt(appId).putInt(body.size).put(body).putInt(0).array()
head.putLong(16L + entries.size)
file.parentFile!!.mkdirs()
file.writeBytes(head.array() + entries + table)
}
}
@Test fun steamsAppInfoIsReadForAnAppWithoutAManifest() {
val file = File(tmp.newFolder(), "appinfo.vdf")
appinfo(file, 1145360)
val info = SteamAppInfo.read(file, 1145360)!!
assertEquals("Some Game", info.name)
assertEquals(listOf("228986", "228990"), info.sharedDepots)
assertEquals(null, SteamAppInfo.read(file, 1))
}
@Test fun customWithAnAppidInItsFilesIsCertainAndOnByItself() {
appinfo(SteamAppInfo.appinfoFile(app), 1145360)
val folder = tmp.newFolder("Hades")
File(folder, "steam_appid.txt").writeText("1145360")
val exe = File(folder, "Hades.exe").apply { writeText("x") }
assertEquals(SteamMatch.Certainty.FILES, SteamMatch.resolve(app, folder.path, exe, "Hades").certainty)
val findings = WinCompSources.forGame(app, game(folder, Library.ADDED, name = "Hades"))
assertEquals(listOf("vcredist2015_dll" to WinCompSources.Origin.STEAM, "d3dx9" to WinCompSources.Origin.STEAM), auto(findings))
}
@Test fun customMatchedByNameIsOnlyRecommended() {
appinfo(SteamAppInfo.appinfoFile(app), 1145360)
val folder = tmp.newFolder("Hades Named")
val exe = File(folder, "Hades.exe").apply { writeText("x") }
assertEquals(SteamMatch.Certainty.NAME, SteamMatch.resolve(app, folder.path, exe, "Hades") { 1145360 }.certainty)
val findings = WinCompSources.forGame(app, game(folder, Library.ADDED, name = "Hades"))
assertTrue(auto(findings).isEmpty())
assertEquals(listOf("vcredist2015_dll", "d3dx9"), findings.filter { it.origin == WinCompSources.Origin.STEAM_NAME }.map { it.component })
}
@Test fun customWithNoMatchHasTheFolderScanOnly() {
val folder = tmp.newFolder("Unknown")
File(folder, "_CommonRedist/vcredist/2013").mkdirs()
File(folder, "_CommonRedist/vcredist/2013/vcredist_x64.exe").writeText("x")
val findings = WinCompSources.forGame(app, game(folder, Library.ADDED))
assertTrue(auto(findings).isEmpty())
assertEquals(listOf("vcredist2013" to WinCompSources.Origin.FOLDER), findings.map { it.component to it.origin })
}
// ---- what turns on, and the user's switches ----
private fun ready(name: String) = WinComponents.Component(name, "", "", "ready", emptyList(),
listOf(WinComponents.Step("override_dll", JSONObject().put("dll", "x").put("type", "native"))))
@Test fun onlyAutoFindingsThatInstallHereTurnOn() {
val catalog = listOf(ready("vcredist2015_dll"), ready("vcredist2015"), ready("d3dx9"),
WinComponents.Component("physx", "", "", "broken", emptyList(), emptyList())).associateBy { it.name }
val findings = listOf(
WinCompSources.Finding("vcredist2015", "MSVC2017", WinCompSources.Origin.GOG, true),
WinCompSources.Finding("physx", "PhysX", WinCompSources.Origin.GOG, true),
WinCompSources.Finding("d3dx9", "d3dx9_43.dll", WinCompSources.Origin.FOLDER, false),
)
val picks = AutoComponents.autoPicks(findings, catalog)
// The detector's name installs as its DLL twin; physx cannot install; the folder scan only recommends.
assertEquals(listOf("vcredist2015_dll"), picks.keys.toList())
assertEquals("GOG", picks.getValue("vcredist2015_dll").kind)
}
@Test fun theUsersSwitchWinsOverAnAutoPickEitherWay() {
val key = "12345"
WinComponents.setAuto(app, key, mapOf("d3dx9" to WinComponents.AutoReason("GOG", listOf("DirectX")), "xact" to WinComponents.AutoReason("GOG", listOf("XACT"))))
assertEquals(listOf("d3dx9", "xact"), WinComponents.picks(app, key))
WinComponents.setUser(app, key, "d3dx9", false)
WinComponents.setUser(app, key, "oalinst_dll", true)
assertEquals(listOf("xact", "oalinst_dll"), WinComponents.picks(app, key))
// A new automatic list keeps the user's switches.
WinComponents.setAuto(app, key, mapOf("d3dx9" to WinComponents.AutoReason("GOG", listOf("DirectX"))))
assertEquals(listOf("oalinst_dll"), WinComponents.picks(app, key))
assertEquals(mapOf("d3dx9" to false, "oalinst_dll" to true), WinComponents.selection(app, key).user)
// What the launch reads stays version 1 with the effective list.
val text = File(app.filesDir, "wincomponents.json").readText()
assertEquals(1, JSONObject(text).getInt("version"))
assertEquals("oalinst_dll", JSONObject(text).getJSONObject("games").getJSONArray(key).getString(0))
}
@Test fun picksFromBeforeOverridesAreTheUsersOwn() {
File(app.filesDir, "wincomponents.json").writeText("""{"version":1,"games":{"777":["d3dx9","xact"]}}""")
assertEquals(mapOf("d3dx9" to true, "xact" to true), WinComponents.selection(app, "777").user)
assertEquals(listOf("d3dx9", "xact"), WinComponents.picks(app, "777"))
}
@Test fun artAndComponentsShareOneSteamMatch() {
val folder = tmp.newFolder("Shared")
val exe = File(folder, "Shared.exe").apply { writeText("x") }
val art = AddedGames.Game(folder, "Shared", exe, "/g/Shared.exe", "/g", 0x81234567L, 0L, emptyList())
// The art fetch finds it by name ...
val match = AddedGameArt.steamMatch(app, art) { 4242 }
assertEquals(4242, match.appId)
// ... and the components read the same record.
assertEquals(4242, SteamMatch.get(app, folder.path)!!.appId)
assertEquals(SteamMatch.Certainty.NAME, SteamMatch.get(app, folder.path)!!.certainty)
assertFalse(WinCompSources.forGame(app, game(folder, Library.ADDED)).any { it.auto })
}
private val catalog3 = listOf(ready("d3dx9"), ready("vcredist2013_dll"), ready("vcredist2019_dll"))
private val installedNow = mutableSetOf<String>()
private val downloaded = ArrayList<String>()
private var session = false
private var gameUp = false
private fun payback(): Library.SteamGame {
val steamapps = tmp.newFolder("steamapps")
val folder = File(steamapps, "common/Payback").apply { mkdirs() }
File(steamapps, "appmanifest_1262580.acf").writeText(
"\"AppState\"\n{\n\t\"SharedDepots\"\n\t{\n\t\t\"228985\"\t\t\"228980\"\n\t\t\"228988\"\t\t\"228980\"\n\t\t\"228990\"\t\t\"228980\"\n\t}\n}\n",
)
return game(folder, Library.SOURCE_STEAM, 1262580, library = "steam", name = "Need for Speed Payback")
}
private fun <T> withFakeWorker(block: () -> T): T {
AutoComponents.catalogOf = { _, _ -> catalog3 }
AutoComponents.installedOf = { installedNow.toSet() }
AutoComponents.installer = { _, c, _, _ -> downloaded.add(c.name); installedNow.add(c.name); null }
AutoComponents.sessionRunning = { session }
AutoComponents.unfavourable = { false }
AutoComponents.online = { true }
AutoComponents.gameRunning = { gameUp }
AutoComponents.runNow = true
try {
return block()
} finally {
AutoComponents.runNow = false
AutoComponents.catalogOf = { c, network -> if (network) WinComponents.fetch(c) else WinComponents.cached(c) }
AutoComponents.installedOf = { WinComponents.installedIds(it).toSet() }
AutoComponents.installer = { c, comp, all, progress -> WinComponents.install(c, comp, all, progress) }
AutoComponents.sessionRunning = { com.droiddeck.launcher.session.SessionState.running }
AutoComponents.online = { c -> AutoComponents.isOnline(c) }
AutoComponents.gameRunning = { false }
}
}
@Test fun aSteamListedComponentNotYetDownloadedTurnsOnWhenItLands() = withFakeWorker {
installedNow.add("d3dx9")
val game = payback()
// The user had switched one off: it is never downloaded for an automatic pick.
WinComponents.setUser(app, "1262580", "vcredist2019_dll", false)
AutoComponents.queueGame(app, game, asked = true)
assertEquals(listOf("vcredist2013_dll"), downloaded)
val sel = WinComponents.selection(app, "1262580")
assertEquals(setOf("d3dx9", "vcredist2013_dll", "vcredist2019_dll"), sel.auto.keys)
assertEquals(setOf("d3dx9", "vcredist2013_dll"), WinComponents.picks(app, "1262580").toSet())
assertEquals("STEAM", sel.auto.getValue("vcredist2013_dll").kind)
// The list is kept for the page.
assertEquals(3, AutoComponents.cachedFindings(app, "1262580")!!.count { it.auto })
}
@Test fun theQueueWaitsWhileASessionRunsAndResumesAfter() = withFakeWorker {
val game = payback()
session = true
AutoComponents.queueGame(app, game, asked = true)
assertTrue(downloaded.isEmpty())
assertEquals(listOf("g:1262580"), AutoComponents.queued())
session = false
AutoComponents.drain(app)
assertEquals(listOf("vcredist2013_dll", "vcredist2019_dll", "d3dx9"), downloaded)
assertTrue(AutoComponents.queued().isEmpty())
}
@Test fun aJobNobodyAskedForWaitsOnAnUnfavourableDevice() = withFakeWorker {
val game = payback()
AutoComponents.unfavourable = { true }
AutoComponents.queueGame(app, game, asked = false)
assertTrue(downloaded.isEmpty())
// The user opens the page: asked for, it goes.
AutoComponents.queueGame(app, game, asked = true)
assertEquals(3, downloaded.size)
}
@Test fun aGameAndAComponentAreQueuedOnceEach() = withFakeWorker {
val first = payback()
val other = game(first.gameFiles, Library.SOURCE_STEAM, 1262580, library = "steam", name = "Same app again")
session = true
AutoComponents.queueGame(app, first, asked = false)
AutoComponents.queueGame(app, other, asked = false)
assertEquals(listOf("g:1262580"), AutoComponents.queued())
session = false
AutoComponents.drain(app)
// Each component once, though two games could want it.
assertEquals(downloaded.distinct(), downloaded)
}
@Test fun aDownloadWaitsForANetwork() = withFakeWorker {
val game = payback()
AutoComponents.online = { false }
AutoComponents.queueGame(app, game, asked = true)
// The list is worked out offline; the downloads wait.
assertTrue(downloaded.isEmpty())
assertTrue(AutoComponents.queued().all { it.startsWith("c:") })
AutoComponents.online = { true }
AutoComponents.drain(app)
assertEquals(3, downloaded.size)
}
// ---- older store installs: the list read once from the store ----
@Test fun anOlderGogInstallGetsItsListFromTheStoreOnce() {
val folder = tmp.newFolder("DOOM I Enhanced")
StoreGameSidecar(Store.GOG, "2015545325", "DOOM", exe = "DOOM.exe", installVersion = "123").write(folder)
var fetches = 0
StoreListBackfill.fetcher = StoreListBackfill.Fetcher { _, sc -> fetches++; assertEquals("123", sc.installVersion); mapOf(WinCompSources.GOG_DEPENDENCIES to "MSVC2019_x64,DirectX") }
AutoComponents.online = { true }
try {
assertTrue(StoreListBackfill.fill(app, folder))
assertEquals("MSVC2019_x64,DirectX", StoreGameSidecar.read(folder)!!.extra[WinCompSources.GOG_DEPENDENCIES])
// Kept like a fresh install: not fetched again.
assertFalse(StoreListBackfill.fill(app, folder))
assertEquals(1, fetches)
assertEquals(listOf("vcredist2019" to WinCompSources.Origin.GOG, "d3dx9" to WinCompSources.Origin.GOG), auto(WinCompSources.forGame(app, game(folder, "gog"))))
} finally {
StoreListBackfill.fetcher = StoreListBackfill.storeFetcher
AutoComponents.online = { c -> AutoComponents.isOnline(c) }
}
}
@Test fun anOlderEpicInstallGetsItsPrerequisite() {
val folder = tmp.newFolder("DOOMBLADE")
StoreGameSidecar(Store.EPIC, "abc", "DOOMBLADE", exe = "DOOMBLADE.exe", extra = mapOf("namespace" to "ns", "catalogItemId" to "cat")).write(folder)
StoreListBackfill.fetcher = StoreListBackfill.Fetcher { _, _ ->
mapOf(WinCompSources.EPIC_PREREQ_NAME to "UE4 Prerequisites (x64)", WinCompSources.EPIC_PREREQ_PATH to "Engine/Extras/Redist/en-us/UE4PrereqSetup_x64.exe")
}
AutoComponents.online = { true }
try {
assertTrue(StoreListBackfill.fill(app, folder))
assertEquals("ns", StoreGameSidecar.read(folder)!!.extra["namespace"])
assertEquals(listOf("vcredist2022" to WinCompSources.Origin.EPIC, "d3dx9" to WinCompSources.Origin.EPIC), auto(WinCompSources.forGame(app, game(folder, "epic"))))
} finally {
StoreListBackfill.fetcher = StoreListBackfill.storeFetcher
AutoComponents.online = { c -> AutoComponents.isOnline(c) }
}
}
@Test fun aFailedFetchWaitsAWeekAndOfflineIsNoTry() {
val folder = tmp.newFolder("Metalstorm")
StoreGameSidecar(Store.EPIC, "m", "Metalstorm", exe = "M.exe").write(folder)
var fetches = 0
StoreListBackfill.fetcher = StoreListBackfill.Fetcher { _, _ -> fetches++; null }
try {
AutoComponents.online = { false }
assertFalse(StoreListBackfill.fill(app, folder))
assertEquals(0, fetches)
AutoComponents.online = { true }
assertFalse(StoreListBackfill.fill(app, folder))
assertFalse(StoreListBackfill.fill(app, folder))
// Signed out or the store refused: once, then a week's rest; the sidecar is untouched.
assertEquals(1, fetches)
assertTrue(StoreListBackfill.needs(StoreGameSidecar.read(folder)!!))
} finally {
StoreListBackfill.fetcher = StoreListBackfill.storeFetcher
AutoComponents.online = { c -> AutoComponents.isOnline(c) }
}
}
// ---- apps the client never saw ----
@Test fun anAppTheClientLearnsLaterIsReadOnceItsCacheIsWritten() {
val file = SteamAppInfo.appinfoFile(app)
appinfo(file, 1)
file.setLastModified(1_000_000_000_000L)
assertEquals(null, SteamAppInfo.info(app, 1145360))
// Unchanged file: the miss stands (no re-read).
assertEquals(null, SteamAppInfo.info(app, 1145360))
// The client wrote its cache with the app in it: read at once, not a week later.
appinfo(file, 1145360)
file.setLastModified(1_000_000_100_000L)
assertEquals(listOf("228986", "228990"), SteamAppInfo.info(app, 1145360)!!.sharedDepots)
}
@Test fun unitysManagedFolderIsNotADotNetRequirement() {
val folder = tmp.newFolder("DOOMBLADE")
File(folder, "DOOMBLADE_Data/Managed").mkdirs()
File(folder, "DOOMBLADE_Data/Managed/com.rlabrecque.steamworks.net.dll").writeText("x")
File(folder, "DOOMBLADE_Data/Managed/System.Net.Http.dll").writeText("x")
assertTrue(DependencyDetector.detect(folder).isEmpty())
}
@Test fun aListReadWholeIsNotFetchedAgainButAnUnmarkedEmptyOneIs() {
val read = tmp.newFolder("Read")
StoreGameSidecar(Store.EPIC, "a", "A", exe = "A.exe", extra = mapOf(WinCompSources.EPIC_PREREQ_NAME to "", WinCompSources.EPIC_PREREQ_PATH to "", WinCompSources.LIST_READ to "1")).write(read)
val older = tmp.newFolder("Older")
StoreGameSidecar(Store.EPIC, "b", "B", exe = "B.exe", extra = mapOf(WinCompSources.EPIC_PREREQ_NAME to "", WinCompSources.EPIC_PREREQ_PATH to "")).write(older)
assertFalse(StoreListBackfill.needs(StoreGameSidecar.read(read)!!))
assertTrue(StoreListBackfill.needs(StoreGameSidecar.read(older)!!))
}
// ---- a game installed during the session ----
private fun manifest(dir: File, appId: Int, state: Int, time: Long) =
File(dir, "appmanifest_$appId.acf").apply {
writeText("\"AppState\"\n{\n\t\"appid\"\t\t\"$appId\"\n\t\"name\"\t\t\"Audiosurf\"\n\t\"installdir\"\t\t\"Audiosurf\"\n\t\"StateFlags\"\t\t\"$state\"\n}\n")
setLastModified(time)
}
@Test fun aManifestTurningInstalledIsNoticedOnce() {
val steamapps = tmp.newFolder("steamapps")
manifest(steamapps, 12900, 1026, 1_000_000L)
manifest(steamapps, 570, 4, 1_000_000L)
val watch = SessionInstalls.Watch { listOf(steamapps) }
// What was there when the session started is not new.
assertTrue(watch.poll().isEmpty())
assertTrue(watch.poll().isEmpty())
manifest(steamapps, 12900, 4, 2_000_000L)
manifest(steamapps, 440, 4, 2_000_000L)
val done = watch.poll()
assertEquals(setOf(12900, 440), done.map { it.appId }.toSet())
assertEquals(File(steamapps, "common/Audiosurf"), SessionInstalls.gameOf(done.first { it.appId == 12900 }).gameFiles)
assertTrue(watch.poll().isEmpty())
}
@Test fun anInSessionJobWaitsWhileAGameRunsThenReadiesItsComponents() = withFakeWorker {
val installed = payback()
session = true
gameUp = true
AutoComponents.queueInSession(app, installed)
assertTrue(downloaded.isEmpty())
// Other work stays paused in the session.
val other = game(tmp.newFolder("Other"), Library.ADDED)
AutoComponents.queueGame(app, other, asked = false)
gameUp = false
AutoComponents.drain(app)
assertEquals(3, downloaded.size)
assertEquals(setOf("d3dx9", "vcredist2013_dll", "vcredist2019_dll"), WinComponents.picks(app, "1262580").toSet())
// Nothing left pending for the launch to wait on; the other game still waits for the session's end.
assertEquals(0, AutoComponents.Pending.read(app).length())
assertEquals(listOf("g:${AutoComponents.appKey(other)}"), AutoComponents.queued())
session = false
AutoComponents.drain(app)
assertTrue(AutoComponents.queued().isEmpty())
}
}
@@ -0,0 +1,164 @@
package com.droiddeck.launcher.input
import android.hardware.input.InputManager
import android.view.InputDevice
import android.view.InputEvent
import android.view.KeyCharacterMap
import android.view.KeyEvent
import android.view.MotionEvent
import org.junit.Assert.*
import org.junit.Test
import org.junit.runner.RunWith
import org.robolectric.RobolectricTestRunner
import org.robolectric.RuntimeEnvironment
import org.robolectric.Shadows.shadowOf
import org.robolectric.annotation.Config
import org.robolectric.annotation.Implementation
import org.robolectric.annotation.Implements
import org.robolectric.annotation.RealObject
import org.robolectric.shadow.api.Shadow
import org.robolectric.shadows.InputDeviceBuilder
import org.robolectric.shadows.ShadowKeyEvent
import org.robolectric.shadows.ShadowMotionEvent
@RunWith(RobolectricTestRunner::class)
@Config(sdk = [34], manifest = Config.NONE, shadows = [PadBridgeRoutingTest.RegisteredKeyEvent::class,
PadBridgeRoutingTest.RegisteredMotionEvent::class])
class PadBridgeRoutingTest {
@Test fun aCompositeKeyboardDoesNotCountAsAConnectedPad() {
val keyboard = device(COMPOSITE, InputDevice.KEYBOARD_TYPE_ALPHABETIC)
assertFalse(PadBridge.isFromController(keyboard))
assertFalse(PadBridge.anyControllerConnected())
assertFalse(PadBridge.isFromController(device(InputDevice.SOURCE_KEYBOARD, InputDevice.KEYBOARD_TYPE_ALPHABETIC)))
}
@Test fun mouseAxesDoNotMakeACompositeKeyboardAJoystick() {
val keyboard = device(COMPOSITE, InputDevice.KEYBOARD_TYPE_ALPHABETIC, rangeSource = InputDevice.SOURCE_MOUSE)
assertFalse(PadBridge.isFromController(keyboard))
assertFalse(PadBridge.anyControllerConnected())
}
@Test fun gamepadsKeepTheirKeyboardCapabilities() {
for (type in listOf(InputDevice.KEYBOARD_TYPE_NON_ALPHABETIC, InputDevice.KEYBOARD_TYPE_ALPHABETIC)) {
assertTrue(PadBridge.isFromController(device(InputDevice.SOURCE_GAMEPAD or InputDevice.SOURCE_KEYBOARD, type)))
}
assertTrue(PadBridge.anyControllerConnected())
}
@Test fun aJoystickWithoutKeyboardCapabilitiesRemainsAController() {
assertTrue(PadBridge.isFromController(device(InputDevice.SOURCE_JOYSTICK, InputDevice.KEYBOARD_TYPE_NONE)))
assertTrue(PadBridge.isFromController(device(COMPOSITE, InputDevice.KEYBOARD_TYPE_NON_ALPHABETIC)))
}
@Test fun joystickRangesDistinguishAHybridFromIncidentalSourceBits() {
for (axis in listOf(MotionEvent.AXIS_X, MotionEvent.AXIS_HAT_Y, MotionEvent.AXIS_RTRIGGER, MotionEvent.AXIS_GAS)) {
val hybrid = device(COMPOSITE, InputDevice.KEYBOARD_TYPE_ALPHABETIC,
rangeSource = InputDevice.SOURCE_JOYSTICK, axis = axis)
assertTrue(PadBridge.isFromController(hybrid))
}
}
@Test fun physicalControllerButtonsDistinguishAHybridWithoutAxes() {
val hybrid = device(COMPOSITE, InputDevice.KEYBOARD_TYPE_ALPHABETIC,
keys = intArrayOf(KeyEvent.KEYCODE_A, KeyEvent.KEYCODE_BUTTON_A))
assertTrue(hybrid.hasKeys(KeyEvent.KEYCODE_BUTTON_A)[0])
assertTrue(PadBridge.isFromController(hybrid))
}
@Test fun absentAndVirtualDevicesDoNotCountAsControllers() {
assertFalse(PadBridge.isFromController(null))
val virtual = device(InputDevice.SOURCE_GAMEPAD or InputDevice.SOURCE_JOYSTICK,
InputDevice.KEYBOARD_TYPE_NONE, id = -1)
assertTrue(virtual.isVirtual)
assertFalse(PadBridge.isFromController(virtual))
}
@Test fun keyAndMotionOwnershipDoesNotFollowEverySourceOnTheDevice() {
val keyboard = device(InputDevice.SOURCE_KEYBOARD, InputDevice.KEYBOARD_TYPE_ALPHABETIC)
val composite = device(COMPOSITE, InputDevice.KEYBOARD_TYPE_ALPHABETIC)
val hybrid = device(COMPOSITE, InputDevice.KEYBOARD_TYPE_ALPHABETIC, rangeSource = InputDevice.SOURCE_JOYSTICK)
val gamepad = device(COMPOSITE or InputDevice.SOURCE_GAMEPAD, InputDevice.KEYBOARD_TYPE_ALPHABETIC)
val joystick = device(InputDevice.SOURCE_JOYSTICK, InputDevice.KEYBOARD_TYPE_NONE)
val virtual = device(InputDevice.SOURCE_GAMEPAD, InputDevice.KEYBOARD_TYPE_NONE, id = -1)
for (action in listOf(KeyEvent.ACTION_DOWN, KeyEvent.ACTION_UP)) {
for (device in listOf(keyboard, composite, hybrid, gamepad, joystick)) {
assertFalse(PadBridge.isControllerKeyEvent(key(device, KeyEvent.KEYCODE_A, action)))
assertFalse(PadBridge.isControllerKeyEvent(key(device, KeyEvent.KEYCODE_A, action, InputDevice.SOURCE_GAMEPAD)))
assertFalse(PadBridge.isControllerKeyEvent(key(device, KeyEvent.KEYCODE_VOLUME_UP, action)))
}
for (code in listOf(KeyEvent.KEYCODE_DPAD_UP, KeyEvent.KEYCODE_DPAD_RIGHT,
KeyEvent.KEYCODE_DPAD_DOWN, KeyEvent.KEYCODE_DPAD_LEFT, KeyEvent.KEYCODE_BACK,
KeyEvent.KEYCODE_MENU, KeyEvent.KEYCODE_HOME)) {
for (device in listOf(keyboard, composite, hybrid)) {
assertFalse(PadBridge.isControllerKeyEvent(key(device, code, action)))
assertFalse(PadBridge.isControllerKeyEvent(key(device, code, action, InputDevice.SOURCE_DPAD)))
}
assertTrue(PadBridge.isControllerKeyEvent(key(gamepad, code, action)))
assertTrue(PadBridge.isControllerKeyEvent(key(joystick, code, action)))
assertTrue(PadBridge.isControllerKeyEvent(key(hybrid, code, action, InputDevice.SOURCE_JOYSTICK)))
}
for (code in listOf(KeyEvent.KEYCODE_BUTTON_A, KeyEvent.KEYCODE_BUTTON_B,
KeyEvent.KEYCODE_BUTTON_L2, KeyEvent.KEYCODE_BUTTON_R2, KeyEvent.KEYCODE_BUTTON_MODE)) {
assertFalse(PadBridge.isControllerKeyEvent(key(keyboard, code, action)))
assertFalse(PadBridge.isControllerKeyEvent(key(virtual, code, action)))
for (device in listOf(composite, hybrid, gamepad, joystick)) {
assertTrue(PadBridge.isControllerKeyEvent(key(device, code, action)))
}
}
}
assertNull(KeyEvent(0L, 0L, KeyEvent.ACTION_DOWN, KeyEvent.KEYCODE_BUTTON_A, 0, 0, 999, 0).device)
assertFalse(PadBridge.isControllerKeyEvent(KeyEvent(0L, 0L, KeyEvent.ACTION_DOWN, KeyEvent.KEYCODE_BUTTON_A, 0, 0, 999, 0)))
for (device in listOf(keyboard, composite, hybrid, gamepad, joystick, virtual)) {
val motion = MotionEvent.obtain(0L, 0L, MotionEvent.ACTION_MOVE, 1,
arrayOf(MotionEvent.PointerProperties().apply { id = 0 }),
arrayOf(MotionEvent.PointerCoords().apply { setAxisValue(MotionEvent.AXIS_X, 0.75f) }),
0, 0, 1f, 1f, device.id, 0, InputDevice.SOURCE_JOYSTICK, 0)
try {
assertSame(device, motion.device)
assertEquals(device in listOf(composite, hybrid, gamepad, joystick), PadBridge.isControllerMotionEvent(motion))
motion.source = InputDevice.SOURCE_MOUSE
assertFalse(PadBridge.isControllerMotionEvent(motion))
} finally { motion.recycle() }
}
}
private fun key(device: InputDevice, code: Int, action: Int, source: Int = InputDevice.SOURCE_KEYBOARD): KeyEvent {
val event = KeyEvent(0L, 0L, action, code, 0, 0, device.id, 0, 0, source)
assertSame(device, event.device)
return event
}
private var nextId = 10
private fun device(sources: Int, type: Int, keys: IntArray = intArrayOf(KeyEvent.KEYCODE_A, KeyEvent.KEYCODE_DPAD_UP),
rangeSource: Int? = null, axis: Int = MotionEvent.AXIS_X, id: Int = nextId++): InputDevice {
val builder = InputDeviceBuilder.newBuilder().setId(id).setName("routing fixture $id")
.setDescriptor("routing-$id").setExternal(true).setSources(sources).setKeyboardType(type)
.setKeyCharacterMap(KeyCharacterMap.load(KeyCharacterMap.VIRTUAL_KEYBOARD))
if (rangeSource != null) builder.addMotionRange(axis, rangeSource, -1f, 1f, 0f, 0f, 0f)
val device = builder.build()
val manager = RuntimeEnvironment.getApplication().getSystemService(InputManager::class.java)
shadowOf(manager).addInputDevice(device)
shadowOf(manager).addInputDeviceKeys(id, keys)
assertSame(device, InputDevice.getDevice(id))
assertEquals(sources, device.sources)
assertEquals(type, device.keyboardType)
return device
}
private companion object { const val COMPOSITE = 0x1002313 }
// Robolectric's default InputEvent shadow has its own nullable device field. Use Android's
// original lookup so key and motion events resolve the registered device by id.
@Implements(KeyEvent::class)
class RegisteredKeyEvent : ShadowKeyEvent() {
@RealObject private lateinit var event: KeyEvent
@Implementation override fun getDevice(): InputDevice? = Shadow.directlyOn(event as InputEvent, InputEvent::class.java, "getDevice")
}
@Implements(MotionEvent::class)
class RegisteredMotionEvent : ShadowMotionEvent() {
@RealObject private lateinit var event: MotionEvent
@Implementation override fun getDevice(): InputDevice? = Shadow.directlyOn(event as InputEvent, InputEvent::class.java, "getDevice")
}
}
@@ -17,10 +17,14 @@ class GameStorageTest {
private val app get() = RuntimeEnvironment.getApplication()
@get:Rule val tmp = TemporaryFolder()
@Test fun gamesFoldersBecomeLibrariesOnAutomatic() {
@Test fun onlyAGamesFolderSteamInstalledIntoStaysALibrary() {
val pc = tmp.newFolder("PC")
File(pc, "steamapps").mkdirs()
File(pc, "steamapps/appmanifest_220.acf").writeText("\"AppState\" { \"appid\" \"220\" }")
val plain = tmp.newFolder("Games")
File(plain, "steamapps/common").mkdirs()
val missing = File(tmp.root, "Unplugged")
SessionPrefs.setAddedGamesDirs(app, listOf(pc.path, missing.path))
SessionPrefs.setAddedGamesDirs(app, listOf(pc.path, plain.path, missing.path))
val libraries = GameStorage.gamesFolderLibraries(app)
assertEquals(listOf(pc), libraries.map { it.host })
assertEquals(listOf(AddedGames.GUEST_DIR + "/PC"), libraries.map { it.guest })
@@ -28,7 +32,10 @@ class GameStorageTest {
}
@Test fun aChosenLibraryOrInternalKeepsGamesFoldersPlain() {
SessionPrefs.setAddedGamesDirs(app, listOf(tmp.newFolder("PC").path))
val pc = tmp.newFolder("PC")
File(pc, "steamapps").mkdirs()
File(pc, "steamapps/appmanifest_220.acf").writeText("x")
SessionPrefs.setAddedGamesDirs(app, listOf(pc.path))
SessionPrefs.setGameStorage(app, SessionPrefs.GAME_STORAGE_OFF, "")
assertTrue(GameStorage.gamesFolderLibraries(app).isEmpty())
SessionPrefs.setGameStorage(app, tmp.newFolder("chosen").path, "Chosen")
@@ -36,7 +43,9 @@ class GameStorageTest {
}
@Test fun namesTheClientCannotStoreAreLeftOut() {
SessionPrefs.setAddedGamesDirs(app, listOf(tmp.newFolder("Quote\"d").path, tmp.newFolder("Back\\slash").path))
val odd = listOf(tmp.newFolder("Quote\"d"), tmp.newFolder("Back\\slash"))
odd.forEach { File(it, "steamapps").mkdirs(); File(it, "steamapps/appmanifest_220.acf").writeText("x") }
SessionPrefs.setAddedGamesDirs(app, odd.map { it.path })
assertTrue(GameStorage.gamesFolderLibraries(app).isEmpty())
}
}
@@ -0,0 +1,44 @@
package com.droiddeck.launcher.session
import android.content.Context
import android.content.pm.ActivityInfo
import org.junit.Assert.*
import org.junit.Before
import org.junit.Test
import org.junit.runner.RunWith
import org.robolectric.RobolectricTestRunner
import org.robolectric.RuntimeEnvironment
import org.robolectric.annotation.Config
@RunWith(RobolectricTestRunner::class)
@Config(sdk = [28], manifest = Config.NONE)
class OrientationTest {
private val context get() = RuntimeEnvironment.getApplication()
@Before fun clearOrientation() {
context.getSharedPreferences("session", Context.MODE_PRIVATE).edit().clear().commit()
}
@Test fun landscapeChoicesPersistGloballyAndCanRestoreAutomaticRotation() {
assertEquals(ActivityInfo.SCREEN_ORIENTATION_SENSOR_LANDSCAPE, SessionPrefs.orientation(context))
for (value in listOf(
ActivityInfo.SCREEN_ORIENTATION_LANDSCAPE,
ActivityInfo.SCREEN_ORIENTATION_REVERSE_LANDSCAPE,
ActivityInfo.SCREEN_ORIENTATION_SENSOR_LANDSCAPE,
)) {
SessionPrefs.setOrientation(context, value)
assertEquals(value, SessionPrefs.orientation(context.createConfigurationContext(context.resources.configuration)))
}
}
@Test fun invalidStoredOrientationFallsBackAndInvalidChoicePreservesTheSavedValue() {
val prefs = context.getSharedPreferences("session", Context.MODE_PRIVATE)
prefs.edit().putInt("orientation", ActivityInfo.SCREEN_ORIENTATION_PORTRAIT).commit()
assertEquals(ActivityInfo.SCREEN_ORIENTATION_SENSOR_LANDSCAPE, SessionPrefs.orientation(context))
SessionPrefs.setOrientation(context, ActivityInfo.SCREEN_ORIENTATION_REVERSE_LANDSCAPE)
assertThrows(IllegalArgumentException::class.java) {
SessionPrefs.setOrientation(context, ActivityInfo.SCREEN_ORIENTATION_PORTRAIT)
}
assertEquals(ActivityInfo.SCREEN_ORIENTATION_REVERSE_LANDSCAPE, SessionPrefs.orientation(context))
}
}
@@ -78,6 +78,24 @@ class RumbleComponentTest {
assertFalse("the phone buzzed as well", shadowOf(vibrator).isVibrating)
}
@Test fun repeatedAndRapidEffectsDoNotRestartTheMotors() {
val pad = FakeMotors()
rumble.controllerMotors = { pad }
activeController(PAD_ID)
repeat(5) { effect(strong = 40000, weak = 1000) }
effect(strong = 20000, weak = 1000)
assertEquals("a stream of packets restarted the motor each time", 1, pad.played.size)
shadowOf(Looper.getMainLooper()).idleFor(java.time.Duration.ofMillis(RumbleComponent.MIN_GAP_MS))
effect(strong = 40000, weak = 1000)
assertEquals("the same strength, still playing, was started again", 1, pad.played.size)
effect(strong = 20000, weak = 1000)
assertEquals(Triple(20000, 1000, 5000L), pad.played.last())
effect(strong = 0, weak = 0)
assertFalse(pad.vibrating)
effect(strong = 20000, weak = 1000)
assertTrue("an effect right after a stop was dropped", pad.vibrating)
}
@Test fun aControllerWithoutMotorsFallsBackToThePhone() {
rumble.controllerMotors = { null }
activeController(PAD_ID)
@@ -38,6 +38,15 @@ class SessionDisplayTest {
assertEquals(2560 to 1440, SessionDisplay.resolveChoice(2560 to 1440, SessionDisplay.MATCH_SCREEN))
}
@Test fun unfoldedControlsKeepTheGameSixteenByNineAcrossTheWidth() {
// Fold inner screen held as a tablet, and upright: 16:9 either way, the controls the rest.
assertEquals(1376, SessionDisplay.unfoldedGameHeight(2448, 1848))
assertEquals(1038, SessionDisplay.unfoldedGameHeight(1848, 2448))
// A window near 16:9 itself still leaves the controls a fifth of it.
assertEquals(864, SessionDisplay.unfoldedGameHeight(1920, 1080))
}
@Test fun followScreenKeepsTheWindowShapeAndSkipsTinyWindows() {
assertEquals(2520 to 1080, SessionDisplay.resolveChoice(1080 to 2520, SessionDisplay.FOLLOW_SCREEN))
assertEquals(1968 to 2184, SessionDisplay.followSize(1968, 2184))
@@ -1,42 +0,0 @@
package com.droiddeck.launcher.session
import android.content.Context
import org.junit.Assert.assertEquals
import org.junit.Before
import org.junit.Test
import org.junit.runner.RunWith
import org.robolectric.RobolectricTestRunner
import org.robolectric.RuntimeEnvironment
import org.robolectric.annotation.Config
@RunWith(RobolectricTestRunner::class)
@Config(sdk = [28], manifest = Config.NONE)
class SteamChannelTest {
private lateinit var context: Context
@Before fun setUp() {
context = RuntimeEnvironment.getApplication()
context.getSharedPreferences("session", Context.MODE_PRIVATE).edit().clear().commit()
}
@Test fun freshSettingsDefaultToDeckBetaWithOrWithoutDeckMode() {
assertEquals("steamdeck_publicbeta", SessionPrefs.steamChannel(context))
SessionPrefs.setSteamDeckMode(context, false)
assertEquals("steamdeck_publicbeta", SessionPrefs.steamChannel(context))
}
@Test fun explicitChannelIsPreservedWhenDeckModeIsOff() {
SessionPrefs.setSteamDeckMode(context, false)
SessionPrefs.setSteamChannel(context, "publicbeta")
assertEquals("publicbeta", SessionPrefs.steamChannel(context))
SessionPrefs.setSteamChannel(context, "steamdeck_publicbeta")
assertEquals("steamdeck_publicbeta", SessionPrefs.steamChannel(context))
}
@Test fun deckModeUsesDeckBetaAndRestoresSavedSelectionWhenDisabled() {
SessionPrefs.setSteamChannel(context, "publicbeta")
assertEquals("steamdeck_publicbeta", SessionPrefs.steamChannel(context))
SessionPrefs.setSteamDeckMode(context, false)
assertEquals("publicbeta", SessionPrefs.steamChannel(context))
}
}
@@ -11,7 +11,7 @@ import org.robolectric.annotation.Config
@RunWith(RobolectricTestRunner::class)
@Config(sdk = [33])
class WinComponentsSupportTest {
private val base = "https://github.com/The412Banner/winlator-contents/releases/download/system-libraries-v1"
private val base = "https://github.com/Droid-Deck/DroidDeck-Components/releases/download/system-libraries-v1"
private fun step(action: String, vararg pairs: Pair<String, String>) =
WinComponents.Step(action, JSONObject().apply { pairs.forEach { (k, v) -> put(k, v) } })
@@ -66,7 +66,7 @@ class WinComponentsSupportTest {
}
@Test fun recordedInstallersInstallHere() {
val snapshot = "https://github.com/The412Banner/winlator-contents/releases/download/component-snapshots-v1/K-Lite.snapshot.json"
val snapshot = "https://github.com/Droid-Deck/DroidDeck-Components/releases/download/component-snapshots-v1/K-Lite.snapshot.json"
// A setup program with a recording of its result installs from the recording.
val klite = WinComponents.Component("K-Lite", "", "", "ready", emptyList(),
listOf(step("install_exe", "url" to "$base/K-Lite.exe", "file_name" to "K-Lite_1960.exe")), snapshot)
@@ -96,5 +96,7 @@ class WinComponentsSupportTest {
assertEquals(Support.UNSUPPORTED, support(component("powershell", step("copy_file", "file_name" to "profile.ps1", "dest" to "win64"))))
assertEquals(Support.UNSUPPORTED, support(component("VulkanRT", step("install_exe", "url" to "$base/VulkanRT.exe"))))
assertEquals(Support.UNSUPPORTED, support(component("ie8_kb2936068", step("install_exe", "url" to "$base/ie8_kb2936068.exe"))))
assertEquals(Support.UNSUPPORTED, support(component("oalinst", step("install_exe", "url" to "$base/oalinst.exe"))))
assertEquals(Support.UNSUPPORTED, support(component("webview2", step("install_exe", "url" to "$base/webview2.exe"))))
}
}
@@ -0,0 +1,88 @@
package com.droiddeck.launcher.ui
import com.droiddeck.launcher.session.SessionDisplay
import com.droiddeck.launcher.ui.GamepadGeometry.Slot
import org.junit.Assert.assertEquals
import org.junit.Assert.assertFalse
import org.junit.Assert.assertTrue
import org.junit.Test
class GamepadGeometryTest {
/** A foldable's unfolded panel (px) and density, as the gamepad gets it under a 16:9 game. */
private data class Panel(val name: String, val width: Int, val height: Int, val density: Float) {
val controlsHeight get() = height - SessionDisplay.unfoldedGameHeight(width, height)
}
private val panels = listOf(
Panel("Fold 8 upright", 1848, 2448, 2.625f),
Panel("Fold 8 as a tablet", 2448, 1848, 2.625f),
Panel("Pixel Fold upright", 1840, 2208, 2.625f),
Panel("Pixel Fold as a tablet", 2208, 1840, 2.625f),
Panel("OnePlus Open upright", 2268, 2440, 2.75f),
Panel("OnePlus Open as a tablet", 2440, 2268, 2.75f),
Panel("Fold 8 upright, large display size", 1848, 2448, 3.5f),
Panel("Fold 8 as a tablet, small display size", 2448, 1848, 2.0f),
)
private fun boxes(panel: Panel) =
GamepadGeometry.layout(panel.width.toFloat(), panel.controlsHeight.toFloat(), panel.density)
@Test fun everyControlIsPlacedAndPanelsOnlyWhenUpright() {
for (panel in panels) {
val upright = panel.height > panel.width
val expected = Slot.values().toSet() - if (upright) emptySet() else setOf(Slot.PANELS)
assertEquals(panel.name, expected, boxes(panel).keys)
}
}
@Test fun everyControlStaysOnThePanel() {
for (panel in panels) for ((slot, box) in boxes(panel)) {
val where = "${panel.name}: $slot $box"
assertTrue(where, box.left >= 0f && box.top >= 0f)
assertTrue(where, box.right <= panel.width && box.bottom <= panel.controlsHeight)
}
}
@Test fun noControlsOverlap() {
for (panel in panels) {
val placed = boxes(panel).entries.toList()
for (i in placed.indices) for (j in i + 1 until placed.size) {
assertFalse("${panel.name}: ${placed[i].key} overlaps ${placed[j].key}",
placed[i].value.overlaps(placed[j].value))
}
}
}
@Test fun everyControlIsBigEnoughForAThumb() {
for (panel in panels) for ((slot, box) in boxes(panel)) {
val minPx = 40 * panel.density
assertTrue("${panel.name}: $slot is ${box.width}x${box.height}, under 40dp",
box.width >= minPx - 1 && box.height >= minPx - 1)
}
}
@Test fun mainControlsSitUnderTheThumbsAndMirror() {
for (panel in panels) {
val b = boxes(panel)
val left = b.getValue(Slot.LEFT_MAIN)
val right = b.getValue(Slot.RIGHT_MAIN)
assertEquals(panel.name, left.cy, right.cy, 0.5f)
assertEquals(panel.name, left.cx, panel.width - right.cx, 0.5f)
// In the bottom half of their side, nearer the corner than the second controls.
assertTrue(panel.name, left.cx < b.getValue(Slot.LEFT_SECOND).cx)
assertTrue(panel.name, left.cy > panel.controlsHeight / 2f)
}
}
@Test fun middleButtonsKeepTheDeckOrder() {
for (panel in panels) {
val b = boxes(panel)
// View before Menu and Steam before QAM: left of it in a row or two by two, above it
// in a single column.
fun before(a: Slot, c: Slot) = b.getValue(a).let { x -> b.getValue(c).let { y ->
x.cx < y.cx - 0.5f || (kotlin.math.abs(x.cx - y.cx) <= 0.5f && x.cy < y.cy) } }
assertTrue(panel.name, before(Slot.VIEW, Slot.MENU))
assertTrue(panel.name, before(Slot.STEAM, Slot.QAM))
}
}
}
@@ -0,0 +1,882 @@
"appinfo"
{
"appid" "2054970"
"common"
{
"name" "Dragon's Dogma 2"
"type" "Game"
"releasestate" "released"
"oslist" "windows"
"osarch" "64"
"osextended" ""
"icon" "f3d469e0d7a0e25ddd13db8ebf08f1c24d6478b0"
"clienticon" "a8eaa474f143bfdab634907e034ea6fa47086ccb"
"clienttga" "0c02d7c5b9790c444597578019b02937234b90d2"
"languages"
{
"english" "1"
"german" "1"
"french" "1"
"italian" "1"
"koreana" "1"
"spanish" "1"
"schinese" "1"
"tchinese" "1"
"russian" "1"
"japanese" "1"
"portuguese" "1"
"polish" "1"
"arabic" "1"
"brazilian" "1"
"latam" "1"
}
"eulas"
{
"0"
{
"id" "2054970_eula_0"
"name" "Dragon's Dogma 2 EULA"
"url" "https://store.steampowered.com//eula/2054970_eula_0"
"version" "3"
}
}
"content_descriptors"
{
"0" "1"
"1" "5"
}
"steam_deck_compatibility"
{
"category" "1"
"steamos_compatibility" "2"
"steam_machine_compatibility" "3"
"test_timestamp" "1784764800"
"tested_build_id" "2054970"
"tests"
{
"0"
{
"display" "2"
"token" "#SteamDeckVerified_TestResult_UnsupportedGraphicsPerformance"
}
}
"steam_machine_tests"
{
"0"
{
"display" "4"
"token" "#SteamMachine_TestResult_DefaultControllerConfigFullyFunctional"
}
"1"
{
"display" "4"
"token" "#SteamMachine_TestResult_ControllerGlyphsMatchDevice"
}
"2"
{
"display" "4"
"token" "#SteamMachine_TestResult_DefaultConfigurationIsPerformant"
}
}
"steamos_tests"
{
"0"
{
"display" "3"
"token" "#SteamOS_TestResult_GameStartupFunctional"
}
"1"
{
"display" "1"
"token" "#SteamOS_TestResult_ExternalControllersNotSupportedPrimaryPlayer"
}
}
"configuration"
{
"supported_input" "other"
"requires_manual_keyboard_invoke" "0"
"requires_non_controller_launcher_nav" "0"
"primary_player_is_controller_slot_0" "1"
"non_deck_display_glyphs" "0"
"small_text" "1"
"requires_internet_for_setup" "0"
"requires_internet_for_singleplayer" "0"
"recommended_runtime" "proton-11.0-beta"
"requires_h264" "0"
"requires_voice_files" "0"
"gamescope_frame_limiter_not_supported" "0"
"hdr_support" "0"
"requires_proton_soundfonts" "0"
}
}
"metacritic_name" "Dragon's Dogma 2"
"controllertagwizard" "1"
"controller_support" "partial"
"small_capsule"
{
"english" "capsule_231x87.jpg"
}
"header_image"
{
"english" "header.jpg"
}
"library_assets"
{
"library_capsule" "en"
"library_hero" "en"
"library_logo" "en"
"library_header" "en"
"logo_position"
{
"pinned_position" "BottomLeft"
"width_pct" "23.186781828785556"
"height_pct" "41.66700658287976"
}
}
"library_assets_full"
{
"library_capsule"
{
"image"
{
"english" "library_600x900.jpg"
}
"image2x"
{
"english" "library_600x900_2x.jpg"
}
}
"library_hero"
{
"image"
{
"english" "library_hero.jpg"
}
"image2x"
{
"english" "library_hero_2x.jpg"
}
}
"library_logo"
{
"logo_position"
{
"pinned_position" "BottomLeft"
"width_pct" "23.186781828785556"
"height_pct" "41.66700658287976"
}
"image"
{
"english" "logo.png"
}
"image2x"
{
"english" "logo_2x.png"
}
}
"library_header"
{
"image"
{
"english" "library_header.jpg"
}
}
}
"store_asset_mtime" "1753923539"
"associations"
{
"0"
{
"type" "developer"
"name" "CAPCOM Co., Ltd."
}
"1"
{
"type" "publisher"
"name" "CAPCOM Co., Ltd."
}
"2"
{
"type" "franchise"
"name" "Dragon's Dogma"
}
}
"primary_genre" "3"
"genres"
{
"0" "1"
"1" "3"
}
"category"
{
"category_2" "1"
"category_18" "1"
"category_55" "1"
"category_57" "1"
"category_60" "1"
"category_22" "1"
"category_29" "1"
"category_23" "1"
"category_61" "1"
"category_45" "1"
"category_46" "1"
"category_62" "1"
}
"supported_languages"
{
"english"
{
"supported" "true"
"full_audio" "true"
"subtitles" "true"
}
"french"
{
"supported" "true"
"subtitles" "true"
}
"italian"
{
"supported" "true"
"subtitles" "true"
}
"german"
{
"supported" "true"
"subtitles" "true"
}
"spanish"
{
"supported" "true"
"subtitles" "true"
}
"arabic"
{
"supported" "true"
"subtitles" "true"
}
"latam"
{
"supported" "true"
"subtitles" "true"
}
"brazilian"
{
"supported" "true"
"subtitles" "true"
}
"russian"
{
"supported" "true"
"subtitles" "true"
}
"schinese"
{
"supported" "true"
"subtitles" "true"
}
"tchinese"
{
"supported" "true"
"subtitles" "true"
}
"japanese"
{
"supported" "true"
"full_audio" "true"
"subtitles" "true"
}
"koreana"
{
"supported" "true"
"subtitles" "true"
}
"polish"
{
"supported" "true"
"subtitles" "true"
}
}
"steam_release_date" "1711065600"
"metacritic_score" "88"
"metacritic_fullurl" "https://www.metacritic.com/game/pc/dragons-dogma-2?ftag=MCD-06-10aaa1f"
"community_visible_stats" "1"
"community_hub_visible" "1"
"gameid" "2054970"
"content_descriptors_including_dlc"
{
"0" "1"
"1" "5"
}
"store_tags"
{
"0" "1695"
"1" "122"
"2" "4231"
"3" "1684"
"4" "4182"
"5" "19"
"6" "21"
"7" "4747"
"8" "4046"
"9" "4057"
"10" "4106"
"11" "4172"
"12" "4608"
"13" "1697"
"14" "3968"
"15" "3854"
"16" "3859"
"17" "7926"
"18" "4191"
"19" "6730"
}
"review_score" "5"
"review_percentage" "63"
}
"extended"
{
"additional_dependencies"
{
"0"
{
"src_os" "windows"
"dest_os" "linux"
"h264" "1"
}
}
"developer" "CAPCOM Co., Ltd."
"publisher" "CAPCOM Co., Ltd."
"homepage" "https://www.dragonsdogma.com/2da/"
"aliases" "ドグマ,ドグマ2,DD2,龙信2,ドラゴンズドグマ,龙之信条"
"listofdlc" "2581000,2593170,2593180,2593190,2593200,2593210,2593220,2593230,2593240,2593250,2593260,2593270,2593280,2593290,2694820,2694830,2694840,2694850,2757060,2757080,2757090,2757120,2757130,2757140,2757170"
"dlcavailableonstore" "1"
}
"config"
{
"installdir" "Dragons Dogma 2"
"launch"
{
"0"
{
"executable" "DD2.exe"
}
}
"steamcontrollertemplateindex" "2"
"steamdecktouchscreen" "1"
"steamconfigurator3rdpartynative" "65534"
}
"depots"
{
"baselanguages" "english,german,french,italian,koreana,spanish,schinese,tchinese,russian,japanese,polish,arabic,brazilian,latam"
"228988"
{
"config"
{
"oslist" "windows"
}
"depotfromapp" "228980"
"sharedinstall" "1"
}
"2054971"
{
"manifests"
{
"public"
{
"gid" "3438547366935427234"
"size" "154689650"
"download" "49702016"
}
}
}
"2054972"
{
"manifests"
{
"public"
{
"gid" "2497482018950166086"
"size" "154689650"
"download" "49702016"
}
}
}
"2054973"
{
"manifests"
{
"public"
{
"gid" "5096395230065303989"
"size" "69392135623"
"download" "68155907184"
}
}
}
"2054974"
{
"manifests"
{
"public"
{
"gid" "4433477873043462508"
"size" "69392135912"
"download" "68163333808"
}
}
}
"2581000"
{
"dlcappid" "2581000"
"manifests"
{
"public"
{
"gid" "6150746279967323915"
"size" "732"
"download" "784"
}
}
}
"2593170"
{
"dlcappid" "2593170"
"manifests"
{
"public"
{
"gid" "810106481153607467"
"size" "731"
"download" "784"
}
}
}
"2593180"
{
"dlcappid" "2593180"
"manifests"
{
"public"
{
"gid" "2908546599585986601"
"size" "14463"
"download" "14528"
}
}
}
"2593190"
{
"dlcappid" "2593190"
"manifests"
{
"public"
{
"gid" "1427647465435055606"
"size" "197908084"
"download" "180975120"
}
}
}
"2593200"
{
"dlcappid" "2593200"
"manifests"
{
"public"
{
"gid" "6641524981969165665"
"size" "5583"
"download" "5472"
}
}
}
"2593210"
{
"dlcappid" "2593210"
"manifests"
{
"public"
{
"gid" "971487897150453015"
"size" "8506"
"download" "8416"
}
}
}
"2593220"
{
"dlcappid" "2593220"
"manifests"
{
"public"
{
"gid" "7751533332300224932"
"size" "6072"
"download" "5984"
}
}
}
"2593230"
{
"dlcappid" "2593230"
"manifests"
{
"public"
{
"gid" "3205009877271857381"
"size" "4433"
"download" "4320"
}
}
}
"2593240"
{
"dlcappid" "2593240"
"manifests"
{
"public"
{
"gid" "2644417301132228564"
"size" "5471"
"download" "5360"
}
}
}
"2593250"
{
"dlcappid" "2593250"
"manifests"
{
"public"
{
"gid" "4059800356804376977"
"size" "727"
"download" "784"
}
}
}
"2593260"
{
"dlcappid" "2593260"
"manifests"
{
"public"
{
"gid" "1542297718911676562"
"size" "5004"
"download" "4944"
}
}
}
"2593270"
{
"dlcappid" "2593270"
"manifests"
{
"public"
{
"gid" "2999068646104164918"
"size" "5098"
"download" "5024"
}
}
}
"2593280"
{
"dlcappid" "2593280"
"manifests"
{
"public"
{
"gid" "3305444330840079072"
"size" "693"
"download" "752"
}
}
}
"2593290"
{
"dlcappid" "2593290"
"manifests"
{
"public"
{
"gid" "3353726914325255074"
"size" "26906257761"
"download" "26506078656"
}
}
}
"2694820"
{
"dlcappid" "2694820"
"manifests"
{
"public"
{
"gid" "8323545382908063536"
"size" "4994"
"download" "4928"
}
}
}
"2694830"
{
"dlcappid" "2694830"
"manifests"
{
"public"
{
"gid" "8243393096252728659"
"size" "4980"
"download" "4912"
}
}
}
"2694840"
{
"dlcappid" "2694840"
"manifests"
{
"public"
{
"gid" "7407203756785514772"
"size" "4980"
"download" "4912"
}
}
}
"2694850"
{
"dlcappid" "2694850"
"manifests"
{
"public"
{
"gid" "8989014742962253789"
"size" "4992"
"download" "4928"
}
}
}
"2757060"
{
"dlcappid" "2757060"
"manifests"
{
"public"
{
"gid" "4478133242487678390"
"size" "727"
"download" "784"
}
}
}
"2757080"
{
"dlcappid" "2757080"
"manifests"
{
"public"
{
"gid" "4246061750770449019"
"size" "729"
"download" "784"
}
}
}
"2757090"
{
"dlcappid" "2757090"
"manifests"
{
"public"
{
"gid" "6279084752133001593"
"size" "726"
"download" "784"
}
}
}
"2757100"
{
"dlcappid" "2757100"
"manifests"
{
"public"
{
"gid" "2163371650537928331"
"size" "728"
"download" "784"
}
}
}
"2757110"
{
"dlcappid" "2757110"
"manifests"
{
"public"
{
"gid" "6048538284791812257"
"size" "728"
"download" "784"
}
}
}
"2757120"
{
"dlcappid" "2757120"
"manifests"
{
"public"
{
"gid" "4411956497046953058"
"size" "730"
"download" "784"
}
}
}
"2757130"
{
"dlcappid" "2757130"
"manifests"
{
"public"
{
"gid" "1901727257188878695"
"size" "726"
"download" "784"
}
}
}
"2757140"
{
"dlcappid" "2757140"
"manifests"
{
"public"
{
"gid" "3192862444394827561"
"size" "730"
"download" "784"
}
}
}
"2757150"
{
"dlcappid" "2757150"
"manifests"
{
"public"
{
"gid" "6674463851485663659"
"size" "730"
"download" "784"
}
}
}
"2757160"
{
"dlcappid" "2757160"
"manifests"
{
"public"
{
"gid" "9188163296361277604"
"size" "729"
"download" "784"
}
}
}
"2757170"
{
"dlcappid" "2757170"
"manifests"
{
"public"
{
"gid" "3408012598466658697"
"size" "727"
"download" "784"
}
}
}
"2757180"
{
"dlcappid" "2757180"
"manifests"
{
"public"
{
"gid" "7470516453980211807"
"size" "731"
"download" "784"
}
}
}
"2757190"
{
"dlcappid" "2757190"
"manifests"
{
"public"
{
"gid" "488116235793758299"
"size" "726"
"download" "784"
}
}
}
"2757200"
{
"dlcappid" "2757200"
"manifests"
{
"public"
{
"gid" "3018062872582304551"
"size" "729"
"download" "784"
}
}
}
"2757210"
{
"dlcappid" "2757210"
"manifests"
{
"public"
{
"gid" "6248888390153964942"
"size" "726"
"download" "784"
}
}
}
"5184170"
{
"dlcappid" "5184170"
"manifests"
{
}
}
"branches"
{
"public"
{
"buildid" "25290080"
"timeupdated" "1791503987"
"timebuildupdated" "1789345188"
}
}
"privatebranches" "1"
}
"ufs"
{
"quota" "2000000000"
"maxnumfiles" "1000"
}
}
+9 -2
View File
@@ -23,8 +23,15 @@ Steam client that runs under `droiddeck-proton-arm64`. They show with the Custom
already listed are counted, not added. A summary follows: games added, how many were
there already, one row per game (cover, name, exe, "?" when unsure); a row opens that
game's editor, straight on Target when unsure, with back to the summary.
- Games folders added before this (Steam settings had a Games section) keep listing their
games; there is no place to add a new one.
- Games folders added before this (Steam settings had a Games section) are migrated once
(`GamesFolderMigration`, at app start and before any scan): each game in one becomes an entry
as "Add all games in this folder" would make it, keeping everything keyed by its folder (the
chosen exe, the shortcut appid, name, Start in, launch options, art, a removal). The folders
stay bound into the session at the guest paths they had (`/root/Games/<name>`,
`SessionPrefs.gameFolderBinds`), so no exe path or shortcut changes. They are no longer Steam
libraries - the session's library list leaves them out and `droiddeck-steam-library` forgets
them - unless Steam installed a game into one (a manifest in its `steamapps/`), which then
stays a library. Log: `games: migrated <root> → N games`.
## Exe and name
Binary file not shown.

After

Width:  |  Height:  |  Size: 17 KiB

@@ -0,0 +1,116 @@
# Windows components by source
A game's Windows components (VC++ runtimes, DirectX's d3dx9, XNA, PhysX...) are picked for it by
itself, from the list its source keeps. The source badge (Steam, GOG, Epic Games, Amazon Games,
Custom) decides whose list is trusted (`WinCompSources`):
| Source | List | On by itself |
| --- | --- | --- |
| Steam | The Steamworks Shared redistributables Steam installs with the game: the appmanifest's `SharedDepots`, else the client's app info (`SteamAppInfo`) | yes |
| GOG | The build manifest's `dependencies`, kept in the sidecar at install (`extra.dependencies`) | yes |
| Epic Games | The manifest's prerequisite (`PrereqName`, `PrereqPath`), kept in the sidecar at install | yes, for the mapped parts |
| Amazon Games | fuel.json's `PostInstall` installers | yes, for the mapped parts |
| Custom, appid in its files | Steam's list for that app (`SteamMatch` certainty `FILES`: steam_appid.txt, also under steam_settings/ or a parent; an emulator ini; an appmanifest) | yes |
| Custom, matched by name | Steam's list for that app (certainty `NAME`, a store search with the same name) | no, Recommended |
| Custom, no match | the folder scan (`DependencyDetector`) | no, Recommended |
The folder scan is offered as Recommended for every game.
## Steam's data
Never scraped from a website. `SteamAppInfo` reads the Steam client's own app-info cache in the
runtime (`Steam/appcache/appinfo.vdf`, versions 27-29): the app's depots that point at app 228980
(Steamworks Shared) and its name. Each appid's answer is kept in `files/steam-appinfo.json`, so it
works offline; an app the client has not seen is looked for again after a week. A Steam game with
its appmanifest beside it uses the manifest first. `droiddeck-seed-redists` still marks Steam's
install scripts for those redistributables as already run, so the client never runs the real
installers.
## One Steam match per game
`SteamMatch` keeps, per added game (by its folder), `{appId, certainty FILES|NAME|NONE, source}`
in `files/steam-matches.json`. The art fetch (`AddedGameArt`) and the components read and write
the same record; an earlier build's art lookup (`added-art/<appid>/steam-appid`) is taken over as
a name match. A miss is tried again after a week.
## Mapping
All in `WinCompSources`:
- GOG ids: `MSVC<year>[_x64]` → `vcredist<year>` (2017 → the 2015 family, as the folder scan),
`DirectX*`/`DX9`/`D3DX*` → `d3dx9`, `DOTNET<n>` → `dotnet35`...`dotnet48`, `PhysX` → `physx`,
`OpenAL` → `oalinst`, `XNA3`/`XNA31` → `xna31`, other `XNA*` → `xna40`, `XLive*`/`GFWL*` →
`XLiveRedist`, `XACT*` → `xact`.
- Epic: Unreal's `UE4PrereqSetup`/`UEPrereqSetup` (or a "UE4/UE5/Unreal Engine Prerequisites" name)
→ `vcredist2022` + `d3dx9`; any other installer by its path, as the folder scan names it.
- Amazon: each `PostInstall` command by its path, as the folder scan names it (Unreal's
prerequisite as above).
- Steam: Steamworks Shared depots 228981-228990 (`SteamRedists.DEPOTS`).
Anything that maps to nothing is listed under "Also listed" with its own name. A mapped component
that needs a Windows installer here, or is not in the catalog, stays Recommended with its status.
## On by itself, and the user's switches
All of it runs on one background thread (`AutoComponents`), low priority (the runtime helpers an
install starts run under `nice -n 19` and `ionice -c3`), one job at a time from one queue: a game's
list (worked out again only when the files it is read from change, by modification time, and kept
in `files/wincomp-findings.json`, so a page opens on it at once) and a component's download (each
component once, for every game that wants it). What the user asked for goes first: the page of a
game (on a metered network too), a store install or an add. A game's list is queued when it is
installed, added or edited, when its page opens, when it is launched from DroidDeck (the launch
itself only takes what is on already), and for every Steam game once per app start, ten seconds
after the launcher is up (Steam installs them without a word to DroidDeck). The queue waits while
a session runs, and a download also waits for a validated network (the page then greys what is not
downloaded yet, "Available when online", and follows the network live); what nobody asked for also waits on battery saver, thermal status moderate or
worse, or a metered network. A component turns on for its games when its download lands, for the
next launch, and an open page shows the progress and reads the picks again. One log line per
queued and per finished download.
`wincomponents.json` keeps, per game,
`auto` (each with its reason) and `user` (each switch the user pressed, on or off) beside `games`,
the effective list the launch reads (still version 1). A user switch always wins over an automatic
pick, either way, and a new automatic list never touches it. Picks saved before this build count
as the user's own.
The page shows "On for this game" with each reason ("From GOG's list for this game", "Steam
installs VC++ 2015 with <game> (app N)"), then Recommended, then "Also listed".
## Older store installs
A GOG or Epic game installed before its sidecar kept the store's list gets it once, on the worker
(`StoreListBackfill`): GOG's build manifest for the installed build (`dependencies`), Epic's
manifest (prerequisite name and path), fetched with the saved sign-in - the manifest alone, never a
game file - and kept in the sidecar as an install keeps it. Fresh installs now keep the keys even
when empty. Without a sign-in or when the store refuses: skipped quietly, tried again a week later;
offline is no try. Like any work nobody asked for it waits for an unmetered network, unless the
game's page is open.
## Apps the client never saw
A Steam app the client in the runtime never saw (not owned, never viewed: a Custom game's
steam_appid.txt) is missing from its `appinfo.vdf`. `SteamPics` asks Steam for it anonymously:
Steam's public CM list (`ISteamDirectory/GetCMListForConnect`), a WebSocket connection to a
connection manager (`wss://<cm>/cmsocket/`, the transport Steam's web clients use), an anonymous
logon (an AnonUser SteamID, no account, nothing of the user's Steam sign-in), a
`ClientPICSAccessTokenRequest` and a `ClientPICSProductInfoRequest` (at most 25 apps each, all in
one connection), the app KeyValues read for depots with `depotfromapp` 228980 and the name, and a
logoff. Kotlin only, no library (TLS from the platform, a minimal WebSocket and protobuf reader).
Answers go into `steam-appinfo.json` like the client's (a miss is asked again after a week).
Order for an appid: the client's `appinfo.vdf` first (free), then the anonymous lookup. It runs on
the components' worker: one batch for every game in the start/after-session pass (before their
lists), or the one app of a game whose page is open; never during a session, and as work nobody
asked for only on an unmetered, favourable device. Log: `steam pics: N apps, M with shared redists`.
## A game installed during a session
`SessionInstalls` looks at the Steam libraries' appmanifests every five seconds while a session
runs (a manifest re-read only when its time changes); a game whose StateFlags turn to 4 (fully
installed) becomes one job allowed to run in the session (`AutoComponents.queueInSession`): its
list (local appmanifest, appinfo.vdf, the kept PICS answer; one small anonymous lookup only for an
app nothing knows), its automatic picks written to `wincomponents.json` (the runtime's copy too) and
only its missing components downloaded, at low priority. It waits while a game runs in the session
(Steam's reaper `SteamLaunch AppId=`, or Proton's `waitforexitandrun`). All other work stays
paused. While its components download, the app lists them per game in the runtime's
`~/.config/droiddeck/wincomponents-pending.json`; `droiddeck-wincomponents` at launch waits up to
10 s for those (no wait when nothing is pending), then applies what is there: log
`wincomp: <appid> waited Nms for <ids>` or `... launched without <ids> (not ready)`.
+80
View File
@@ -7,6 +7,86 @@ the timeline, then lessons and backlog. Companion to the README (what the app *d
---
## 2026-10-10 - `feat/wincomp-auto-sources`: Windows components picked by the game's source
- **Per source** (`WinCompSources`): Steam - Steamworks Shared depots from the appmanifest or the
client's app info (`SteamAppInfo`, appinfo.vdf v27-29, cached per appid, weekly retry); GOG - the
build's `dependencies` (now kept in the sidecar); Epic - the manifest's prerequisite (now parsed
and kept); Amazon - fuel.json `PostInstall`; Custom - Steam's list when its files name the appid
(on by itself), when only the name matches (Recommended); else the folder scan. Unmapped entries
are listed with their own names; mapping tables in one place.
- **One Steam match per game** (`SteamMatch`, `files/steam-matches.json`), used by the art fetch
and the components alike; the earlier art lookup is taken over.
- **On by itself** (`AutoComponents`): at store install, add/edit, page open and before a launch
from DroidDeck; `wincomponents.json` keeps `auto` (with reasons) and `user` switches beside the
effective `games` list (still version 1). A user switch always wins. The page gets "On for this
game" with reasons and "Also listed".
- **Device fix:** on a Steam game (Need for Speed Payback) only the already-downloaded d3dx9 went
on; vcredist2013/2019 stayed Recommended, never downloaded (Steam games have no install event
here). Every refresh (page open, launch, an app-start sweep over Steam games, install/add/edit)
now queues the missing automatic picks for a background download and turns each on when it
lands; the open page shows the progress and reloads.
- **Gentle on the device:** one low-priority worker and one queue (games and components each once,
what the user asked for first); nothing while a session runs; the start-up sweep and pre-launch
looks only queue, wait ten seconds after start, and skip battery saver, a warm device or a
metered network; runtime helpers under nice 19 / ionice idle; a game's list kept and worked out
again only when its files change, on the worker. Tests: download lands, queue paused while a
session runs, unfavourable device, dedupe.
- **Fluid:** after a session the Games tab reads Steam's manifests first (no wait for the added
games' walk), a newly installed game eases into the list and its components' list is queued on
the worker. The components page shows "Checking what this game needs…" (small spinner) until
the list is worked out, never "nothing found" before; "On for this game" expands in, components
on their way sit there with their progress and a "Turning on N components…" line, and rows ease
in (~250 ms; instant with animations off).
- **Quieter:** "Checking…" only when the game's list was never worked out; a kept list shows at
once and is checked again silently, its changes easing in. The lists wait for the catalog and
fade in once; "Loading the components…" only after 300 ms.
- **Offline:** with no validated network, components not downloaded yet are greyed and cannot be
switched ("Available when online"), automatic picks on their way included (no progress bar);
downloaded ones stay usable. The page follows the network live, and downloads wait in the queue
until it is back.
- **Every source, older installs:** the start/after-session pass covers all games (Custom, GOG,
Epic, Amazon). GOG/Epic games installed before the sidecar kept the list get it once from the
store's build manifest with the saved sign-in (`StoreListBackfill`; no game files; weekly retry
on failure). Fresh installs keep the keys even when empty.
- **Apps the client never saw** (Dragon's Dogma 2, 2054970 not in appinfo.vdf): the client ask
gave nothing (no `SteamClient` call returns depots), so Steam is asked anonymously instead
(`SteamPics`: CM list, WebSocket CM, anonymous logon, PICS access tokens + product info, ≤25
apps a request, one connection a pass, on the worker; Kotlin, no new library). Prototyped first
against Steam from the build host: 2054970 → depot 228988 (VC++ 2019), 1190460 → one shared
depot. Cached in steam-appinfo.json like the client's, weekly retry on a miss.
- **Installed during a session:** the Steam libraries' appmanifests are looked at every 5 s while
a session runs; a game turning fully installed gets one in-session job (waits while a game runs)
that writes its automatic picks and downloads only its missing components; the launch waits up
to 10 s for components listed in `wincomponents-pending.json`, never when nothing is pending.
- **Checks:** a store list counts as read only when the manifest was read whole (`listRead`;
GOG: a manifest with depots, Epic: the prerequisite fields read); older empty lists are fetched
once more. Log: `backfill: <game> gog deps=[...]` / `epic prereq=<name|none>`, plus the
manifest's depot/file count. Unity's `*_Data/Managed` is skipped by the folder scan (Mono
assemblies, not .NET Framework).
- Docs: `docs/development/windows-components-sources.md`. Tests: `WinCompSourcesTest` (each
source's fixture, certainty rules, appinfo reading, auto picks, user overrides, shared match),
`AddedGameArtLookupTest` updated. Not yet on a device.
## 2026-10-10 - `fix/games-folder-migration`: Games folders become entries, not Steam libraries
- **Seen on a device:** Steam's Install dialog listed "Games (Internal shared storage)" and "Games
(android)", the user's two Games folders saved before #492, still registered as libraries
while Second library is Automatic; with the settings section gone they could not be removed.
- **Migration** (`GamesFolderMigration`, once at app start and before any scan, idempotent):
every game a Games folder listed becomes an entry (`AddedExes`), keeping its chosen exe, stored
appid, name / Start in / launch-option overrides, art choices and removal (all keyed by its
folder). Store installs and `steamapps` are skipped. The folders stay bound at their old guest
paths (`SessionPrefs.gameFolderBinds`), so the listing and every Steam shortcut are unchanged;
`addedGamesDirs` is cleared. Log: `games: migrated <root> → N games`.
- **Libraries:** `GameStorage.gamesFolderLibraries` returns only a Games folder Steam installed
into (an appmanifest in its `steamapps/`); the others drop out of `BL_STEAM_LIBRARIES` and the
runtime's `droiddeck-steam-library` forgets the `/root/Games/...` entries it had registered.
The user's two folders have `steamapps/` without manifests (checked on the device), so both go.
- **Tests:** `GamesFolderMigrationTest` (exe / appid / name / removal kept, guest paths unchanged,
idempotent, store and steamapps skipped, libraries empty, same-name folders), `GameStorageTest`
updated. Not yet on a device.
## 2026-10-09 - `feat/games-add-edit`: add games from the Games tab, edit them like Steam's Properties
- **Steam ⚙ settings:** the Games section is gone (it was a feature, not a setting). Games
+8 -3
View File
@@ -26,9 +26,14 @@ for arch in x86_64 i386; do
fi
done
done
for name in faultreport thunkaudit; do
lib=$out/x86_64/lib$name.so
gcc -m64 -shared -fPIC -O2 -Wall -Wextra -nostdlib -fno-stack-protector -fno-builtin -fvisibility=hidden \
for target in x86_64/faultreport i386/faultreport x86_64/thunkaudit; do
arch=${target%/*} name=${target#*/}
case $arch in
x86_64) bits=-m64 ;;
i386) bits=-m32 ;;
esac
lib=$out/$arch/lib$name.so
gcc $bits -shared -fPIC -O2 -Wall -Wextra -nostdlib -fno-stack-protector -fno-builtin -fvisibility=hidden \
-o "$lib" tools/linuxfs/fex/$name.c
strip --strip-unneeded "$lib"
if objdump -p "$lib" | grep -q NEEDED || [ -n "$(nm -D --undefined-only "$lib" 2>/dev/null)" ]; then
+9
View File
@@ -38,6 +38,8 @@ export MOZ_SHM_NO_SEALS=1
# to the app as a Steam session - SteamOS's "Return to Gaming Mode". The session's own GL choice
# (zink on Turnip, for the Steam client) is put back by droiddeck-gpu.
export KWIN_COMPOSE=Q QT_QUICK_BACKEND=software LIBGL_ALWAYS_SOFTWARE=1 GALLIUM_DRIVER=llvmpipe
# mpv refuses that software GL and then fails its X11 fallback (an assertion): allowed it, it plays.
[ -e /etc/mpv/mpv.conf ] || { mkdir -p /etc/mpv && echo gpu-sw=yes >/etc/mpv/mpv.conf; } 2>/dev/null
export DROIDDECK_NESTED_SCALE=1
export DROIDDECK_GLTHREAD=${mesa_glthread:-}
unset MESA_LOADER_DRIVER_OVERRIDE LIBGL_KOPPER_DRI2 mesa_glthread
@@ -75,6 +77,13 @@ python3 /usr/local/bin/droiddeck-pad-defaults 2>&1 | sed 's/^/[pads] /'
python3 /usr/local/bin/droiddeck-desktop-gpu 2>&1 | sed 's/^/[gpu] /'
# The windowed action still opens melonDS at the size/placement the LXQt desktop used.
/usr/local/bin/droiddeck-desktop-window-rules 2>&1 | sed 's/^/[rules] /'
# Windows programs, Linux programs, AppImages and scripts open with droiddeck-open (Steam's Proton,
# FEX for x86); an exec the kernel refuses is handed to it as well (libblsession's binfmt.c). An
# AppImage run directly cannot mount itself under proot, so it unpacks itself to run.
python3 /usr/local/bin/droiddeck-open --install 2>&1 | sed 's/^/[open] /'
export APPIMAGE_EXTRACT_AND_RUN=1
# A click runs an executable script or program without KIO asking first; Dolphin's settings can still ask.
[ -e /etc/xdg/kiorc ] || printf '[Executable scripts]\nbehaviourOnLaunch=execute\n' >/etc/xdg/kiorc 2>/dev/null
# SteamOS's desktop icons: Return to Gaming Mode and Steam, put there once - what the user moves
# or deletes stays that way.
desktop_dir=$(xdg-user-dir DESKTOP 2>/dev/null); [ -n "$desktop_dir" ] && [ "$desktop_dir" != "$HOME" ] || desktop_dir=$HOME/Desktop
+26 -7
View File
@@ -1,15 +1,30 @@
#if !defined(__x86_64__)
#error faultreport is built for x86_64 guests only
#endif
#if defined(__x86_64__)
#define SYS_read 0
#define SYS_write 1
#define SYS_close 3
#define SYS_rt_sigaction 13
#define SYS_rt_sigreturn "15"
#define SYS_getpid 39
#define SYS_gettid 186
#define SYS_tgkill 234
#define SYS_openat 257
#define SI_ADDR 16
#define TRAP "syscall"
#elif defined(__i386__)
#define SYS_read 3
#define SYS_write 4
#define SYS_close 6
#define SYS_rt_sigaction 174
#define SYS_rt_sigreturn "173"
#define SYS_getpid 20
#define SYS_gettid 224
#define SYS_tgkill 270
#define SYS_openat 295
#define SI_ADDR 12
#define TRAP "int $0x80"
#else
#error faultreport is built for x86 guests only
#endif
#define AT_FDCWD (-100)
#define SIGILL 4
#define SA_SIGINFO 0x4UL
@@ -21,14 +36,18 @@ struct kernel_sigaction {
void (*handler)(int, void *, void *);
unsigned long flags;
void (*restorer)(void);
unsigned long mask;
unsigned long long mask;
};
static long sys(long n, long a, long b, long c, long d)
{
long r;
#if defined(__x86_64__)
register long r10 __asm__("r10") = d;
__asm__ volatile("syscall" : "=a"(r) : "a"(n), "D"(a), "S"(b), "d"(c), "r"(r10) : "rcx", "r11", "memory");
#else
__asm__ volatile("int $0x80" : "=a"(r) : "a"(n), "b"(a), "c"(b), "d"(c), "S"(d) : "memory");
#endif
return r;
}
@@ -100,7 +119,7 @@ static int find_mapping(unsigned long addr, const char **line, unsigned long *le
static void report(int sig, void *info, void *context)
{
(void)context;
unsigned long addr = *(unsigned long *)((char *)info + 16);
unsigned long addr = *(unsigned long *)((char *)info + SI_ADDR);
int code = *(int *)((char *)info + 8);
used = 0;
put("droiddeck-fex: SIGILL at 0x");
@@ -135,7 +154,7 @@ static void report(int sig, void *info, void *context)
}
void faultreport_restore(void);
__asm__(".text\n.type faultreport_restore,@function\nfaultreport_restore:\nmov $15, %eax\nsyscall\nhlt\n");
__asm__(".text\n.type faultreport_restore,@function\nfaultreport_restore:\nmov $" SYS_rt_sigreturn ", %eax\n" TRAP "\nhlt\n");
__attribute__((constructor)) static void install(void)
{
@@ -287,6 +287,9 @@ class FocusWatcher(threading.Thread):
x = X(os.environ["DISPLAY"])
wanted = {x.atom(name) for name in FOCUS_PROPS}
x.select(x.root, 0x400000) # PropertyChangeMask
# Waits for events as long as there are none: the connect timeout left in place
# here dropped and remade the connection, and took a snapshot, every few seconds.
x.s.settimeout(None)
self.changed.set()
while True:
event = x.next_event()
@@ -12,12 +12,14 @@ import sys
HOME = os.environ.get("HOME", "/root")
STEAM_ROOT = sys.argv[1] if len(sys.argv) > 1 else os.path.join(HOME, ".local/share/Steam")
LIBRARIES = ((STEAM_ROOT, "Steam Games"), ("/mnt/droiddeck-sd", "SD Games"))
# The client's own tools and runtimes live in steamapps beside the games; they are not titles.
NOT_GAMES = {228980, 1493710, 3127680, 4183110, 4427310, 4185400}
# The client's own tools and runtimes live in steamapps beside the games; they are not titles. A
# depot with a toolmanifest.vdf is one too (a runtime or a Proton), listed here or not.
NOT_GAMES = {228980, 1070560, 1391110, 1493710, 1628350, 3127680, 4183110, 4185400, 4427310, 4628740}
APPS_DIR = os.path.join(HOME, ".local/share/applications")
PREFIX = "droiddeck-game-"
NAME = re.compile(r'^\s*"name"\s*"([^"]*)"', re.M)
STATE = re.compile(r'^\s*"StateFlags"\s*"(\d+)"', re.M)
INSTALLDIR = re.compile(r'^\s*"installdir"\s*"([^"]*)"', re.M)
def games(library):
@@ -42,6 +44,9 @@ def games(library):
# StateFlags 4 = fully installed; anything else is downloading, updating or broken.
if not name or not state or (int(state.group(1)) & 4) == 0:
continue
installdir = INSTALLDIR.search(text)
if installdir and os.path.isfile(os.path.join(steamapps, "common", installdir.group(1), "toolmanifest.vdf")):
continue
out.append((appid, name.group(1)))
return out
@@ -34,7 +34,6 @@ LAYOUT = 1
GUEST_LIBDIRS = (("x86_64", "lib/x86_64-linux-gnu"), ("i386", "lib/i386-linux-gnu"))
PRELOAD_NAMES = ("libblsession.so", "libfakeinput.so")
FAULT_REPORT = "libfaultreport.so"
FAULT_REPORT_GUEST = "/usr/lib/x86_64-linux-gnu/droiddeck/" + FAULT_REPORT
THUNK_AUDIT = "libthunkaudit.so"
THUNK_AUDIT_GUEST = "/usr/lib/x86_64-linux-gnu/droiddeck/" + THUNK_AUDIT
VULKAN_PIN = "libvulkan-thunk.so"
@@ -241,18 +240,23 @@ def host_preloads():
def install_preloads(root):
shipped = set()
shipped = {}
for arch, libdir in GUEST_LIBDIRS:
for name in PRELOAD_NAMES:
for name in PRELOAD_NAMES + (FAULT_REPORT,):
source = os.path.join(PRELOADS, arch, name)
if os.path.isfile(source):
copy_if_changed(source, os.path.join(root, libdir, "droiddeck", name))
shipped.add(name)
for name in (FAULT_REPORT, THUNK_AUDIT, VULKAN_PIN):
shipped.setdefault(name, set()).add(arch)
for name in (THUNK_AUDIT, VULKAN_PIN):
source = os.path.join(PRELOADS, "x86_64", name)
if os.path.isfile(source):
copy_if_changed(source, os.path.join(root, "lib/x86_64-linux-gnu/droiddeck", name))
wanted = [name for name in host_preloads() if name in shipped]
# The fault report is preloaded from the runtime's own list, which only x86 loaders read: through
# LD_PRELOAD it reached FEX's own loader too, and every FEX after it, which could not open it and
# said so at each start. Listed only with both builds, or i386 programs would say so instead.
if len(shipped.get(FAULT_REPORT, ())) == len(GUEST_LIBDIRS):
wanted.append(FAULT_REPORT)
write_if_changed(os.path.join(root, "etc", "ld.so.preload"), "".join("/usr/$LIB/droiddeck/%s\n" % name for name in wanted))
@@ -406,12 +410,22 @@ def split_paths(value):
return [part for part in re.split(r"[:\s]+", value or "") if part]
def data_dir(env):
# FEXServer's socket lives here. Under ~/.local its host path passes sun_path's 108 bytes, and
# proot then binds it to a temporary name only the server sees: no client ever reaches it.
runtime = env.get("XDG_RUNTIME_DIR")
if runtime and os.path.isdir(runtime) and os.access(runtime, os.W_OK):
return os.path.join(runtime, "fex")
return DATA_DIR
def guest_environment(fex, env):
result = dict(env)
for directory in (CONFIG_DIR, DATA_DIR, CACHE_DIR):
data = data_dir(env)
for directory in (CONFIG_DIR, data, CACHE_DIR):
os.makedirs(directory, exist_ok=True)
result["FEX_APP_CONFIG_LOCATION"] = CONFIG_DIR + "/"
result["FEX_APP_DATA_LOCATION"] = DATA_DIR + "/"
result["FEX_APP_DATA_LOCATION"] = data + "/"
result["FEX_APP_CACHE_LOCATION"] = CACHE_DIR + "/"
result.setdefault("FEX_SILENTLOG", "0")
result.setdefault("FEX_OUTPUTLOG", "stderr")
@@ -492,8 +506,6 @@ def launch(command, env, mode, decide=None):
return 126
guest = guest_environment(fex, env)
x86_64 = elf_arch(program) != "i386"
if os.path.isfile(os.path.join(PRELOADS, "x86_64", FAULT_REPORT)) and x86_64:
guest["LD_PRELOAD"] = ":".join([FAULT_REPORT_GUEST] + split_paths(guest.get("LD_PRELOAD")))
thunks = "on" if "FEX_THUNKCONFIG" in guest else "off"
folder = decide if decide and os.path.isdir(decide) else os.path.dirname(program)
if thunks == "on" and x86_64 and all(os.path.isfile(os.path.join(PRELOADS, "x86_64", name)) for name in (THUNK_AUDIT, VULKAN_PIN)) \
@@ -106,6 +106,19 @@ def godot_sentry(game):
return {"WINEDLLOVERRIDES": ";".join(n + "=d" for n in sentry)}, [], "Godot: Sentry crash handler off"
def skip_red_launcher(command):
"""Cyberpunk 2077's prelauncher opens REDlauncher, a Qt WebEngine window that hands over to the
game half a minute later and then stays up beside it, well over a core busy for as long as the
game runs. The game is started itself instead; the prelauncher's own --launcher-skip started it
twice."""
if len(command) > 2 and os.path.basename(command[2]).lower() == "redprelauncher.exe":
game = os.path.join(os.path.dirname(command[2]), "bin", "x64", "Cyberpunk2077.exe")
if os.path.isfile(game):
print("droiddeck-game-env: REDlauncher skipped", file=sys.stderr)
return command[:2] + [game] + command[3:]
return command
def engine_fixes(exe, args):
"""Environment and extra arguments a game needs because of what it is built with."""
game = Game(exe, args)
@@ -269,6 +282,7 @@ def main():
waiting = len(command) > 1 and command[1] == "waitforexitandrun"
fixes = {}
if waiting:
command = skip_red_launcher(command)
fixes, extra, notes = engine_fixes(command[2], command[3:]) if len(command) > 2 else ({}, [], [])
for note in notes:
print("droiddeck-game-env: " + note, file=sys.stderr)
@@ -0,0 +1,434 @@
#!/usr/bin/python3
"""Runs a program from the desktop: a Windows program through the Proton Steam has chosen, a Linux
program (x86 ones under FEX), an AppImage, or a script (under FEX beside x86 programs, as a game's
start script is). Shared storage is noexec: FEX runs x86 programs there where they lie (libblsession's
noexec.c), while a native one is copied - alone into the cache, or with its folder by the app once (as
Add an app does) when that holds native programs or libraries. A Steam library's games are Steam's.
droiddeck-open [--window | --terminal] FILE [ARGS...] the file manager's Run entries
droiddeck-open --exec FILE [ARGS...] an exec the kernel refused (libblsession)
droiddeck-open --install the Run entries and default handlers
The desktop composites in software, so a program started from it goes full screen on the GPU
(droiddeck-gpu), as the menu's games do: --window keeps it in a desktop window instead, --terminal
runs it in Konsole. A plain script stays on the desktop, and an exec whose output is read (a terminal,
a pipe, a file) stays where it was started.
"""
import fcntl
import hashlib
import os
import runpy
import shutil
import stat
import subprocess
import sys
import time
HOME = os.environ.get("HOME") or "/root"
BIN = "/usr/local/bin"
FEX = os.path.join(BIN, "droiddeck-fex")
GPU = os.path.join(BIN, "droiddeck-gpu")
PROTON_RUN = os.path.join(BIN, "droiddeck-proton-run")
APPIMAGE_RUN = os.path.join(BIN, "droiddeck-appimage-run")
SCRIPT_RUN = os.path.join(BIN, "droiddeck-script-run")
STEAM_LAUNCH = os.path.join(BIN, "droiddeck-steam-launch")
CACHE = os.path.join(HOME, ".cache", "droiddeck")
# Where the app adds each kind, and what an added one holds.
IMPORTS = {"appimage": ("/opt/appimages/user", "app"), "folder": ("/opt/droiddeck-apps", "entry")}
# Android's own folders on shared storage: never copied in whole.
SHARED_FOLDERS = ("alarms", "android", "audiobooks", "dcim", "documents", "download", "movies", "music",
"notifications", "pictures", "podcasts", "recordings", "ringtones")
SCAN_LIMIT = 4000
APPS_DIR = "/usr/local/share/applications"
WINDOWS = (".exe", ".bat", ".cmd", ".com", ".msi")
NOT_PROGRAMS = (".dll", ".sys", ".drv", ".cpl", ".ocx", ".efi", ".scr", ".mui")
SCRIPTS = (".sh", ".bash")
MACHINES = {3: "i386", 62: "x86_64", 183: "arm64"}
EMULATED = (3, 62)
QUICK_FAILURE = 20
IMPORT_WAIT = 1800
WINDOWS_TYPES = ("application/x-ms-dos-executable", "application/x-msdownload",
"application/vnd.microsoft.portable-executable", "application/x-msi", "application/x-bat")
SCRIPT_TYPES = ("application/x-shellscript", "text/x-shellscript")
LINUX_TYPES = ("application/x-executable", "application/x-pie-executable", "application/vnd.appimage")
ENTRIES = (
("droiddeck-open.desktop", "Run", "", WINDOWS_TYPES + SCRIPT_TYPES + LINUX_TYPES),
("droiddeck-open-window.desktop", "Run in a Desktop Window", "--window ", WINDOWS_TYPES + SCRIPT_TYPES + LINUX_TYPES),
("droiddeck-open-terminal.desktop", "Run in Konsole", "--terminal ", SCRIPT_TYPES + LINUX_TYPES),
)
def say(message):
print("droiddeck-open: %s" % message, file=sys.stderr, flush=True)
def notify(title, body, icon="dialog-error"):
if not os.environ.get("DBUS_SESSION_BUS_ADDRESS") or not shutil.which("notify-send"):
return
try:
subprocess.run(["notify-send", "-a", "DroidDeck", "-i", icon, title, body],
stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL, timeout=5)
except (OSError, subprocess.SubprocessError):
pass
def head(path, size=64):
try:
with open(path, "rb") as f:
return f.read(size)
except OSError:
return b""
def machine(data):
if len(data) < 20 or data[:4] != b"\x7fELF":
return None
return int.from_bytes(data[18:20], "big" if data[5] == 2 else "little")
def kind(path):
data = head(path)
ext = os.path.splitext(path)[1].lower()
if data[:4] == b"\x7fELF":
return "appimage" if data[8:11] == b"AI\x02" or ext == ".appimage" else "elf"
if ext in NOT_PROGRAMS:
return None
if ext in WINDOWS or data[:2] == b"MZ":
return "windows"
if data[:2] == b"#!" or ext in SCRIPTS:
return "script"
return None
def noexec(path):
try:
return bool(os.statvfs(path).f_flag & os.ST_NOEXEC)
except OSError:
return False
def interpreter(path):
line = head(path, 256).split(b"\n", 1)[0].rstrip(b"\r")
if line.startswith(b"#!"):
words = line[2:].decode("utf-8", "replace").split()
if words:
return words
return ["/bin/bash"]
def for_x86(script):
return subprocess.call([FEX, "needs", os.path.dirname(script)], stdout=subprocess.DEVNULL,
stderr=subprocess.DEVNULL) == 0
def runnable(path):
"""The program itself, or a copy in the cache of a native one on noexec storage."""
if not noexec(path) or machine(head(path)) in EMULATED:
return path
st = os.stat(path)
key = hashlib.sha1(os.path.realpath(path).encode("utf-8")).hexdigest()[:16]
copy = os.path.join(CACHE, "exec", key, os.path.basename(path))
try:
same = os.stat(copy)
if same.st_size == st.st_size and same.st_mtime_ns == st.st_mtime_ns:
return copy
except OSError:
pass
os.makedirs(os.path.dirname(copy), exist_ok=True)
shutil.copyfile(path, copy + ".tmp")
os.utime(copy + ".tmp", ns=(st.st_atime_ns, st.st_mtime_ns))
os.chmod(copy + ".tmp", 0o755)
os.replace(copy + ".tmp", copy)
return copy
def appimage(path):
"""The image unpacked once into the cache, laid out as droiddeck-appimage-run expects."""
st = os.stat(path)
real = os.path.realpath(path)
key = hashlib.sha1(("%s\0%d\0%d" % (real, st.st_size, st.st_mtime_ns)).encode("utf-8")).hexdigest()[:16]
images = os.path.join(CACHE, "appimages")
return once(key, lambda: unpacked(path, real, key, images, os.path.join(images, key)))
def once(key, make):
"""[make]() for [key] one start at a time: a second start waits for the first, then finds it done."""
locks = os.path.join(CACHE, "locks")
os.makedirs(locks, exist_ok=True)
with open(os.path.join(locks, key), "w") as lock:
fcntl.flock(lock, fcntl.LOCK_EX)
return make()
def unpacked(path, real, key, images, folder):
if os.path.exists(os.path.join(folder, "app", "AppRun")):
return folder
earlier = import_of(key, "appimage")
if earlier:
return earlier
name = os.path.splitext(os.path.basename(path))[0]
notify("Preparing %s" % name, "Unpacking it for its first start.", "system-run")
work = folder + ".tmp"
shutil.rmtree(work, ignore_errors=True)
os.makedirs(work)
status = subprocess.call([FEX, "extract", real], cwd=work, stdout=subprocess.DEVNULL)
unpacked = os.path.realpath(os.path.join(work, "squashfs-root"))
if status != 0 or not os.path.isdir(unpacked):
shutil.rmtree(work, ignore_errors=True)
return imported("appimage", real, key, name)
os.replace(unpacked, os.path.join(work, "app"))
if os.path.islink(os.path.join(work, "squashfs-root")):
os.unlink(os.path.join(work, "squashfs-root"))
for file, value in (("name", name), ("arch", MACHINES.get(machine(head(path)), "")), ("source", real)):
with open(os.path.join(work, file), "w", encoding="utf-8") as f:
f.write(value + "\n")
for other in os.listdir(images):
old = os.path.join(images, other)
if other != key + ".tmp" and (head(os.path.join(old, "source"), 4096).decode("utf-8", "replace").strip() == real):
shutil.rmtree(old, ignore_errors=True)
os.replace(work, folder)
return folder
def import_of(key, what):
root, made = IMPORTS[what]
try:
entries = sorted(os.listdir(root))
except OSError:
return None
for entry in entries:
folder = os.path.join(root, entry)
if head(os.path.join(folder, "desktop-import"), 64).split(b"\n", 1)[0] == key.encode() \
and os.path.exists(os.path.join(folder, made)):
return folder
return None
def imported(what, real, key, name):
"""[real] added by the app instead, as Add an app adds it (to the menu too): an AppImage whose hard
links (sharun's) only the app's own proot can make, or a folder of native programs off noexec storage."""
launch = os.environ.get("BL_LAUNCH_DIR")
if not launch or not os.path.isdir(launch):
raise RuntimeError("%s cannot run from here; add it in DroidDeck with Add an app." % name)
result = os.path.join(launch, "desktop-import-%s.result" % key)
request = os.path.join(launch, "desktop-import-%s.request" % key)
with open(request + ".tmp", "w", encoding="utf-8") as f:
f.write("id=%s\nkind=%s\npath=%s\n" % (key, what, real))
os.replace(request + ".tmp", request)
if what == "appimage":
notify("Adding %s to DroidDeck" % name, "It is unpacked once, then it starts, and it stays in the menu.", "system-run")
else:
notify("Adding %s to DroidDeck" % name, "Shared storage cannot run its programs, so the folder is copied in "
"once; then it starts, and it stays in the menu.", "system-run")
deadline = time.monotonic() + IMPORT_WAIT
while time.monotonic() < deadline:
try:
with open(result, encoding="utf-8") as f:
answer = f.read().strip()
except OSError:
time.sleep(1)
continue
os.unlink(result)
verdict, _, detail = answer.partition(" ")
if verdict == "ok" and os.path.exists(os.path.join(detail, IMPORTS[what][1])):
return detail
raise RuntimeError(detail or "DroidDeck could not add %s." % name)
raise RuntimeError("DroidDeck did not finish adding %s." % name)
def game_folder(path):
"""The folder of [path] when it runs only from a copy: on noexec storage, holding native programs or
libraries (besides [path]) that it starts or loads. Not the storage itself, one of Android's folders
or a Steam library."""
real = os.path.realpath(path)
folder = os.path.dirname(real)
if in_steam_library(real) or not noexec(folder):
return None
top = storage_root(folder)
parts = os.path.relpath(folder, top).split(os.sep)
if folder == top or (len(parts) == 1 and parts[0].lower() in SHARED_FOLDERS):
return None
seen = 0
for root, dirs, files in os.walk(folder):
if root[len(folder):].count(os.sep) >= 3:
dirs[:] = []
for name in files:
child = os.path.join(root, name)
if child == real:
continue
seen += 1
if seen > SCAN_LIMIT:
return None
if machine(head(child)) not in (None,) + EMULATED:
return folder
return None
def in_steam_library(path):
"""Whether [path] is in a Steam library: its games are Steam's to start, never copied in."""
return "steamapps" in os.path.realpath(path).lower().split(os.sep)
def storage_root(folder):
"""The top of the storage [folder] is on: the last folder above it on the same file system."""
top = folder
try:
dev = os.stat(folder).st_dev
while os.path.dirname(top) != top and os.stat(os.path.dirname(top)).st_dev == dev:
top = os.path.dirname(top)
except OSError:
pass
return top
def copied_in(path):
"""The app's copy of [path]'s folder, added (once) for it."""
real = os.path.realpath(path)
key = hashlib.sha1(("folder\0%s" % real).encode("utf-8")).hexdigest()[:16]
name = os.path.basename(os.path.dirname(real))
return once(key, lambda: import_of(key, "folder") or imported("folder", real, key, name))
def steam_title(path):
"""The Steam game [path] is the program of: Steam starts it, with the client most games need."""
try:
run = runpy.run_path(PROTON_RUN)
root = os.environ.get("STEAM_COMPAT_CLIENT_INSTALL_PATH") or run["STEAM_ROOT"]
target = run["locate"](path, root)
return target.appid if run["steam_launches"](target, path, root) else None
except (OSError, ValueError, KeyError, IndexError):
return None
def command(path, args, what, window=False, appid=None):
"""(command, working directory) that runs [path] in place."""
folder = os.path.dirname(path)
if what == "steam":
return [STEAM_LAUNCH, "desktop", appid], folder
if what == "windows":
return [PROTON_RUN] + (["--window"] if window else []) + [path] + args, folder
if what == "x86 script":
return [FEX, "run", "--mode", "on", "--for", folder, "--"] + interpreter(path) + [path] + args, folder
if what == "script":
return interpreter(path) + [path] + args, folder
if what == "appimage":
return [APPIMAGE_RUN, appimage(path)] + args, folder
if what == "folder":
return [SCRIPT_RUN, copied_in(path)] + args, folder
return [FEX, "run", "--mode", "auto", "--", runnable(path)] + args, folder
def on_desktop(env):
return env.get("BL_DESKTOP") == "1" and not env.get("GAMESCOPE_WAYLAND_DISPLAY") and os.access(GPU, os.X_OK)
def unattended(env):
"""Nobody reads the output: it goes to /dev/null or the desktop's log, as from Dolphin or the menu."""
try:
out = os.fstat(1)
except OSError:
return True
if stat.S_ISCHR(out.st_mode):
return not os.isatty(1)
folder = env.get("BL_DEBUG_DIR")
try:
log = os.stat(os.path.join(folder, "desktop.log")) if folder else None
except OSError:
log = None
return log is not None and (log.st_dev, log.st_ino) == (out.st_dev, out.st_ino)
def placed(cmd, what, mode, env):
"""[cmd] where it shows: full screen on the GPU from the desktop, else where it was started."""
if mode == "terminal" and what not in ("windows", "steam") and shutil.which("konsole"):
return ["konsole", "--hold", "-e"] + cmd
if mode == "window" or what in ("script", "steam") or not on_desktop(env):
return cmd
if mode == "exec" and what != "windows" and not unattended(env):
return cmd
return [GPU] + cmd
def install(directory=APPS_DIR):
os.makedirs(directory, exist_ok=True)
defaults = []
for file, name, option, types in ENTRIES:
body = ("[Desktop Entry]\nType=Application\nName=%s\nIcon=system-run\nExec=%s %s%%f\nTerminal=false\n"
"NoDisplay=true\nMimeType=%s;\n" % (name, os.path.join(BIN, "droiddeck-open"), option, ";".join(types)))
write(os.path.join(directory, file), body)
for mime in WINDOWS_TYPES + SCRIPT_TYPES + LINUX_TYPES:
defaults.append("%s=%s" % (mime, ENTRIES[0][0]))
added = ["%s=%s" % (mime, ";".join(f for f, _, _, types in ENTRIES if mime in types) + ";")
for mime in WINDOWS_TYPES + SCRIPT_TYPES + LINUX_TYPES]
write(os.path.join(directory, "mimeapps.list"),
"[Default Applications]\n%s\n\n[Added Associations]\n%s\n" % ("\n".join(defaults), "\n".join(added)))
return 0
def write(path, text):
try:
with open(path, encoding="utf-8") as f:
if f.read() == text:
return
except OSError:
pass
with open(path + ".tmp", "w", encoding="utf-8") as f:
f.write(text)
os.replace(path + ".tmp", path)
def main(argv):
mode = "run"
if argv and argv[0] == "--install":
return install()
if argv and argv[0] in ("--window", "--terminal", "--exec"):
mode = argv.pop(0)[2:]
if argv and argv[0] == "--":
argv.pop(0)
if not argv:
say("usage: droiddeck-open [--window | --terminal | --exec] FILE [ARGS...] | --install")
return 2
path = os.path.abspath(argv[0])
what = kind(path)
if what is None:
say("%s is not a program this desktop can run" % path)
if mode != "exec":
notify("Cannot run %s" % os.path.basename(path), "It is not a Windows or Linux program or a script.")
return 126
linux_game = what in ("script", "elf") and mode == "run" and in_steam_library(path)
appid = steam_title(path) if (what == "windows" and mode != "window") or linux_game else None
if appid:
what = "steam"
elif what == "script" and for_x86(path):
what = "x86 script"
elif what in ("script", "elf") and game_folder(path):
what = "folder"
try:
cmd, folder = command(path, argv[1:], what, mode == "window", appid)
except (OSError, RuntimeError) as error:
say(str(error))
notify("Cannot run %s" % os.path.basename(path), str(error))
return 126
cmd = placed(cmd, what, mode, os.environ)
if mode != "exec" or not sys.stderr.isatty():
say("%s (%s): %s" % (path, what, " ".join(cmd[:4])))
try:
os.chdir(folder)
except OSError:
pass
if mode == "exec":
os.execv(cmd[0], cmd)
started = time.monotonic()
try:
status = subprocess.call(cmd)
except OSError as error:
status = 127
say("%s: %s" % (cmd[0], error))
if status != 0 and time.monotonic() - started < QUICK_FAILURE:
notify("%s did not start" % os.path.basename(path),
"It exited with status %d; the session's desktop.log says why." % status)
return status
if __name__ == "__main__":
sys.exit(main(sys.argv[1:]))
@@ -0,0 +1,331 @@
#!/usr/bin/python3
"""Runs a Windows program with the Proton the Steam client has chosen for it.
droiddeck-proton-run [--window] PROGRAM [ARGS...]
The program belongs to an installed Steam game when it sits in one's folder, to a prefix when it
sits in one's drive_c, or to a non-Steam shortcut that names it; any other program gets a prefix of
its own, under the id the client gives a new shortcut. That title's CompatToolMapping entry in
config.vdf picks the tool, else the client's default for all other titles ("0"). The tool is
started as the client starts it - its toolmanifest's command line, the waitforexitandrun verb and
the STEAM_COMPAT_* environment - so its wrapper applies the game's settings, components and audio.
--window keeps the program in the desktop's own window, drawn with WineD3D.
"""
import json
import os
import re
import runpy
import struct
import sys
import zlib
HOME = os.environ.get("HOME") or "/root"
STEAM_ROOT = os.path.join(HOME, ".local", "share", "Steam")
BIN_DIR = os.path.dirname(os.path.abspath(__file__))
STATE = os.path.join(HOME, ".local", "share", "droiddeck-compat", "state.json")
EXTRA_LIBRARIES = ("/mnt/droiddeck", "/mnt/droiddeck-sd")
SYSTEM_TOOLS = ("/usr/share/steam/compatibilitytools.d", "/usr/local/share/steam/compatibilitytools.d")
VERB = "waitforexitandrun"
SHORTCUT_BIT = 0x80000000
STEAM_APP_LIMIT = 2 ** 31
_compat = None
def say(message):
print("droiddeck-proton-run: %s" % message, file=sys.stderr, flush=True)
def compat():
global _compat
if _compat is None:
_compat = runpy.run_path(os.path.join(BIN_DIR, "steam-compatibility"))
return _compat
def read_text(path):
try:
with open(path, encoding="utf-8", errors="replace") as f:
return f.read()
except OSError:
return None
def libraries(root):
found, seen = [], set()
for library in compat()["libraries"](root) + list(EXTRA_LIBRARIES):
try:
key = os.stat(os.path.join(library, "steamapps"))
except OSError:
continue
if (key.st_dev, key.st_ino) not in seen:
seen.add((key.st_dev, key.st_ino))
found.append(library)
return found
def mapping(root):
text = read_text(os.path.join(root, "config", "config.vdf"))
if not text:
return {}
c = compat()
tokens = c["tokenize"](text)
steam = c["find_block"](tokens, ["InstallConfigStore", "Software", "Valve", "Steam"])
if steam is None:
return {}
block = c["find_block"](tokens[steam + 1:], ["CompatToolMapping"])
return {} if block is None else c["read_mapping"](tokens, block + steam + 1)
def tools(root):
"""Windows compatibility tools this runtime can start, by the name config.vdf uses."""
found = {}
for base in [os.path.join(root, "compatibilitytools.d")] + list(SYSTEM_TOOLS):
try:
entries = sorted(os.listdir(base))
except OSError:
continue
for entry in entries:
folder = os.path.join(base, entry)
vdf = os.path.join(folder, "compatibilitytool.vdf")
if not re.search(r'"from_oslist"\s+"[^"]*windows', read_text(vdf) or "", re.I):
continue
command = compat()["manifest_entry_point"](os.path.join(folder, "toolmanifest.vdf"))
if not command or not os.access(os.path.join(folder, command), os.X_OK):
continue
name = compat()["first_key"](vdf, ["compatibilitytools", "compat_tools"]) or entry
found.setdefault(name, (folder, command))
return found
def state_default():
try:
with open(STATE, encoding="utf-8") as f:
value = json.load(f).get("default")
return value if isinstance(value, str) else None
except (OSError, ValueError, AttributeError):
return None
def choose_tool(appid, current, available):
"""(tool name, why) from the client's mapping, else the session's own default."""
for key, why in ((appid, "Steam's choice for this title"), ("0", "Steam's default")):
name = current.get(key)
if name in available:
return name, why
c = compat()
for name in (state_default(), c["TOOL"], c["TOOL_11"]):
if name in available:
return name, "the default ARM64 Proton"
return (sorted(available)[0], "the only one installed") if available else (None, None)
class Target:
def __init__(self, appid, compatdata, install, library, kind, start=None):
self.appid = str(appid)
self.compatdata = compatdata
self.install = install
self.library = library
self.kind = kind
self.start = start or install
def ancestors(path):
"""(folder, the entry below it on the way to path) from the program's folder up to /."""
child, folder = path, os.path.dirname(path)
while True:
yield folder, os.path.basename(child)
if folder == os.path.dirname(folder):
return
child, folder = folder, os.path.dirname(folder)
def identity(path):
try:
st = os.stat(path)
return st.st_dev, st.st_ino
except OSError:
return None
def installed_app(steamapps, installdir):
for name in sorted(os.listdir(steamapps)):
m = re.fullmatch(r"appmanifest_(\d+)\.acf", name)
if not m:
continue
found = re.search(r'"installdir"\s+"([^"]+)"', read_text(os.path.join(steamapps, name)) or "")
if found and found.group(1).lower() == installdir.lower():
return m.group(1)
return None
def steam_game(program, libs):
commons = {}
for library in libs:
key = identity(os.path.join(library, "steamapps", "common"))
if key:
commons.setdefault(key, library)
for folder, below in ancestors(program):
library = commons.get(identity(folder))
if library is None:
continue
appid = installed_app(os.path.join(library, "steamapps"), below)
if appid is None or appid in compat()["NOT_GAMES"]:
return None
return Target(appid, os.path.join(library, "steamapps", "compatdata", appid),
os.path.join(folder, below), library, "steam")
return None
def prefix_of(program):
for folder, below in ancestors(program):
parent = os.path.dirname(folder)
if below == "pfx" and os.path.basename(parent) == "compatdata" and os.path.basename(folder).isdigit():
library = os.path.dirname(os.path.dirname(parent))
return Target(os.path.basename(folder), folder, os.path.dirname(program), library, "prefix")
return None
def unquoted(value):
return value.strip().strip('"') if isinstance(value, str) else ""
def same_file(a, b):
try:
return bool(b) and os.path.samefile(a, b)
except OSError:
return False
def shortcut_entries(root):
"""(appid, exe, start dir) of the client's non-Steam shortcuts and the app's added games."""
entries = []
try:
tool = runpy.run_path(os.path.join(BIN_DIR, "droiddeck-steam-shortcuts"))
account = tool["account_dir"](root)
if account:
with open(os.path.join(account, "config", "shortcuts.vdf"), "rb") as f:
shortcuts = tool["parse"](f.read()).get("shortcuts", {})
for value in shortcuts.values():
if not isinstance(value, dict):
continue
appid = value.get("appid", value.get("AppId"))
exe = value.get("Exe", value.get("exe"))
if isinstance(appid, int) and exe:
entries.append((appid & 0xFFFFFFFF, unquoted(exe), unquoted(value.get("StartDir", ""))))
except (OSError, ValueError, IndexError, KeyError, struct.error):
pass
try:
with open(os.environ.get("BL_ADDED_GAMES", ""), encoding="utf-8") as f:
for game in json.load(f):
if isinstance(game, dict) and game.get("exe") and str(game.get("appid", "")).isdigit():
entries.append((int(game["appid"]) & 0xFFFFFFFF, game["exe"], game.get("dir") or ""))
except (OSError, ValueError, TypeError):
pass
return entries
def shortcut(program, root):
for appid, exe, start in shortcut_entries(root):
if same_file(program, exe):
return Target(appid, os.path.join(root, "steamapps", "compatdata", str(appid)),
os.path.dirname(program), root, "shortcut", start if os.path.isdir(start) else None)
return None
def own_id(program):
"""The id the client gives a new shortcut: the CRC of its quoted target and its name."""
name = os.path.splitext(os.path.basename(program))[0]
return zlib.crc32(('"%s"%s' % (program, name)).encode("utf-8")) | SHORTCUT_BIT
def locate(program, root):
libs = libraries(root)
found = steam_game(program, libs) or prefix_of(program) or shortcut(program, root)
if found:
return found
appid = own_id(program)
return Target(appid, os.path.join(root, "steamapps", "compatdata", str(appid)),
os.path.dirname(program), root, "own")
def steam_launches(target, program, root):
"""Whether a launch option of the Steam game starts [program]: then Steam should start it."""
if target.kind != "steam":
return False
entry = compat()["appinfo_entries"](os.path.join(root, "appcache", "appinfo.vdf")).get(target.appid, {})
return os.path.relpath(program, target.install).replace(os.sep, "/").lower() in entry.get("launch", ())
def environment(env, target, tool_folder, root, window=False):
env = dict(env)
steamapps = [os.path.join(lib, "steamapps") for lib in libraries(root)]
env.update({
"STEAM_COMPAT_DATA_PATH": target.compatdata,
"STEAM_COMPAT_CLIENT_INSTALL_PATH": root,
"STEAM_COMPAT_INSTALL_PATH": target.install,
"STEAM_COMPAT_LIBRARY_PATHS": ":".join(steamapps),
"STEAM_COMPAT_TOOL_PATHS": tool_folder,
"STEAM_COMPAT_SHADER_PATH": os.path.join(target.library, "steamapps", "shadercache", target.appid),
})
number = int(target.appid)
if number < STEAM_APP_LIMIT:
env.update(SteamAppId=target.appid, SteamGameId=target.appid, SteamOverlayGameId=target.appid,
STEAM_COMPAT_APP_ID=target.appid)
else:
game = str(number << 32 | 0x02000000)
env.update(SteamAppId="0", STEAM_COMPAT_APP_ID="0", SteamGameId=game, SteamOverlayGameId=game)
if window:
env.setdefault("PROTON_USE_WINED3D", "1")
return env
def windows_path(path):
return "Z:" + path.replace("/", "\\")
def arguments(program, args):
if program.lower().endswith(".msi"):
return ["msiexec", "/i", windows_path(program)] + args
return [program] + args
def main(argv):
window = False
while argv and argv[0].startswith("--"):
option = argv.pop(0)
if option == "--window":
window = True
elif option == "--":
break
else:
say("unknown option %s" % option)
return 2
if not argv:
say("usage: droiddeck-proton-run [--window] PROGRAM [ARGS...]")
return 2
program = os.path.abspath(argv[0])
if not os.path.isfile(program):
say("%s does not exist" % program)
return 2
root = os.environ.get("STEAM_COMPAT_CLIENT_INSTALL_PATH") or STEAM_ROOT
target = locate(program, root)
available = tools(root)
name, why = choose_tool(target.appid, mapping(root), available)
if name is None:
say("no Proton is installed yet; open Steam once and it is downloaded for you")
return 69
folder, command = available[name]
os.makedirs(target.compatdata, exist_ok=True)
env = environment(os.environ, target, folder, root, window)
say("%s with %s (%s), %s prefix %s" % (program, name, why, target.kind, target.compatdata))
try:
os.chdir(target.start)
except OSError:
os.chdir(os.path.dirname(program))
entry = os.path.join(folder, command)
os.execve(entry, [entry, VERB] + arguments(program, argv[1:]), env)
if __name__ == "__main__":
sys.exit(main(sys.argv[1:]))
Loaded 100 of 128 files, more files were not shown because too many files have changed in this diff. Show more