mirror of
https://github.com/Droid-Deck/DroidDeck.git
synced 2026-10-14 19:27:09 -04:00
Compare commits
16
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
f635e92498 | ||
|
|
eda1e9b9c6 | ||
|
|
fa9a9446bb | ||
|
|
3e3c4d69e4 | ||
|
|
913806f169 | ||
|
|
7b018e6c42 | ||
|
|
e45d6f7192 | ||
|
|
b3ff93e80f | ||
|
|
c035351b4f | ||
|
|
10cdf97480 | ||
|
|
f614483d01 | ||
|
|
f5eb5e7b81 | ||
|
|
bf99dbbe73 | ||
|
|
46fac69438 | ||
|
|
82f04fb22e | ||
|
|
9796525449 |
No files matched your search
@@ -1,2 +1,4 @@
|
||||
tools/linuxfs/** text=auto eol=lf
|
||||
tools/mangoapp/** text=auto eol=lf
|
||||
tools/comet/LICENSE whitespace=-blank-at-eof
|
||||
tools/comet/droiddeck.patch whitespace=-blank-at-eol,-blank-at-eof
|
||||
@@ -17,7 +17,7 @@ DroidDeck brings the SteamOS experience to Android: Valve's Steam client in Big
|
||||
|
||||
Use Android 9 or newer on a supported Adreno device (730 or newer, or 8xx). Adreno 6xx is experimental: DirectX 11 uses DXVK 2 and may run, and DirectX 12 games can still crash. Mali, Xclipse, PowerVR, and Adreno 710 are unsupported. No root is required. Allow about 3 GB for the runtime and 1.1 GB more for the desktop and emulators. Install the APK from [Releases](https://github.com/Droid-Deck/DroidDeck/releases), install the Linux runtime, then press **Play** and sign in. Steam downloads on first launch. Install **Desktop & apps** to use the desktop and emulators. The **Store** installs Linux apps and games from Flathub (ARM64 builds) with Flatpak; with additional options to install Appimages and set up scripts. **Stores** (Setup › Stores › "Show Stores in the rail") signs in to GOG, Epic Games and Amazon Games, browses their catalogs and libraries, downloads games into the Games storage and adds each one to the Steam client as a non-Steam game, so it launches through Proton like everything else; the cog on its chip row sets which tab a store opens on and the download speed tier.
|
||||
|
||||
Before Steam launches, you must turn off **Restrict child processes** in Developer options. If this option is not available in developer settings (Android 12 and 13 devices), first launch of Steam will present a "Fix it for me" button, which will help automate the setup process.
|
||||
On Android 14 and newer, before Steam launches, you must turn on **Disable child process restrictions** in Developer options. If this option is not available in developer settings (Android 12 and 13 devices), first launch of Steam will present a "Fix it for me" button, which will help automate the setup process.
|
||||
|
||||
## Community
|
||||
|
||||
|
||||
@@ -6,6 +6,7 @@
|
||||
<uses-permission android:name="android.permission.ACCESS_NETWORK_STATE" />
|
||||
<uses-permission android:name="android.permission.WRITE_EXTERNAL_STORAGE" />
|
||||
<uses-permission android:name="android.permission.READ_EXTERNAL_STORAGE" />
|
||||
<uses-permission android:name="android.permission.MANAGE_EXTERNAL_STORAGE" />
|
||||
<uses-permission android:name="android.permission.WAKE_LOCK" />
|
||||
<uses-permission android:name="android.permission.POST_NOTIFICATIONS" />
|
||||
<uses-permission android:name="android.permission.FOREGROUND_SERVICE" />
|
||||
|
||||
@@ -2194,6 +2194,7 @@ struct constraint {
|
||||
int pending_region_set;
|
||||
int hint_set, pending_hint_set;
|
||||
double hint_x, hint_y, pending_hint_x, pending_hint_y;
|
||||
int gave_keys;
|
||||
};
|
||||
static struct wl_list g_constraints;
|
||||
static struct constraint *g_active_constraint;
|
||||
@@ -2272,6 +2273,14 @@ static void constraint_end(struct constraint *k, int tell_client, const char *wh
|
||||
g_ptr_y = oy + k->hint_y;
|
||||
}
|
||||
g_raw_valid = 0;
|
||||
if (k->gave_keys) {
|
||||
k->gave_keys = 0;
|
||||
for (int i = 0; i < g_nkbs; i++) {
|
||||
if (!k->surface || g_kbs[i].focus != k->surface->resource) continue;
|
||||
wl_keyboard_send_leave(g_kbs[i].kb, wl_display_next_serial(g_display), g_kbs[i].focus);
|
||||
g_kbs[i].focus = NULL;
|
||||
}
|
||||
}
|
||||
if (tell_client) constraint_send_state(k, 0);
|
||||
constraint_describe(k, name, sizeof(name));
|
||||
droiddeck_log("pointer", "%s: %s (%s), pointer at %d,%d", k->is_lock ? "unlocked" : "unconfined",
|
||||
@@ -2290,6 +2299,12 @@ static void constraint_activate(struct constraint *k) {
|
||||
if (!k->is_lock) confine_clamp(k, &g_ptr_x, &g_ptr_y);
|
||||
constraint_origin(k, &ox, &oy);
|
||||
pointer_focus(k->surface->resource, wl_fixed_from_double(g_ptr_x - ox), wl_fixed_from_double(g_ptr_y - oy));
|
||||
int focused = 0;
|
||||
for (int i = 0; i < g_nkbs; i++) if (g_kbs[i].focus) focused = 1;
|
||||
if (k->is_lock && !focused && keyboard_for(wl_resource_get_client(k->surface->resource))) {
|
||||
keyboard_focus(k->surface->resource);
|
||||
k->gave_keys = 1;
|
||||
}
|
||||
constraint_send_state(k, 1);
|
||||
constraint_describe(k, name, sizeof(name));
|
||||
if (k->is_lock)
|
||||
|
||||
@@ -177,6 +177,11 @@ class SessionActivity : ComponentActivity(), SurfaceHolder.Callback {
|
||||
if (pointerLocked == locked) return@post
|
||||
pointerLocked = locked
|
||||
Log.i(TAG, "pointer lock " + if (locked) "taken by a program" else "released")
|
||||
// Touchpad deltas are measured from the middle while locked (moveLockedPointer).
|
||||
if (locked && ::touchpad.isInitialized) {
|
||||
drawnRect()?.let { touchpad.place(it.centerX(), it.centerY()) }
|
||||
cursorVisible = false
|
||||
}
|
||||
updatePointerCapture()
|
||||
}
|
||||
}
|
||||
@@ -308,16 +313,16 @@ class SessionActivity : ComponentActivity(), SurfaceHolder.Callback {
|
||||
override fun onInputDeviceAdded(deviceId: Int) {
|
||||
logInputDevice("connected", deviceId)
|
||||
padBridge?.syncConnectedControllers()
|
||||
updateOnScreenControls()
|
||||
refreshControls()
|
||||
updatePointerCapture()
|
||||
}
|
||||
override fun onInputDeviceRemoved(deviceId: Int) {
|
||||
Log.i(TAG, "input device $deviceId disconnected")
|
||||
padBridge?.onDeviceRemoved(deviceId)
|
||||
updateOnScreenControls()
|
||||
refreshControls()
|
||||
updatePointerCapture()
|
||||
}
|
||||
override fun onInputDeviceChanged(deviceId: Int) = updateOnScreenControls()
|
||||
override fun onInputDeviceChanged(deviceId: Int) = refreshControls()
|
||||
}
|
||||
|
||||
private var runtimeRemovalBlocked = false
|
||||
@@ -381,7 +386,7 @@ class SessionActivity : ComponentActivity(), SurfaceHolder.Callback {
|
||||
bridge.setSelectSteam(ControllerPrefs.read(this).selectSteam)
|
||||
padBridge = bridge
|
||||
SessionState.padBridge = bridge
|
||||
SteamControllerBle.listener = { uiHandler.post { updatePadMotion(); updateOnScreenControls() } }
|
||||
SteamControllerBle.listener = { uiHandler.post { updatePadMotion(); refreshControls() } }
|
||||
steamController = SteamControllerBle(this).also { it.start() }
|
||||
padMotion = com.droiddeck.launcher.input.PadMotion(this) {
|
||||
@Suppress("DEPRECATION")
|
||||
@@ -1705,9 +1710,11 @@ class SessionActivity : ComponentActivity(), SurfaceHolder.Callback {
|
||||
}
|
||||
|
||||
private val pointerListener = object : PointerGestures.Listener {
|
||||
override fun onMove(x: Float, y: Float) = movePointer(x, y)
|
||||
override fun onMove(x: Float, y: Float) {
|
||||
if (pointerLocked) moveLockedPointer(x, y) else movePointer(x, y)
|
||||
}
|
||||
override fun onButton(button: Int, pressed: Boolean, x: Float, y: Float) {
|
||||
movePointer(x, y)
|
||||
if (!pointerLocked) movePointer(x, y)
|
||||
WaylandCompositor.nativeSendSceneInput(3, button, if (pressed) 1 else 0)
|
||||
}
|
||||
override fun onWheel(steps: Int) { WaylandCompositor.nativeSendSceneInput(4, steps, 0) }
|
||||
@@ -1717,6 +1724,14 @@ class SessionActivity : ComponentActivity(), SurfaceHolder.Callback {
|
||||
}
|
||||
}
|
||||
|
||||
private fun moveLockedPointer(x: Float, y: Float) {
|
||||
val rect = drawnRect() ?: return
|
||||
val scale = SessionState.outputSize.first / rect.width()
|
||||
WaylandCompositor.sendPointerDelta((x - rect.centerX()) * scale, (y - rect.centerY()) * scale)
|
||||
touchpad.place(rect.centerX(), rect.centerY())
|
||||
cursorVisible = false
|
||||
}
|
||||
|
||||
private fun movePointer(x: Float, y: Float) {
|
||||
val rect = drawnRect() ?: return
|
||||
val cx = x.coerceIn(rect.left, rect.right)
|
||||
@@ -1859,11 +1874,17 @@ class SessionActivity : ComponentActivity(), SurfaceHolder.Callback {
|
||||
applyUnfoldedControls()
|
||||
}
|
||||
|
||||
private fun refreshControls() {
|
||||
applyUnfoldedControls()
|
||||
updateOnScreenControls()
|
||||
}
|
||||
|
||||
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) &&
|
||||
!PadBridge.anyControllerConnected() && !SteamControllerBle.connected &&
|
||||
SessionState.mode == SessionService.MODE_STEAM && SessionState.deckPad &&
|
||||
!pipUi && !isInPictureInPictureMode && width > 0 && height > 0 &&
|
||||
resources.configuration.smallestScreenWidthDp >= com.droiddeck.launcher.session.SessionDisplay.UNFOLDED_MIN_DP
|
||||
@@ -2274,12 +2295,12 @@ class SessionActivity : ComponentActivity(), SurfaceHolder.Callback {
|
||||
if (SessionState.guestPid > 1) padBridge?.syncConnectedControllers()
|
||||
onScreenControls?.reload()
|
||||
controllerSettings = ControllerPrefs.read(this)
|
||||
updateOnScreenControls()
|
||||
resumed = true
|
||||
updatePointerCapture()
|
||||
if (!pipUi) sessionClipboard.start()
|
||||
updatePadMotion()
|
||||
readPrefs()
|
||||
refreshControls()
|
||||
if (::pip.isInitialized) pip.resumed()
|
||||
if (CompositorHost.isStarted) applyFrameGen()
|
||||
SessionPrefs.sessionPrefs(this).registerOnSharedPreferenceChangeListener(cardboardPrefListener)
|
||||
|
||||
@@ -5,12 +5,14 @@ import android.content.Context
|
||||
import android.content.Intent
|
||||
import android.content.pm.ShortcutInfo
|
||||
import android.content.pm.ShortcutManager
|
||||
import android.graphics.Bitmap
|
||||
import android.graphics.BitmapFactory
|
||||
import android.graphics.drawable.Icon
|
||||
import android.net.Uri
|
||||
import android.widget.Toast
|
||||
import com.droiddeck.launcher.MainActivity
|
||||
import com.droiddeck.launcher.R
|
||||
import java.io.File
|
||||
|
||||
object GameShortcuts {
|
||||
fun launchIntent(context: Context, gameId: String) = Intent(Intent.ACTION_VIEW,
|
||||
@@ -19,22 +21,35 @@ object GameShortcuts {
|
||||
|
||||
private fun info(context: Context, game: Library.SteamGame): ShortcutInfo {
|
||||
val name = game.name.ifBlank { context.getString(R.string.app_name) }
|
||||
val icon = listOfNotNull(game.icon, game.art).firstNotNullOfOrNull { file -> runCatching {
|
||||
val bounds = BitmapFactory.Options().apply { inJustDecodeBounds = true }
|
||||
BitmapFactory.decodeFile(file.path, bounds)
|
||||
if (bounds.outWidth <= 0 || bounds.outHeight <= 0) return@runCatching null
|
||||
val options = BitmapFactory.Options().apply {
|
||||
inSampleSize = 1
|
||||
while (maxOf(bounds.outWidth, bounds.outHeight) / inSampleSize > 256) inSampleSize *= 2
|
||||
}
|
||||
BitmapFactory.decodeFile(file.path, options)?.let(Icon::createWithBitmap)
|
||||
}.getOrNull() } ?: Icon.createWithResource(context, R.mipmap.ic_launcher)
|
||||
val icon = iconBitmap(game)?.let(Icon::createWithBitmap) ?: Icon.createWithResource(context, R.mipmap.ic_launcher)
|
||||
return ShortcutInfo.Builder(context, "game:${game.gameIdString}")
|
||||
.setActivity(android.content.ComponentName(context, MainActivity::class.java))
|
||||
.setShortLabel(name.take(40)).setLongLabel(name).setIcon(icon)
|
||||
.setIntent(launchIntent(context, game.gameIdString)).build()
|
||||
}
|
||||
|
||||
internal fun iconBitmap(game: Library.SteamGame): Bitmap? {
|
||||
val icon = game.icon?.let(::decode)
|
||||
if (icon != null && minOf(icon.width, icon.height) >= SHARP_ICON_PX) return icon
|
||||
return game.art?.let(::decode)?.let(::square) ?: icon
|
||||
}
|
||||
|
||||
private fun decode(file: File): Bitmap? = runCatching {
|
||||
val bounds = BitmapFactory.Options().apply { inJustDecodeBounds = true }
|
||||
BitmapFactory.decodeFile(file.path, bounds)
|
||||
if (bounds.outWidth <= 0 || bounds.outHeight <= 0) return@runCatching null
|
||||
val options = BitmapFactory.Options().apply {
|
||||
inSampleSize = 1
|
||||
while (minOf(bounds.outWidth, bounds.outHeight) / (inSampleSize * 2) >= ICON_PX) inSampleSize *= 2
|
||||
}
|
||||
BitmapFactory.decodeFile(file.path, options)
|
||||
}.getOrNull()
|
||||
|
||||
private fun square(bitmap: Bitmap): Bitmap {
|
||||
val side = minOf(bitmap.width, bitmap.height)
|
||||
return Bitmap.createBitmap(bitmap, (bitmap.width - side) / 2, (bitmap.height - side) / 2, side, side)
|
||||
}
|
||||
|
||||
fun pin(activity: Activity, game: Library.SteamGame) {
|
||||
val manager = activity.getSystemService(ShortcutManager::class.java)
|
||||
val requested = runCatching { manager.isRequestPinShortcutSupported &&
|
||||
@@ -44,4 +59,7 @@ object GameShortcuts {
|
||||
|
||||
fun result(activity: Activity, game: Library.SteamGame): Intent =
|
||||
activity.getSystemService(ShortcutManager::class.java).createShortcutResultIntent(info(activity, game))
|
||||
|
||||
private const val ICON_PX = 256
|
||||
private const val SHARP_ICON_PX = 128
|
||||
}
|
||||
@@ -0,0 +1,91 @@
|
||||
package com.droiddeck.launcher.frontend
|
||||
|
||||
import android.content.Context
|
||||
import android.util.Log
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.setValue
|
||||
import com.droiddeck.launcher.stores.StoreNet
|
||||
import org.json.JSONArray
|
||||
import org.json.JSONObject
|
||||
import java.util.Locale
|
||||
import java.util.concurrent.atomic.AtomicBoolean
|
||||
|
||||
object SteamFeatured {
|
||||
class Game(val appId: Int, val name: String, val art: String)
|
||||
|
||||
private const val TAG = "SteamFeatured"
|
||||
private const val PREFS = "steam_featured"
|
||||
private const val KEY_GAMES = "games_v2"
|
||||
private const val KEY_AT = "at"
|
||||
private const val FRESH_MS = 6 * 60 * 60 * 1000L
|
||||
private val SECTIONS = listOf("top_sellers", "new_releases", "specials")
|
||||
private const val ASSET_ROOT = "https://shared.akamai.steamstatic.com/store_item_assets/"
|
||||
|
||||
private val HIDDEN_DESCRIPTORS = setOf(1, 3, 4)
|
||||
|
||||
var games by mutableStateOf<List<Game>>(emptyList())
|
||||
private set
|
||||
private val loading = AtomicBoolean(false)
|
||||
|
||||
fun load(context: Context) {
|
||||
val prefs = context.applicationContext.getSharedPreferences(PREFS, Context.MODE_PRIVATE)
|
||||
if (games.isEmpty()) parse(prefs.getString(KEY_GAMES, null))?.let { games = it }
|
||||
if (System.currentTimeMillis() - prefs.getLong(KEY_AT, 0L) in 0 until FRESH_MS && games.isNotEmpty()) return
|
||||
if (!loading.compareAndSet(false, true)) return
|
||||
Thread({
|
||||
try {
|
||||
val cc = Locale.getDefault().country.takeIf { it.length == 2 } ?: "US"
|
||||
val body = StoreNet.get("https://store.steampowered.com/api/featuredcategories?cc=$cc&l=english") ?: return@Thread
|
||||
val root = JSONObject(body)
|
||||
val ids = LinkedHashSet<Int>()
|
||||
for (section in SECTIONS) {
|
||||
val items = root.optJSONObject(section)?.optJSONArray("items") ?: continue
|
||||
for (i in 0 until items.length()) {
|
||||
val o = items.optJSONObject(i) ?: continue
|
||||
val id = o.optInt("id")
|
||||
if (id > 0 && o.optInt("type", 0) == 0) ids += id
|
||||
}
|
||||
}
|
||||
val list = withArt(ids, cc)
|
||||
if (list.isEmpty()) return@Thread
|
||||
prefs.edit().putString(KEY_GAMES, JSONArray(list.map { JSONObject().put("id", it.appId).put("name", it.name).put("art", it.art) }).toString())
|
||||
.putLong(KEY_AT, System.currentTimeMillis()).apply()
|
||||
android.os.Handler(android.os.Looper.getMainLooper()).post { games = list }
|
||||
Log.i(TAG, "featured games: ${list.size}")
|
||||
} catch (e: Exception) {
|
||||
Log.w(TAG, "featured: ${e.javaClass.simpleName}")
|
||||
} finally {
|
||||
loading.set(false)
|
||||
}
|
||||
}, "steam-featured").start()
|
||||
}
|
||||
|
||||
private fun withArt(ids: Collection<Int>, cc: String): List<Game> {
|
||||
if (ids.isEmpty()) return emptyList()
|
||||
val input = JSONObject()
|
||||
.put("ids", JSONArray(ids.map { JSONObject().put("appid", it) }))
|
||||
.put("context", JSONObject().put("language", "english").put("country_code", cc))
|
||||
.put("data_request", JSONObject().put("include_assets", true))
|
||||
val body = StoreNet.get(
|
||||
"https://api.steampowered.com/IStoreBrowseService/GetItems/v1/?input_json=" + java.net.URLEncoder.encode(input.toString(), "UTF-8"),
|
||||
) ?: return emptyList()
|
||||
val items = JSONObject(body).optJSONObject("response")?.optJSONArray("store_items") ?: return emptyList()
|
||||
return (0 until items.length()).mapNotNull { i ->
|
||||
val o = items.optJSONObject(i) ?: return@mapNotNull null
|
||||
val descriptors = o.optJSONArray("content_descriptorids")
|
||||
if (descriptors != null && (0 until descriptors.length()).any { descriptors.optInt(it) in HIDDEN_DESCRIPTORS }) return@mapNotNull null
|
||||
val assets = o.optJSONObject("assets") ?: return@mapNotNull null
|
||||
val format = assets.optString("asset_url_format").takeIf { "\${FILENAME}" in it } ?: return@mapNotNull null
|
||||
val file = listOf("library_capsule_2x", "library_capsule").firstNotNullOfOrNull { k -> assets.optString(k).takeIf { it.isNotEmpty() } }
|
||||
?: return@mapNotNull null
|
||||
Game(o.optInt("appid"), o.optString("name"), ASSET_ROOT + format.replace("\${FILENAME}", file))
|
||||
}
|
||||
}
|
||||
|
||||
private fun parse(text: String?): List<Game>? {
|
||||
val a = runCatching { JSONArray(text ?: return null) }.getOrNull() ?: return null
|
||||
return (0 until a.length()).mapNotNull { a.optJSONObject(it) }.map { Game(it.optInt("id"), it.optString("name"), it.optString("art")) }
|
||||
.filter { it.appId > 0 && it.art.isNotEmpty() }.takeIf { it.isNotEmpty() }
|
||||
}
|
||||
}
|
||||
@@ -19,6 +19,7 @@ public final class PadBridge {
|
||||
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_RX, MotionEvent.AXIS_RY,
|
||||
MotionEvent.AXIS_HAT_X, MotionEvent.AXIS_HAT_Y, MotionEvent.AXIS_LTRIGGER,
|
||||
MotionEvent.AXIS_RTRIGGER, MotionEvent.AXIS_BRAKE, MotionEvent.AXIS_GAS
|
||||
};
|
||||
@@ -100,6 +101,7 @@ public final class PadBridge {
|
||||
}
|
||||
|
||||
public synchronized void stop() {
|
||||
releaseBackPaddles();
|
||||
activeControllerId = NO_CONTROLLER;
|
||||
for (int slot = 0; slot < SLOTS; slot++) {
|
||||
owners[slot] = NO_CONTROLLER;
|
||||
@@ -243,6 +245,12 @@ public final class PadBridge {
|
||||
case KeyEvent.KEYCODE_BUTTON_THUMBR: pad.press(9, pressed); break;
|
||||
case KeyEvent.KEYCODE_BUTTON_MODE:
|
||||
case KeyEvent.KEYCODE_HOME: pad.press(PadState.GUIDE, pressed); break;
|
||||
case KeyEvent.KEYCODE_BUTTON_Z:
|
||||
if (isPrimary(event)) DeckControls.INSTANCE.setGrip(DeckControls.L4, pressed);
|
||||
break;
|
||||
case KeyEvent.KEYCODE_BUTTON_C:
|
||||
if (isPrimary(event)) DeckControls.INSTANCE.setGrip(DeckControls.R4, pressed);
|
||||
break;
|
||||
case KeyEvent.KEYCODE_BUTTON_L2: pad.leftTrigger = pressed ? 1f : 0f; break;
|
||||
case KeyEvent.KEYCODE_BUTTON_R2: pad.rightTrigger = pressed ? 1f : 0f; break;
|
||||
case KeyEvent.KEYCODE_DPAD_UP: pad.up = pressed; break;
|
||||
@@ -269,12 +277,27 @@ public final class PadBridge {
|
||||
scheduleStats();
|
||||
pad.leftX = axis(event, MotionEvent.AXIS_X);
|
||||
pad.leftY = axis(event, MotionEvent.AXIS_Y);
|
||||
pad.rightX = axis(event, MotionEvent.AXIS_Z);
|
||||
pad.rightY = axis(event, MotionEvent.AXIS_RZ);
|
||||
float lt = event.getAxisValue(MotionEvent.AXIS_LTRIGGER);
|
||||
float rt = event.getAxisValue(MotionEvent.AXIS_RTRIGGER);
|
||||
if (lt == 0f) lt = event.getAxisValue(MotionEvent.AXIS_BRAKE);
|
||||
if (rt == 0f) rt = event.getAxisValue(MotionEvent.AXIS_GAS);
|
||||
InputDevice device = event.getDevice();
|
||||
boolean wiredXboxLayout = device != null
|
||||
&& ((device.getVendorId() == 0x20d6 && device.getProductId() == 0x2063)
|
||||
|| (device.getVendorId() == 0x0e6f && device.getProductId() == 0x02f0))
|
||||
&& device.getMotionRange(MotionEvent.AXIS_RX, event.getSource()) != null
|
||||
&& device.getMotionRange(MotionEvent.AXIS_RY, event.getSource()) != null
|
||||
&& device.getMotionRange(MotionEvent.AXIS_Z, event.getSource()) != null
|
||||
&& device.getMotionRange(MotionEvent.AXIS_RZ, event.getSource()) != null;
|
||||
pad.rightX = axis(event, wiredXboxLayout ? MotionEvent.AXIS_RX : MotionEvent.AXIS_Z);
|
||||
pad.rightY = axis(event, wiredXboxLayout ? MotionEvent.AXIS_RY : MotionEvent.AXIS_RZ);
|
||||
float lt;
|
||||
float rt;
|
||||
if (wiredXboxLayout) {
|
||||
lt = Math.max(0f, Math.min(1f, (event.getAxisValue(MotionEvent.AXIS_Z) + 1f) / 2f));
|
||||
rt = Math.max(0f, Math.min(1f, (event.getAxisValue(MotionEvent.AXIS_RZ) + 1f) / 2f));
|
||||
} else {
|
||||
lt = event.getAxisValue(MotionEvent.AXIS_LTRIGGER);
|
||||
rt = event.getAxisValue(MotionEvent.AXIS_RTRIGGER);
|
||||
if (lt == 0f) lt = event.getAxisValue(MotionEvent.AXIS_BRAKE);
|
||||
if (rt == 0f) rt = event.getAxisValue(MotionEvent.AXIS_GAS);
|
||||
}
|
||||
pad.leftTrigger = lt;
|
||||
pad.rightTrigger = rt;
|
||||
float hatX = event.getAxisValue(MotionEvent.AXIS_HAT_X);
|
||||
@@ -322,6 +345,7 @@ public final class PadBridge {
|
||||
}
|
||||
|
||||
public synchronized void releaseAll() {
|
||||
releaseBackPaddles();
|
||||
state.clear();
|
||||
publish();
|
||||
for (int slot = 1; slot < SLOTS; slot++) {
|
||||
@@ -415,11 +439,21 @@ public final class PadBridge {
|
||||
return slot == 0 ? state : playerStates[slot];
|
||||
}
|
||||
|
||||
private boolean isPrimary(KeyEvent event) {
|
||||
return owners[0] == event.getDevice().getId();
|
||||
}
|
||||
|
||||
private void releaseBackPaddles() {
|
||||
DeckControls.INSTANCE.setGrip(DeckControls.L4, false);
|
||||
DeckControls.INSTANCE.setGrip(DeckControls.R4, false);
|
||||
}
|
||||
|
||||
private void free(int slot) {
|
||||
Log.i(TAG, "player " + (slot + 1) + " (id " + owners[slot] + ") left");
|
||||
owners[slot] = NO_CONTROLLER;
|
||||
slotControllers.set(slot, NO_CONTROLLER);
|
||||
if (slot == 0) {
|
||||
releaseBackPaddles();
|
||||
state.clear();
|
||||
publish();
|
||||
return;
|
||||
|
||||
@@ -158,7 +158,8 @@ object SteamTouchConfig {
|
||||
|
||||
fun load(context: Context, appId: Int): Config {
|
||||
val dir = configDir(context)
|
||||
val file = dir?.let { autosaveConfig(it, appId) }
|
||||
val file = dir?.let { deviceConfig(context, it, appId) }
|
||||
?: dir?.let { autosaveConfig(it, appId) }
|
||||
?: loggedConfig(context, appId)?.takeIf { isTouchConfig(it) }
|
||||
?: dir?.let { configsetConfig(context, it, appId) }
|
||||
if (file == null) {
|
||||
@@ -207,11 +208,16 @@ object SteamTouchConfig {
|
||||
null
|
||||
}
|
||||
|
||||
private fun configsetConfig(context: Context, dir: File, appId: Int): File? = try {
|
||||
val set = KeyValues.parse(File(dir, CONFIGSET).readText()).child("controller_config")?.child(appId.toString())
|
||||
private fun configsetConfig(context: Context, dir: File, appId: Int): File? = entryConfig(context, dir, CONFIGSET, appId)
|
||||
|
||||
private fun deviceConfig(context: Context, dir: File, appId: Int): File? = entryConfig(context, dir, DEVICE_CONFIGSET, appId)
|
||||
|
||||
private fun entryConfig(context: Context, dir: File, configset: String, appId: Int): File? = try {
|
||||
val set = KeyValues.parse(File(dir, configset).readText()).child("controller_config")?.child(appId.toString())
|
||||
when {
|
||||
set == null -> null
|
||||
set.string("autosave") == "1" -> File(dir, "$appId/controller_mobile_touch.vdf")
|
||||
set.string("workshop") != null -> workshopConfig(context, set.string("workshop")!!)
|
||||
set.string("template") != null -> File(steamRoot(context), "controller_base/templates/" + set.string("template"))
|
||||
else -> null
|
||||
}?.takeIf { it.isFile }
|
||||
@@ -219,7 +225,16 @@ object SteamTouchConfig {
|
||||
null
|
||||
}
|
||||
|
||||
internal fun workshopConfig(context: Context, id: String): File? {
|
||||
if (!id.all(Char::isDigit)) return null
|
||||
return File(steamRoot(context), "steamapps/workshop/content/$CONFIGS_APP/$id").listFiles()
|
||||
?.filter { it.isFile && (it.name.endsWith(".vdf") || it.name.endsWith(".bin")) }
|
||||
?.maxByOrNull { it.lastModified() }
|
||||
}
|
||||
|
||||
private const val CONFIGSET = "configset_controller_mobile_touch.vdf"
|
||||
private const val DEVICE_CONFIGSET = "configset_MT-DROIDDECK0001.vdf"
|
||||
private const val CONFIGS_APP = 241100
|
||||
|
||||
|
||||
private fun availability(mappings: KeyValues.Node): Map<Int, Set<Int>> {
|
||||
@@ -363,10 +378,20 @@ object SteamTouchConfig {
|
||||
tmp.writeText(text)
|
||||
if (!tmp.renameTo(target)) return null
|
||||
selectAutosave(dir, appId)
|
||||
dropDeviceEntry(dir, appId)
|
||||
Log.i(TAG, "steam touch: saved layouts ${sets.keys} for app $appId to ${target.path}")
|
||||
return target
|
||||
}
|
||||
|
||||
private fun dropDeviceEntry(dir: File, appId: Int) {
|
||||
val file = File(dir, DEVICE_CONFIGSET)
|
||||
if (!file.isFile) return
|
||||
val text = file.readText()
|
||||
val block = Regex("""\n\s*"$appId"\s*\{[^}]*\}""")
|
||||
if (!block.containsMatchIn(text)) return
|
||||
File(file.path + ".tmp").apply { writeText(text.replace(block, "")) }.renameTo(file)
|
||||
}
|
||||
|
||||
private fun selectAutosave(dir: File, appId: Int) {
|
||||
val file = File(dir, CONFIGSET)
|
||||
val text = if (file.isFile) file.readText() else "\"controller_config\"\n{\n}\n"
|
||||
|
||||
@@ -45,8 +45,9 @@ class BatteryComponent(val dir: File, private val vpowerDir: File) : SessionPart
|
||||
val tempDeci = sticky?.getIntExtra(BatteryManager.EXTRA_TEMPERATURE, 0) ?: 0
|
||||
val tech = sticky?.getStringExtra(BatteryManager.EXTRA_TECHNOLOGY)?.takeIf { it.isNotBlank() } ?: "Li-ion"
|
||||
val chargeNowUah = bm?.getLongProperty(BatteryManager.BATTERY_PROPERTY_CHARGE_COUNTER)?.takeIf { it > 0 && it != Long.MIN_VALUE } ?: 0L
|
||||
val currentNowUa = bm?.getLongProperty(BatteryManager.BATTERY_PROPERTY_CURRENT_NOW)?.takeIf { it != Long.MIN_VALUE } ?: 0L
|
||||
val currentAvgUa = bm?.getLongProperty(BatteryManager.BATTERY_PROPERTY_CURRENT_AVERAGE)?.takeIf { it != Long.MIN_VALUE && it != 0L } ?: currentNowUa
|
||||
val currentNowUa = toMicroamps(bm?.getLongProperty(BatteryManager.BATTERY_PROPERTY_CURRENT_NOW)?.takeIf { it != Long.MIN_VALUE } ?: 0L)
|
||||
val currentAvgUa = toMicroamps(bm?.getLongProperty(BatteryManager.BATTERY_PROPERTY_CURRENT_AVERAGE)?.takeIf { it != Long.MIN_VALUE && it != 0L } ?: 0L)
|
||||
.takeIf { it != 0L } ?: currentNowUa
|
||||
val discharging = status == BatteryManager.BATTERY_STATUS_DISCHARGING || status == BatteryManager.BATTERY_STATUS_NOT_CHARGING
|
||||
val chargeFullUah = if (chargeNowUah > 0 && pct > 0) chargeNowUah * 100 / pct else 0L
|
||||
val energyNowUwh = chargeNowUah * voltageUv / 1_000_000L
|
||||
@@ -125,6 +126,12 @@ class BatteryComponent(val dir: File, private val vpowerDir: File) : SessionPart
|
||||
|
||||
companion object {
|
||||
private const val TAG = "SessionService"
|
||||
// CURRENT_NOW/CURRENT_AVERAGE are documented as µA, but Samsung and other vendors report mA,
|
||||
// which made every lifetime estimate 1000x too long. No real µA reading under load is below 10 mA,
|
||||
// and no mA reading is above 10 A, so anything smaller than 10_000 is taken as mA.
|
||||
private const val MILLIAMP_CEILING = 10_000L
|
||||
|
||||
internal fun toMicroamps(raw: Long): Long = if (abs(raw) < MILLIAMP_CEILING) raw * 1000L else raw
|
||||
private const val PERIOD_MS = 5_000L
|
||||
private val VPOWER_FILES = listOf(
|
||||
"ac_status", "battery_percent", "battery_status",
|
||||
|
||||
@@ -119,7 +119,7 @@ object SessionArtifacts {
|
||||
val lines = StringBuilder("rules ${LogRedactor.RULES_VERSION}\n")
|
||||
record.files.forEach { (f, s) ->
|
||||
lines.append(s.length).append('\t').append(s.modified).append('\t')
|
||||
.append(f.relativeTo(dir).path).append('\n')
|
||||
.append(f.relativeTo(dir).invariantSeparatorsPath).append('\n')
|
||||
}
|
||||
tree.writeText(lines.toString())
|
||||
true
|
||||
|
||||
@@ -58,7 +58,7 @@ object SessionLogShare {
|
||||
if (percent != shown) { shown = percent; onProgress?.invoke(percent / 100f) }
|
||||
}
|
||||
ZipOutputStream(zip.outputStream().buffered()).use { z ->
|
||||
files.forEach { f -> addEntry(z, folder.name + "/" + f.relativeTo(folder).path, f); advance(f) }
|
||||
files.forEach { f -> addEntry(z, folder.name + "/" + f.relativeTo(folder).invariantSeparatorsPath, f); advance(f) }
|
||||
live.forEach { f -> addEntry(z, folder.name + "/steam/" + f.name, f); advance(f) }
|
||||
stores.forEach { f -> addEntry(z, "$STORES_DIR/" + f.name, f); advance(f) }
|
||||
tools.forEach { f -> addEntry(z, SessionPaths.TOOLS_DIR + "/" + f.name, f); advance(f) }
|
||||
|
||||
@@ -31,6 +31,7 @@ class StoreShelves(
|
||||
val deals: List<CatalogItem> = emptyList(),
|
||||
val free: List<CatalogItem> = emptyList(),
|
||||
val trending: List<CatalogItem> = emptyList(),
|
||||
val art: List<String> = emptyList(),
|
||||
) {
|
||||
val isEmpty: Boolean get() = whatsNew.isEmpty() && deals.isEmpty() && free.isEmpty() && trending.isEmpty()
|
||||
val all: List<CatalogItem> get() = (whatsNew + deals + free + trending).distinctBy { it.id }
|
||||
|
||||
@@ -55,7 +55,20 @@ object AmazonBackend : StoreBackend {
|
||||
Thread({ AmazonLibrary.sync(app, force) }, "amazon-sync").start()
|
||||
}
|
||||
|
||||
override fun loadShelves(context: Context, force: Boolean) {}
|
||||
override fun loadShelves(context: Context, force: Boolean) {
|
||||
val app = context.applicationContext
|
||||
AmazonShelves.cached(app)?.let { s -> StoresState.post { withArt(s.art) } }
|
||||
Thread({
|
||||
val shelves = AmazonShelves.featured(app, force)
|
||||
if (shelves != null) StoresState.post { withArt(shelves.art) }
|
||||
}, "amazon-shelves").start()
|
||||
}
|
||||
|
||||
private fun withArt(art: List<String>) {
|
||||
val s = StoresState.shelves[store]
|
||||
StoresState.shelves[store] = if (s == null) com.droiddeck.launcher.stores.StoreShelves(art = art)
|
||||
else com.droiddeck.launcher.stores.StoreShelves(s.whatsNew, s.deals, s.free, s.trending, art)
|
||||
}
|
||||
|
||||
override fun install(context: Context, item: CatalogItem, root: java.io.File) {
|
||||
val app = context.applicationContext
|
||||
|
||||
@@ -102,7 +102,7 @@ object AmazonLibrary {
|
||||
StoresState.post {
|
||||
StoresState.library[store] = mergeItems(StoresState.library[store], items)
|
||||
val installed = StoresState.installed.filter { it.sidecar.store == store }.map { it.sidecar.id }.toSet()
|
||||
StoresState.shelves[store] = StoreShelves(trending = items.filterNot { it.id in installed })
|
||||
StoresState.shelves[store] = StoreShelves(trending = items.filterNot { it.id in installed }, art = StoresState.shelves[store]?.art.orEmpty())
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,92 @@
|
||||
package com.droiddeck.launcher.stores.amazon
|
||||
|
||||
import android.content.Context
|
||||
import android.util.Log
|
||||
import com.droiddeck.launcher.stores.StoreNet
|
||||
import com.droiddeck.launcher.stores.StoreShelves
|
||||
import org.json.JSONArray
|
||||
import org.json.JSONObject
|
||||
import java.net.HttpURLConnection
|
||||
import java.net.URL
|
||||
|
||||
object AmazonShelves {
|
||||
private const val TAG = "AmazonShelves"
|
||||
private const val PAGE = "https://luna.amazon.com/claims/home"
|
||||
private const val GRAPHQL = "https://luna.amazon.com/graphql"
|
||||
private const val KEY_ART = "store_art"
|
||||
private const val KEY_ART_AT = "store_art_at"
|
||||
private const val FRESH_MS = 3 * 60 * 60 * 1000L
|
||||
private const val QUERY = "query { items(collectionType: FEATURED_CONTENT, pageSize: 50) { items { assets { title " +
|
||||
"cardMedia { defaultMedia { src1x src2x } } } } } }"
|
||||
|
||||
@Volatile private var memory: StoreShelves? = null
|
||||
|
||||
fun cached(context: Context): StoreShelves? =
|
||||
memory ?: fromPrefs(AmazonPrefs.get(context).getString(KEY_ART, null))?.also { memory = it }
|
||||
|
||||
fun featured(context: Context, force: Boolean): StoreShelves? {
|
||||
val prefs = AmazonPrefs.get(context)
|
||||
if (!force && System.currentTimeMillis() - prefs.getLong(KEY_ART_AT, 0L) in 0 until FRESH_MS) cached(context)?.let { return it }
|
||||
val art = runCatching { fetch() }.onFailure { Log.w(TAG, "featured: ${it.javaClass.simpleName}") }.getOrNull().orEmpty()
|
||||
if (art.isEmpty()) return cached(context)
|
||||
prefs.edit().putString(KEY_ART, JSONArray(art).toString()).putLong(KEY_ART_AT, System.currentTimeMillis()).apply()
|
||||
Log.i(TAG, "featured art: ${art.size}")
|
||||
return StoreShelves(art = art).also { memory = it }
|
||||
}
|
||||
|
||||
private fun fromPrefs(text: String?): StoreShelves? {
|
||||
val a = runCatching { JSONArray(text ?: return null) }.getOrNull() ?: return null
|
||||
val art = List(a.length()) { a.optString(it) }.filter { it.isNotEmpty() }
|
||||
return if (art.isEmpty()) null else StoreShelves(art = art)
|
||||
}
|
||||
|
||||
private fun fetch(): List<String> {
|
||||
val (page, cookies) = openPage() ?: return emptyList()
|
||||
val token = Regex("""name=['"]csrf-key['"] value=['"]([^'"]+)['"]""").find(page)?.groupValues?.get(1) ?: run {
|
||||
Log.w(TAG, "claims page had no csrf key")
|
||||
return emptyList()
|
||||
}
|
||||
val body = StoreNet.postJson(
|
||||
GRAPHQL, JSONObject().put("query", QUERY).toString(),
|
||||
headers = mapOf("client-id" to "CarboniteApp", "csrf-token" to token, "Cookie" to cookies, "Origin" to "https://luna.amazon.com", "Referer" to PAGE),
|
||||
) ?: return emptyList()
|
||||
val items = JSONObject(body).optJSONObject("data")?.optJSONObject("items")?.optJSONArray("items") ?: return emptyList()
|
||||
return (0 until items.length()).mapNotNull { i ->
|
||||
val media = items.optJSONObject(i)?.optJSONObject("assets")?.optJSONObject("cardMedia")?.optJSONObject("defaultMedia")
|
||||
media?.optString("src2x")?.takeIf { it.isNotEmpty() } ?: media?.optString("src1x")?.takeIf { it.isNotEmpty() }
|
||||
}.distinct()
|
||||
}
|
||||
|
||||
private fun openPage(): Pair<String, String>? {
|
||||
val jar = LinkedHashMap<String, String>()
|
||||
var url = PAGE
|
||||
repeat(5) {
|
||||
val conn = (URL(url).openConnection() as HttpURLConnection).apply {
|
||||
connectTimeout = 20_000
|
||||
readTimeout = 20_000
|
||||
instanceFollowRedirects = false
|
||||
setRequestProperty("User-Agent", StoreNet.BROWSER_UA)
|
||||
setRequestProperty("Accept", "text/html")
|
||||
if (jar.isNotEmpty()) setRequestProperty("Cookie", jar.entries.joinToString("; ") { "${it.key}=${it.value}" })
|
||||
}
|
||||
try {
|
||||
val code = conn.responseCode
|
||||
conn.headerFields["Set-Cookie"].orEmpty().forEach { c ->
|
||||
val pair = c.substringBefore(';')
|
||||
val name = pair.substringBefore('=').trim()
|
||||
if (name.isNotEmpty() && '=' in pair) jar[name] = pair.substringAfter('=')
|
||||
}
|
||||
if (code in 300..399) {
|
||||
url = URL(URL(url), conn.getHeaderField("Location") ?: return null).toString()
|
||||
return@repeat
|
||||
}
|
||||
if (code !in 200..299) { Log.w(TAG, "claims page $code"); return null }
|
||||
val page = conn.inputStream.bufferedReader().use { it.readText() }
|
||||
return page to jar.entries.joinToString("; ") { "${it.key}=${it.value}" }
|
||||
} finally {
|
||||
runCatching { conn.disconnect() }
|
||||
}
|
||||
}
|
||||
return null
|
||||
}
|
||||
}
|
||||
@@ -64,14 +64,16 @@ object EpicShelves {
|
||||
val a = o.optJSONArray(key) ?: return emptyList()
|
||||
return (0 until a.length()).mapNotNull { a.optJSONObject(it) }.mapNotNull(::itemFrom).distinctBy { it.id }
|
||||
}
|
||||
val s = StoreShelves(shelf("new"), shelf("deals"), shelf("free"), shelf("trending"))
|
||||
return if (s.isEmpty) null else s
|
||||
val art = o.optJSONArray("art")?.let { a -> List(a.length()) { a.optString(it) }.filter { it.isNotEmpty() } }.orEmpty()
|
||||
val s = StoreShelves(shelf("new"), shelf("deals"), shelf("free"), shelf("trending"), art)
|
||||
return if (s.isEmpty && art.isEmpty()) null else s
|
||||
}
|
||||
|
||||
fun toJson(s: StoreShelves): String = JSONObject().apply {
|
||||
listOf(s.whatsNew, s.deals, s.free, s.trending).zip(SHELF_KEYS).forEach { (items, key) ->
|
||||
put(key, JSONArray().apply { items.forEach { put(itemJson(it)) } })
|
||||
}
|
||||
put("art", JSONArray(s.art))
|
||||
}.toString()
|
||||
|
||||
fun cached(context: Context): StoreShelves? =
|
||||
@@ -111,6 +113,6 @@ object EpicShelves {
|
||||
releaseDate = owned.releaseDate.ifBlank { shelf.releaseDate },
|
||||
)
|
||||
}
|
||||
return StoreShelves(mark(s.whatsNew), mark(s.deals), mark(s.free), mark(s.trending))
|
||||
return StoreShelves(mark(s.whatsNew), mark(s.deals), mark(s.free), mark(s.trending), s.art)
|
||||
}
|
||||
}
|
||||
@@ -62,7 +62,7 @@ object GogBackend : StoreBackend {
|
||||
private fun markOwned(s: com.droiddeck.launcher.stores.StoreShelves): com.droiddeck.launcher.stores.StoreShelves {
|
||||
val owned = StoresState.library[store]?.associateBy { it.id } ?: return s
|
||||
fun mark(l: List<CatalogItem>) = l.map { i -> owned[i.id]?.let { o -> i.copy(owned = true, sizeBytes = o.sizeBytes) } ?: i }
|
||||
return com.droiddeck.launcher.stores.StoreShelves(mark(s.whatsNew), mark(s.deals), mark(s.free), mark(s.trending))
|
||||
return com.droiddeck.launcher.stores.StoreShelves(mark(s.whatsNew), mark(s.deals), mark(s.free), mark(s.trending), s.art)
|
||||
}
|
||||
|
||||
override fun install(context: Context, item: CatalogItem, root: File) {
|
||||
|
||||
@@ -46,10 +46,10 @@ import kotlin.math.min
|
||||
import kotlin.math.sin
|
||||
|
||||
@Composable
|
||||
internal fun EmptyLibrary(avoid: Rect?, fade: @Composable () -> Unit) {
|
||||
internal fun EmptyLibrary(avoid: Rect?, wall: (@Composable () -> Unit)? = null, fade: @Composable () -> Unit) {
|
||||
val clock = rememberMarkClock(avoid)
|
||||
Box(Modifier.fillMaxSize().onSizeChanged { clock.page = Size(it.width.toFloat(), it.height.toFloat()) }) {
|
||||
EmptyWall(clock)
|
||||
if (wall != null) wall() else EmptyWall(clock)
|
||||
fade()
|
||||
Mark(clock)
|
||||
}
|
||||
|
||||
@@ -6,6 +6,8 @@ import androidx.compose.ui.focus.FocusRequester
|
||||
import android.os.Build
|
||||
import androidx.compose.animation.AnimatedVisibility
|
||||
import androidx.compose.animation.animateColorAsState
|
||||
import androidx.compose.animation.core.Spring
|
||||
import androidx.compose.animation.core.animateFloatAsState
|
||||
import androidx.compose.animation.expandVertically
|
||||
import androidx.compose.animation.fadeIn
|
||||
import androidx.compose.animation.fadeOut
|
||||
@@ -16,10 +18,14 @@ import androidx.compose.foundation.border
|
||||
import androidx.compose.foundation.clickable
|
||||
import androidx.compose.foundation.hoverable
|
||||
import androidx.compose.foundation.interaction.MutableInteractionSource
|
||||
import androidx.compose.foundation.interaction.collectIsPressedAsState
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.IntrinsicSize
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.Spacer
|
||||
import androidx.compose.foundation.layout.fillMaxHeight
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.height
|
||||
@@ -30,15 +36,6 @@ import androidx.compose.foundation.rememberScrollState
|
||||
import androidx.compose.foundation.shape.CircleShape
|
||||
import androidx.compose.foundation.verticalScroll
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.foundation.layout.widthIn
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.ui.graphics.RectangleShape
|
||||
import androidx.compose.material.icons.automirrored.filled.KeyboardArrowRight
|
||||
import androidx.compose.material.icons.filled.KeyboardArrowDown
|
||||
import androidx.compose.material.icons.outlined.Layers
|
||||
import androidx.compose.material.icons.outlined.AutoAwesome
|
||||
import androidx.compose.material.icons.outlined.SportsEsports
|
||||
import androidx.compose.material.icons.outlined.Storefront
|
||||
import androidx.compose.material.icons.filled.Refresh
|
||||
import androidx.compose.material.icons.filled.Check
|
||||
import androidx.compose.material.icons.filled.PriorityHigh
|
||||
@@ -65,6 +62,7 @@ import androidx.compose.ui.draw.alpha
|
||||
import androidx.compose.ui.draw.clip
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.graphics.graphicsLayer
|
||||
import androidx.compose.ui.platform.LocalInputModeManager
|
||||
import androidx.compose.ui.input.InputMode
|
||||
import androidx.compose.ui.semantics.Role
|
||||
@@ -449,42 +447,42 @@ private fun FrameGenMenu(s: FrontEndState, a: FrontEndActions, host: MenuHost) {
|
||||
|
||||
@Composable
|
||||
internal fun LaunchSettings(s: FrontEndState, a: FrontEndActions, host: MenuHost, game: com.droiddeck.launcher.frontend.Library.SteamGame? = null) {
|
||||
val columns = if (LocalNarrowPane.current) 2 else 3
|
||||
val controller = a.controller
|
||||
val context = androidx.compose.ui.platform.LocalContext.current
|
||||
val inputMode = androidx.compose.ui.platform.LocalInputModeManager.current
|
||||
val wincompKey = game?.takeIf { it.library == com.droiddeck.launcher.frontend.Library.ADDED || it.appId > 0 }?.profileKey
|
||||
var wincompOpen by remember(wincompKey) { mutableStateOf<Boolean?>(null) }
|
||||
val wincompPicks = remember(wincompKey, wincompOpen) { wincompKey?.let { com.droiddeck.launcher.session.WinComponents.picks(context, it) }.orEmpty() }
|
||||
val rows = buildList<@Composable () -> Unit> {
|
||||
add {
|
||||
val cards = buildList<@Composable (Modifier) -> Unit> {
|
||||
add { m ->
|
||||
val back = stringResource(R.string.hop_from_games)
|
||||
SettingLine(Icons.Outlined.Layers, stringResource(R.string.setup_card_components), stringResource(R.string.setup_card_components_hint), "card:components", Modifier.hopSource("card:components")) {
|
||||
SettingCard(stringResource(R.string.setup_card_components), stringResource(R.string.setup_card_components_hint), "card:components", m.hopSource("card:components")) {
|
||||
val at = Hops.sources["card:components"]
|
||||
if (at == null) a.onComponents(true)
|
||||
else Hops.go(HopOrigin("games", HOP_SURFACE_GAMES, null, "card:components", at, back, dest = "components", page = "components")) { a.onComponents(true) }
|
||||
}
|
||||
}
|
||||
add {
|
||||
Box(Modifier.fillMaxWidth()) {
|
||||
SettingLine(Icons.Outlined.AutoAwesome, stringResource(R.string.frame_gen_title), s.frameGenLabel, "card:fg", menu = true) {
|
||||
add { m ->
|
||||
Box(m) {
|
||||
SettingCard(stringResource(R.string.frame_gen_title), s.frameGenLabel, "card:fg", Modifier.fillMaxSize()) {
|
||||
host.open = if (host.open == "fg") null else "fg"
|
||||
}
|
||||
FrameGenMenu(s, a, host)
|
||||
}
|
||||
}
|
||||
if (controller != null) add { SettingLine(Icons.Outlined.SportsEsports, stringResource(R.string.setup_card_controls), stringResource(R.string.setup_card_controls_hint), "card:controls", onClick = controller.onMapping) }
|
||||
if (controller != null) add { m -> SettingCard(stringResource(R.string.setup_card_controls), stringResource(R.string.setup_card_controls_hint), "card:controls", m, controller.onMapping) }
|
||||
val cardStore = com.droiddeck.launcher.stores.Store.byId(game?.source.orEmpty())?.takeIf { it == com.droiddeck.launcher.stores.Store.EPIC || it == com.droiddeck.launcher.stores.Store.GOG }
|
||||
if (cardStore != null && game?.gameFiles != null) add {
|
||||
Box(Modifier.fillMaxWidth()) {
|
||||
if (cardStore != null && game?.gameFiles != null) add { m ->
|
||||
Box(m) {
|
||||
StoreLaunchCard(host, game.gameFiles!!, cardStore)
|
||||
}
|
||||
}
|
||||
if (wincompKey != null) add {
|
||||
SettingLine(
|
||||
Icons.Outlined.Extension,
|
||||
if (wincompKey != null) add { m ->
|
||||
SettingCard(
|
||||
stringResource(R.string.wincomp_title),
|
||||
if (wincompPicks.isEmpty()) stringResource(R.string.wincomp_card_none) else wincompPicks.joinToString(", ") { com.droiddeck.launcher.session.WinComponentNames.of(it) },
|
||||
"card:wincomp",
|
||||
"card:wincomp", m,
|
||||
) { wincompOpen = inputMode.inputMode == androidx.compose.ui.input.InputMode.Keyboard }
|
||||
}
|
||||
}
|
||||
@@ -494,14 +492,12 @@ internal fun LaunchSettings(s: FrontEndState, a: FrontEndActions, host: MenuHost
|
||||
steamAppId = game.appId.takeIf { game.library != com.droiddeck.launcher.frontend.Library.ADDED },
|
||||
game = game,
|
||||
) { wincompOpen = null }
|
||||
val pal = LocalPalette.current
|
||||
Column(
|
||||
modifier = Modifier.fillMaxWidth().padding(bottom = 16.dp)
|
||||
.clip(Shape14).background(MaterialTheme.colorScheme.surface).border(1.dp, pal.line2, Shape14),
|
||||
) {
|
||||
rows.forEachIndexed { i, row ->
|
||||
if (i > 0) Box(Modifier.fillMaxWidth().height(1.dp).background(pal.line))
|
||||
row()
|
||||
Column(verticalArrangement = Arrangement.spacedBy(10.dp), modifier = Modifier.fillMaxWidth().padding(bottom = 16.dp)) {
|
||||
for (row in cards.chunked(columns)) {
|
||||
Row(horizontalArrangement = Arrangement.spacedBy(10.dp), modifier = Modifier.fillMaxWidth().height(IntrinsicSize.Min)) {
|
||||
for (card in row) card(Modifier.weight(1f).fillMaxHeight())
|
||||
repeat(columns - row.size) { Spacer(Modifier.weight(1f)) }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -535,7 +531,7 @@ private fun StoreLaunchCard(host: MenuHost, folder: java.io.File, store: com.dro
|
||||
com.droiddeck.launcher.stores.StoresState.post { sidecar = written ?: com.droiddeck.launcher.stores.StoreGameSidecar.read(folder) }
|
||||
}, "epic-options").start()
|
||||
}
|
||||
SettingLine(Icons.Outlined.Storefront, store.shortLabel, value, "card:$key", menu = true) {
|
||||
SettingCard(store.shortLabel, value, "card:$key", Modifier.fillMaxSize()) {
|
||||
host.open = if (host.open == key) null else key
|
||||
}
|
||||
AnchoredMenu(host.open == key, onDismiss = { if (host.open == key) host.open = null }, title = store.shortLabel) { first ->
|
||||
@@ -565,35 +561,26 @@ private fun StoreLaunchCard(host: MenuHost, folder: java.io.File, store: com.dro
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun SettingLine(icon: ImageVector, label: String, value: String, id: String, modifier: Modifier = Modifier, menu: Boolean = false, onClick: () -> Unit) {
|
||||
private fun SettingCard(label: String, value: String, id: String, modifier: Modifier, onClick: () -> Unit) {
|
||||
val colors = MaterialTheme.colorScheme
|
||||
val pal = LocalPalette.current
|
||||
val src = remember { MutableInteractionSource() }
|
||||
val hot = rememberHot(src)
|
||||
Row(
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
horizontalArrangement = Arrangement.spacedBy(14.dp),
|
||||
modifier = modifier.fillMaxWidth().heightIn(min = 58.dp).paneItem(id)
|
||||
.background(if (hot) pal.signal.copy(alpha = 0.10f) else Color.Transparent)
|
||||
.glideBorder(hot, RectangleShape, pal.signal, Color.Transparent)
|
||||
val pressed by src.collectIsPressedAsState()
|
||||
val scale by animateFloatAsState(if (pressed) 0.97f else 1f, Motion.sp(0.5f, Spring.StiffnessMedium), label = "cardScale")
|
||||
Column(
|
||||
verticalArrangement = Arrangement.spacedBy(3.dp),
|
||||
modifier = modifier.paneItem(id)
|
||||
.graphicsLayer { scaleX = scale; scaleY = scale }
|
||||
.clip(Shape14)
|
||||
.background(if (hot) pal.signal.copy(alpha = 0.10f) else colors.surface)
|
||||
.glideBorder(hot, Shape14, pal.signal, pal.line2)
|
||||
.hoverable(src).clickable(interactionSource = src, indication = LocalIndication.current, role = Role.Button, onClick = onClick)
|
||||
.controllerConfirm(onClick = onClick)
|
||||
.padding(horizontal = 16.dp, vertical = 10.dp),
|
||||
.padding(horizontal = 14.dp, vertical = 12.dp),
|
||||
) {
|
||||
Icon(icon, contentDescription = null, tint = if (hot) pal.signal else colors.onSurfaceVariant, modifier = Modifier.size(20.dp))
|
||||
Text(label, fontSize = 15.sp, fontWeight = FontWeight.Medium, color = if (hot) pal.signal else colors.onBackground,
|
||||
maxLines = 1, overflow = TextOverflow.Ellipsis, modifier = Modifier.weight(1f))
|
||||
Row(
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
horizontalArrangement = Arrangement.spacedBy(6.dp),
|
||||
modifier = Modifier.widthIn(max = 360.dp).clip(RoundedCornerShape(10.dp)).background(colors.surfaceVariant)
|
||||
.border(1.dp, pal.line2, RoundedCornerShape(10.dp)).padding(start = 12.dp, end = 8.dp, top = 6.dp, bottom = 6.dp),
|
||||
) {
|
||||
Text(value, fontSize = 14.sp, fontWeight = FontWeight.SemiBold, color = colors.onBackground,
|
||||
maxLines = 1, overflow = TextOverflow.Ellipsis, modifier = Modifier.weight(1f, fill = false))
|
||||
Icon(if (menu) Icons.Filled.KeyboardArrowDown else Icons.AutoMirrored.Filled.KeyboardArrowRight, contentDescription = null,
|
||||
tint = colors.onSurfaceVariant, modifier = Modifier.size(18.dp))
|
||||
}
|
||||
Text(label, fontSize = 13.sp, color = if (hot) pal.signal else colors.onSurfaceVariant, maxLines = 1, overflow = TextOverflow.Ellipsis)
|
||||
Text(value, fontSize = 16.sp, fontWeight = FontWeight.SemiBold, color = colors.onBackground, maxLines = 1, overflow = TextOverflow.Ellipsis)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -68,11 +68,15 @@ import coil.transform.RoundedCornersTransformation
|
||||
import kotlin.math.roundToInt
|
||||
|
||||
private const val WALL_MIN_GAMES = 4
|
||||
private const val WALL_DISTINCT = 40
|
||||
|
||||
private const val DRIFT_STEP_NS = 15_000_000L
|
||||
|
||||
@Composable
|
||||
internal fun SteamHome(s: FrontEndState, a: FrontEndActions, modifier: Modifier) {
|
||||
val ctx = androidx.compose.ui.platform.LocalContext.current
|
||||
LaunchedEffect(Unit) { com.droiddeck.launcher.frontend.SteamFeatured.load(ctx) }
|
||||
val featured = com.droiddeck.launcher.frontend.SteamFeatured.games
|
||||
val games = remember(s.steamGames) { s.steamGames.sortedWith(compareByDescending<Library.SteamGame> { it.lastPlayed }.thenBy { it.gameId }) }
|
||||
val narrow = LocalNarrowPane.current
|
||||
var words by remember { mutableStateOf<Rect?>(null) }
|
||||
@@ -100,9 +104,12 @@ internal fun SteamHome(s: FrontEndState, a: FrontEndActions, modifier: Modifier)
|
||||
start = if (narrow) 20.dp else 44.dp, bottom = if (narrow) 20.dp else 44.dp,
|
||||
background = {
|
||||
when {
|
||||
games.isEmpty() -> EmptyLibrary(avoid = words) { Box(Modifier.fillMaxSize().graphicsLayer { scaleX = 1.06f; scaleY = 1.06f }) { WallFade() } }
|
||||
games.isEmpty() -> EmptyLibrary(
|
||||
avoid = words,
|
||||
wall = if (featured.isEmpty()) null else { { CapsuleWall(emptyList(), featured = featured) } },
|
||||
) { Box(Modifier.fillMaxSize().graphicsLayer { scaleX = 1.06f; scaleY = 1.06f }) { WallFade() } }
|
||||
games.size < WALL_MIN_GAMES -> CapsuleFan(games)
|
||||
else -> CapsuleWall(games)
|
||||
else -> CapsuleWall(games, featured = featured)
|
||||
}
|
||||
},
|
||||
scrim = { if (games.size >= WALL_MIN_GAMES) WallFade() },
|
||||
@@ -143,10 +150,21 @@ internal fun CapsuleWall(
|
||||
games: List<Library.SteamGame>,
|
||||
driftMs: Int = if (games.isEmpty()) 75_000 else 40_000,
|
||||
modifier: Modifier = Modifier,
|
||||
featured: List<com.droiddeck.launcher.frontend.SteamFeatured.Game> = emptyList(),
|
||||
) {
|
||||
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) }
|
||||
val padding = remember(games, featured) {
|
||||
val owned = games.map { it.appId }.toSet()
|
||||
featured.filter { it.appId !in owned }.take((WALL_DISTINCT - games.size).coerceAtLeast(0))
|
||||
}
|
||||
TiltedWall(games.size + padding.size, driftMs, modifier) { i ->
|
||||
when {
|
||||
i < games.size -> WallCapsule(artBrush(hueOf(games[i].name)), games[i].art)
|
||||
i - games.size < padding.size -> padding[i - games.size].let { WallCapsule(artBrush(hueOf(it.name)), it.art) }
|
||||
else -> blank
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
internal class WallCapsule(val ground: Brush, val art: Any? = null, val edge: Color? = null, val icon: Any? = null, val bar: Color? = null)
|
||||
|
||||
@@ -649,8 +649,14 @@ private fun SignInCard(store: Store, height: Dp) {
|
||||
val c = sourceColours(store.id)
|
||||
LaunchedEffect(store) { StoresState.preview(ctx, store) }
|
||||
val art = remember(StoresState.shelves[store]) {
|
||||
StoresState.shelves[store]?.all.orEmpty().filter { !it.mature }.mapNotNull { it.tallImageUrl ?: it.imageUrl }.distinct().take(48)
|
||||
.takeIf { it.size >= 8 }.orEmpty()
|
||||
val shelves = StoresState.shelves[store]
|
||||
val urls = (shelves?.art?.takeIf { it.isNotEmpty() }
|
||||
?: shelves?.all.orEmpty().filter { !it.mature && !it.owned }.mapNotNull { it.tallImageUrl ?: it.imageUrl }).distinct().take(48)
|
||||
when {
|
||||
urls.size >= 8 -> urls
|
||||
urls.size >= 4 -> urls + urls.drop(urls.size / 2) + urls.take(urls.size / 2)
|
||||
else -> emptyList()
|
||||
}
|
||||
}
|
||||
val shape = RoundedCornerShape(20.dp)
|
||||
val blank = WallCapsule(Brush.linearGradient(listOf(c.fill, colors.background)), edge = c.dot.copy(alpha = 0.22f))
|
||||
|
||||
@@ -2232,4 +2232,18 @@
|
||||
<string name="stores_hltb_completionist">Tamamlayıcı</string>
|
||||
<string name="stores_hltb_hours">%1$s sa</string>
|
||||
<string name="stores_hltb_minutes">%1$d dk</string>
|
||||
<string name="mode_zram">zRAM sıkıştırma</string>
|
||||
<string name="mode_zram_hint">Bir oyun çalışırken Steam istemcisinin belleği cihazın zram\'ına sıkıştırılır ve oyuna daha fazla RAM kalır. Steam\'in bir sonraki başlatılışında uygulanır.</string>
|
||||
<string name="mode_zram_unsupported">Kullanılamıyor: bu cihazda zram veya swap yok ya da çekirdeği 5.4\'ten eski.</string>
|
||||
<string name="setup_reset_settings">Tüm ayarları sıfırla</string>
|
||||
<string name="setup_reset_settings_title">Tüm ayarlar sıfırlansın mı?</string>
|
||||
<string name="setup_reset_settings_body">Ekran, performans, denetleyici, tema, dil ve başlatıcı ayarları varsayılanlarına döner. Oyunlarınız, Steam oturum bilgileriniz, mağaza hesaplarınız, indirilen sürücüler ve depolama klasörleriniz korunur.</string>
|
||||
<string name="setup_reset_settings_button">Sıfırla</string>
|
||||
<string name="setup_reset_settings_hint">Her ayarı varsayılanına döndürür</string>
|
||||
<string name="setup_reset_settings_partial">Ayarlar sıfırlandı, ancak bazı bölümler sıfırlanamadı. Ayrıntılar günlüklerde.</string>
|
||||
<string name="setup_reset_settings_profiles">Oyun profillerini de sıfırla (her oyunun Proton, FEX, bileşenleri ve ortamı ile Varsayılan profilin ortamı)</string>
|
||||
<string name="setup_reset_settings_session_running">Ayarları sıfırlamadan önce oturumu durdurun.</string>
|
||||
<string name="stores_storage_insufficient">Yeterli boş alan yok: %1$s gerekli, yalnızca %2$s boş</string>
|
||||
<string name="stores_storage_low">Yetersiz alan (%1$s boş, %2$s gerekli)</string>
|
||||
<string name="stores_target_free_unknown">Boş alan bilinmiyor</string>
|
||||
</resources>
|
||||
@@ -155,7 +155,7 @@ synchronous on the calling thread (network calls block: call them off the main t
|
||||
| `build.info` | `accessToken`, `namespace`, `catalogItemId`, `appName` | `info`: the opaque build description (`manifest`, `buildVersion`, `deploymentId`, `cdns[]` of `base`/`dir`/`auth`) |
|
||||
| `build.cdns` | `info` | `cdns[]` |
|
||||
| `build.prereq` | `info` | `name`, `path`, `read` (downloads and parses the manifest) |
|
||||
| `shop.shelves` | `country` | `new`, `deals`, `free`, `trending`: arrays in the `store_featured` item shape plus `allTags` |
|
||||
| `shop.shelves` | `country` | `new`, `deals`, `free`, `trending`: arrays in the `store_featured` item shape plus `allTags`; `art`: cover URLs from every promotion, for the signed-out wall |
|
||||
| `achievements` | `accessToken`, `namespace`, `accountId`, `locale` | `list[]` of `id`, `name`, `description`, `hidden`, `unlocked`, `unlockedAt` (s), `icon`, `iconLocked` |
|
||||
| `cloud.list` | `accessToken`, `accountId`, `appName` | `files[]` of `name`, `modified` (ms, -1 unknown), `hash`, `readLink` |
|
||||
| `cloud.writeLinks` | `accessToken`, `accountId`, `appName`, `names[]` | `links`: name → write link |
|
||||
|
||||
@@ -232,6 +232,28 @@ fn unique(items: impl IntoIterator<Item = Value>) -> Vec<Value> {
|
||||
items.into_iter().filter(|i| seen.insert(net::str_of(i, "id").to_string())).collect()
|
||||
}
|
||||
|
||||
const MATURE_TAGS: [&str; 8] = ["mature", "nsfw", "adult", "adult only", "adults only", "nudity", "sexual content", "sexual-content"];
|
||||
|
||||
fn mature_tags(item: &Value) -> bool {
|
||||
item["allTags"].as_array().is_some_and(|tags| {
|
||||
tags.iter().filter_map(|t| t.as_str()).any(|t| MATURE_TAGS.contains(&t.trim().to_lowercase().as_str()))
|
||||
})
|
||||
}
|
||||
|
||||
pub fn wall_art(promotion_list: &[Value]) -> Vec<String> {
|
||||
let mut seen = HashSet::new();
|
||||
promotion_list
|
||||
.iter()
|
||||
.filter_map(item_of)
|
||||
.filter(|i| !mature_tags(i))
|
||||
.map(|i| {
|
||||
let tall = net::str_of(&i, "tall");
|
||||
if tall.is_empty() { net::str_of(&i, "image").to_string() } else { tall.to_string() }
|
||||
})
|
||||
.filter(|u| !u.is_empty() && seen.insert(u.clone()))
|
||||
.collect()
|
||||
}
|
||||
|
||||
pub fn shelves(country: &str) -> Value {
|
||||
let country = country_code(country);
|
||||
let now = now_stamp();
|
||||
@@ -240,10 +262,12 @@ pub fn shelves(country: &str) -> Value {
|
||||
};
|
||||
let new = unique(shelf(Shelf::New));
|
||||
let deals = unique(shelf(Shelf::Deals).into_iter().filter(|i| i["disc"].as_i64().unwrap_or(0) > 0));
|
||||
let promos: Vec<Value> = promotions(&country).iter().filter_map(free_now).collect();
|
||||
let promotion_list = promotions(&country);
|
||||
let art = wall_art(&promotion_list);
|
||||
let promos: Vec<Value> = promotion_list.iter().filter_map(free_now).collect();
|
||||
let free = unique(promos.into_iter().chain(shelf(Shelf::Free)));
|
||||
let trending = unique(shelf(Shelf::Trending));
|
||||
json!({ "new": new, "deals": deals, "free": free, "trending": trending })
|
||||
json!({ "new": new, "deals": deals, "free": free, "trending": trending, "art": art })
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
@@ -304,6 +328,19 @@ mod tests {
|
||||
assert!(free_now(&el).is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn wall_art_takes_every_promotion_once_and_skips_mature() {
|
||||
let mut clean = element();
|
||||
clean["tags"] = json!([{"name": "Action"}]);
|
||||
let mut wide_only = clean.clone();
|
||||
wide_only["id"] = json!("offer2");
|
||||
wide_only["keyImages"] = json!([{"type": "OfferImageWide", "url": "w2"}]);
|
||||
let mut mature = element();
|
||||
mature["id"] = json!("offer3");
|
||||
mature["keyImages"] = json!([{"type": "DieselStoreFrontTall", "url": "m"}]);
|
||||
assert_eq!(wall_art(&[clean.clone(), clean, wide_only, mature]), vec!["tl", "w2"]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn query_and_dates() {
|
||||
assert_eq!(country_code("de"), "DE");
|
||||
|
||||
@@ -0,0 +1,66 @@
|
||||
package com.droiddeck.launcher.frontend
|
||||
|
||||
import android.graphics.Bitmap
|
||||
import android.graphics.Canvas
|
||||
import android.graphics.Color
|
||||
import android.graphics.Paint
|
||||
import java.io.File
|
||||
import java.nio.file.Files
|
||||
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.annotation.Config
|
||||
import org.robolectric.annotation.GraphicsMode
|
||||
|
||||
@RunWith(RobolectricTestRunner::class)
|
||||
@Config(sdk = [33], manifest = Config.NONE)
|
||||
@GraphicsMode(GraphicsMode.Mode.NATIVE)
|
||||
class GameShortcutsIconTest {
|
||||
private lateinit var dir: File
|
||||
|
||||
@Before fun setUp() {
|
||||
dir = Files.createTempDirectory("shortcut-icons").toFile()
|
||||
}
|
||||
|
||||
@Test fun aSmallSteamIconGivesWayToTheMiddleOfTheCoverArt() {
|
||||
val icon = image("icon.png", 32, 32, Color.GREEN)
|
||||
val cover = image("cover.png", 300, 450, Color.BLUE, band = 75, bandColor = Color.RED)
|
||||
val bitmap = GameShortcuts.iconBitmap(game(icon = icon, art = cover))!!
|
||||
assertEquals(300, bitmap.width)
|
||||
assertEquals(300, bitmap.height)
|
||||
assertEquals(Color.BLUE, bitmap.getPixel(0, 0))
|
||||
assertEquals(Color.BLUE, bitmap.getPixel(299, 299))
|
||||
}
|
||||
|
||||
@Test fun anIconLargeEnoughForTheHomeScreenIsKept() {
|
||||
val icon = image("icon.png", 256, 256, Color.GREEN)
|
||||
val cover = image("cover.png", 300, 450, Color.BLUE)
|
||||
val bitmap = GameShortcuts.iconBitmap(game(icon = icon, art = cover))!!
|
||||
assertEquals(256, bitmap.width)
|
||||
assertEquals(Color.GREEN, bitmap.getPixel(128, 128))
|
||||
}
|
||||
|
||||
@Test fun theSmallIconIsStillUsedWhenThereIsNoArt() {
|
||||
val icon = image("icon.png", 32, 32, Color.GREEN)
|
||||
val bitmap = GameShortcuts.iconBitmap(game(icon = icon, art = null))!!
|
||||
assertEquals(32, bitmap.width)
|
||||
assertEquals(Color.GREEN, bitmap.getPixel(16, 16))
|
||||
}
|
||||
|
||||
private fun game(icon: File?, art: File?) =
|
||||
Library.SteamGame(appId = 17480, name = "Game", art = art, library = "test", icon = icon)
|
||||
|
||||
private fun image(name: String, width: Int, height: Int, color: Int, band: Int = 0, bandColor: Int = color): File {
|
||||
val bitmap = Bitmap.createBitmap(width, height, Bitmap.Config.ARGB_8888)
|
||||
val canvas = Canvas(bitmap)
|
||||
canvas.drawColor(color)
|
||||
if (band > 0) {
|
||||
val paint = Paint().apply { this.color = bandColor }
|
||||
canvas.drawRect(0f, 0f, width.toFloat(), band.toFloat(), paint)
|
||||
canvas.drawRect(0f, (height - band).toFloat(), width.toFloat(), height.toFloat(), paint)
|
||||
}
|
||||
return File(dir, name).also { file -> file.outputStream().use { bitmap.compress(Bitmap.CompressFormat.PNG, 100, it) } }
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,60 @@
|
||||
package com.droiddeck.launcher.input
|
||||
|
||||
import com.droiddeck.launcher.runtime.LinuxRuntime
|
||||
import java.io.File
|
||||
import org.junit.After
|
||||
import org.junit.Assert.assertEquals
|
||||
import org.junit.Assert.assertFalse
|
||||
import org.junit.Assert.assertTrue
|
||||
import org.junit.Test
|
||||
import org.junit.runner.RunWith
|
||||
import org.robolectric.RobolectricTestRunner
|
||||
import org.robolectric.RuntimeEnvironment
|
||||
import org.robolectric.annotation.Config
|
||||
|
||||
@RunWith(RobolectricTestRunner::class)
|
||||
@Config(sdk = [33])
|
||||
class SteamTouchConfigFilesTest {
|
||||
private val context = RuntimeEnvironment.getApplication()
|
||||
private val steam = File(LinuxRuntime.rootDir(context), "root/.local/share/Steam")
|
||||
private val dir = File(steam, "steamapps/common/Steam Controller Configs/123/config").apply { mkdirs() }
|
||||
|
||||
@After fun tearDown() { steam.deleteRecursively() }
|
||||
|
||||
private fun touch(title: String) = "\"controller_mappings\"\n{\n\t\"title\"\t\t\"$title\"\n\t\"controller_type\"\t\t\"controller_mobile_touch\"\n}\n"
|
||||
|
||||
private fun configset(name: String, vararg entries: Pair<String, String>) = File(dir, name).writeText(
|
||||
"\"controller_config\"\n{\n" + entries.joinToString("") { (app, line) -> "\t\"$app\"\n\t{\n\t\t$line\n\t}\n" } + "}\n",
|
||||
)
|
||||
|
||||
private fun autosave(app: Int, title: String) = File(dir, "$app/controller_mobile_touch.vdf").apply { parentFile!!.mkdirs(); writeText(touch(title)) }
|
||||
|
||||
private fun workshop(id: String, title: String) =
|
||||
File(steam, "steamapps/workshop/content/241100/$id/123_legacy.bin").apply { parentFile!!.mkdirs(); writeText(touch(title)) }
|
||||
|
||||
@Test fun theDevicesWorkshopPickWinsOverAnOlderAutosave() {
|
||||
autosave(204360, "old autosave")
|
||||
val picked = workshop("1764689049", "official")
|
||||
configset("configset_controller_mobile_touch.vdf", "204360" to "\"autosave\"\t\t\"1\"")
|
||||
configset("configset_MT-DROIDDECK0001.vdf", "204360" to "\"workshop\"\t\t\"1764689049\"")
|
||||
assertEquals(picked, SteamTouchConfig.load(context, 204360).file)
|
||||
}
|
||||
|
||||
@Test fun aWorkshopPickInTheTypeConfigsetIsRead() {
|
||||
val picked = workshop("42", "community")
|
||||
configset("configset_controller_mobile_touch.vdf", "413150" to "\"workshop\"\t\t\"42\"")
|
||||
assertEquals(picked, SteamTouchConfig.load(context, 413150).file)
|
||||
}
|
||||
|
||||
@Test fun savingAnEditDropsTheDevicePickSoTheEditLoads() {
|
||||
workshop("1764689049", "official")
|
||||
configset("configset_MT-DROIDDECK0001.vdf", "204360" to "\"workshop\"\t\t\"1764689049\"", "8400" to "\"workshop\"\t\t\"7\"")
|
||||
val config = SteamTouchConfig.load(context, 204360)
|
||||
val saved = SteamTouchConfig.saveLayouts(context, 204360, config, mapOf(1 to emptyList()))
|
||||
assertEquals(File(dir, "204360/controller_mobile_touch.vdf"), saved)
|
||||
val device = File(dir, "configset_MT-DROIDDECK0001.vdf").readText()
|
||||
assertFalse(device.contains("\"204360\""))
|
||||
assertTrue(device.contains("\"8400\""))
|
||||
assertEquals(saved, SteamTouchConfig.load(context, 204360).file)
|
||||
}
|
||||
}
|
||||
@@ -41,6 +41,14 @@ class BatteryComponentTest {
|
||||
assertTelemetry(supplies, vpower)
|
||||
}
|
||||
|
||||
@Test fun vendorsReportingMilliampsGetTheSameEstimatesAsMicroamps() {
|
||||
val supplies = tmp.newFolder("power_supply")
|
||||
val vpower = tmp.newFolder("vpower")
|
||||
start(supplies, vpower, -2_000L)
|
||||
assertTelemetry(supplies, vpower)
|
||||
assertEquals("-2000000", File(supplies, "BAT1/current_now").readText().trim())
|
||||
}
|
||||
|
||||
private fun start(supplies: File, vpower: File, currentUa: Long) {
|
||||
val context = RuntimeEnvironment.getApplication()
|
||||
context.sendStickyBroadcast(Intent(Intent.ACTION_BATTERY_CHANGED).apply {
|
||||
|
||||
@@ -105,6 +105,7 @@ class SessionLogShareTest {
|
||||
@Test fun aFileThatCouldNotBeScrubbedLeavesTheFolderUnrecorded() {
|
||||
val folder = tmp.newFolder("2026-10-06-06-steam")
|
||||
File(folder, "session.log").writeText("$secret\n")
|
||||
org.junit.Assume.assumeFalse(System.getProperty("os.name").startsWith("Windows"))
|
||||
folder.setWritable(false)
|
||||
try {
|
||||
SessionArtifacts.scrubAndMark(folder)
|
||||
|
||||
+62
-14
@@ -72,7 +72,33 @@ trap cleanup EXIT
|
||||
trap 'exit 130' INT
|
||||
trap 'exit 143' TERM
|
||||
|
||||
cache_dir=${DROIDDECK_BUILD_CACHE:-"${HOME}/.cache/droiddeck-build"}
|
||||
mkdir -p "${cache_dir}"
|
||||
# Steps whose output depends only on tracked sources are kept per input hash in the
|
||||
# shared cache, so a rerun or a fresh worktree copies them instead of rebuilding.
|
||||
inputs_hash() {
|
||||
(cd "${repo_root}" && { printf '%s\n' "$1"; shift; find "$@" -type f ! -name '*.pyc' | LC_ALL=C sort | xargs shasum -a 256; }) \
|
||||
| shasum -a 256 | cut -d' ' -f1
|
||||
}
|
||||
restore_step() {
|
||||
local stored="${cache_dir}/steps/$1/$2"
|
||||
[[ -f "${stored}/.complete" ]] || return 1
|
||||
mkdir -p "$3"
|
||||
(cd "${stored}" && tar -cf - --exclude .complete .) | tar -xf - -C "$3" || return 1
|
||||
echo "$1: inputs unchanged, reused ${stored}"
|
||||
}
|
||||
save_step() {
|
||||
local stored="${cache_dir}/steps/$1/$2" part
|
||||
mkdir -p "${cache_dir}/steps/$1"
|
||||
part=$(mktemp -d "${stored}.part.XXXXXX")
|
||||
(cd "$3" && tar -cf - .) | tar -xf - -C "${part}"
|
||||
touch "${part}/.complete"
|
||||
# Another checkout may have published the same key meanwhile; its copy is equivalent.
|
||||
if [[ -e "${stored}" ]]; then rm -rf "${part}"; else mv "${part}" "${stored}" || rm -rf "${part}"; fi
|
||||
}
|
||||
|
||||
python3 "${repo_root}/tools/docker_preflight.py" "${image_name}"
|
||||
image_id=$(docker image inspect --format '{{.Id}}' "${image_name}")
|
||||
rm -rf -- "${linuxfs_dir}"
|
||||
mkdir -p "${linuxfs_dir}"
|
||||
|
||||
@@ -118,17 +144,30 @@ docker run --rm --platform linux/amd64 \
|
||||
READELF=aarch64-linux-gnu-readelf bash tools/linuxfs/check-preloads.sh "$d"
|
||||
'
|
||||
|
||||
docker run --rm --platform linux/amd64 \
|
||||
-v "${repo_root}:/src" -w /src public.ecr.aws/docker/library/debian:bullseye bash -c '
|
||||
set -euo pipefail
|
||||
printf "deb http://archive.debian.org/debian bullseye main\ndeb http://archive.debian.org/debian-security bullseye-security main\n" > /etc/apt/sources.list
|
||||
apt-get -o Acquire::Check-Valid-Until=false update -qq
|
||||
DEBIAN_FRONTEND=noninteractive apt-get install -y -qq --no-install-recommends gcc g++ gcc-multilib g++-multilib binutils >/dev/null
|
||||
tools/linuxfs/build-x86-preloads.sh app/src/main/assets/linuxfs
|
||||
chown -R '"$(id -u):$(id -g)"' app/src/main/assets/linuxfs
|
||||
'
|
||||
x86_key=$(inputs_hash bullseye tools/linuxfs/build-x86-preloads.sh tools/linuxfs/preload tools/linuxfs/fex \
|
||||
app/src/main/cpp/fakeinput_steam.cpp)
|
||||
x86_stage="${staging_dir}/x86-preloads"
|
||||
if restore_step x86-preloads "${x86_key}" "${linuxfs_dir}"; then :; else
|
||||
docker run --rm --platform linux/amd64 \
|
||||
-v "${repo_root}:/src" -w /src public.ecr.aws/docker/library/debian:bullseye bash -c '
|
||||
set -euo pipefail
|
||||
printf "deb http://archive.debian.org/debian bullseye main\ndeb http://archive.debian.org/debian-security bullseye-security main\n" > /etc/apt/sources.list
|
||||
apt-get -o Acquire::Check-Valid-Until=false update -qq
|
||||
DEBIAN_FRONTEND=noninteractive apt-get install -y -qq --no-install-recommends gcc g++ gcc-multilib g++-multilib binutils >/dev/null
|
||||
tools/linuxfs/build-x86-preloads.sh app/src/main/assets/linuxfs
|
||||
chown -R '"$(id -u):$(id -g)"' app/src/main/assets/linuxfs
|
||||
'
|
||||
mkdir -p "${x86_stage}"
|
||||
cp -R "${linuxfs_dir}/x86_64" "${linuxfs_dir}/i386" "${x86_stage}/"
|
||||
save_step x86-preloads "${x86_key}" "${x86_stage}"
|
||||
fi
|
||||
|
||||
DROIDDECK_BUILD_IMAGE="${image_name}" "${repo_root}/tools/vr/build.sh" "${linuxfs_dir}/usr/local/lib/droiddeck-vr"
|
||||
vr_out="${linuxfs_dir}/usr/local/lib/droiddeck-vr"
|
||||
vr_key=$(inputs_hash "${image_id} ${ndk_version} ${VK_INCLUDE:-}" tools/vr app/src/main/cpp/waylandcomp/generated/xdg-shell-protocol.c)
|
||||
if ! restore_step vr "${vr_key}" "${vr_out}"; then
|
||||
DROIDDECK_BUILD_IMAGE="${image_name}" "${repo_root}/tools/vr/build.sh" "${vr_out}"
|
||||
save_step vr "${vr_key}" "${vr_out}"
|
||||
fi
|
||||
|
||||
github_repo=${DROIDDECK_GITHUB_REPOSITORY:-}
|
||||
if [[ -z "${github_repo}" ]]; then
|
||||
@@ -146,8 +185,6 @@ if [[ -z "${github_repo}" ]]; then
|
||||
github_repo=${github_repo%.git}
|
||||
fi
|
||||
|
||||
cache_dir=${DROIDDECK_BUILD_CACHE:-"${HOME}/.cache/droiddeck-build"}
|
||||
mkdir -p "${cache_dir}"
|
||||
cached() {
|
||||
local sha=$1 name=$2 out
|
||||
shift 2
|
||||
@@ -268,7 +305,12 @@ if [[ -f "${proot_out}/libproot.so" && -f "${proot_out}/libproot-loader.so" \
|
||||
&& (cd "${proot_out}" && shasum -a 256 -c .proot-outputs >/dev/null 2>&1); then
|
||||
echo "proot: sources unchanged, keeping ${proot_out}/libproot.so"
|
||||
else
|
||||
"${repo_root}/tools/proot/build.sh" "${proot_out}"
|
||||
proot_stage="${staging_dir}/proot"
|
||||
if ! restore_step proot "${proot_inputs}" "${proot_stage}"; then
|
||||
"${repo_root}/tools/proot/build.sh" "${proot_stage}"
|
||||
save_step proot "${proot_inputs}" "${proot_stage}"
|
||||
fi
|
||||
install -m755 "${proot_stage}/libproot.so" "${proot_stage}/libproot-loader.so" "${proot_out}/"
|
||||
echo "${proot_inputs}" > "${proot_out}/.proot-inputs"
|
||||
(cd "${proot_out}" && shasum -a 256 libproot.so libproot-loader.so > .proot-outputs)
|
||||
fi
|
||||
@@ -301,6 +343,7 @@ else
|
||||
fi
|
||||
done
|
||||
rustup target add --toolchain stable aarch64-linux-android
|
||||
export CARGO_TARGET_DIR="${CARGO_TARGET_DIR:-${cache_dir}/stores-target}"
|
||||
if ! command -v cargo-ndk >/dev/null 2>&1; then
|
||||
cargo install cargo-ndk --locked
|
||||
fi
|
||||
@@ -308,7 +351,12 @@ fi
|
||||
|
||||
cd "${repo_root}"
|
||||
python3 tools/quest/build-driver.py "${NDK}"
|
||||
bash tools/comet/build-local.sh
|
||||
comet_out="${linuxfs_dir}/usr/local/lib/droiddeck-comet"
|
||||
comet_key=$(inputs_hash "${image_id}" tools/comet)
|
||||
if ! restore_step comet "${comet_key}" "${comet_out}"; then
|
||||
DROIDDECK_BUILD_IMAGE="${image_name}" bash tools/comet/build-local.sh
|
||||
save_step comet "${comet_key}" "${comet_out}"
|
||||
fi
|
||||
python3 tools/release/runtime_inputs.py record
|
||||
./gradlew "${gradle_task}" --console=plain -PndkVersion="${ndk_version}" ${gradle_rust_arg}
|
||||
python3 tools/release/check_session_assets.py "app/build/outputs/apk/${build_variant}/app-${build_variant}.apk"
|
||||
|
||||
@@ -9,11 +9,18 @@ done
|
||||
work=$(mktemp -d)
|
||||
trap 'rm -rf "${work}"' EXIT
|
||||
|
||||
cache=${DROIDDECK_BUILD_CACHE:-"${HOME}/.cache/droiddeck-build"}
|
||||
mkdir -p "${cache}"
|
||||
fetch() {
|
||||
local name=$1 sha=$2
|
||||
curl -fsSL --retry 3 -o "${work}/${name}" \
|
||||
"https://github.com/${DIRECTAUDIO_REPO}/releases/download/${DIRECTAUDIO_TAG}/${name}"
|
||||
echo "${sha} ${work}/${name}" | sha256sum -c - >/dev/null || { echo "${name}: checksum mismatch" >&2; exit 1; }
|
||||
local name=$1 sha=$2 kept="${cache}/$2-$1" part
|
||||
if ! echo "${sha} ${kept}" | sha256sum -c - >/dev/null 2>&1; then
|
||||
part=$(mktemp "${kept}.part.XXXXXX")
|
||||
curl -fsSL --retry 3 -o "${part}" \
|
||||
"https://github.com/${DIRECTAUDIO_REPO}/releases/download/${DIRECTAUDIO_TAG}/${name}"
|
||||
echo "${sha} ${part}" | sha256sum -c - >/dev/null || { echo "${name}: checksum mismatch" >&2; rm -f "${part}"; exit 1; }
|
||||
mv "${part}" "${kept}"
|
||||
fi
|
||||
cp "${kept}" "${work}/${name}"
|
||||
}
|
||||
fetch directaudio-linux-wine11.zip "${DIRECTAUDIO_WINE11_SHA256}"
|
||||
fetch directaudio-linux-wine11-systhread.zip "${DIRECTAUDIO_SYSTHREAD_SHA256}"
|
||||
|
||||
@@ -98,6 +98,14 @@ def godot_sentry(game):
|
||||
return {"WINEDLLOVERRIDES": ";".join(n + "=d" for n in sentry)}, [], "Godot: Sentry crash handler off"
|
||||
|
||||
|
||||
@fix
|
||||
def arkham_city_x87(game):
|
||||
"""Batman: Arkham City (BatmanAC.exe, also started through BmLauncher.exe beside it) leans on x87
|
||||
and stops with Unreal's "Fatal error!" right after start when FEX reduces its precision."""
|
||||
if "batmanac.exe" in game.files:
|
||||
return {"FEX_X87REDUCEDPRECISION": "0"}, [], "Batman: Arkham City: FEX with full x87 precision"
|
||||
|
||||
|
||||
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
|
||||
|
||||
@@ -139,6 +139,51 @@ unseed() {
|
||||
return 1
|
||||
}
|
||||
|
||||
# The ledger lines whose values the registry still holds, in both views where we wrote both. A Proton
|
||||
# update replaces its default_pfx system.reg but not our ledger beside it, and new prefixes copy both.
|
||||
present() { awk -F'\t' '
|
||||
NR == FNR {
|
||||
path = $1
|
||||
if (path != "vcrt") {
|
||||
sub(/^HKEY_LOCAL_MACHINE\\\\/, "", path)
|
||||
if (path == $1)
|
||||
next
|
||||
}
|
||||
n++
|
||||
line[n] = $0
|
||||
need[n] = 0
|
||||
if (path == "vcrt") {
|
||||
for (a = split("x86 x64 arm64", arch, " "); a > 0; a--) {
|
||||
runtime = "microsoft\\\\visualstudio\\\\14.0\\\\vc\\\\runtimes\\\\" arch[a]
|
||||
key[n, ++need[n]] = "[software\\\\" runtime "]" SUBSEP "installed"
|
||||
key[n, ++need[n]] = "[software\\\\wow6432node\\\\" runtime "]" SUBSEP "installed"
|
||||
}
|
||||
} else {
|
||||
both = substr(path, 1, 10) == "Software\\\\"
|
||||
path = tolower(path)
|
||||
key[n, ++need[n]] = "[" path "]" SUBSEP tolower($2)
|
||||
if (both)
|
||||
key[n, ++need[n]] = "[software\\\\wow6432node\\\\" substr(path, 11) "]" SUBSEP tolower($2)
|
||||
}
|
||||
for (j = 1; j <= need[n]; j++) wanted[key[n, j]] = 1
|
||||
next
|
||||
}
|
||||
/^\[/ { section = tolower(substr($0, 1, index($0, "]"))) }
|
||||
/^"/ {
|
||||
value = substr($0, 2)
|
||||
value = tolower(substr(value, 1, index(value, "\"=") - 1))
|
||||
if ((section SUBSEP value) in wanted)
|
||||
seen[section SUBSEP value] = 1
|
||||
}
|
||||
END {
|
||||
for (i = 1; i <= n; i++) {
|
||||
ok = 1
|
||||
for (j = 1; j <= need[i]; j++) if (!(key[i, j] in seen)) ok = 0
|
||||
if (ok) print line[i]
|
||||
}
|
||||
}
|
||||
' - "$1"; }
|
||||
|
||||
for reg in "$steam_root"/steamapps/common/Proton*/files/share/default_pfx*/system.reg \
|
||||
/mnt/droiddeck-sd/steamapps/common/Proton*/files/share/default_pfx*/system.reg \
|
||||
/root/Games/*/steamapps/common/Proton*/files/share/default_pfx*/system.reg \
|
||||
@@ -166,6 +211,17 @@ for reg in "$steam_root"/steamapps/common/Proton*/files/share/default_pfx*/syste
|
||||
printf '%s\n' "${kept[@]}" >"$seeded"
|
||||
echo "== took Vulkan runtime markers out of $reg"
|
||||
fi
|
||||
if [ ${#had[@]} -gt 0 ]; then
|
||||
declare -A held=()
|
||||
while IFS= read -r line; do [[ $line == *$'\t'* ]] && held[$line]=1; done < <(printf '%s\n' "${!had[@]}" | present "$reg")
|
||||
if [ ${#held[@]} -lt ${#had[@]} ]; then
|
||||
echo "== $((${#had[@]} - ${#held[@]})) recorded redist markers are gone from $reg"
|
||||
had=()
|
||||
for line in "${!held[@]}"; do had[$line]=1; done
|
||||
printf '%s\n' "${!had[@]}" >"$seeded"
|
||||
fi
|
||||
unset held
|
||||
fi
|
||||
missing=()
|
||||
for line in "${want[@]}"; do [ -n "${had[$line]:-}" ] || missing+=("$line"); done
|
||||
[ ${#missing[@]} -eq 0 ] && continue
|
||||
|
||||
@@ -256,9 +256,10 @@ static void install(struct listing *l) {
|
||||
int slot = find(l->dev, l->ino);
|
||||
if (slot >= 0) {
|
||||
struct listing *old = table[slot];
|
||||
int old_wd = old->wd;
|
||||
table[slot] = l;
|
||||
detach(old);
|
||||
if (old->wd != l->wd) release(old->wd);
|
||||
if (old_wd != l->wd) release(old_wd);
|
||||
return;
|
||||
}
|
||||
int victim = -1;
|
||||
|
||||
@@ -37,7 +37,7 @@ def sources(root):
|
||||
for name in SOURCE_PATHS:
|
||||
path = root / name
|
||||
files.extend(path.rglob('*') if path.is_dir() else [path])
|
||||
return {str(path.relative_to(root)): digest(path) for path in sorted(set(files))
|
||||
return {path.relative_to(root).as_posix(): digest(path) for path in sorted(set(files))
|
||||
if path.is_file() and '__pycache__' not in path.parts and path.suffix != '.pyc'}
|
||||
|
||||
|
||||
@@ -47,7 +47,7 @@ def outputs(root):
|
||||
base = root / 'app/src/main/assets' / tree
|
||||
for path in base.rglob('*'):
|
||||
if path.is_file() and not any(part.startswith('.') for part in path.relative_to(base).parts):
|
||||
result['assets/' + tree + '/' + str(path.relative_to(base))] = str(path.relative_to(root))
|
||||
result['assets/' + tree + '/' + path.relative_to(base).as_posix()] = path.relative_to(root).as_posix()
|
||||
for library in ['libproot.so', 'libproot-loader.so', 'libdirectaudiorelay.so']:
|
||||
result['lib/arm64-v8a/' + library] = 'app/src/main/jniLibs/arm64-v8a/' + library
|
||||
result['assets/pulseaudio.tzst'] = 'app/build/prepared-assets/pulseaudio.tzst'
|
||||
|
||||
@@ -173,6 +173,25 @@ class GameEnvironmentTest(unittest.TestCase):
|
||||
(other / "Other.exe").touch()
|
||||
self.assertEqual(MODULE["engine_fixes"](str(other / "Other.exe"), []), ({}, [], []))
|
||||
|
||||
def test_arkham_city_gets_full_x87_precision_over_the_preset(self):
|
||||
with tempfile.TemporaryDirectory() as tmp:
|
||||
city = Path(tmp) / "Batman Arkham City GOTY/Binaries/Win32"
|
||||
city.mkdir(parents=True)
|
||||
for name in ("BatmanAC.exe", "BmLauncher.exe"):
|
||||
(city / name).touch()
|
||||
for exe in ("BatmanAC.exe", "BmLauncher.exe"):
|
||||
env, extra, notes = MODULE["engine_fixes"](str(city / exe), [])
|
||||
self.assertEqual((env, extra), ({"FEX_X87REDUCEDPRECISION": "0"}, []))
|
||||
config = {"version": 1, "shared": {"FEX_X87REDUCEDPRECISION": "1"}}
|
||||
self.assertEqual(MODULE["apply_config"]({}, config, "200260", env)["FEX_X87REDUCEDPRECISION"], "0")
|
||||
config["games"] = {"200260": {"FEX_X87REDUCEDPRECISION": "1"}}
|
||||
self.assertEqual(MODULE["apply_config"]({}, config, "200260", env)["FEX_X87REDUCEDPRECISION"], "1")
|
||||
asylum = Path(tmp) / "Batman Arkham Asylum GOTY/Binaries"
|
||||
asylum.mkdir(parents=True)
|
||||
for name in ("ShippingPC-BmGame.exe", "BmLauncher.exe"):
|
||||
(asylum / name).touch()
|
||||
self.assertEqual(MODULE["engine_fixes"](str(asylum / "BmLauncher.exe"), []), ({}, [], []))
|
||||
|
||||
def test_a_new_fix_only_needs_registering(self):
|
||||
with tempfile.TemporaryDirectory() as tmp:
|
||||
(Path(tmp) / "Game.exe").touch()
|
||||
|
||||
@@ -100,6 +100,53 @@ class SeedRedistsTest(unittest.TestCase):
|
||||
self.run_seed()
|
||||
self.assertIn('"VCRedist2022"=dword:00000001', self.reg.read_text())
|
||||
|
||||
def test_markers_a_replaced_registry_lost_are_seeded_again(self):
|
||||
self.game()
|
||||
stock = self.reg.read_text()
|
||||
self.run_seed()
|
||||
ledger = (self.reg.parent / ".droiddeck-redists").read_text()
|
||||
self.assertIn("VCRedist2022", ledger)
|
||||
self.reg.write_text(stock)
|
||||
self.run_seed()
|
||||
reg = self.reg.read_text()
|
||||
self.assertEqual(reg.count('"VCRedist2022"=dword:00000001'), 2, "both views again")
|
||||
self.assertIn("[Software\\\\Microsoft\\\\VisualStudio\\\\14.0\\\\VC\\\\Runtimes\\\\x86]", reg)
|
||||
self.assertEqual(sorted((self.reg.parent / ".droiddeck-redists").read_text().splitlines()), sorted(ledger.splitlines()))
|
||||
self.run_seed()
|
||||
self.assertEqual(reg, self.reg.read_text())
|
||||
|
||||
def test_a_marker_kept_in_only_one_view_is_seeded_again(self):
|
||||
self.game()
|
||||
self.run_seed()
|
||||
wow = "[Software\\\\Wow6432Node\\\\Valve\\\\Steam\\\\Apps\\\\CommonRedist\\\\vcredist\\\\2022]"
|
||||
blocks = self.reg.read_text().split("\n\n")
|
||||
self.reg.write_text("\n\n".join(b for b in blocks if not (b.startswith(wow) and '"VCRedist2022"' in b)))
|
||||
self.assertEqual(self.reg.read_text().count('"VCRedist2022"'), 1)
|
||||
self.run_seed()
|
||||
reg = self.reg.read_text()
|
||||
self.assertEqual(reg.count('"VCRedist2022"'), 3, "the one left plus both views again")
|
||||
self.assertTrue(any(b.startswith(wow) and '"VCRedist2022"' in b for b in reg.split("\n\n")))
|
||||
|
||||
def test_a_vc_runtime_another_architecture_lost_is_seeded_again(self):
|
||||
self.run_seed()
|
||||
arm64 = "[Software\\\\Wow6432Node\\\\Microsoft\\\\VisualStudio\\\\14.0\\\\VC\\\\Runtimes\\\\arm64]"
|
||||
blocks = self.reg.read_text().split("\n\n")
|
||||
self.reg.write_text("\n\n".join(b for b in blocks if not b.startswith(arm64)))
|
||||
self.assertNotIn(arm64, self.reg.read_text())
|
||||
self.run_seed()
|
||||
reg = self.reg.read_text()
|
||||
self.assertIn(arm64, reg)
|
||||
self.assertEqual(reg.count("[Software\\\\Microsoft\\\\VisualStudio\\\\14.0\\\\VC\\\\Runtimes\\\\x86]"), 2)
|
||||
|
||||
def test_a_marker_written_in_one_view_only_is_kept_as_it_is(self):
|
||||
self.game(script=GAME_SCRIPT.replace("Software\\\\Valve\\\\Steam\\\\Apps\\\\CommonRedist\\\\vcredist\\\\2022", "SOFTWARE\\\\AGEIA Technologies").replace("VCRedist2022", "PhysX Version"))
|
||||
self.run_seed()
|
||||
once = self.reg.read_text()
|
||||
self.assertEqual(once.count('"PhysX Version"=dword:00000001'), 1)
|
||||
result = self.run_seed()
|
||||
self.assertEqual(once, self.reg.read_text())
|
||||
self.assertNotIn("gone", result.stdout)
|
||||
|
||||
def test_an_evaluator_script_nested_deeper_is_read(self):
|
||||
script = self.tmp / "legacycompat/evaluatorscript_409710.vdf"
|
||||
script.parent.mkdir(parents=True)
|
||||
|
||||
Reference in new issue
Block a user