mirror of
https://github.com/Droid-Deck/DroidDeck.git
synced 2026-11-10 01:51:33 -10:00
Compare commits
22
Commits
feat/ea-store
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
13c8edb3d5 | ||
|
|
1ec2b696b7 | ||
|
|
621fd8d43e | ||
|
|
17936530aa | ||
|
|
ae3e9509f4 | ||
|
|
6bcd431ca6 | ||
|
|
836340f3a2 | ||
|
|
1b19837fc6 | ||
|
|
c85b84a964 | ||
|
|
75a0230304 | ||
|
|
7c89c14a84 | ||
|
|
b837611efe | ||
|
|
bf434b2652 | ||
|
|
35941bb9e7 | ||
|
|
96a12465e0 | ||
|
|
88d6b70881 | ||
|
|
29823bb1bf | ||
|
|
92a7124589 | ||
|
|
bf95a40e3e | ||
|
|
1355cb3a2b | ||
|
|
51407155fc | ||
|
|
b4a953c659 |
No files matched your search
@@ -10,7 +10,7 @@ DroidDeck brings the SteamOS experience to Android: Steam Gaming Mode, full Linu
|
||||
<p align="center"><b>100% open source. No ads. No telemetry. No proprietary components. No source withheld.</b></p>
|
||||
|
||||
<p align="center">
|
||||
<a href="https://github.com/Droid-Deck/DroidDeck/releases/download/0.6.9/DroidDeck-0.6.9.apk"><img src="https://img.shields.io/badge/Download-DroidDeck%200.6.9%20APK-3DDC84?style=for-the-badge&logo=android&logoColor=white" alt="Download DroidDeck 0.6.9"></a>
|
||||
<a href="https://github.com/Droid-Deck/DroidDeck/releases/download/0.7.0/DroidDeck-0.7.0.apk"><img src="https://img.shields.io/badge/Download-DroidDeck%200.7.0%20APK-3DDC84?style=for-the-badge&logo=android&logoColor=white" alt="Download DroidDeck 0.7.0"></a>
|
||||
<a href="https://discord.gg/JRGAvawjsm"><img src="https://img.shields.io/badge/Discord-Join%20us-5865F2?style=for-the-badge&logo=discord&logoColor=white" alt="Join the DroidDeck Discord"></a>
|
||||
</p>
|
||||
|
||||
@@ -36,9 +36,9 @@ DroidDeck brings the SteamOS experience to Android: Steam Gaming Mode, full Linu
|
||||
|
||||
## Install
|
||||
|
||||
1. Android 9 or newer on Adreno 730 or newer, or any 8xx. Adreno 6xx is experimental. Mali, Xclipse, PowerVR and Adreno 710 are unsupported. No root needed.
|
||||
1. Android 9 or newer on Adreno 730 or newer, or any 8xx. Adreno 6xx, Mali, Xclipse and PowerVR are experimental. Adreno 710 is currently unsupported. No root needed.
|
||||
2. About 3 GB free for the runtime, plus 1.1 GB for the desktop and emulators.
|
||||
3. Install the [APK](https://github.com/Droid-Deck/DroidDeck/releases/download/0.6.9/DroidDeck-0.6.9.apk), install the Linux runtime, press **Play** and sign in to Steam.
|
||||
3. Install the [APK](https://github.com/Droid-Deck/DroidDeck/releases/download/0.7.0/DroidDeck-0.7.0.apk), install the Linux runtime, press **Play** and sign in to Steam.
|
||||
4. On Android 14 and newer, turn on **Disable child process restrictions** in Developer options. On Android 12 and 13, Steam's first launch shows a **Fix it for me** button that walks you through it.
|
||||
|
||||
Smart glasses setup is in the [support matrix](docs/smart-glasses-support.md). Release notes are in [docs/releases](docs/releases).
|
||||
|
||||
@@ -91,14 +91,6 @@
|
||||
</intent-filter>
|
||||
<meta-data android:name="android.accessibilityservice" android:resource="@xml/home_button_service" />
|
||||
</service>
|
||||
<service android:name=".session.BottomNotificationListener"
|
||||
android:label="@string/bottom_notifications_listener"
|
||||
android:permission="android.permission.BIND_NOTIFICATION_LISTENER_SERVICE"
|
||||
android:exported="true">
|
||||
<intent-filter>
|
||||
<action android:name="android.service.notification.NotificationListenerService" />
|
||||
</intent-filter>
|
||||
</service>
|
||||
<receiver android:name=".core.ShortcutsListingReceiver" android:exported="false"
|
||||
android:process=":shortcuts_sync">
|
||||
<intent-filter>
|
||||
@@ -352,6 +344,14 @@
|
||||
<action android:name="android.content.action.DOCUMENTS_PROVIDER" />
|
||||
</intent-filter>
|
||||
</provider>
|
||||
<receiver
|
||||
android:name=".agent.AgentCallReceiver"
|
||||
android:exported="true"
|
||||
android:permission="android.permission.DUMP">
|
||||
<intent-filter>
|
||||
<action android:name="${applicationId}.AGENT_CALL" />
|
||||
</intent-filter>
|
||||
</receiver>
|
||||
<provider
|
||||
android:name=".agent.AgentBridgeProvider"
|
||||
android:authorities="${applicationId}.agent"
|
||||
|
||||
@@ -30,6 +30,7 @@
|
||||
#include <linux/uinput.h>
|
||||
#include <poll.h>
|
||||
#include <pthread.h>
|
||||
#include <sched.h>
|
||||
#include <signal.h>
|
||||
#include <stdarg.h>
|
||||
#include <stdbool.h>
|
||||
@@ -208,23 +209,33 @@ struct FakeConfig {
|
||||
bool uinput_enabled;
|
||||
};
|
||||
|
||||
// The first allocation here can start the host program's own allocator, which may call a hooked
|
||||
// function (mimalloc probes NUMA nodes with access()) and land back in config(). A guarded static
|
||||
// would throw a recursive-init error there, so the fields are filled without allocating, and a
|
||||
// reentry on the initializing thread gets them while other threads wait for the fork handlers.
|
||||
static FakeConfig &config() {
|
||||
static FakeConfig *cfg = [] {
|
||||
auto *c = new FakeConfig();
|
||||
const char *dir = getenv("FAKE_EVDEV_DIR");
|
||||
c->hook_dir = dir ? dir : "/data/data/com.termux/files/home/fake-input";
|
||||
c->udev_data_dir = getenv("FAKE_UDEV_DATA_DIR");
|
||||
c->vibration_enabled =
|
||||
getenv("FAKE_EVDEV_VIBRATION") && atoi(getenv("FAKE_EVDEV_VIBRATION"));
|
||||
const char *identity = getenv("FAKE_EVDEV_IDENTITY");
|
||||
c->xbox360_identity = identity && !strcmp(identity, "xbox360");
|
||||
c->uinput_enabled = getenv("FAKE_EVDEV_UINPUT") && atoi(getenv("FAKE_EVDEV_UINPUT"));
|
||||
pthread_atfork(controller_fork_prepare, controller_fork_parent,
|
||||
controller_fork_child);
|
||||
Logger::init();
|
||||
return c;
|
||||
}();
|
||||
return *cfg;
|
||||
static FakeConfig cfg;
|
||||
static std::atomic<int> state{0};
|
||||
static __thread bool initializing __attribute__((tls_model("initial-exec")));
|
||||
if (state.load(std::memory_order_acquire) == 2 || initializing) return cfg;
|
||||
int expected = 0;
|
||||
if (!state.compare_exchange_strong(expected, 1, std::memory_order_acq_rel)) {
|
||||
while (state.load(std::memory_order_acquire) != 2) sched_yield();
|
||||
return cfg;
|
||||
}
|
||||
initializing = true;
|
||||
const char *dir = getenv("FAKE_EVDEV_DIR");
|
||||
cfg.hook_dir = dir ? dir : "/data/data/com.termux/files/home/fake-input";
|
||||
cfg.udev_data_dir = getenv("FAKE_UDEV_DATA_DIR");
|
||||
cfg.vibration_enabled = getenv("FAKE_EVDEV_VIBRATION") && atoi(getenv("FAKE_EVDEV_VIBRATION"));
|
||||
const char *identity = getenv("FAKE_EVDEV_IDENTITY");
|
||||
cfg.xbox360_identity = identity && !strcmp(identity, "xbox360");
|
||||
cfg.uinput_enabled = getenv("FAKE_EVDEV_UINPUT") && atoi(getenv("FAKE_EVDEV_UINPUT"));
|
||||
pthread_atfork(controller_fork_prepare, controller_fork_parent, controller_fork_child);
|
||||
Logger::init();
|
||||
state.store(2, std::memory_order_release);
|
||||
initializing = false;
|
||||
return cfg;
|
||||
}
|
||||
|
||||
static std::recursive_mutex &controller_mutex() {
|
||||
|
||||
@@ -213,7 +213,7 @@ static struct {
|
||||
} g_map;
|
||||
static pthread_mutex_t g_game_picture_lock = PTHREAD_MUTEX_INITIALIZER;
|
||||
static float g_game_picture_rect[4];
|
||||
static int g_game_picture_top;
|
||||
static int g_game_picture_placed;
|
||||
|
||||
static int64_t g_swap_retry_at_ns;
|
||||
static int g_swap_fail_logged;
|
||||
@@ -362,7 +362,7 @@ int vkp_completion_unknown(void) { return g_dev_state == -3; }
|
||||
|
||||
void vk_present_set_scale_mode(int mode, int alignment) {
|
||||
if (mode < VKP_MODE_OFF || mode > VKP_MODE_INTEGER) mode = VKP_MODE_FIT;
|
||||
if (alignment < VKP_ALIGN_CENTER || alignment > VKP_ALIGN_PICTURE_BOTTOM) alignment = VKP_ALIGN_CENTER;
|
||||
if (alignment < VKP_ALIGN_CENTER || alignment > VKP_ALIGN_PICTURE_RIGHT) alignment = VKP_ALIGN_CENTER;
|
||||
g_mode = mode;
|
||||
g_align = alignment;
|
||||
}
|
||||
@@ -383,6 +383,8 @@ static const char *align_name(int a) {
|
||||
case VKP_ALIGN_BOTTOM: return "bottom";
|
||||
case VKP_ALIGN_PICTURE_TOP: return "picture top";
|
||||
case VKP_ALIGN_PICTURE_BOTTOM: return "picture bottom";
|
||||
case VKP_ALIGN_PICTURE_LEFT: return "picture left";
|
||||
case VKP_ALIGN_PICTURE_RIGHT: return "picture right";
|
||||
default: return "center";
|
||||
}
|
||||
}
|
||||
@@ -420,9 +422,11 @@ static void update_map(int scene_w, int scene_h) {
|
||||
}
|
||||
if (align == VKP_ALIGN_PICTURE_TOP) g_map.off_y = 0;
|
||||
else if (align == VKP_ALIGN_PICTURE_BOTTOM) g_map.off_y = H - scene_h * g_map.ky;
|
||||
else if (align == VKP_ALIGN_PICTURE_LEFT) g_map.off_x = 0;
|
||||
else if (align == VKP_ALIGN_PICTURE_RIGHT) g_map.off_x = W - scene_w * g_map.kx;
|
||||
g_map.valid = 1;
|
||||
pthread_mutex_lock(&g_game_picture_lock);
|
||||
g_game_picture_top = align == VKP_ALIGN_PICTURE_TOP || align == VKP_ALIGN_PICTURE_BOTTOM;
|
||||
g_game_picture_placed = align == VKP_ALIGN_PICTURE_TOP || align == VKP_ALIGN_PICTURE_BOTTOM || align == VKP_ALIGN_PICTURE_LEFT || align == VKP_ALIGN_PICTURE_RIGHT;
|
||||
g_game_picture_rect[0] = g_map.off_x;
|
||||
g_game_picture_rect[1] = g_map.off_y;
|
||||
g_game_picture_rect[2] = g_map.off_x + scene_w * g_map.kx;
|
||||
@@ -436,7 +440,7 @@ static void update_map(int scene_w, int scene_h) {
|
||||
|
||||
int vkp_game_picture_rect(float rect[4]) {
|
||||
pthread_mutex_lock(&g_game_picture_lock);
|
||||
int active = g_game_picture_top;
|
||||
int active = g_game_picture_placed;
|
||||
if (active) memcpy(rect, g_game_picture_rect, sizeof(g_game_picture_rect));
|
||||
pthread_mutex_unlock(&g_game_picture_lock);
|
||||
return active;
|
||||
|
||||
@@ -15,7 +15,9 @@ int vkp_quest_layer_presentation(void);
|
||||
|
||||
enum vkp_scale_mode { VKP_MODE_OFF = 0, VKP_MODE_FIT = 1, VKP_MODE_STRETCH = 2, VKP_MODE_FILL = 3,
|
||||
VKP_MODE_INTEGER = 4 };
|
||||
enum vkp_align { VKP_ALIGN_CENTER = 0, VKP_ALIGN_TOP = 1, VKP_ALIGN_BOTTOM = 2, VKP_ALIGN_PICTURE_TOP = 3, VKP_ALIGN_PICTURE_BOTTOM = 4 };
|
||||
enum vkp_align { VKP_ALIGN_CENTER = 0, VKP_ALIGN_TOP = 1, VKP_ALIGN_BOTTOM = 2,
|
||||
VKP_ALIGN_PICTURE_TOP = 3, VKP_ALIGN_PICTURE_BOTTOM = 4,
|
||||
VKP_ALIGN_PICTURE_LEFT = 5, VKP_ALIGN_PICTURE_RIGHT = 6 };
|
||||
void vk_present_set_scale_mode(int mode, int alignment);
|
||||
int vkp_game_picture_rect(float rect[4]);
|
||||
int vkp_output_to_scene(double ox, double oy, double *sx, double *sy);
|
||||
|
||||
@@ -121,6 +121,7 @@ import com.droiddeck.launcher.session.SessionArtifacts
|
||||
import com.droiddeck.launcher.session.SessionLogShare
|
||||
import com.droiddeck.launcher.session.SessionPhase
|
||||
import com.droiddeck.launcher.session.SessionPaths
|
||||
import com.droiddeck.launcher.session.SessionLoadingDecision
|
||||
import com.droiddeck.launcher.wayland.HdrSupport
|
||||
import com.droiddeck.launcher.session.SessionService
|
||||
import com.droiddeck.launcher.session.SessionState
|
||||
@@ -172,6 +173,8 @@ class SessionActivity : ComponentActivity(), SurfaceHolder.Callback {
|
||||
val alignment = if (!active) ALIGN_CENTER else when (picturePosition) {
|
||||
"top" -> ALIGN_PICTURE_TOP
|
||||
"bottom" -> ALIGN_PICTURE_BOTTOM
|
||||
"left" -> ALIGN_PICTURE_LEFT
|
||||
"right" -> ALIGN_PICTURE_RIGHT
|
||||
else -> ALIGN_CENTER
|
||||
}
|
||||
WaylandCompositor.nativeSetScaleMode(SCALE_FIT, alignment)
|
||||
@@ -286,6 +289,7 @@ class SessionActivity : ComponentActivity(), SurfaceHolder.Callback {
|
||||
private var directHoldBehavior by mutableStateOf(com.droiddeck.launcher.input.TouchGestureConfig.MOUSE_BEHAVIOR_CLICK)
|
||||
private var relativeMouseMode by mutableIntStateOf(0)
|
||||
@Volatile private var appliedRelativeMouseMode: Int? = null
|
||||
private val agentReadyListener: () -> Unit = { relativeMouseRetries = 0; requestRelativeMouse() }
|
||||
private val appTextFocus get() = wowChat.request != null
|
||||
private val wowChat by lazy { com.droiddeck.launcher.ui.WowChat(this, ::sendWowChat, ::cancelWowChat, ::readGameField) }
|
||||
private var wowChatBottom: SecondScreenPresentation? = null
|
||||
@@ -636,6 +640,7 @@ class SessionActivity : ComponentActivity(), SurfaceHolder.Callback {
|
||||
padBridge?.syncConnectedControllers()
|
||||
refreshControls()
|
||||
updatePointerCapture()
|
||||
requestRelativeMouse()
|
||||
updateHandCamera()
|
||||
refreshSecondScreenDisplays()
|
||||
}
|
||||
@@ -651,12 +656,14 @@ class SessionActivity : ComponentActivity(), SurfaceHolder.Callback {
|
||||
if (::padMouse.isInitialized) padMouse.reset()
|
||||
refreshControls()
|
||||
updatePointerCapture()
|
||||
requestRelativeMouse()
|
||||
updateHandCamera()
|
||||
refreshSecondScreenDisplays()
|
||||
}
|
||||
override fun onInputDeviceChanged(deviceId: Int) {
|
||||
joyConVr?.refresh()
|
||||
refreshControls()
|
||||
requestRelativeMouse()
|
||||
refreshSecondScreenDisplays()
|
||||
}
|
||||
}
|
||||
@@ -665,6 +672,7 @@ class SessionActivity : ComponentActivity(), SurfaceHolder.Callback {
|
||||
|
||||
override fun onCreate(savedInstanceState: Bundle?) {
|
||||
super.onCreate(savedInstanceState)
|
||||
AgentGuest.readyListener = agentReadyListener
|
||||
if (com.droiddeck.launcher.runtime.LinuxRuntimeInstaller.isRemoving() ||
|
||||
com.droiddeck.launcher.runtime.LinuxRuntimeInstaller.isMaintaining()) {
|
||||
runtimeRemovalBlocked = true
|
||||
@@ -1960,6 +1968,7 @@ class SessionActivity : ComponentActivity(), SurfaceHolder.Callback {
|
||||
val nextRoutedTouchMode = effectiveTouchMode()
|
||||
if (routedTouchMode != nextRoutedTouchMode) gestureTouchStreamGate.reset()
|
||||
routedTouchMode = nextRoutedTouchMode
|
||||
requestRelativeMouse()
|
||||
frameGen = FrameGen.mode(this)
|
||||
fexPreset = SessionPrefs.fexPreset(this)
|
||||
suspendPolicy = SessionPrefs.suspendPolicy(this, SessionState.mode)
|
||||
@@ -2429,6 +2438,7 @@ class SessionActivity : ComponentActivity(), SurfaceHolder.Callback {
|
||||
private fun watchSession() {
|
||||
val handler = Handler(Looper.getMainLooper())
|
||||
var ticks = 0
|
||||
val loadingStartedAt = SystemClock.elapsedRealtime()
|
||||
val poll = object : Runnable {
|
||||
override fun run() {
|
||||
if (!watching) return
|
||||
@@ -2441,6 +2451,12 @@ class SessionActivity : ComponentActivity(), SurfaceHolder.Callback {
|
||||
finish()
|
||||
return
|
||||
}
|
||||
if (loading.visible && !loading.ended && !SessionState.running && !installingRuntime &&
|
||||
SessionLoadingDecision.shouldReturnToLauncher(
|
||||
loadingStartedAt, SystemClock.elapsedRealtime(), SessionState.running, SessionState.phase)) {
|
||||
finish()
|
||||
return
|
||||
}
|
||||
if (loading.visible && !loading.ended) {
|
||||
if (!installingRuntime) loading.update(this@SessionActivity, SessionState.logFile)
|
||||
if (ticks++ % 2 == 0) loading.tick()
|
||||
@@ -2465,8 +2481,7 @@ class SessionActivity : ComponentActivity(), SurfaceHolder.Callback {
|
||||
val hint = sessionExitHint(status)
|
||||
runOnUiThread {
|
||||
if (isFinishing || isDestroyed) return@runOnUiThread
|
||||
if (status == 0) SessionState.relaunch?.let { next ->
|
||||
SessionState.relaunch = null
|
||||
if (status == 0) SessionState.takeRelaunch()?.let { next ->
|
||||
next.putExtra(EXTRA_RETURN_HOME, intent.getBooleanExtra(EXTRA_RETURN_HOME, false))
|
||||
next.putExtra(EXTRA_LINK_TASK, intent.getIntExtra(EXTRA_LINK_TASK, -1))
|
||||
setIntent(next)
|
||||
@@ -2981,7 +2996,15 @@ class SessionActivity : ComponentActivity(), SurfaceHolder.Callback {
|
||||
}
|
||||
|
||||
private fun applyRelativeMouse() {
|
||||
val mode = desiredRelativeMouseMode
|
||||
val wanted = desiredRelativeMouseMode
|
||||
val touchpadRoute = com.droiddeck.launcher.input.TouchRouter.decide(com.droiddeck.launcher.input.TouchRouter.Snapshot(
|
||||
uiBlocking = false,
|
||||
mode = routedTouchMode,
|
||||
scrollLayer = false,
|
||||
steamSession = SessionState.mode == SessionService.MODE_STEAM,
|
||||
steamDesktopUi = SessionState.steamUi == "desktop",
|
||||
)) == com.droiddeck.launcher.input.TouchRouter.Route.TOUCHPAD
|
||||
val mode = com.droiddeck.launcher.input.TouchGestureConfig.gamescopeRelativeMouse(wanted, mouseAttached(), touchpadRoute)
|
||||
if (appliedRelativeMouseMode == mode) return
|
||||
val request = org.json.JSONObject()
|
||||
.put("kind", "exec")
|
||||
@@ -2997,7 +3020,7 @@ class SessionActivity : ComponentActivity(), SurfaceHolder.Callback {
|
||||
Log.w(TAG, "gamescope did not take relative mouse $mode: ${result.exceptionOrNull() ?: answer}")
|
||||
if (relativeMouseRetries++ < RELATIVE_MOUSE_MAX_RETRIES) uiHandler.postDelayed({ requestRelativeMouse() }, 2_000)
|
||||
}
|
||||
if (desiredRelativeMouseMode != mode) requestRelativeMouse()
|
||||
if (desiredRelativeMouseMode != wanted) requestRelativeMouse()
|
||||
}
|
||||
|
||||
private fun refreshGestureConfig() {
|
||||
@@ -3192,12 +3215,17 @@ class SessionActivity : ComponentActivity(), SurfaceHolder.Callback {
|
||||
cursorVisible = false
|
||||
return
|
||||
}
|
||||
val rect = drawnRect() ?: return
|
||||
val rect = if (glassesOutputActive) pointerSurfaceRect() else drawnRect()
|
||||
val pointerRect = rect ?: return
|
||||
val position = cursorPos
|
||||
val x = if (position.x >= 0f) position.x.coerceIn(rect.left, rect.right) else rect.centerX()
|
||||
val y = if (position.y >= 0f) position.y.coerceIn(rect.top, rect.bottom) else rect.centerY()
|
||||
val x = if (position.x >= 0f) position.x.coerceIn(pointerRect.left, pointerRect.right) else pointerRect.centerX()
|
||||
val y = if (position.y >= 0f) position.y.coerceIn(pointerRect.top, pointerRect.bottom) else pointerRect.centerY()
|
||||
val out = SessionState.outputSize
|
||||
movePointer(x + dx * rect.width() / out.first, y + dy * rect.height() / out.second)
|
||||
val nextX = (x + dx * pointerRect.width() / out.first)
|
||||
.coerceIn(pointerRect.left, pointerRect.right)
|
||||
val nextY = (y + dy * pointerRect.height() / out.second)
|
||||
.coerceIn(pointerRect.top, pointerRect.bottom)
|
||||
movePointer(nextX, nextY)
|
||||
// Make frame-callback updates visible to drawing before this frame's traversal.
|
||||
androidx.compose.runtime.snapshots.Snapshot.sendApplyNotifications()
|
||||
}
|
||||
@@ -3445,50 +3473,9 @@ class SessionActivity : ComponentActivity(), SurfaceHolder.Callback {
|
||||
private fun bottomPages(): List<BottomPage> = sessionBottomPages(BottomPageActions(
|
||||
takeScreenshot = ::captureScreenshot,
|
||||
fps = { if (::hud.isInitialized) hud.liveFps else null },
|
||||
openSettings = ::openSettingsBelow,
|
||||
runningGame = { gestureGame },
|
||||
))
|
||||
|
||||
private fun openSettingsBelow(intent: android.content.Intent, done: () -> Boolean) {
|
||||
val presentation = secondScreenPresentation
|
||||
if (presentation == null) {
|
||||
if (!bottomOutputActive) return
|
||||
listOf(intent, android.content.Intent(android.provider.Settings.ACTION_NOTIFICATION_LISTENER_SETTINGS)).any {
|
||||
runCatching { startActivity(android.content.Intent(it)) }
|
||||
.onFailure { e -> Log.w(TAG, "could not open ${it.action}", e) }
|
||||
.isSuccess
|
||||
}
|
||||
return
|
||||
}
|
||||
val options = android.app.ActivityOptions.makeBasic().setLaunchDisplayId(presentation.display.displayId)
|
||||
val opened = listOf(intent, android.content.Intent(android.provider.Settings.ACTION_NOTIFICATION_LISTENER_SETTINGS)).any {
|
||||
runCatching { startActivity(android.content.Intent(it).addFlags(android.content.Intent.FLAG_ACTIVITY_NEW_TASK), options.toBundle()) }
|
||||
.onFailure { e -> Log.w(TAG, "could not open ${it.action} on display ${presentation.display.displayId}", e) }
|
||||
.isSuccess
|
||||
}
|
||||
if (!opened) return
|
||||
presentation.hide()
|
||||
var checks = 0
|
||||
fun showAgain() {
|
||||
try {
|
||||
presentation.show()
|
||||
} catch (e: Exception) {
|
||||
Log.w(TAG, "could not show the dashboard again", e)
|
||||
if (secondScreenPresentation === presentation) closeSecondScreen(reset = true)
|
||||
}
|
||||
}
|
||||
uiHandler.postDelayed(object : Runnable {
|
||||
override fun run() {
|
||||
if (secondScreenPresentation !== presentation || presentation.isShowing) return
|
||||
if (done()) {
|
||||
showAgain()
|
||||
return
|
||||
}
|
||||
if (++checks < SETTINGS_WATCH_CHECKS) uiHandler.postDelayed(this, SETTINGS_WATCH_MS) else showAgain()
|
||||
}
|
||||
}, SETTINGS_WATCH_MS)
|
||||
}
|
||||
|
||||
private fun captureScreenshot(onDone: (Boolean) -> Unit) {
|
||||
val source = if (glassesOutputActive) glassesScreen?.surfaceView else if (bottomOutputActive) bottomGame?.surfaceView
|
||||
else if (::surfaceView.isInitialized) surfaceView else null
|
||||
@@ -3854,6 +3841,7 @@ class SessionActivity : ComponentActivity(), SurfaceHolder.Callback {
|
||||
if (next == SessionPrefs.TOUCH_OFF) cursorVisible = false
|
||||
gestureTouchStreamGate.reset()
|
||||
routedTouchMode = next
|
||||
requestRelativeMouse()
|
||||
if (directTouchCursorHidden()) cursorVisible = false
|
||||
}
|
||||
|
||||
@@ -3946,8 +3934,29 @@ class SessionActivity : ComponentActivity(), SurfaceHolder.Callback {
|
||||
val width = outputView().width.takeIf { it > 0 } ?: return null
|
||||
val height = outputView().height.takeIf { it > 0 } ?: return null
|
||||
topPictureMapped = CompositorHost.isStarted && WaylandCompositor.nativeGamePictureRect(gamePictureBounds)
|
||||
if (topPictureMapped) return android.graphics.RectF(
|
||||
gamePictureBounds[0], gamePictureBounds[1], gamePictureBounds[2], gamePictureBounds[3])
|
||||
if (topPictureMapped) {
|
||||
val rect = android.graphics.RectF(
|
||||
gamePictureBounds[0], gamePictureBounds[1],
|
||||
gamePictureBounds[2], gamePictureBounds[3]
|
||||
)
|
||||
|
||||
if (glassesOutputActive) {
|
||||
val mode = glassesScreen?.display?.mode ?: return null
|
||||
val outputWidth = mode.physicalWidth.takeIf { it > 0 } ?: return null
|
||||
val outputHeight = mode.physicalHeight.takeIf { it > 0 } ?: return null
|
||||
val scaleX = width.toFloat() / outputWidth
|
||||
val scaleY = height.toFloat() / outputHeight
|
||||
|
||||
return android.graphics.RectF(
|
||||
rect.left * scaleX,
|
||||
rect.top * scaleY,
|
||||
rect.right * scaleX,
|
||||
rect.bottom * scaleY
|
||||
)
|
||||
}
|
||||
|
||||
return rect
|
||||
}
|
||||
val out = SessionState.outputSize
|
||||
val scale = minOf(width / out.first.toFloat(), height / out.second.toFloat())
|
||||
val drawnW = out.first * scale
|
||||
@@ -5360,6 +5369,7 @@ class SessionActivity : ComponentActivity(), SurfaceHolder.Callback {
|
||||
}
|
||||
|
||||
override fun onDestroy() {
|
||||
if (AgentGuest.readyListener === agentReadyListener) AgentGuest.readyListener = null
|
||||
com.droiddeck.launcher.input.HomeButtonShortcuts.detach(this)
|
||||
relativeMouseRequests.shutdownNow()
|
||||
backQamInput.cancel()
|
||||
@@ -5470,8 +5480,6 @@ class SessionActivity : ComponentActivity(), SurfaceHolder.Callback {
|
||||
private const val TEXT_FIELD_TAP_MS = 1_000L
|
||||
private const val PAUSED_FRAME_MAX_EDGE = 1920f
|
||||
private const val PAUSED_FRAME_REDRAW_MS = 150L
|
||||
private const val SETTINGS_WATCH_MS = 1_000L
|
||||
private const val SETTINGS_WATCH_CHECKS = 600
|
||||
private const val BOTTOM_RECOVERY_MS = 1_000L
|
||||
private const val BOTTOM_RECOVERY_TRIES = 3
|
||||
private const val TAG = "SessionActivity"
|
||||
@@ -5513,6 +5521,8 @@ class SessionActivity : ComponentActivity(), SurfaceHolder.Callback {
|
||||
private const val ALIGN_CENTER = 0
|
||||
private const val ALIGN_PICTURE_TOP = 3
|
||||
private const val ALIGN_PICTURE_BOTTOM = 4
|
||||
private const val ALIGN_PICTURE_LEFT = 5
|
||||
private const val ALIGN_PICTURE_RIGHT = 6
|
||||
private val RUNTIME_LINES = PackageLines(
|
||||
R.string.session_runtime_downloading, R.string.session_runtime_downloading_mb,
|
||||
R.string.session_runtime_checking, R.string.session_runtime_unpacking,
|
||||
|
||||
@@ -0,0 +1,41 @@
|
||||
package com.droiddeck.launcher.agent
|
||||
|
||||
import android.content.BroadcastReceiver
|
||||
import android.content.Context
|
||||
import android.content.Intent
|
||||
import android.os.Bundle
|
||||
import java.io.File
|
||||
|
||||
/**
|
||||
* The agent bridge for devices whose shell has no `content` command: the same calls as
|
||||
* [AgentBridgeProvider], answered in a file under the app's external files folder.
|
||||
*/
|
||||
class AgentCallReceiver : BroadcastReceiver() {
|
||||
|
||||
override fun onReceive(context: Context, intent: Intent) {
|
||||
val method = intent.getStringExtra("method") ?: return
|
||||
val id = intent.getStringExtra("id")?.takeIf { it.matches(Regex("[A-Za-z0-9_-]{1,64}")) } ?: return
|
||||
val arg = intent.getStringExtra("arg")
|
||||
val request = intent.getStringExtra(AgentBridgeProvider.EXTRA_REQUEST)
|
||||
val pending = goAsync()
|
||||
Thread({
|
||||
val json = try {
|
||||
val extras = Bundle().apply { putString(AgentBridgeProvider.EXTRA_REQUEST, request) }
|
||||
context.contentResolver.call("${context.packageName}.agent", method, arg, extras)
|
||||
?.getString(AgentBridgeProvider.RESULT_JSON) ?: """{"ok":false,"error":{"code":"NO_RESPONSE","message":"no response"}}"""
|
||||
} catch (e: Exception) {
|
||||
org.json.JSONObject().put("ok", false)
|
||||
.put("error", org.json.JSONObject().put("code", "COMMAND_FAILED").put("message", e.message ?: e.javaClass.simpleName)).toString()
|
||||
}
|
||||
try {
|
||||
val dir = File(context.getExternalFilesDir(null), "agent-calls").apply { mkdirs() }
|
||||
File(dir, "$id.json.part").writeText(json)
|
||||
File(dir, "$id.json.part").renameTo(File(dir, "$id.json"))
|
||||
} catch (e: Exception) {
|
||||
android.util.Log.w("AgentCallReceiver", "could not write the answer to $method: ${e.message}")
|
||||
} finally {
|
||||
pending.finish()
|
||||
}
|
||||
}, "agent-call-$id").start()
|
||||
}
|
||||
}
|
||||
@@ -33,6 +33,7 @@ object AgentGuest {
|
||||
var keyboardDismissals by mutableIntStateOf(0)
|
||||
private set
|
||||
@Volatile private var keyboardRoute = false
|
||||
@Volatile var readyListener: (() -> Unit)? = null
|
||||
@Volatile private var routeDir: File? = null
|
||||
private const val TAG = "AgentGuest"
|
||||
private const val POLL_MS = 50L
|
||||
@@ -59,6 +60,7 @@ object AgentGuest {
|
||||
when (path) {
|
||||
"hello.json" -> readJson(File(dir, path))?.let {
|
||||
SessionEvents.record("agent.ready", mapOf("pid" to it.optInt("pid"), "focus" to it.optBoolean("focus")))
|
||||
readyListener?.invoke()
|
||||
}
|
||||
"focus.json" -> readJson(File(dir, path))?.let(::recordFocus)
|
||||
"keyboard.json" -> readJson(File(dir, path))?.let { recordKeyboard(this, it) }
|
||||
|
||||
@@ -191,6 +191,7 @@ public final class SystemVulkanDriver {
|
||||
|
||||
/** Collect abandoned stages, and obsolete versions once older guests can no longer load them. */
|
||||
public static synchronized void pruneUnused(Context context, boolean guestsStopped) throws IOException {
|
||||
if (!AddonPack.POWERVR.installed(context)) return;
|
||||
pruneUnused(runtimeDirectory(context), guestsStopped);
|
||||
}
|
||||
|
||||
|
||||
@@ -6,6 +6,7 @@ import kotlin.math.roundToInt
|
||||
internal class FrameStats {
|
||||
private val graph = FloatArray(GRAPH_FRAMES)
|
||||
private var graphHead = 0
|
||||
private var graphFrames = 0L
|
||||
private var lastPresentNs = 0L
|
||||
private var lastFpsUpdateNs = 0L
|
||||
private var framesSinceUpdate = 0
|
||||
@@ -38,6 +39,7 @@ internal class FrameStats {
|
||||
if (lastPresentNs != 0L) {
|
||||
graph[graphHead] = frametimeNs / 1e6f
|
||||
graphHead = (graphHead + 1) % GRAPH_FRAMES
|
||||
graphFrames++
|
||||
}
|
||||
val elapsed = timeNs - lastFpsUpdateNs
|
||||
if (elapsed >= FPS_PERIOD_NS) {
|
||||
@@ -68,6 +70,7 @@ internal class FrameStats {
|
||||
fun reset() {
|
||||
graph.fill(0f)
|
||||
graphHead = 0
|
||||
graphFrames = 0L
|
||||
lastPresentNs = 0L
|
||||
lastFpsUpdateNs = 0L
|
||||
framesSinceUpdate = 0
|
||||
@@ -86,8 +89,9 @@ internal class FrameStats {
|
||||
fun frametimeMs(): Float = 1000f / maxOf(fps, 1f)
|
||||
|
||||
@Synchronized
|
||||
fun graph(out: FloatArray) {
|
||||
fun graph(out: FloatArray): Long {
|
||||
for (i in 0 until GRAPH_FRAMES) out[i] = graph[(graphHead + i) % GRAPH_FRAMES]
|
||||
return graphFrames
|
||||
}
|
||||
|
||||
@Synchronized
|
||||
|
||||
@@ -213,6 +213,9 @@ data class TouchGestureConfig(
|
||||
return if (value == MOUSE_BEHAVIOR_CLICK) MOUSE_BEHAVIOR_CLICK else MOUSE_BEHAVIOR_HOLD
|
||||
}
|
||||
|
||||
fun gamescopeRelativeMouse(mode: Int, mouseAttached: Boolean, touchpad: Boolean): Int =
|
||||
if (mode == RELATIVE_MOUSE_AUTO && !mouseAttached && !touchpad) RELATIVE_MOUSE_OFF else mode
|
||||
|
||||
private fun normalizeRelativeMouseMode(mode: Int): Int {
|
||||
return if (mode in RELATIVE_MOUSE_AUTO..RELATIVE_MOUSE_OFF) mode else RELATIVE_MOUSE_AUTO
|
||||
}
|
||||
|
||||
@@ -1,60 +0,0 @@
|
||||
package com.droiddeck.launcher.session
|
||||
|
||||
import android.app.Notification
|
||||
import android.content.Context
|
||||
import android.service.notification.NotificationListenerService
|
||||
import android.service.notification.StatusBarNotification
|
||||
import androidx.compose.runtime.mutableStateListOf
|
||||
import androidx.core.app.NotificationManagerCompat
|
||||
|
||||
data class BottomNotification(val key: String, val app: String, val title: String, val text: String, val postedAt: Long)
|
||||
|
||||
object BottomNotifications {
|
||||
val items = mutableStateListOf<BottomNotification>()
|
||||
|
||||
fun granted(context: Context): Boolean =
|
||||
context.packageName in NotificationManagerCompat.getEnabledListenerPackages(context)
|
||||
|
||||
internal fun keep(own: String, pkg: String, ongoing: Boolean, groupSummary: Boolean, title: String, text: String): Boolean =
|
||||
pkg != own && !ongoing && !groupSummary && (title.isNotBlank() || text.isNotBlank())
|
||||
|
||||
internal fun add(list: MutableList<BottomNotification>, item: BottomNotification) {
|
||||
list.removeAll { it.key == item.key }
|
||||
list.add(0, item)
|
||||
while (list.size > MAX_ITEMS) list.removeAt(list.lastIndex)
|
||||
}
|
||||
|
||||
fun clear() = items.clear()
|
||||
|
||||
internal fun post(context: Context, sbn: StatusBarNotification) {
|
||||
val n = sbn.notification ?: return
|
||||
val extras = n.extras
|
||||
val title = extras?.getCharSequence(Notification.EXTRA_TITLE)?.toString().orEmpty()
|
||||
val text = (extras?.getCharSequence(Notification.EXTRA_BIG_TEXT) ?: extras?.getCharSequence(Notification.EXTRA_TEXT))?.toString().orEmpty()
|
||||
if (!keep(context.packageName, sbn.packageName, sbn.isOngoing, (n.flags and Notification.FLAG_GROUP_SUMMARY) != 0, title, text)) return
|
||||
val app = runCatching {
|
||||
val pm = context.packageManager
|
||||
pm.getApplicationLabel(pm.getApplicationInfo(sbn.packageName, 0)).toString()
|
||||
}.getOrDefault(sbn.packageName)
|
||||
add(items, BottomNotification(sbn.key, app, title, text, sbn.postTime))
|
||||
}
|
||||
|
||||
internal fun remove(key: String) {
|
||||
items.removeAll { it.key == key }
|
||||
}
|
||||
|
||||
private const val MAX_ITEMS = 30
|
||||
}
|
||||
|
||||
class BottomNotificationListener : NotificationListenerService() {
|
||||
override fun onListenerConnected() {
|
||||
BottomNotifications.clear()
|
||||
runCatching { activeNotifications }.getOrNull()?.sortedBy { it.postTime }?.forEach { BottomNotifications.post(this, it) }
|
||||
}
|
||||
|
||||
override fun onNotificationPosted(sbn: StatusBarNotification) = BottomNotifications.post(this, sbn)
|
||||
|
||||
override fun onNotificationRemoved(sbn: StatusBarNotification) = BottomNotifications.remove(sbn.key)
|
||||
|
||||
override fun onListenerDisconnected() = BottomNotifications.clear()
|
||||
}
|
||||
@@ -14,6 +14,7 @@ object SessionEvents {
|
||||
SessionPaths.take()
|
||||
val dir = SessionPaths.beginOrCurrent(context, label ?: SessionPaths.label(mode, null))
|
||||
synchronized(SessionState) {
|
||||
SessionState.clearRelaunch()
|
||||
SessionState.phase = SessionPhase.PREPARING
|
||||
SessionState.mode = mode
|
||||
SessionState.sessionId = dir.name
|
||||
|
||||
@@ -43,6 +43,7 @@ object SessionFiles {
|
||||
"usr/local/bin/droiddeck-game-gpu" to "usr/local/bin/droiddeck-game-gpu",
|
||||
"usr/local/bin/droiddeck-diagnostic-inventory" to "usr/local/bin/droiddeck-diagnostic-inventory",
|
||||
"usr/local/bin/droiddeck-wincomponents" to "usr/local/bin/droiddeck-wincomponents",
|
||||
"usr/local/bin/droiddeck-wine-pe" to "usr/local/bin/droiddeck-wine-pe",
|
||||
"usr/local/bin/droiddeck-msi-install" to "usr/local/bin/droiddeck-msi-install",
|
||||
"usr/local/bin/droiddeck-esync" to "usr/local/bin/droiddeck-esync",
|
||||
"usr/local/bin/droiddeck-steam-compat" to "usr/local/bin/droiddeck-steam-compat",
|
||||
|
||||
@@ -0,0 +1,8 @@
|
||||
package com.droiddeck.launcher.session
|
||||
|
||||
internal object SessionLoadingDecision {
|
||||
const val TIMEOUT_MS = 10_000L
|
||||
|
||||
fun shouldReturnToLauncher(startedAt: Long, now: Long, running: Boolean, phase: SessionPhase): Boolean =
|
||||
!running && phase in setOf(SessionPhase.IDLE, SessionPhase.FAILED) && now - startedAt >= TIMEOUT_MS
|
||||
}
|
||||
@@ -558,11 +558,11 @@ object SessionPrefs {
|
||||
|
||||
fun picturePosition(context: Context): String =
|
||||
prefs(context).getString("picturePosition", "center")
|
||||
?.takeIf { it in listOf("top", "bottom", "center") } ?: "center"
|
||||
?.takeIf { it in listOf("top", "bottom", "left", "right", "center") } ?: "center"
|
||||
|
||||
fun setPicturePosition(context: Context, position: String) {
|
||||
prefs(context).edit().putString("picturePosition",
|
||||
position.takeIf { it in listOf("top", "bottom", "center") } ?: "center").apply()
|
||||
position.takeIf { it in listOf("top", "bottom", "left", "right", "center") } ?: "center").apply()
|
||||
}
|
||||
|
||||
fun returnAfterGame(context: Context): Boolean = prefs(context).getBoolean("returnAfterGame", true)
|
||||
|
||||
@@ -1697,8 +1697,8 @@ class SessionService : Service() {
|
||||
.putExtra(EXTRA_STEAM_UI, ui)
|
||||
.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK)
|
||||
if (url.isNotEmpty()) next.putExtra(EXTRA_STEAM_URL, url)
|
||||
SessionState.relaunch = next
|
||||
android.os.Handler(android.os.Looper.getMainLooper()).post { stopSession(0) }
|
||||
SessionState.queueRelaunch(next)
|
||||
android.os.Handler(android.os.Looper.getMainLooper()).post { stopSession(0, relaunch = true) }
|
||||
}
|
||||
}
|
||||
watcher.startWatching()
|
||||
@@ -2013,7 +2013,7 @@ class SessionService : Service() {
|
||||
}
|
||||
}
|
||||
|
||||
private fun stopSession(status: Int, gen: Int? = null) {
|
||||
private fun stopSession(status: Int, gen: Int? = null, relaunch: Boolean = false) {
|
||||
if (gen != null && gen != sessionGen) {
|
||||
Log.i(TAG, "ignoring a stop from session $gen; session $sessionGen is current")
|
||||
return
|
||||
@@ -2024,6 +2024,7 @@ class SessionService : Service() {
|
||||
cancelQueuedSteamGame()
|
||||
gameExitWatchToken++
|
||||
if (!SessionState.running) return
|
||||
if (relaunch) SessionState.confirmRelaunchStop()
|
||||
SessionEvents.record("guest.exited", mapOf("status" to status))
|
||||
SessionEvents.transition(SessionPhase.STOPPING, "session.stopping", mapOf("status" to status))
|
||||
SessionState.running = false
|
||||
|
||||
@@ -73,7 +73,44 @@ object SessionState {
|
||||
@Volatile
|
||||
var steamClientArch: String? = null
|
||||
var steamVr by mutableStateOf(false)
|
||||
@Volatile
|
||||
var relaunch: android.content.Intent? = null
|
||||
@Volatile
|
||||
private var relaunchSessionId: String? = null
|
||||
@Volatile
|
||||
private var relaunchStopRequested = false
|
||||
|
||||
fun queueRelaunch(intent: android.content.Intent) {
|
||||
synchronized(this) {
|
||||
relaunch = intent
|
||||
relaunchSessionId = sessionId
|
||||
relaunchStopRequested = false
|
||||
}
|
||||
}
|
||||
|
||||
fun confirmRelaunchStop() {
|
||||
synchronized(this) {
|
||||
relaunchStopRequested = relaunch != null && relaunchSessionId == sessionId
|
||||
}
|
||||
}
|
||||
|
||||
fun takeRelaunch(): android.content.Intent? = synchronized(this) {
|
||||
val next = relaunch
|
||||
val valid = relaunchStopRequested && relaunchSessionId == sessionId
|
||||
relaunch = null
|
||||
relaunchSessionId = null
|
||||
relaunchStopRequested = false
|
||||
next.takeIf { valid }
|
||||
}
|
||||
|
||||
fun clearRelaunch() {
|
||||
synchronized(this) {
|
||||
relaunch = null
|
||||
relaunchSessionId = null
|
||||
relaunchStopRequested = false
|
||||
}
|
||||
}
|
||||
|
||||
@JvmStatic var hdr = false
|
||||
|
||||
@Volatile private var deckPadValue = false
|
||||
|
||||
@@ -16,6 +16,7 @@ internal object GogContent {
|
||||
|
||||
fun token(context: Context) = GogAuth.validToken(context) ?: error("Sign in to GOG and retry")
|
||||
|
||||
@JvmStatic
|
||||
fun builds(id: String, token: String): JSONObject {
|
||||
val result = json("$SYSTEM/products/$id/os/windows/builds?generation=2", token)
|
||||
if (result.optJSONArray("items")?.length() != 0) return result
|
||||
@@ -24,6 +25,36 @@ internal object GogContent {
|
||||
|
||||
fun product(id: String, token: String): JSONObject = json("https://api.gog.com/products/$id?expand=downloads", token)
|
||||
|
||||
@JvmStatic
|
||||
fun ownedProductIds(token: String): Set<String>? = runCatching {
|
||||
val body = StoreNet.get("https://embed.gog.com/user/data/games", bearer = token) ?: return@runCatching null
|
||||
JSONObject(body).optJSONArray("owned")?.strings()?.toSet() ?: emptySet()
|
||||
}.getOrNull()
|
||||
|
||||
@JvmStatic
|
||||
fun depotProducts(depot: JSONObject, baseProductId: String): List<String> =
|
||||
depot.optJSONArray("gameIDs")?.strings() ?: listOf(depot.optString("productId").ifEmpty { baseProductId })
|
||||
|
||||
@JvmStatic
|
||||
fun clientCredentials(manifest: JSONObject): Pair<String, String>? {
|
||||
val clientId = manifest.optString("clientId")
|
||||
val clientSecret = manifest.optString("clientSecret")
|
||||
return clientId.takeIf { it.isNotBlank() }?.let { id -> clientSecret.takeIf { it.isNotBlank() }?.let { id to it } }
|
||||
}
|
||||
|
||||
@JvmStatic
|
||||
fun tempExecutable(manifest: JSONObject): String? = manifest.optJSONArray("products")?.optJSONObject(0)
|
||||
?.optString("temp_executable")?.takeIf { it.isNotBlank() }
|
||||
|
||||
@JvmStatic
|
||||
fun installProducts(baseProductId: String, depots: JSONArray, installed: List<String>, token: String,
|
||||
ownership: (String) -> Set<String>? = { ownedProductIds(it) }): Set<String> {
|
||||
val listed = depots.objects().flatMap { depotProducts(it, baseProductId) }.distinct()
|
||||
val owned = if (listed.any { it != baseProductId } || installed.any { it != baseProductId }) ownership(token) else emptySet()
|
||||
check(installed.all { it == baseProductId || owned?.contains(it) == true }) { "Could not confirm ownership of installed GOG DLC" }
|
||||
return (listOf(baseProductId) + installed + listed.filter { it == baseProductId || owned?.contains(it) == true }).toSet()
|
||||
}
|
||||
|
||||
fun downloadFilename(url: String): String = java.net.URLDecoder.decode(URL(url).path.substringAfterLast('/').replace("+", "%2B"), "UTF-8")
|
||||
|
||||
fun resolveDownload(download: GogOfflineDownload, token: String): GogOfflineDownload = download.copy(parts = download.parts.map { part ->
|
||||
@@ -42,6 +73,7 @@ internal object GogContent {
|
||||
} finally { connection.disconnect() }
|
||||
})
|
||||
|
||||
@JvmStatic
|
||||
fun json(url: String, token: String? = null): JSONObject {
|
||||
require(URL(url).protocol == "https") { "GOG metadata requires HTTPS" }
|
||||
val connection = URL(url).openConnection() as HttpURLConnection
|
||||
@@ -61,21 +93,21 @@ internal object GogContent {
|
||||
}
|
||||
|
||||
fun snapshot(old: GogInstallRecord, build: GogBuild, token: String,
|
||||
metadata: (String, String?) -> JSONObject = ::json): GogInstallRecord {
|
||||
ownership: (String) -> Set<String>? = { ownedProductIds(it) },
|
||||
metadata: (String, String?) -> JSONObject = ::json,
|
||||
onManifest: (JSONObject) -> Unit = {}): GogInstallRecord {
|
||||
check(build.link.isNotBlank()) { "GOG build manifest is unavailable" }
|
||||
val manifest = metadata(build.link, token)
|
||||
onManifest(manifest)
|
||||
val files = JSONArray()
|
||||
val depots = if (build.generation == 1) manifest.optJSONObject("product")?.optJSONArray("depots") ?: manifest.optJSONArray("depot") else manifest.optJSONArray("depots")
|
||||
check(depots != null) { "GOG build has no depot list" }
|
||||
val resolvedLanguage = StoreLanguage.pick(StoreLanguage.codesFromDepots(depots), old.language)
|
||||
val selectedBitness = if (depots.objects().any { it.optJSONArray("osBitness")?.strings()?.contains("64") == true }) "64" else "32"
|
||||
val owned = if (old.products.any { it != old.id }) StoreNet.get("https://embed.gog.com/user/data/games", bearer = token)?.let { JSONObject(it).optJSONArray("owned")?.strings()?.toSet() } else emptySet()
|
||||
val products = old.products.filter { it == old.id || owned?.contains(it) == true }
|
||||
check(old.products.all { it == old.id || owned?.contains(it) == true }) { "Could not confirm ownership of installed GOG DLC" }
|
||||
val products = installProducts(old.id, depots, old.products, token, ownership)
|
||||
for (depot in depots.objects()) {
|
||||
if (depot.has("redist")) continue
|
||||
val ids = depot.optJSONArray("gameIDs")?.strings() ?: listOf(depot.optString("productId", old.id))
|
||||
val product = ids.firstOrNull { it in products } ?: continue
|
||||
val product = depotProducts(depot, old.id).firstOrNull { it in products } ?: continue
|
||||
val languages = depot.optJSONArray("languages")?.strings().orEmpty()
|
||||
if (!languageSelected(languages, resolvedLanguage)) continue
|
||||
val bitness = depot.optJSONArray("osBitness")?.strings().orEmpty()
|
||||
@@ -108,7 +140,7 @@ internal object GogContent {
|
||||
}
|
||||
check(files.length() > 0) { "GOG build contains no matching files" }
|
||||
return old.copy(source = "depot", generation = build.generation, build = build.id, version = build.version,
|
||||
branch = build.branch, language = resolvedLanguage, manifest = sanitize(manifest), files = files, products = products, verified = true)
|
||||
branch = build.branch, language = resolvedLanguage, manifest = sanitize(manifest), files = files, products = products.toList(), verified = true)
|
||||
}
|
||||
|
||||
fun languageSelected(list: List<String>, selected: String): Boolean = list.any { it.equals("Neutral", true) } || StoreLanguage.depotMatchesResolved(list.toTypedArray(), selected)
|
||||
|
||||
@@ -125,211 +125,22 @@ public final class GogDownloadManager {
|
||||
private static Gen2Outcome runGen2(Context ctx, GogGame game, String token, String buildsJson, File installPath, Callback cb, AtomicBoolean cancelled, String language) {
|
||||
Gen2Outcome out = new Gen2Outcome();
|
||||
try {
|
||||
cb.onProgress("Fetching manifest…", 5);
|
||||
Gen2Manifest gm = resolveGen2Manifest(buildsJson, token, cb);
|
||||
if (gm == null) { out.error = "gen2 manifest unavailable"; return out; }
|
||||
JSONObject manifest = gm.manifest;
|
||||
JSONArray depots = manifest.optJSONArray("depots");
|
||||
if (depots == null) { out.error = "no depots in manifest"; return out; }
|
||||
JSONArray products = manifest.optJSONArray("products");
|
||||
String tempExe = null;
|
||||
if (products != null && products.length() > 0) {
|
||||
tempExe = products.getJSONObject(0).optString("temp_executable", null);
|
||||
if (tempExe != null && tempExe.isEmpty()) tempExe = null;
|
||||
}
|
||||
String baseProductId = manifest.optString("baseProductId", null);
|
||||
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;
|
||||
JSONArray deps = manifest.optJSONArray("dependencies");
|
||||
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<>();
|
||||
List<String> depotJsons = new ArrayList<>();
|
||||
JSONArray owned = new JSONArray();
|
||||
List<JSONObject> ownedDepots = new ArrayList<>();
|
||||
for (int i = 0; i < depots.length(); i++) {
|
||||
JSONObject depot = depots.getJSONObject(i);
|
||||
String depotPid = depot.optString("productId", "");
|
||||
if (!depotPid.isEmpty() && !depotPid.equals(baseProductId)) continue;
|
||||
owned.put(depot);
|
||||
ownedDepots.add(depot);
|
||||
}
|
||||
String resolved = StoreLanguage.pick(StoreLanguage.codesFromDepots(owned), language);
|
||||
cb.onLog("gog: language " + resolved);
|
||||
for (int i = 0; i < ownedDepots.size(); i++) {
|
||||
if (cancelled.get()) return out;
|
||||
JSONObject depot = ownedDepots.get(i);
|
||||
if (!depotSelected(depot, resolved)) continue;
|
||||
String manifestHash = depot.optString("manifest");
|
||||
if (manifestHash == null || manifestHash.isEmpty()) continue;
|
||||
byte[] dmRaw = fetchBytes("https://gog-cdn-fastly.gog.com/content-system/v2/meta/" + buildCdnPath(manifestHash), null);
|
||||
if (dmRaw == null) { cb.onLog("gog: depot " + i + " meta fetch failed"); continue; }
|
||||
String dmStr = decompressBytes(dmRaw);
|
||||
if (dmStr == null) { cb.onLog("gog: depot " + i + " decompress failed"); continue; }
|
||||
parseDepotManifest(dmStr, files);
|
||||
depotJsons.add(dmStr);
|
||||
}
|
||||
if (files.isEmpty()) { out.error = "no depot files collected"; return out; }
|
||||
|
||||
cb.onProgress("Fetching CDN link…", 15);
|
||||
String secureLinkUrl = "https://content-system.gog.com/products/" + baseProductId + "/secure_link?_version=2&generation=2&path=/";
|
||||
String cdnBase = parseCdnUrl(httpGet(secureLinkUrl, token));
|
||||
if (cdnBase == null) { out.error = "secure link refused"; return out; }
|
||||
|
||||
installPath.mkdirs();
|
||||
long plannedBytes = 0;
|
||||
for (DepotFile df : files) plannedBytes += df.totalSize;
|
||||
long remainingBytes = remainingInstallBytes(files, installPath);
|
||||
if (remainingBytes > 0) {
|
||||
long usable = com.droiddeck.launcher.stores.StoreInstallRoot.availableBytes(installPath);
|
||||
if (com.droiddeck.launcher.stores.StoreInstallRoot.insufficientSpace(remainingBytes, usable)) {
|
||||
out.hardStop = true;
|
||||
out.error = "Not enough free space: need " + formatBytes(remainingBytes) + ", only " + formatBytes(usable) + " free";
|
||||
return out;
|
||||
}
|
||||
}
|
||||
Collections.sort(files, (a, b) -> Long.compare(b.totalSize, a.totalSize));
|
||||
|
||||
final int total = files.size();
|
||||
final long planned = plannedBytes;
|
||||
final AtomicInteger doneCount = new AtomicInteger(0);
|
||||
final AtomicLong totalBytes = new AtomicLong(0);
|
||||
final AtomicLong lastSpeedMs = new AtomicLong(System.currentTimeMillis());
|
||||
final AtomicLong lastSpeedB = new AtomicLong(0);
|
||||
final AtomicLong speedBps = new AtomicLong(0);
|
||||
final AtomicBoolean anyFailed = new AtomicBoolean(false);
|
||||
final AtomicReference<String> cdnBaseRef = new AtomicReference<>(cdnBase);
|
||||
final AtomicInteger cdnRefreshCount = new AtomicInteger(0);
|
||||
final int MAX_CDN_REFRESH = 5;
|
||||
cb.onSizes(planned, planned);
|
||||
cb.onBytes(0, planned, 0);
|
||||
cb.onLog("gog: " + total + " files, " + formatBytes(planned) + ", base=" + baseProductId);
|
||||
|
||||
int threads = downloadThreads();
|
||||
if (GogNative.isAvailable()) {
|
||||
runNativeEngine(ctx, GogNative.KIND_GEN2_CHUNKS, "Verified…", depotJsons, installPath, cdnBaseRef, secureLinkUrl, token,
|
||||
cdnRefreshCount, MAX_CDN_REFRESH, threads, threads, true, "gog base=" + baseProductId,
|
||||
cancelled, anyFailed, doneCount, total, planned, totalBytes, lastSpeedMs, lastSpeedB, speedBps, cb);
|
||||
} else {
|
||||
cb.onLog("gog: engine=built-in (" + threads + " threads)");
|
||||
ExecutorService pool = Executors.newFixedThreadPool(threads, com.droiddeck.launcher.stores.download.DownloadQueue.workerFactory("gog-dl"));
|
||||
List<Future<Void>> futures = new ArrayList<>();
|
||||
for (DepotFile df : files) {
|
||||
futures.add(pool.submit((Callable<Void>) () -> {
|
||||
if (cancelled.get() || anyFailed.get()) return null;
|
||||
File outFile = new File(installPath, df.relativePath);
|
||||
outFile.getParentFile().mkdirs();
|
||||
if (fileVerified(outFile, df.totalSize, df.md5)) {
|
||||
int done = doneCount.incrementAndGet();
|
||||
long tb = totalBytes.addAndGet(df.totalSize);
|
||||
cb.onProgress("Verified…", 15 + (int) ((done / (float) total) * 80));
|
||||
cb.onBytes(tb, planned, speedBps.get());
|
||||
return null;
|
||||
}
|
||||
boolean ok = assembleDepotFile(df, outFile, cdnBaseRef, secureLinkUrl, token, cdnRefreshCount, MAX_CDN_REFRESH, cancelled, cb);
|
||||
if (cancelled.get()) return null;
|
||||
if (!ok) { cb.onLog("FAIL file=" + df.relativePath); anyFailed.set(true); return null; }
|
||||
int done = doneCount.incrementAndGet();
|
||||
long tb = totalBytes.addAndGet(df.totalSize);
|
||||
sampleSpeed(tb, lastSpeedMs, lastSpeedB, speedBps);
|
||||
cb.onProgress("Downloading: " + baseName(df.relativePath) + speedSuffix(speedBps.get()), 15 + (int) ((done / (float) total) * 80));
|
||||
cb.onBytes(tb, planned, speedBps.get());
|
||||
return null;
|
||||
}));
|
||||
}
|
||||
pool.shutdown();
|
||||
try {
|
||||
for (Future<Void> f : futures) f.get();
|
||||
} catch (Exception e) {
|
||||
pool.shutdownNow();
|
||||
out.error = "download pool error: " + e.getClass().getSimpleName();
|
||||
return out;
|
||||
}
|
||||
}
|
||||
if (cancelled.get()) return out;
|
||||
if (anyFailed.get()) { out.error = "one or more chunks failed to download"; return out; }
|
||||
|
||||
cb.onProgress("Finishing…", 96);
|
||||
try {
|
||||
GogMaintenance.captureDepot(game.gameId, installPath, gm.buildId, buildsJson, manifest, new JSONArray(depotJsons), resolved);
|
||||
} catch (Exception e) {
|
||||
cb.onLog("gog: maintenance inventory not recorded: " + e.getMessage());
|
||||
}
|
||||
writeFile(new File(installPath, MARKER), ("{\"gameId\":\"" + game.gameId + "\",\"buildId\":\"" + gm.buildId + "\"}").getBytes("UTF-8"));
|
||||
deleteCounted(new File(installPath, ".gog_chunks"), cb);
|
||||
String clientId = manifest.optString("clientId", null);
|
||||
if (clientId != null && !clientId.isEmpty()) GogPrefs.get(ctx).edit().putString("client_id_" + game.gameId, clientId).apply();
|
||||
String clientSecret = manifest.optString("clientSecret", null);
|
||||
if (clientSecret != null && !clientSecret.isEmpty()) GogPrefs.get(ctx).edit().putString("client_secret_" + game.gameId, clientSecret).apply();
|
||||
GameTask task = pickTask(installPath, game.gameId, tempExe, game.title);
|
||||
out.result = new Result(task == null ? "" : task.path, gm.buildId, planned);
|
||||
if (task != null) out.result.workDir = task.workDir;
|
||||
out.result.dependencies = dependencies;
|
||||
out.result.dependenciesRead = true;
|
||||
out.result.language = resolved;
|
||||
return out;
|
||||
out.result = GogMaintenance.installContent(ctx, game, installPath, new JSONObject(buildsJson), language, cb, cancelled);
|
||||
if (out.result == null && !cancelled.get()) out.error = "no usable public Windows build";
|
||||
} catch (Exception e) {
|
||||
out.error = "exception: " + e.getClass().getSimpleName() + (e.getMessage() != null ? ": " + e.getMessage() : "");
|
||||
return out;
|
||||
}
|
||||
}
|
||||
|
||||
private static final class Gen2Manifest {
|
||||
final JSONObject manifest; final String buildId;
|
||||
Gen2Manifest(JSONObject manifest, String buildId) { this.manifest = manifest; this.buildId = buildId; }
|
||||
}
|
||||
|
||||
private static Gen2Manifest resolveGen2Manifest(String buildsJson, String token, Callback cb) {
|
||||
try {
|
||||
JSONArray items = new JSONObject(buildsJson).optJSONArray("items");
|
||||
if (items == null || items.length() == 0) { cb.onLog("gog: no gen2 builds"); return null; }
|
||||
String buildId = null, manifestUrl = null;
|
||||
for (int i = 0; i < items.length(); i++) {
|
||||
JSONObject item = items.getJSONObject(i);
|
||||
if ("windows".equals(item.optString("os")) && (item.isNull("branch") || item.optString("branch").isEmpty())) {
|
||||
buildId = item.optString("build_id");
|
||||
manifestUrl = item.optString("link");
|
||||
if (manifestUrl == null || manifestUrl.isEmpty()) manifestUrl = item.optString("meta_url");
|
||||
break;
|
||||
}
|
||||
}
|
||||
if (buildId == null || manifestUrl == null || manifestUrl.isEmpty()) { cb.onLog("gog: no windows build"); return null; }
|
||||
byte[] raw = fetchBytes(manifestUrl, token);
|
||||
if (raw == null) { cb.onLog("gog: manifest fetch failed"); return null; }
|
||||
String str = decompressBytes(raw);
|
||||
if (str == null) { cb.onLog("gog: manifest decompress failed"); return null; }
|
||||
return new Gen2Manifest(new JSONObject(str), buildId);
|
||||
} catch (Exception e) {
|
||||
cb.onLog("gog: manifest " + e.getClass().getSimpleName());
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
private static boolean depotSelected(JSONObject depot, String resolved) {
|
||||
JSONArray languages = depot.optJSONArray("languages");
|
||||
if (languages == null || languages.length() == 0) return true;
|
||||
String[] listed = new String[languages.length()];
|
||||
for (int i = 0; i < languages.length(); i++) listed[i] = languages.optString(i, "");
|
||||
return StoreLanguage.depotMatchesResolved(listed, resolved);
|
||||
return out;
|
||||
}
|
||||
|
||||
public static List<String> listLanguages(Context ctx, String gameId) {
|
||||
try {
|
||||
String token = GogAuth.validToken(ctx);
|
||||
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 Collections.emptyList();
|
||||
Callback silent = new Callback() {
|
||||
@Override public void onProgress(String message, int pct) {}
|
||||
};
|
||||
Gen2Manifest gm = resolveGen2Manifest(buildsJson, token, silent);
|
||||
if (gm == null) return Collections.emptyList();
|
||||
return StoreLanguage.codesFromDepots(gm.manifest.optJSONArray("depots"));
|
||||
JSONObject builds = GogContent.builds(gameId, token);
|
||||
GogBuild build = GogBuild.Companion.select(builds, "", "");
|
||||
if (build == null || build.getGeneration() != 2) return Collections.emptyList();
|
||||
return StoreLanguage.codesFromDepots(GogContent.json(build.getLink(), token).optJSONArray("depots"));
|
||||
} catch (Exception e) {
|
||||
return Collections.emptyList();
|
||||
}
|
||||
@@ -398,7 +209,7 @@ public final class GogDownloadManager {
|
||||
int threads = downloadThreads();
|
||||
if (GogNative.isAvailable()) {
|
||||
runNativeEngine(ctx, GogNative.KIND_GEN1_RANGES, "Resuming…", Collections.singletonList(manifestStr), installPath,
|
||||
new AtomicReference<>(""), null, null, new AtomicInteger(0), 0, threads, threads, false, "gog gen1=" + game.gameId,
|
||||
threads, threads, false, "gog gen1=" + game.gameId,
|
||||
cancelled, anyFailed, done, total, planned, totalBytes, lastSpeedMs, lastSpeedB, speedBps, cb);
|
||||
} else {
|
||||
ExecutorService pool = Executors.newFixedThreadPool(threads, com.droiddeck.launcher.stores.download.DownloadQueue.workerFactory("gog-dl"));
|
||||
@@ -659,8 +470,7 @@ public final class GogDownloadManager {
|
||||
|
||||
|
||||
private static void runNativeEngine(Context ctx, int kind, String verifiedMsg, List<String> depotJsons, File installPath,
|
||||
AtomicReference<String> cdnBaseRef, String secureLinkUrl, String token,
|
||||
AtomicInteger cdnRefreshCount, int maxCdnRefresh, int maxWorkers, int processWorkers,
|
||||
int maxWorkers, int processWorkers,
|
||||
boolean sortLargestFirst, String label, AtomicBoolean cancelled, AtomicBoolean anyFailed,
|
||||
AtomicInteger doneCount, int total, long planned, AtomicLong totalBytes,
|
||||
AtomicLong lastSpeedMs, AtomicLong lastSpeedB, AtomicLong speedBps, Callback cb) {
|
||||
@@ -673,10 +483,8 @@ public final class GogDownloadManager {
|
||||
int run = 0;
|
||||
while (!cancelled.get()) {
|
||||
run++;
|
||||
final String base = cdnBaseRef.get();
|
||||
final CountDownLatch latch = new CountDownLatch(1);
|
||||
final AtomicBoolean okRef = new AtomicBoolean(false);
|
||||
final AtomicBoolean linkExpiryRef = new AtomicBoolean(false);
|
||||
final AtomicReference<String> errRef = new AtomicReference<>("");
|
||||
final long runStart = totalBytes.get();
|
||||
final AtomicLong verifiedInRun = new AtomicLong(0);
|
||||
@@ -702,12 +510,10 @@ public final class GogDownloadManager {
|
||||
@Override public void onLog(String line) { cb.onLog(line); }
|
||||
@Override public void onMaintenance(long networkBytes, long reusedBytes, long appliedBytes) {}
|
||||
@Override public void onComplete(boolean success, boolean wasCancelled, boolean linkExpiry, String error, long bytesWritten, int filesDone) {
|
||||
okRef.set(success); linkExpiryRef.set(linkExpiry); errRef.set(error == null ? "" : error); latch.countDown();
|
||||
okRef.set(success); errRef.set(error == null ? "" : error); latch.countDown();
|
||||
}
|
||||
};
|
||||
String[] bases = (base == null || base.isEmpty()) ? new String[0] : base.split("\n");
|
||||
if (run == 1) cb.onLog("gog: " + bases.length + " CDN host(s) offered");
|
||||
long handle = GogNative.start(kind, manifests, bases, installPath.getAbsolutePath(), donePaths.toArray(new String[0]), "",
|
||||
long handle = GogNative.start(kind, manifests, new String[0], installPath.getAbsolutePath(), donePaths.toArray(new String[0]), "",
|
||||
workers, process, sortLargestFirst, label + " run=" + run, listener);
|
||||
if (handle == 0L) { cb.onLog("gog: native start failed"); anyFailed.set(true); return; }
|
||||
boolean interrupted = false;
|
||||
@@ -721,85 +527,13 @@ public final class GogDownloadManager {
|
||||
}
|
||||
if (interrupted) { Thread.currentThread().interrupt(); anyFailed.set(true); return; }
|
||||
if (cancelled.get() || okRef.get()) return;
|
||||
String err = errRef.get();
|
||||
if (linkExpiryRef.get() && tryRefreshCdn(cdnBaseRef, base, secureLinkUrl, token, cdnRefreshCount, maxCdnRefresh, cb)) {
|
||||
cb.onLog("gog: secure link expired (" + err + "); refreshed, running again for the remaining files");
|
||||
continue;
|
||||
}
|
||||
cb.onLog("gog: engine failed: " + err);
|
||||
cb.onLog("gog: engine failed: " + errRef.get());
|
||||
anyFailed.set(true);
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
private static void parseDepotManifest(String json, List<DepotFile> out) {
|
||||
try {
|
||||
JSONObject depotObj = new JSONObject(json).optJSONObject("depot");
|
||||
if (depotObj == null) return;
|
||||
JSONArray depot = depotObj.optJSONArray("items");
|
||||
if (depot == null) return;
|
||||
for (int i = 0; i < depot.length(); i++) {
|
||||
JSONObject entry = depot.getJSONObject(i);
|
||||
String path = entry.optString("path", "").replace("\\", "/");
|
||||
if (path.startsWith("/")) path = path.substring(1);
|
||||
JSONArray chunks = entry.optJSONArray("chunks");
|
||||
if (path.isEmpty() || chunks == null || chunks.length() == 0) continue;
|
||||
DepotFile df = new DepotFile(path);
|
||||
String fileMd5 = entry.optString("md5", null);
|
||||
if (fileMd5 != null && !fileMd5.isEmpty()) df.md5 = fileMd5;
|
||||
long fileTotal = 0;
|
||||
for (int c = 0; c < chunks.length(); c++) {
|
||||
JSONObject chunk = chunks.getJSONObject(c);
|
||||
String compressedMd5 = chunk.optString("compressedMd5"), decMd5 = chunk.optString("md5");
|
||||
String hash = (compressedMd5 != null && !compressedMd5.isEmpty()) ? compressedMd5 : decMd5;
|
||||
if (hash == null || hash.isEmpty()) continue;
|
||||
long decSize = chunk.optLong("size", 0);
|
||||
df.chunks.add(new DepotFile.ChunkRef(hash, compressedMd5, decMd5, chunk.optLong("compressedSize", 0), decSize));
|
||||
fileTotal += decSize;
|
||||
}
|
||||
df.totalSize = fileTotal;
|
||||
if (!df.chunks.isEmpty()) out.add(df);
|
||||
}
|
||||
} catch (Exception e) {
|
||||
Log.w(TAG, "depot manifest parse: " + e.getClass().getSimpleName());
|
||||
}
|
||||
}
|
||||
|
||||
static String buildCdnPath(String hash) { return hash.substring(0, 2) + "/" + hash.substring(2, 4) + "/" + hash; }
|
||||
|
||||
private static boolean assembleDepotFile(DepotFile df, File outFile, AtomicReference<String> cdnBaseRef, String secureLinkUrl, String token,
|
||||
AtomicInteger cdnRefreshCount, int maxCdnRefresh, AtomicBoolean cancelled, Callback cb) {
|
||||
File tmpFile = new File(outFile.getAbsolutePath() + ".bhtmp");
|
||||
File parent = outFile.getParentFile();
|
||||
if (parent != null) parent.mkdirs();
|
||||
tmpFile.delete();
|
||||
boolean ok = true;
|
||||
try (FileOutputStream fos = new FileOutputStream(tmpFile)) {
|
||||
for (DepotFile.ChunkRef chunk : df.chunks) {
|
||||
if (cancelled.get()) { ok = false; break; }
|
||||
byte[] inflated = fetchChunkVerified(cdnBaseRef, buildCdnPath(chunk.hash), chunk, secureLinkUrl, token, cdnRefreshCount, maxCdnRefresh, cancelled, cb);
|
||||
if (inflated == null) { ok = false; break; }
|
||||
fos.write(inflated);
|
||||
}
|
||||
} catch (Exception e) {
|
||||
ok = false;
|
||||
}
|
||||
if (!ok || cancelled.get()) { tmpFile.delete(); return false; }
|
||||
if (df.totalSize > 0 && tmpFile.length() != df.totalSize) { cb.onLog("size mismatch file=" + df.relativePath); tmpFile.delete(); return false; }
|
||||
if (df.md5 != null && !df.md5.isEmpty()) {
|
||||
String actual = md5HexFile(tmpFile);
|
||||
if (actual == null || !actual.equalsIgnoreCase(df.md5)) { cb.onLog("md5 mismatch file=" + df.relativePath); tmpFile.delete(); return false; }
|
||||
}
|
||||
if (outFile.exists()) outFile.delete();
|
||||
return tmpFile.renameTo(outFile);
|
||||
}
|
||||
|
||||
private static String parseCdnUrl(String json) {
|
||||
java.util.List<String> all = parseCdnUrls(json);
|
||||
return all.isEmpty() ? null : String.join("\n", all);
|
||||
}
|
||||
|
||||
static java.util.List<String> parseCdnUrls(String json) {
|
||||
java.util.List<String> out = new ArrayList<>();
|
||||
if (json == null) return out;
|
||||
@@ -824,18 +558,6 @@ public final class GogDownloadManager {
|
||||
return out;
|
||||
}
|
||||
|
||||
private static final AtomicInteger CDN_TURN = new AtomicInteger();
|
||||
|
||||
static String pickCdn(String set) {
|
||||
String[] all = set.split("\n");
|
||||
return all[Math.floorMod(CDN_TURN.getAndIncrement(), all.length)];
|
||||
}
|
||||
|
||||
static String firstCdn(String set) {
|
||||
int nl = set.indexOf('\n');
|
||||
return nl < 0 ? set : set.substring(0, nl);
|
||||
}
|
||||
|
||||
private static boolean downloadRange(String url, long offset, long size, File out) {
|
||||
try {
|
||||
HttpURLConnection conn = (HttpURLConnection) new URL(url).openConnection();
|
||||
@@ -951,15 +673,6 @@ public final class GogDownloadManager {
|
||||
}
|
||||
}
|
||||
|
||||
private static String buildChunkUrl(String base, String chunkPath) {
|
||||
int q = base.indexOf('?');
|
||||
return q >= 0 ? base.substring(0, q) + "/" + chunkPath + base.substring(q) : base + "/" + chunkPath;
|
||||
}
|
||||
|
||||
private static String md5Hex(byte[] data) {
|
||||
try { return toHex(java.security.MessageDigest.getInstance("MD5").digest(data)); } catch (Exception e) { return null; }
|
||||
}
|
||||
|
||||
private static String md5HexFile(File f) {
|
||||
try (FileInputStream fis = new FileInputStream(f)) {
|
||||
java.security.MessageDigest md = java.security.MessageDigest.getInstance("MD5");
|
||||
@@ -988,58 +701,6 @@ public final class GogDownloadManager {
|
||||
return true;
|
||||
}
|
||||
|
||||
private static void sleepBackoff(int attempt) {
|
||||
try { Thread.sleep(Math.min(1000L << Math.max(0, attempt - 1), 8000L)); } catch (InterruptedException ie) { Thread.currentThread().interrupt(); }
|
||||
}
|
||||
|
||||
private static boolean tryRefreshCdn(AtomicReference<String> cdnBaseRef, String staleBase, String secureLinkUrl, String token,
|
||||
AtomicInteger refreshCount, int cap, Callback cb) {
|
||||
if (secureLinkUrl == null) return false;
|
||||
synchronized (cdnBaseRef) {
|
||||
String current = cdnBaseRef.get();
|
||||
if (staleBase == null || !staleBase.equals(current)) return true;
|
||||
if (refreshCount.get() >= cap) { cb.onLog("gog: CDN refresh cap reached (" + cap + ")"); return false; }
|
||||
String neu = parseCdnUrl(httpGet(secureLinkUrl, token));
|
||||
if (neu == null || neu.isEmpty()) { cb.onLog("gog: CDN refresh failed"); return false; }
|
||||
cdnBaseRef.set(neu);
|
||||
cb.onLog("gog: secure link refreshed #" + refreshCount.incrementAndGet());
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
private static byte[] fetchChunkVerified(AtomicReference<String> cdnBaseRef, String chunkPath, DepotFile.ChunkRef chunk, String secureLinkUrl, String token,
|
||||
AtomicInteger cdnRefreshCount, int maxCdnRefresh, AtomicBoolean cancelled, Callback cb) {
|
||||
int hardFail = 0, guard = 0;
|
||||
while (guard++ < 8) {
|
||||
if (cancelled.get()) return null;
|
||||
String base = cdnBaseRef.get();
|
||||
HttpResult res = fetchBytesEx(buildChunkUrl(pickCdn(base), chunkPath), null);
|
||||
if (res.body == null) {
|
||||
int code = res.status;
|
||||
if ((code == 401 || code == 403 || code == 404 || code == 500) && tryRefreshCdn(cdnBaseRef, base, secureLinkUrl, token, cdnRefreshCount, maxCdnRefresh, cb)) continue;
|
||||
cb.onLog("chunk http fail code=" + code);
|
||||
if (++hardFail >= 3) return null;
|
||||
sleepBackoff(hardFail);
|
||||
continue;
|
||||
}
|
||||
byte[] raw = res.body;
|
||||
if (chunk.compressedSize > 0 && raw.length != chunk.compressedSize) { if (++hardFail >= 3) return null; sleepBackoff(hardFail); continue; }
|
||||
if (chunk.compressedMd5 != null && !chunk.compressedMd5.isEmpty()) {
|
||||
String actual = md5Hex(raw);
|
||||
if (actual == null || !actual.equalsIgnoreCase(chunk.compressedMd5)) { if (++hardFail >= 3) return null; sleepBackoff(hardFail); continue; }
|
||||
}
|
||||
byte[] inflated = inflateZlib(raw);
|
||||
if (inflated == null) inflated = raw;
|
||||
if (chunk.size > 0 && inflated.length != chunk.size) { if (++hardFail >= 3) return null; sleepBackoff(hardFail); continue; }
|
||||
if (chunk.md5 != null && !chunk.md5.isEmpty()) {
|
||||
String actual = md5Hex(inflated);
|
||||
if (actual == null || !actual.equalsIgnoreCase(chunk.md5)) { if (++hardFail >= 3) return null; sleepBackoff(hardFail); continue; }
|
||||
}
|
||||
return inflated;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
private static void sampleSpeed(long totalBytes, AtomicLong lastSpeedMs, AtomicLong lastSpeedB, AtomicLong speedBps) {
|
||||
long nowMs = System.currentTimeMillis(), prevMs = lastSpeedMs.get();
|
||||
if (nowMs - prevMs >= 500 && lastSpeedMs.compareAndSet(prevMs, nowMs)) {
|
||||
@@ -1289,30 +950,6 @@ public final class GogDownloadManager {
|
||||
for (File f : files) if (f.isDirectory()) collectExeRecursive(f, out);
|
||||
}
|
||||
|
||||
static long remainingInstallBytes(List<DepotFile> files, File installPath) {
|
||||
long remaining = 0;
|
||||
for (DepotFile file : files) {
|
||||
File output = new File(installPath, file.relativePath);
|
||||
if (!fileVerified(output, file.totalSize, file.md5)) remaining += file.totalSize;
|
||||
}
|
||||
return remaining;
|
||||
}
|
||||
|
||||
static final class DepotFile {
|
||||
final String relativePath;
|
||||
final List<ChunkRef> chunks = new ArrayList<>();
|
||||
String md5;
|
||||
long totalSize;
|
||||
DepotFile(String relativePath) { this.relativePath = relativePath; }
|
||||
static final class ChunkRef {
|
||||
final String hash, compressedMd5, md5;
|
||||
final long compressedSize, size;
|
||||
ChunkRef(String hash, String compressedMd5, String md5, long compressedSize, long size) {
|
||||
this.hash = hash; this.compressedMd5 = compressedMd5; this.md5 = md5; this.compressedSize = compressedSize; this.size = size;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private static final class Gen1File {
|
||||
final String path, url; final long offset, size;
|
||||
Gen1File(String path, String url, long offset, long size) { this.path = path; this.url = url; this.offset = offset; this.size = size; }
|
||||
@@ -1382,7 +1019,11 @@ public final class GogDownloadManager {
|
||||
String resolved = StoreLanguage.pick(StoreLanguage.codesFromDepots(depots), StoreLanguage.preferredCode());
|
||||
for (int i = 0; i < depots.length(); i++) {
|
||||
JSONObject depot = depots.getJSONObject(i);
|
||||
if (depotSelected(depot, resolved)) total += depot.optLong("size", 0);
|
||||
JSONArray languages = depot.optJSONArray("languages");
|
||||
String[] listed = new String[languages == null ? 0 : languages.length()];
|
||||
for (int j = 0; j < listed.length; j++) listed[j] = languages.optString(j, "");
|
||||
if (listed.length == 0 || StoreLanguage.depotMatchesResolved(listed, resolved))
|
||||
total += depot.optLong("size", 0);
|
||||
}
|
||||
return total > 0 ? total : -1;
|
||||
} catch (Exception e) {
|
||||
|
||||
@@ -377,16 +377,24 @@ internal object GogMaintenance {
|
||||
val build = GogBuild.select(builds) ?: return null
|
||||
folder.mkdirs()
|
||||
val old = GogInstallRecord(game.gameId, "depot", language = language)
|
||||
val target = GogContent.snapshot(old, build, GogContent.token(context))
|
||||
var credentials: Pair<String, String>? = null
|
||||
val target = GogContent.snapshot(old, build, GogContent.token(context), onManifest = { manifest ->
|
||||
credentials = GogContent.clientCredentials(manifest)
|
||||
})
|
||||
GogFiles.validate(target.files, folder)
|
||||
val bytes = target.files.objects().sumOf { it.optJSONArray("chunks")?.objects()?.sumOf { c -> c.optLong("compressedSize") }?.takeIf { n -> n > 0 } ?: it.optLong("size") }
|
||||
val disk = target.files.objects().sumOf { it.optLong("size") }
|
||||
check(!StoreInstallRoot.insufficientSpace(disk, StoreInstallRoot.availableBytes(folder))) { "Not enough free space for GOG installation" }
|
||||
val remaining = GogEstimate.calculate(folder, old, target, JSONArray(), reuseChunks = GogNative.isAvailable()).required
|
||||
check(!StoreInstallRoot.insufficientSpace(remaining, StoreInstallRoot.availableBytes(folder))) { "Not enough free space for GOG installation" }
|
||||
callback.onSizes(bytes, disk)
|
||||
transfer(context, Proposal(folder, old, target, GogOperation.UPDATE, bytes, disk), null, cancelled, callback)
|
||||
if (cancelled.get()) return null
|
||||
target.write(folder)
|
||||
val task = GogDownloadManager.pickTask(folder, game.gameId, null, game.title)
|
||||
credentials?.let { (clientId, clientSecret) ->
|
||||
GogPrefs.get(context).edit().putString("client_id_${game.gameId}", clientId)
|
||||
.putString("client_secret_${game.gameId}", clientSecret).apply()
|
||||
}
|
||||
val task = GogDownloadManager.pickTask(folder, game.gameId, GogContent.tempExecutable(target.manifest), game.title)
|
||||
val result = GogDownloadManager.Result(task?.path ?: "", build.id, disk)
|
||||
result.workDir = task?.workDir
|
||||
result.language = target.language
|
||||
@@ -395,20 +403,6 @@ internal object GogMaintenance {
|
||||
return result
|
||||
}
|
||||
|
||||
@JvmStatic
|
||||
fun captureDepot(id: String, folder: File, build: String, buildsJson: String, manifest: JSONObject, depots: JSONArray, language: String) {
|
||||
val selected = GogBuild.select(JSONObject(buildsJson), pinned = build)
|
||||
val files = JSONArray()
|
||||
for (depot in depots.strings()) for (item in JSONObject(depot).getJSONObject("depot").getJSONArray("items").objects()) {
|
||||
val chunks = item.optJSONArray("chunks") ?: continue
|
||||
files.put(JSONObject().put("path", item.getString("path").replace('\\', '/')).put("size", chunks.objects().sumOf { it.optLong("size") })
|
||||
.put("md5", item.optString("md5")).put("chunks", chunks).put("product", id))
|
||||
}
|
||||
GogFiles.validate(files, folder)
|
||||
GogInstallRecord(id, "depot", 2, build, selected?.version.orEmpty(), selected?.branch.orEmpty(), language,
|
||||
manifest = GogContent.sanitize(manifest), files = files).write(folder)
|
||||
}
|
||||
|
||||
@JvmStatic
|
||||
fun captureOffline(id: String, folder: File, downloads: JSONObject) {
|
||||
val selected = GogOfflineDownload.parse(downloads, "installers").firstOrNull() ?: return
|
||||
|
||||
@@ -6,8 +6,6 @@ import com.droiddeck.launcher.stores.StoresNative
|
||||
object GogNative {
|
||||
private const val TAG = "GogNative"
|
||||
|
||||
const val KIND_GEN2_CHUNKS = 0
|
||||
|
||||
const val KIND_GEN1_RANGES = 1
|
||||
|
||||
interface Listener {
|
||||
|
||||
@@ -1,116 +0,0 @@
|
||||
package com.droiddeck.launcher.ui
|
||||
|
||||
import android.content.Intent
|
||||
import android.provider.Settings
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.clickable
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.height
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.lazy.LazyColumn
|
||||
import androidx.compose.foundation.lazy.items
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.LaunchedEffect
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.draw.clip
|
||||
import androidx.compose.ui.platform.LocalContext
|
||||
import androidx.compose.ui.platform.testTag
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import androidx.compose.ui.semantics.Role
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.text.style.TextAlign
|
||||
import androidx.compose.ui.text.style.TextOverflow
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.ui.unit.sp
|
||||
import com.droiddeck.launcher.R
|
||||
import com.droiddeck.launcher.session.BottomNotifications
|
||||
import kotlinx.coroutines.delay
|
||||
|
||||
internal fun notificationAccessIntent(context: android.content.Context): Intent =
|
||||
if (android.os.Build.VERSION.SDK_INT >= android.os.Build.VERSION_CODES.R) {
|
||||
Intent(Settings.ACTION_NOTIFICATION_LISTENER_DETAIL_SETTINGS).putExtra(
|
||||
Settings.EXTRA_NOTIFICATION_LISTENER_COMPONENT_NAME,
|
||||
android.content.ComponentName(context, com.droiddeck.launcher.session.BottomNotificationListener::class.java).flattenToString(),
|
||||
)
|
||||
} else Intent(Settings.ACTION_NOTIFICATION_LISTENER_SETTINGS)
|
||||
|
||||
@Composable
|
||||
fun BottomNotificationsPage(openSettings: ((Intent, () -> Boolean) -> Unit)?) {
|
||||
val context = LocalContext.current
|
||||
val colors = MaterialTheme.colorScheme
|
||||
val pal = LocalPalette.current
|
||||
var granted by remember { mutableStateOf(BottomNotifications.granted(context)) }
|
||||
LaunchedEffect(Unit) {
|
||||
while (true) {
|
||||
granted = BottomNotifications.granted(context)
|
||||
delay(2_000L)
|
||||
}
|
||||
}
|
||||
if (!granted) {
|
||||
Column(Modifier.fillMaxSize().padding(24.dp), horizontalAlignment = Alignment.CenterHorizontally,
|
||||
verticalArrangement = Arrangement.spacedBy(16.dp, Alignment.CenterVertically)) {
|
||||
Text(stringResource(R.string.bottom_notifications_why), fontSize = 15.sp, color = colors.onBackground, textAlign = TextAlign.Center)
|
||||
BottomPillButton(stringResource(R.string.bottom_notifications_allow), "bottom-notifications-allow") {
|
||||
val intent = notificationAccessIntent(context)
|
||||
if (openSettings != null) openSettings(intent) { BottomNotifications.granted(context) }
|
||||
else runCatching { context.startActivity(intent.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK)) }
|
||||
}
|
||||
}
|
||||
return
|
||||
}
|
||||
val shown = BottomNotifications.items
|
||||
if (shown.isEmpty()) {
|
||||
Box(Modifier.fillMaxSize(), contentAlignment = Alignment.Center) {
|
||||
Text(stringResource(R.string.bottom_notifications_none), fontSize = 15.sp, color = colors.onSurfaceVariant)
|
||||
}
|
||||
return
|
||||
}
|
||||
val timeFormat = remember { android.text.format.DateFormat.getTimeFormat(context) }
|
||||
Column(Modifier.fillMaxSize(), verticalArrangement = Arrangement.spacedBy(8.dp)) {
|
||||
Row(Modifier.fillMaxWidth(), verticalAlignment = Alignment.CenterVertically) {
|
||||
Text(stringResource(R.string.bottom_notifications_count, shown.size), fontSize = 13.sp, color = colors.onSurfaceVariant,
|
||||
modifier = Modifier.weight(1f))
|
||||
BottomPillButton(stringResource(R.string.bottom_notifications_clear), "bottom-notifications-clear") { BottomNotifications.clear() }
|
||||
}
|
||||
LazyColumn(Modifier.weight(1f).fillMaxWidth(), verticalArrangement = Arrangement.spacedBy(8.dp)) {
|
||||
items(shown.toList(), key = { it.key }) { n ->
|
||||
Column(Modifier.fillMaxWidth().clip(RoundedCornerShape(14.dp)).background(pal.surface).padding(horizontal = 14.dp, vertical = 10.dp),
|
||||
verticalArrangement = Arrangement.spacedBy(4.dp)) {
|
||||
Row(verticalAlignment = Alignment.CenterVertically) {
|
||||
Text(n.app, fontSize = 12.sp, fontWeight = FontWeight.SemiBold, color = pal.signal, maxLines = 1,
|
||||
overflow = TextOverflow.Ellipsis, modifier = Modifier.weight(1f))
|
||||
Text(timeFormat.format(java.util.Date(n.postedAt)), fontSize = 12.sp, color = colors.onSurfaceVariant)
|
||||
}
|
||||
if (n.title.isNotBlank()) Text(n.title, fontSize = 15.sp, fontWeight = FontWeight.SemiBold, color = colors.onBackground,
|
||||
maxLines = 1, overflow = TextOverflow.Ellipsis)
|
||||
if (n.text.isNotBlank()) Text(n.text, fontSize = 14.sp, color = colors.onBackground, maxLines = 4, overflow = TextOverflow.Ellipsis)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
internal fun BottomPillButton(label: String, tag: String, onClick: () -> Unit) {
|
||||
val pal = LocalPalette.current
|
||||
Box(
|
||||
Modifier.height(40.dp).clip(RoundedCornerShape(20.dp)).background(pal.signal.copy(alpha = 0.16f))
|
||||
.clickable(role = Role.Button, onClick = onClick).testTag(tag).padding(horizontal = 16.dp),
|
||||
contentAlignment = Alignment.Center,
|
||||
) {
|
||||
Text(label, fontSize = 14.sp, fontWeight = FontWeight.SemiBold, color = pal.signal, maxLines = 1)
|
||||
}
|
||||
}
|
||||
@@ -1,17 +1,31 @@
|
||||
package com.droiddeck.launcher.ui
|
||||
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.clickable
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.height
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.outlined.Download
|
||||
import androidx.compose.material.icons.outlined.Forum
|
||||
import androidx.compose.material.icons.outlined.Speed
|
||||
import androidx.compose.material.icons.outlined.Notifications
|
||||
import androidx.compose.material.icons.outlined.PhotoLibrary
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.draw.clip
|
||||
import androidx.compose.ui.platform.testTag
|
||||
import androidx.compose.ui.semantics.Role
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.ui.unit.sp
|
||||
import com.droiddeck.launcher.R
|
||||
|
||||
class BottomPageActions(
|
||||
val takeScreenshot: ((onDone: (Boolean) -> Unit) -> Unit)? = null,
|
||||
val fps: () -> Float? = { null },
|
||||
val openSettings: ((intent: android.content.Intent, done: () -> Boolean) -> Unit)? = null,
|
||||
val runningGame: () -> String? = { null },
|
||||
)
|
||||
|
||||
@@ -20,7 +34,18 @@ internal const val BOTTOM_PAGE_TELEMETRY = "telemetry"
|
||||
fun sessionBottomPages(actions: BottomPageActions): List<BottomPage> = listOf(
|
||||
BottomPage(BOTTOM_PAGE_TELEMETRY, R.string.bottom_page_telemetry, Icons.Outlined.Speed) { BottomTelemetryPage(actions.fps, actions.runningGame) },
|
||||
BottomPage("downloads", R.string.bottom_page_downloads, Icons.Outlined.Download) { BottomDownloadsPage() },
|
||||
BottomPage("notifications", R.string.bottom_page_notifications, Icons.Outlined.Notifications) { BottomNotificationsPage(actions.openSettings) },
|
||||
BottomPage("screenshots", R.string.bottom_page_screenshots, Icons.Outlined.PhotoLibrary) { BottomScreenshotsPage(actions.takeScreenshot) },
|
||||
BottomPage("chat", R.string.bottom_page_chat, Icons.Outlined.Forum) { BottomChatPage() },
|
||||
)
|
||||
|
||||
@Composable
|
||||
internal fun BottomPillButton(label: String, tag: String, onClick: () -> Unit) {
|
||||
val pal = LocalPalette.current
|
||||
Box(
|
||||
Modifier.height(40.dp).clip(RoundedCornerShape(20.dp)).background(pal.signal.copy(alpha = 0.16f))
|
||||
.clickable(role = Role.Button, onClick = onClick).testTag(tag).padding(horizontal = 16.dp),
|
||||
contentAlignment = Alignment.Center,
|
||||
) {
|
||||
Text(label, fontSize = 14.sp, fontWeight = FontWeight.SemiBold, color = pal.signal, maxLines = 1)
|
||||
}
|
||||
}
|
||||
@@ -304,7 +304,8 @@ fun BottomTelemetryPage(fps: () -> Float?, runningGame: () -> String? = { null }
|
||||
val now = System.nanoTime()
|
||||
val live = frames.live
|
||||
val stats = frames.stats
|
||||
val graph = FloatArray(FrameStats.GRAPH_FRAMES).also { if (live) stats.graph(it) }
|
||||
val graph = FloatArray(FrameStats.GRAPH_FRAMES)
|
||||
val graphEnd = if (live) stats.graph(graph) else 0L
|
||||
val hz = (frames.refreshMilliHz + 499) / 1000
|
||||
val data = HudData(
|
||||
fps = fpsNow, avgFps = if (live) stats.averageFps() else null, lows = if (live) stats.lows(now) else FrameStats.Lows.EMPTY,
|
||||
@@ -317,6 +318,7 @@ fun BottomTelemetryPage(fps: () -> Float?, runningGame: () -> String? = { null }
|
||||
title = title, cover = cover, played = playedSeconds?.let(::playedText), clock = clock,
|
||||
fpsHistory = fpsHistory.snapshot(), cpuHistory = cpuHistory.snapshot(), gpuHistory = gpuHistory.snapshot(),
|
||||
historySeconds = maxOf(fpsHistory.spanSeconds(), cpuHistory.spanSeconds(), gpuHistory.spanSeconds()),
|
||||
graphEnd = graphEnd,
|
||||
coreGroups = coreGroups, gameCores = gameCores, fahrenheit = tempDisplay.fahrenheit,
|
||||
tempColor = { sensor, c ->
|
||||
thresholds[sensor]?.let { Color(HudMetrics.tempColor(c.toFloat(), it, tempDisplay, TEMP_PLAIN)) } ?: Color(TEMP_PLAIN)
|
||||
|
||||
@@ -224,6 +224,8 @@ fun ModeSheetRows(s: ModeSettings, a: ModeSettingsActions, tab: String, extras:
|
||||
ChoiceRow(host, "picture-position", stringResource(R.string.display_picture_position), null,
|
||||
listOf("top" to stringResource(R.string.display_position_top),
|
||||
"bottom" to stringResource(R.string.display_position_bottom),
|
||||
"left" to stringResource(R.string.display_position_left),
|
||||
"right" to stringResource(R.string.display_position_right),
|
||||
"center" to stringResource(R.string.display_position_center)),
|
||||
position, onPick = a.onPicturePosition)
|
||||
}
|
||||
|
||||
@@ -98,6 +98,7 @@ internal class HudData(
|
||||
val tempColor: (HudMetrics.TempSensor, Int) -> Color,
|
||||
val redC: (HudMetrics.TempSensor) -> Int?,
|
||||
val historySeconds: Int = 0,
|
||||
val graphEnd: Long = 0L,
|
||||
) {
|
||||
val frametimeMs: Float? get() = fps?.takeIf { it > 0f }?.let { 1000f / it }
|
||||
val hz: Int get() = if (refreshHz > 0) refreshHz else 60
|
||||
@@ -268,18 +269,26 @@ private fun lowsText(d: HudData, size: TextUnit, accent: Color, deep: Boolean =
|
||||
private val GRAPH_MIN_GAP = 3.dp
|
||||
|
||||
@Composable
|
||||
private fun Graph(values: List<Float>, lo: Float, hi: Float, color: Color, modifier: Modifier) {
|
||||
private fun Graph(values: List<Float>, lo: Float, hi: Float, color: Color, modifier: Modifier, end: Long = 0L) {
|
||||
Canvas(modifier) {
|
||||
if (values.size < 2 || hi <= lo) return@Canvas
|
||||
val stride = ceil(GRAPH_MIN_GAP.toPx() * (values.size - 1) / size.width).toInt().coerceAtLeast(1)
|
||||
val points = if (stride == 1) values else values.chunked(stride) { it.average().toFloat() }
|
||||
if (points.size < 2) return@Canvas
|
||||
// Buckets start at multiples of stride counted from the first sample ever drawn, so a sample keeps
|
||||
// its bucket as the window scrolls; counted from the window's start, every new frame re-paired them.
|
||||
val skip = Math.floorMod(values.size.toLong() - end, stride.toLong()).toInt()
|
||||
val path = Path()
|
||||
points.forEachIndexed { i, v ->
|
||||
val x = i / (points.size - 1f) * size.width
|
||||
val y = size.height - ((v - lo) / (hi - lo)).coerceIn(0f, 1f) * size.height
|
||||
if (i == 0) path.moveTo(x, y) else path.lineTo(x, y)
|
||||
var drawn = 0
|
||||
var start = skip
|
||||
while (start < values.size) {
|
||||
val stop = minOf(start + stride, values.size)
|
||||
var sum = 0f
|
||||
for (i in start until stop) sum += values[i]
|
||||
val x = (start + stop - 1) / 2f / (values.size - 1f) * size.width
|
||||
val y = size.height - ((sum / (stop - start) - lo) / (hi - lo)).coerceIn(0f, 1f) * size.height
|
||||
if (drawn++ == 0) path.moveTo(x, y) else path.lineTo(x, y)
|
||||
start = stop
|
||||
}
|
||||
if (drawn < 2) return@Canvas
|
||||
drawPath(path, color.copy(alpha = 0.22f), style = Stroke(width = 6.dp.toPx(), cap = StrokeCap.Round, join = StrokeJoin.Round))
|
||||
drawPath(path, color, style = Stroke(width = 2.2f.dp.toPx(), cap = StrokeCap.Round, join = StrokeJoin.Round))
|
||||
}
|
||||
@@ -287,7 +296,7 @@ private fun Graph(values: List<Float>, lo: Float, hi: Float, color: Color, modif
|
||||
|
||||
@Composable
|
||||
private fun FrameGraph(d: HudData, accent: Color, height: Dp) {
|
||||
Graph(d.graph.toList(), 0f, FrameStats.GRAPH_MAX_MS, lerp(accent, Color.White, 0.3f), Modifier.fillMaxWidth().height(height))
|
||||
Graph(d.graph.toList(), 0f, FrameStats.GRAPH_MAX_MS, lerp(accent, Color.White, 0.3f), Modifier.fillMaxWidth().height(height), d.graphEnd)
|
||||
}
|
||||
|
||||
@Composable
|
||||
@@ -589,7 +598,7 @@ private fun ColumnScope.Pacing(d: HudData, accent: Color) {
|
||||
drawLine(color, Offset(0f, y), Offset(size.width, y), 1.dp.toPx(), pathEffect = dash)
|
||||
}
|
||||
}
|
||||
Graph(d.graph.toList(), 0f, FrameStats.GRAPH_MAX_MS, graphColor, Modifier.fillMaxSize())
|
||||
Graph(d.graph.toList(), 0f, FrameStats.GRAPH_MAX_MS, graphColor, Modifier.fillMaxSize(), d.graphEnd)
|
||||
for ((ms, color) in listOf(target to HudInk.dim, target * 2 to HudInk.amber)) {
|
||||
Small("%.1f ms · %d fps".format(ms, (1000f / ms).roundToInt()), 11.sp, color,
|
||||
Modifier.align(Alignment.TopEnd).offset(y = maxHeight * (1f - ms / FrameStats.GRAPH_MAX_MS) - 17.dp))
|
||||
|
||||
@@ -58,6 +58,7 @@ object Themes {
|
||||
const val RGB_SPEEDS = 10
|
||||
const val DEFAULT_RGB_SPEED = 3
|
||||
const val RGB_HUE_STEP = 3f
|
||||
const val WHITE_RED = "white_red"
|
||||
|
||||
var rgbSpeed by mutableStateOf(DEFAULT_RGB_SPEED)
|
||||
private set
|
||||
@@ -127,6 +128,13 @@ object Themes {
|
||||
primary = Color(0xFF2E86FF), primary2 = Color(0xFF2684FF), onPrimary = Color(0xFF03111F), signal = Color(0xFF2E86FF),
|
||||
good = Color(0xFF4CD37F),
|
||||
),
|
||||
Palette(
|
||||
WHITE_RED, R.string.theme_white_red, R.string.theme_white_red_detail,
|
||||
background = Color(0xFFF8F8F8), surface = Color(0xFFFFFFFF), surfaceVariant = Color(0xFFF0F0F0), line = Color(0xFFDDDDDD), line2 = Color(0xFFC8C8C8),
|
||||
onBackground = Color(0xFF191919), onSurfaceVariant = Color(0xFF555555),
|
||||
primary = Color(0xFFD62828), primary2 = Color(0xFFB71C1C), onPrimary = Color(0xFFFFFFFF), signal = Color(0xFFD62828),
|
||||
good = Color(0xFF248548),
|
||||
),
|
||||
innerGamer(0f),
|
||||
ThemeColors.palette(DEFAULT_PRIMARY, DEFAULT_SECONDARY, R.string.theme_gradient, R.string.theme_gradient_detail),
|
||||
)
|
||||
|
||||
@@ -1873,13 +1873,6 @@
|
||||
<string name="bottom_downloads_shader">جارٍ تنزيل ذاكرة المظلِّلات المؤقتة</string>
|
||||
<string name="bottom_downloads_paused">متوقف مؤقتًا</string>
|
||||
<string name="bottom_downloads_queued">بانتظار البدء</string>
|
||||
<string name="bottom_page_notifications">الإشعارات</string>
|
||||
<string name="bottom_notifications_listener">الشاشة السفلية لـ DroidDeck</string>
|
||||
<string name="bottom_notifications_why">اعرض الرسائل والإشعارات الأخرى هنا أثناء اللعب. تبقى على هذه الشاشة فقط ولا تُحفظ. يطلب Android الإذن مرة واحدة: تُفتح صفحة الوصول إلى الإشعارات على هذه الشاشة؛ فعِّل DroidDeck هناك وستعود هذه الصفحة تلقائيًا.</string>
|
||||
<string name="bottom_notifications_allow">السماح بالوصول إلى الإشعارات</string>
|
||||
<string name="bottom_notifications_none">لا توجد إشعارات.</string>
|
||||
<string name="bottom_notifications_count">الإشعارات: %1$d</string>
|
||||
<string name="bottom_notifications_clear">مسح</string>
|
||||
<string name="bottom_page_screenshots">لقطات الشاشة</string>
|
||||
<string name="stats_fps_needs_hud">يتطلب FPS تشغيل واجهة الأداء</string>
|
||||
<string name="stats_speed">السرعة</string>
|
||||
@@ -2983,6 +2976,8 @@
|
||||
<string name="display_position_top">أعلى</string>
|
||||
<string name="display_position_bottom">أسفل</string>
|
||||
<string name="display_position_center">الوسط (افتراضي)</string>
|
||||
<string name="display_position_left">يسار</string>
|
||||
<string name="display_position_right">يمين</string>
|
||||
<string name="setup_tab_storage">التخزين</string>
|
||||
<string name="storage_measuring">جارٍ قياس التخزين…</string>
|
||||
<string name="storage_measuring_hint">يستغرق القياس الأول بضع ثوانٍ.</string>
|
||||
@@ -3816,4 +3811,7 @@
|
||||
<string name="input_reset_positions">إعادة تعيين المواضع</string>
|
||||
<string name="input_show_label">إظهار التسمية</string>
|
||||
<string name="input_layout_import_failed">تعذّر استيراد هذا التخطيط</string>
|
||||
|
||||
<string name="theme_white_red">ثلج قرمزي</string>
|
||||
|
||||
</resources>
|
||||
@@ -1865,13 +1865,6 @@
|
||||
<string name="bottom_downloads_shader">Descargando caché de shaders</string>
|
||||
<string name="bottom_downloads_paused">En pausa</string>
|
||||
<string name="bottom_downloads_queued">Esperando para empezar</string>
|
||||
<string name="bottom_page_notifications">Notificaciones</string>
|
||||
<string name="bottom_notifications_listener">DroidDeck – pantalla inferior</string>
|
||||
<string name="bottom_notifications_why">Muestra aquí los mensajes y otras notificaciones mientras juegas. Solo permanecen en esta pantalla y no se guardan. Android lo pregunta una vez: su página de acceso a notificaciones se abre en esta pantalla; activa DroidDeck ahí y esta página volverá sola.</string>
|
||||
<string name="bottom_notifications_allow">Permitir acceso a notificaciones</string>
|
||||
<string name="bottom_notifications_none">No hay notificaciones.</string>
|
||||
<string name="bottom_notifications_count">%1$d notificaciones</string>
|
||||
<string name="bottom_notifications_clear">Borrar</string>
|
||||
<string name="bottom_page_screenshots">Capturas de pantalla</string>
|
||||
<string name="stats_fps_needs_hud">Los FPS requieren el HUD activado</string>
|
||||
<string name="stats_speed">Velocidad</string>
|
||||
@@ -2835,6 +2828,8 @@
|
||||
<string name="display_position_top">Arriba</string>
|
||||
<string name="display_position_bottom">Abajo</string>
|
||||
<string name="display_position_center">Centro (predeterminado)</string>
|
||||
<string name="display_position_left">Izquierda</string>
|
||||
<string name="display_position_right">Derecha</string>
|
||||
<string name="setup_tab_storage">Almacenamiento</string>
|
||||
<string name="storage_measuring">Midiendo el almacenamiento…</string>
|
||||
<string name="storage_measuring_hint">La primera vez tarda unos segundos.</string>
|
||||
@@ -3672,4 +3667,6 @@
|
||||
<string name="input_reset_positions">Restablecer posiciones</string>
|
||||
<string name="input_show_label">Mostrar etiqueta</string>
|
||||
<string name="input_layout_import_failed">No se pudo importar esta disposición</string>
|
||||
<string name="theme_white_red">Nieve escarlata</string>
|
||||
<string name="theme_white_red_detail">Superficies blancas con acentos rojos.</string>
|
||||
</resources>
|
||||
@@ -1777,13 +1777,6 @@
|
||||
<string name="bottom_downloads_shader">Изтегляне на кеша на шейдърите</string>
|
||||
<string name="bottom_downloads_paused">На пауза</string>
|
||||
<string name="bottom_downloads_queued">Изчаква стартиране</string>
|
||||
<string name="bottom_page_notifications">Известия</string>
|
||||
<string name="bottom_notifications_listener">Долен екран на DroidDeck</string>
|
||||
<string name="bottom_notifications_why">Показвайте тук съобщения и други известия, докато играете. Те остават само на този екран и не се запазват. Android пита веднъж: страницата за достъп до известията се отваря на този екран; включете DroidDeck там и тази страница ще се върне сама.</string>
|
||||
<string name="bottom_notifications_allow">Разрешаване на достъпа до известията</string>
|
||||
<string name="bottom_notifications_none">Няма известия.</string>
|
||||
<string name="bottom_notifications_count">Известия: %1$d</string>
|
||||
<string name="bottom_notifications_clear">Изчисти</string>
|
||||
<string name="bottom_page_screenshots">Екранни снимки</string>
|
||||
<string name="stats_fps_needs_hud">За FPS трябва HUD да е включен</string>
|
||||
<string name="stats_speed">Скорост</string>
|
||||
@@ -2867,6 +2860,8 @@
|
||||
<string name="display_position_top">Горе</string>
|
||||
<string name="display_position_bottom">Долу</string>
|
||||
<string name="display_position_center">В средата (по подразбиране)</string>
|
||||
<string name="display_position_left">Наляво</string>
|
||||
<string name="display_position_right">Надясно</string>
|
||||
<string name="setup_tab_storage">Съхранение</string>
|
||||
<string name="storage_measuring">Измерване на съхранението…</string>
|
||||
<string name="storage_measuring_hint">Първият път отнема няколко секунди.</string>
|
||||
@@ -3660,4 +3655,7 @@
|
||||
<string name="input_reset_positions">Нулиране на позициите</string>
|
||||
<string name="input_show_label">Показване на етикета</string>
|
||||
<string name="input_layout_import_failed">Това разположение не можа да бъде импортирано</string>
|
||||
|
||||
<string name="theme_white_red">Ален сняг</string>
|
||||
|
||||
</resources>
|
||||
@@ -1825,13 +1825,6 @@
|
||||
<string name="bottom_downloads_shader">Stahování mezipaměti shaderů</string>
|
||||
<string name="bottom_downloads_paused">Pozastaveno</string>
|
||||
<string name="bottom_downloads_queued">Čeká na spuštění</string>
|
||||
<string name="bottom_page_notifications">Oznámení</string>
|
||||
<string name="bottom_notifications_listener">Spodní obrazovka DroidDeck</string>
|
||||
<string name="bottom_notifications_why">Zobrazujte zde zprávy a další oznámení během hraní. Zůstávají jen na této obrazovce a neukládají se. Android se zeptá jednou: na této obrazovce se otevře jeho stránka přístupu k oznámením; zapněte tam DroidDeck a tato stránka se sama vrátí.</string>
|
||||
<string name="bottom_notifications_allow">Povolit přístup k oznámením</string>
|
||||
<string name="bottom_notifications_none">Žádná oznámení.</string>
|
||||
<string name="bottom_notifications_count">Oznámení: %1$d</string>
|
||||
<string name="bottom_notifications_clear">Vymazat</string>
|
||||
<string name="bottom_page_screenshots">Snímky obrazovky</string>
|
||||
<string name="stats_fps_needs_hud">FPS vyžaduje zapnutý HUD</string>
|
||||
<string name="stats_speed">Rychlost</string>
|
||||
@@ -2925,6 +2918,8 @@
|
||||
<string name="display_position_top">Nahoře</string>
|
||||
<string name="display_position_bottom">Dole</string>
|
||||
<string name="display_position_center">Uprostřed (výchozí)</string>
|
||||
<string name="display_position_left">Vlevo</string>
|
||||
<string name="display_position_right">Vpravo</string>
|
||||
<string name="setup_tab_storage">Úložiště</string>
|
||||
<string name="storage_measuring">Měření úložiště…</string>
|
||||
<string name="storage_measuring_hint">Poprvé to trvá pár sekund.</string>
|
||||
@@ -3738,4 +3733,7 @@
|
||||
<string name="input_reset_positions">Obnovit pozice</string>
|
||||
<string name="input_show_label">Zobrazit popisek</string>
|
||||
<string name="input_layout_import_failed">Toto rozložení nelze importovat</string>
|
||||
|
||||
<string name="theme_white_red">Šarlatový sníh</string>
|
||||
|
||||
</resources>
|
||||
@@ -1777,13 +1777,6 @@
|
||||
<string name="bottom_downloads_shader">Downloader shader-cache</string>
|
||||
<string name="bottom_downloads_paused">På pause</string>
|
||||
<string name="bottom_downloads_queued">Venter på at starte</string>
|
||||
<string name="bottom_page_notifications">Notifikationer</string>
|
||||
<string name="bottom_notifications_listener">DroidDecks nederste skærm</string>
|
||||
<string name="bottom_notifications_why">Vis beskeder og andre notifikationer her, mens du spiller. De bliver kun på denne skærm og gemmes ikke. Android spørger én gang: siden for notifikationsadgang åbnes på denne skærm; slå DroidDeck til der, så vender denne side selv tilbage.</string>
|
||||
<string name="bottom_notifications_allow">Tillad notifikationsadgang</string>
|
||||
<string name="bottom_notifications_none">Ingen notifikationer.</string>
|
||||
<string name="bottom_notifications_count">%1$d notifikationer</string>
|
||||
<string name="bottom_notifications_clear">Ryd</string>
|
||||
<string name="bottom_page_screenshots">Skærmbilleder</string>
|
||||
<string name="stats_fps_needs_hud">FPS kræver, at HUD er slået til</string>
|
||||
<string name="stats_speed">Hastighed</string>
|
||||
@@ -2867,6 +2860,8 @@
|
||||
<string name="display_position_top">Øverst</string>
|
||||
<string name="display_position_bottom">Nederst</string>
|
||||
<string name="display_position_center">Midten (standard)</string>
|
||||
<string name="display_position_left">Venstre</string>
|
||||
<string name="display_position_right">Højre</string>
|
||||
<string name="setup_tab_storage">Lager</string>
|
||||
<string name="storage_measuring">Måler lager…</string>
|
||||
<string name="storage_measuring_hint">Første gang tager et par sekunder.</string>
|
||||
@@ -3660,4 +3655,7 @@
|
||||
<string name="input_reset_positions">Nulstil placeringer</string>
|
||||
<string name="input_show_label">Vis etiket</string>
|
||||
<string name="input_layout_import_failed">Dette layout kunne ikke importeres</string>
|
||||
|
||||
<string name="theme_white_red">Skarlagensne</string>
|
||||
|
||||
</resources>
|
||||
@@ -1769,13 +1769,6 @@
|
||||
<string name="bottom_downloads_shader">Shader-Cache wird heruntergeladen</string>
|
||||
<string name="bottom_downloads_paused">Pausiert</string>
|
||||
<string name="bottom_downloads_queued">Wartet auf Start</string>
|
||||
<string name="bottom_page_notifications">Benachrichtigungen</string>
|
||||
<string name="bottom_notifications_listener">DroidDeck – unterer Bildschirm</string>
|
||||
<string name="bottom_notifications_why">Zeigt Nachrichten und andere Benachrichtigungen hier an, während du spielst. Sie bleiben nur auf diesem Bildschirm und werden nicht gespeichert. Android fragt einmal: Die Seite für den Benachrichtigungszugriff öffnet sich auf diesem Bildschirm; aktiviere dort DroidDeck, dann kehrt diese Seite von selbst zurück.</string>
|
||||
<string name="bottom_notifications_allow">Benachrichtigungszugriff erlauben</string>
|
||||
<string name="bottom_notifications_none">Keine Benachrichtigungen.</string>
|
||||
<string name="bottom_notifications_count">%1$d Benachrichtigungen</string>
|
||||
<string name="bottom_notifications_clear">Löschen</string>
|
||||
<string name="bottom_page_screenshots">Screenshots</string>
|
||||
<string name="stats_fps_needs_hud">FPS benötigt das aktive HUD</string>
|
||||
<string name="stats_speed">Geschwindigkeit</string>
|
||||
@@ -2867,6 +2860,8 @@
|
||||
<string name="display_position_top">Oben</string>
|
||||
<string name="display_position_bottom">Unten</string>
|
||||
<string name="display_position_center">Mitte (Standard)</string>
|
||||
<string name="display_position_left">Links</string>
|
||||
<string name="display_position_right">Rechts</string>
|
||||
<string name="setup_tab_storage">Speicher</string>
|
||||
<string name="storage_measuring">Speicher wird erfasst…</string>
|
||||
<string name="storage_measuring_hint">Die erste Erfassung dauert ein paar Sekunden.</string>
|
||||
@@ -3660,4 +3655,7 @@
|
||||
<string name="input_reset_positions">Positionen zurücksetzen</string>
|
||||
<string name="input_show_label">Beschriftung anzeigen</string>
|
||||
<string name="input_layout_import_failed">Dieses Layout konnte nicht importiert werden</string>
|
||||
|
||||
<string name="theme_white_red">Scharlachroter Schnee</string>
|
||||
|
||||
</resources>
|
||||
@@ -1777,13 +1777,6 @@
|
||||
<string name="bottom_downloads_shader">Λήψη cache shader</string>
|
||||
<string name="bottom_downloads_paused">Σε παύση</string>
|
||||
<string name="bottom_downloads_queued">Αναμονή για έναρξη</string>
|
||||
<string name="bottom_page_notifications">Ειδοποιήσεις</string>
|
||||
<string name="bottom_notifications_listener">DroidDeck – κάτω οθόνη</string>
|
||||
<string name="bottom_notifications_why">Εμφάνιση μηνυμάτων και άλλων ειδοποιήσεων εδώ ενώ παίζετε. Παραμένουν μόνο σε αυτή την οθόνη και δεν αποθηκεύονται. Το Android ρωτά μία φορά: η σελίδα πρόσβασης ειδοποιήσεων ανοίγει σε αυτή την οθόνη· ενεργοποιήστε εκεί το DroidDeck και αυτή η σελίδα επιστρέφει μόνη της.</string>
|
||||
<string name="bottom_notifications_allow">Αποδοχή πρόσβασης ειδοποιήσεων</string>
|
||||
<string name="bottom_notifications_none">Δεν υπάρχουν ειδοποιήσεις.</string>
|
||||
<string name="bottom_notifications_count">%1$d ειδοποιήσεις</string>
|
||||
<string name="bottom_notifications_clear">Απαλοιφή</string>
|
||||
<string name="bottom_page_screenshots">Στιγμιότυπα οθόνης</string>
|
||||
<string name="stats_fps_needs_hud">Τα FPS απαιτούν ενεργό HUD</string>
|
||||
<string name="stats_speed">Ταχύτητα</string>
|
||||
@@ -2867,6 +2860,8 @@
|
||||
<string name="display_position_top">Πάνω</string>
|
||||
<string name="display_position_bottom">Κάτω</string>
|
||||
<string name="display_position_center">Κέντρο (προεπιλογή)</string>
|
||||
<string name="display_position_left">Αριστερά</string>
|
||||
<string name="display_position_right">Δεξιά</string>
|
||||
<string name="setup_tab_storage">Αποθήκευση</string>
|
||||
<string name="storage_measuring">Μέτρηση αποθήκευσης…</string>
|
||||
<string name="storage_measuring_hint">Την πρώτη φορά παίρνει λίγα δευτερόλεπτα.</string>
|
||||
@@ -3660,4 +3655,7 @@
|
||||
<string name="input_reset_positions">Επαναφορά θέσεων</string>
|
||||
<string name="input_show_label">Εμφάνιση ετικέτας</string>
|
||||
<string name="input_layout_import_failed">Δεν ήταν δυνατή η εισαγωγή αυτής της διάταξης</string>
|
||||
|
||||
<string name="theme_white_red">Κατακόκκινο χιόνι</string>
|
||||
|
||||
</resources>
|
||||
@@ -1801,13 +1801,6 @@
|
||||
<string name="bottom_downloads_shader">Descargando caché de shaders</string>
|
||||
<string name="bottom_downloads_paused">En pausa</string>
|
||||
<string name="bottom_downloads_queued">Esperando para empezar</string>
|
||||
<string name="bottom_page_notifications">Notificaciones</string>
|
||||
<string name="bottom_notifications_listener">DroidDeck – pantalla inferior</string>
|
||||
<string name="bottom_notifications_why">Muestra aquí los mensajes y otras notificaciones mientras juegas. Solo permanecen en esta pantalla y no se guardan. Android lo pregunta una vez: su página de acceso a notificaciones se abre en esta pantalla; activa DroidDeck ahí y esta página volverá sola.</string>
|
||||
<string name="bottom_notifications_allow">Permitir acceso a notificaciones</string>
|
||||
<string name="bottom_notifications_none">No hay notificaciones.</string>
|
||||
<string name="bottom_notifications_count">%1$d notificaciones</string>
|
||||
<string name="bottom_notifications_clear">Borrar</string>
|
||||
<string name="bottom_page_screenshots">Capturas de pantalla</string>
|
||||
<string name="stats_fps_needs_hud">Los FPS requieren el HUD activado</string>
|
||||
<string name="stats_speed">Velocidad</string>
|
||||
@@ -2896,6 +2889,8 @@
|
||||
<string name="display_position_top">Arriba</string>
|
||||
<string name="display_position_bottom">Abajo</string>
|
||||
<string name="display_position_center">Centro (predeterminado)</string>
|
||||
<string name="display_position_left">Izquierda</string>
|
||||
<string name="display_position_right">Derecha</string>
|
||||
<string name="setup_tab_storage">Almacenamiento</string>
|
||||
<string name="storage_measuring">Midiendo el almacenamiento…</string>
|
||||
<string name="storage_measuring_hint">La primera vez tarda unos segundos.</string>
|
||||
@@ -3699,4 +3694,7 @@
|
||||
<string name="input_reset_positions">Restablecer posiciones</string>
|
||||
<string name="input_show_label">Mostrar etiqueta</string>
|
||||
<string name="input_layout_import_failed">No se ha podido importar esta disposición</string>
|
||||
|
||||
<string name="theme_white_red">Nieve escarlata</string>
|
||||
|
||||
</resources>
|
||||
@@ -1777,13 +1777,6 @@
|
||||
<string name="bottom_downloads_shader">Ladataan sävytinvälimuistia</string>
|
||||
<string name="bottom_downloads_paused">Keskeytetty</string>
|
||||
<string name="bottom_downloads_queued">Odottaa alkamista</string>
|
||||
<string name="bottom_page_notifications">Ilmoitukset</string>
|
||||
<string name="bottom_notifications_listener">DroidDeckin alanäyttö</string>
|
||||
<string name="bottom_notifications_why">Näytä viestit ja muut ilmoitukset täällä pelatessasi. Ne näkyvät vain tällä näytöllä, eikä niitä tallenneta. Android kysyy kerran: sen ilmoitusten käyttöoikeussivu avautuu tälle näytölle; kytke DroidDeck siellä päälle, niin tämä sivu palaa itsestään.</string>
|
||||
<string name="bottom_notifications_allow">Salli ilmoitusten käyttö</string>
|
||||
<string name="bottom_notifications_none">Ei ilmoituksia.</string>
|
||||
<string name="bottom_notifications_count">Ilmoituksia: %1$d</string>
|
||||
<string name="bottom_notifications_clear">Tyhjennä</string>
|
||||
<string name="bottom_page_screenshots">Kuvakaappaukset</string>
|
||||
<string name="stats_fps_needs_hud">FPS vaatii HUD:n päälle</string>
|
||||
<string name="stats_speed">Nopeus</string>
|
||||
@@ -2867,6 +2860,8 @@
|
||||
<string name="display_position_top">Ylhäällä</string>
|
||||
<string name="display_position_bottom">Alhaalla</string>
|
||||
<string name="display_position_center">Keskellä (oletus)</string>
|
||||
<string name="display_position_left">Vasen</string>
|
||||
<string name="display_position_right">Oikea</string>
|
||||
<string name="setup_tab_storage">Tallennustila</string>
|
||||
<string name="storage_measuring">Mitataan tallennustilaa…</string>
|
||||
<string name="storage_measuring_hint">Ensimmäisellä kerralla kestää muutaman sekunnin.</string>
|
||||
@@ -3660,4 +3655,7 @@
|
||||
<string name="input_reset_positions">Palauta sijainnit</string>
|
||||
<string name="input_show_label">Näytä nimi</string>
|
||||
<string name="input_layout_import_failed">Tätä asettelua ei voitu tuoda</string>
|
||||
|
||||
<string name="theme_white_red">Helakanpunainen lumi</string>
|
||||
|
||||
</resources>
|
||||
@@ -1801,13 +1801,6 @@
|
||||
<string name="bottom_downloads_shader">Téléchargement du cache de shaders</string>
|
||||
<string name="bottom_downloads_paused">En pause</string>
|
||||
<string name="bottom_downloads_queued">En attente de démarrage</string>
|
||||
<string name="bottom_page_notifications">Notifications</string>
|
||||
<string name="bottom_notifications_listener">Écran inférieur DroidDeck</string>
|
||||
<string name="bottom_notifications_why">Affichez ici les messages et autres notifications pendant que vous jouez. Ils restent uniquement sur cet écran et ne sont pas enregistrés. Android ne le demande qu\'une fois : sa page d\'accès aux notifications s\'ouvre sur cet écran ; activez-y DroidDeck et cette page reviendra d\'elle-même.</string>
|
||||
<string name="bottom_notifications_allow">Autoriser l\'accès aux notifications</string>
|
||||
<string name="bottom_notifications_none">Aucune notification.</string>
|
||||
<string name="bottom_notifications_count">%1$d notifications</string>
|
||||
<string name="bottom_notifications_clear">Effacer</string>
|
||||
<string name="bottom_page_screenshots">Captures d\'écran</string>
|
||||
<string name="stats_fps_needs_hud">Les FPS nécessitent d\'activer le HUD</string>
|
||||
<string name="stats_speed">Vitesse</string>
|
||||
@@ -2896,6 +2889,8 @@
|
||||
<string name="display_position_top">Haut</string>
|
||||
<string name="display_position_bottom">Bas</string>
|
||||
<string name="display_position_center">Centre (par défaut)</string>
|
||||
<string name="display_position_left">Gauche</string>
|
||||
<string name="display_position_right">Droite</string>
|
||||
<string name="setup_tab_storage">Stockage</string>
|
||||
<string name="storage_measuring">Mesure du stockage…</string>
|
||||
<string name="storage_measuring_hint">Cela prend quelques secondes la première fois.</string>
|
||||
@@ -3699,4 +3694,7 @@
|
||||
<string name="input_reset_positions">Réinitialiser les positions</string>
|
||||
<string name="input_show_label">Afficher le libellé</string>
|
||||
<string name="input_layout_import_failed">Impossible d’importer cette disposition</string>
|
||||
|
||||
<string name="theme_white_red">Neige écarlate</string>
|
||||
|
||||
</resources>
|
||||
@@ -1777,13 +1777,6 @@
|
||||
<string name="bottom_downloads_shader">शेडर कैश डाउनलोड हो रहा है</string>
|
||||
<string name="bottom_downloads_paused">रुका हुआ</string>
|
||||
<string name="bottom_downloads_queued">शुरू होने का इंतज़ार</string>
|
||||
<string name="bottom_page_notifications">नोटिफ़िकेशन</string>
|
||||
<string name="bottom_notifications_listener">DroidDeck नीचे की स्क्रीन</string>
|
||||
<string name="bottom_notifications_why">खेलते समय मैसेज और अन्य नोटिफ़िकेशन यहां दिखाएं। वे सिर्फ़ इसी स्क्रीन पर रहते हैं और सेव नहीं होते। Android एक बार पूछता है: उसका नोटिफ़िकेशन ऐक्सेस पेज इसी स्क्रीन पर खुलता है; वहां DroidDeck चालू करें और यह पेज अपने-आप वापस आ जाएगा।</string>
|
||||
<string name="bottom_notifications_allow">नोटिफ़िकेशन ऐक्सेस की अनुमति दें</string>
|
||||
<string name="bottom_notifications_none">कोई नोटिफ़िकेशन नहीं।</string>
|
||||
<string name="bottom_notifications_count">%1$d नोटिफ़िकेशन</string>
|
||||
<string name="bottom_notifications_clear">साफ़ करें</string>
|
||||
<string name="bottom_page_screenshots">स्क्रीनशॉट</string>
|
||||
<string name="stats_fps_needs_hud">FPS के लिए HUD चालू होना चाहिए</string>
|
||||
<string name="stats_speed">स्पीड</string>
|
||||
@@ -2867,6 +2860,8 @@
|
||||
<string name="display_position_top">ऊपर</string>
|
||||
<string name="display_position_bottom">नीचे</string>
|
||||
<string name="display_position_center">बीच में (डिफ़ॉल्ट)</string>
|
||||
<string name="display_position_left">बाएँ</string>
|
||||
<string name="display_position_right">दाएँ</string>
|
||||
<string name="setup_tab_storage">स्टोरेज</string>
|
||||
<string name="storage_measuring">स्टोरेज मापा जा रहा है…</string>
|
||||
<string name="storage_measuring_hint">पहली बार कुछ सेकंड लगते हैं।</string>
|
||||
@@ -3660,4 +3655,7 @@
|
||||
<string name="input_reset_positions">स्थितियाँ रीसेट करें</string>
|
||||
<string name="input_show_label">लेबल दिखाएँ</string>
|
||||
<string name="input_layout_import_failed">यह लेआउट इम्पोर्ट नहीं किया जा सका</string>
|
||||
|
||||
<string name="theme_white_red">किरमिज़ी बर्फ</string>
|
||||
|
||||
</resources>
|
||||
@@ -1777,13 +1777,6 @@
|
||||
<string name="bottom_downloads_shader">Shader-gyorsítótár letöltése</string>
|
||||
<string name="bottom_downloads_paused">Szüneteltetve</string>
|
||||
<string name="bottom_downloads_queued">Indításra vár</string>
|
||||
<string name="bottom_page_notifications">Értesítések</string>
|
||||
<string name="bottom_notifications_listener">DroidDeck alsó képernyő</string>
|
||||
<string name="bottom_notifications_why">Itt jelenítheti meg az üzeneteket és más értesítéseket játék közben. Csak ezen a képernyőn láthatók, és nem kerülnek mentésre. Az Android egyszer kérdez rá: az értesítési hozzáférés oldala ezen a képernyőn nyílik meg; kapcsolja be ott a DroidDecket, és ez az oldal magától visszatér.</string>
|
||||
<string name="bottom_notifications_allow">Értesítési hozzáférés engedélyezése</string>
|
||||
<string name="bottom_notifications_none">Nincsenek értesítések.</string>
|
||||
<string name="bottom_notifications_count">%1$d értesítés</string>
|
||||
<string name="bottom_notifications_clear">Törlés</string>
|
||||
<string name="bottom_page_screenshots">Képernyőképek</string>
|
||||
<string name="stats_fps_needs_hud">Az FPS-hez be kell kapcsolni a HUD-ot</string>
|
||||
<string name="stats_speed">Sebesség</string>
|
||||
@@ -2867,6 +2860,8 @@
|
||||
<string name="display_position_top">Fent</string>
|
||||
<string name="display_position_bottom">Lent</string>
|
||||
<string name="display_position_center">Középen (alapértelmezett)</string>
|
||||
<string name="display_position_left">Bal</string>
|
||||
<string name="display_position_right">Jobb</string>
|
||||
<string name="setup_tab_storage">Tárhely</string>
|
||||
<string name="storage_measuring">Tárhely mérése…</string>
|
||||
<string name="storage_measuring_hint">Első alkalommal pár másodpercig tart.</string>
|
||||
@@ -3660,4 +3655,7 @@
|
||||
<string name="input_reset_positions">Pozíciók visszaállítása</string>
|
||||
<string name="input_show_label">Címke megjelenítése</string>
|
||||
<string name="input_layout_import_failed">Ez az elrendezés nem importálható</string>
|
||||
|
||||
<string name="theme_white_red">Skarlátvörös hó</string>
|
||||
|
||||
</resources>
|
||||
@@ -1753,13 +1753,6 @@
|
||||
<string name="bottom_downloads_shader">Mengunduh cache shader</string>
|
||||
<string name="bottom_downloads_paused">Dijeda</string>
|
||||
<string name="bottom_downloads_queued">Menunggu dimulai</string>
|
||||
<string name="bottom_page_notifications">Notifikasi</string>
|
||||
<string name="bottom_notifications_listener">Layar bawah DroidDeck</string>
|
||||
<string name="bottom_notifications_why">Tampilkan pesan dan notifikasi lain di sini saat Anda bermain. Notifikasi hanya muncul di layar ini dan tidak disimpan. Android hanya meminta sekali: halaman akses notifikasinya terbuka di layar ini; aktifkan DroidDeck di sana dan halaman ini akan kembali dengan sendirinya.</string>
|
||||
<string name="bottom_notifications_allow">Izinkan akses notifikasi</string>
|
||||
<string name="bottom_notifications_none">Tidak ada notifikasi.</string>
|
||||
<string name="bottom_notifications_count">%1$d notifikasi</string>
|
||||
<string name="bottom_notifications_clear">Hapus</string>
|
||||
<string name="bottom_page_screenshots">Tangkapan layar</string>
|
||||
<string name="stats_fps_needs_hud">FPS memerlukan HUD aktif</string>
|
||||
<string name="stats_speed">Kecepatan</string>
|
||||
@@ -2838,6 +2831,8 @@
|
||||
<string name="display_position_top">Atas</string>
|
||||
<string name="display_position_bottom">Bawah</string>
|
||||
<string name="display_position_center">Tengah (bawaan)</string>
|
||||
<string name="display_position_left">Kiri</string>
|
||||
<string name="display_position_right">Kanan</string>
|
||||
<string name="setup_tab_storage">Penyimpanan</string>
|
||||
<string name="storage_measuring">Mengukur penyimpanan…</string>
|
||||
<string name="storage_measuring_hint">Butuh beberapa detik pada kali pertama.</string>
|
||||
@@ -3630,4 +3625,7 @@
|
||||
<string name="input_reset_positions">Atur ulang posisi</string>
|
||||
<string name="input_show_label">Tampilkan label</string>
|
||||
<string name="input_layout_import_failed">Tata letak ini tidak dapat diimpor</string>
|
||||
|
||||
<string name="theme_white_red">Salju Kirmizi</string>
|
||||
|
||||
</resources>
|
||||
@@ -1793,13 +1793,6 @@
|
||||
<string name="bottom_downloads_shader">Download della cache degli shader</string>
|
||||
<string name="bottom_downloads_paused">In pausa</string>
|
||||
<string name="bottom_downloads_queued">In attesa di avvio</string>
|
||||
<string name="bottom_page_notifications">Notifiche</string>
|
||||
<string name="bottom_notifications_listener">Schermo inferiore DroidDeck</string>
|
||||
<string name="bottom_notifications_why">Mostra qui messaggi e altre notifiche mentre giochi. Restano solo su questo schermo e non vengono salvate. Android lo chiede una sola volta: la sua pagina di accesso alle notifiche si apre su questo schermo; attiva DroidDeck lì e questa pagina torna da sola.</string>
|
||||
<string name="bottom_notifications_allow">Consenti accesso alle notifiche</string>
|
||||
<string name="bottom_notifications_none">Nessuna notifica.</string>
|
||||
<string name="bottom_notifications_count">%1$d notifiche</string>
|
||||
<string name="bottom_notifications_clear">Cancella</string>
|
||||
<string name="bottom_page_screenshots">Screenshot</string>
|
||||
<string name="stats_fps_needs_hud">Gli FPS richiedono l\'HUD attivo</string>
|
||||
<string name="stats_speed">Velocità</string>
|
||||
@@ -2896,6 +2889,8 @@
|
||||
<string name="display_position_top">In alto</string>
|
||||
<string name="display_position_bottom">In basso</string>
|
||||
<string name="display_position_center">Centro (predefinito)</string>
|
||||
<string name="display_position_left">Sinistra</string>
|
||||
<string name="display_position_right">Destra</string>
|
||||
<string name="setup_tab_storage">Archiviazione</string>
|
||||
<string name="storage_measuring">Misurazione dell\'archiviazione…</string>
|
||||
<string name="storage_measuring_hint">La prima volta richiede qualche secondo.</string>
|
||||
@@ -3699,4 +3694,7 @@
|
||||
<string name="input_reset_positions">Ripristina posizioni</string>
|
||||
<string name="input_show_label">Mostra etichetta</string>
|
||||
<string name="input_layout_import_failed">Impossibile importare questo layout</string>
|
||||
|
||||
<string name="theme_white_red">Neve scarlatta</string>
|
||||
|
||||
</resources>
|
||||
@@ -1753,13 +1753,6 @@
|
||||
<string name="bottom_downloads_shader">シェーダーキャッシュをダウンロード中</string>
|
||||
<string name="bottom_downloads_paused">一時停止中</string>
|
||||
<string name="bottom_downloads_queued">開始待ち</string>
|
||||
<string name="bottom_page_notifications">通知</string>
|
||||
<string name="bottom_notifications_listener">DroidDeck下部画面</string>
|
||||
<string name="bottom_notifications_why">プレイ中、メッセージなどの通知をここに表示します。通知はこの画面にのみ表示され、保存されません。Androidによる確認は一度だけです。この画面に通知へのアクセスのページが開くので、そこでDroidDeckをオンにすると、このページに自動的に戻ります。</string>
|
||||
<string name="bottom_notifications_allow">通知へのアクセスを許可</string>
|
||||
<string name="bottom_notifications_none">通知はありません。</string>
|
||||
<string name="bottom_notifications_count">%1$d件の通知</string>
|
||||
<string name="bottom_notifications_clear">クリア</string>
|
||||
<string name="bottom_page_screenshots">スクリーンショット</string>
|
||||
<string name="stats_fps_needs_hud">FPSの表示にはHUDをオンにする必要があります</string>
|
||||
<string name="stats_speed">速度</string>
|
||||
@@ -2838,6 +2831,8 @@
|
||||
<string name="display_position_top">上</string>
|
||||
<string name="display_position_bottom">下</string>
|
||||
<string name="display_position_center">中央(既定)</string>
|
||||
<string name="display_position_left">左</string>
|
||||
<string name="display_position_right">右</string>
|
||||
<string name="setup_tab_storage">ストレージ</string>
|
||||
<string name="storage_measuring">ストレージを集計しています…</string>
|
||||
<string name="storage_measuring_hint">初回の集計には数秒かかります。</string>
|
||||
@@ -3630,4 +3625,7 @@
|
||||
<string name="input_reset_positions">位置をリセット</string>
|
||||
<string name="input_show_label">ラベルを表示</string>
|
||||
<string name="input_layout_import_failed">このレイアウトをインポートできませんでした</string>
|
||||
|
||||
<string name="theme_white_red">緋色の雪</string>
|
||||
|
||||
</resources>
|
||||
@@ -1753,13 +1753,6 @@
|
||||
<string name="bottom_downloads_shader">셰이더 캐시 다운로드 중</string>
|
||||
<string name="bottom_downloads_paused">일시 중지됨</string>
|
||||
<string name="bottom_downloads_queued">시작 대기 중</string>
|
||||
<string name="bottom_page_notifications">알림</string>
|
||||
<string name="bottom_notifications_listener">DroidDeck 하단 화면</string>
|
||||
<string name="bottom_notifications_why">플레이하는 동안 메시지와 기타 알림을 여기에 표시합니다. 알림은 이 화면에만 표시되며 저장되지 않습니다. Android에서 한 번만 묻습니다. 이 화면에 알림 접근 권한 페이지가 열리면 DroidDeck을 켜세요. 그러면 이 페이지로 자동으로 돌아옵니다.</string>
|
||||
<string name="bottom_notifications_allow">알림 접근 허용</string>
|
||||
<string name="bottom_notifications_none">알림이 없습니다.</string>
|
||||
<string name="bottom_notifications_count">알림 %1$d개</string>
|
||||
<string name="bottom_notifications_clear">지우기</string>
|
||||
<string name="bottom_page_screenshots">스크린샷</string>
|
||||
<string name="stats_fps_needs_hud">FPS를 보려면 HUD를 켜야 합니다</string>
|
||||
<string name="stats_speed">속도</string>
|
||||
@@ -2838,6 +2831,8 @@
|
||||
<string name="display_position_top">위</string>
|
||||
<string name="display_position_bottom">아래</string>
|
||||
<string name="display_position_center">가운데(기본)</string>
|
||||
<string name="display_position_left">왼쪽</string>
|
||||
<string name="display_position_right">오른쪽</string>
|
||||
<string name="setup_tab_storage">저장 공간</string>
|
||||
<string name="storage_measuring">저장 공간 계산 중…</string>
|
||||
<string name="storage_measuring_hint">처음 계산할 때는 몇 초 걸립니다.</string>
|
||||
@@ -3630,4 +3625,7 @@
|
||||
<string name="input_reset_positions">위치 초기화</string>
|
||||
<string name="input_show_label">라벨 표시</string>
|
||||
<string name="input_layout_import_failed">이 레이아웃을 가져올 수 없습니다</string>
|
||||
|
||||
<string name="theme_white_red">진홍빛 눈</string>
|
||||
|
||||
</resources>
|
||||
@@ -1753,13 +1753,6 @@
|
||||
<string name="bottom_downloads_shader">Memuat turun cache shader</string>
|
||||
<string name="bottom_downloads_paused">Dijeda</string>
|
||||
<string name="bottom_downloads_queued">Menunggu untuk bermula</string>
|
||||
<string name="bottom_page_notifications">Pemberitahuan</string>
|
||||
<string name="bottom_notifications_listener">Skrin bawah DroidDeck</string>
|
||||
<string name="bottom_notifications_why">Paparkan mesej dan pemberitahuan lain di sini semasa anda bermain. Ia hanya kekal pada skrin ini dan tidak disimpan. Android bertanya sekali sahaja: halaman akses pemberitahuannya dibuka pada skrin ini; hidupkan DroidDeck di sana dan halaman ini akan kembali dengan sendirinya.</string>
|
||||
<string name="bottom_notifications_allow">Benarkan akses pemberitahuan</string>
|
||||
<string name="bottom_notifications_none">Tiada pemberitahuan.</string>
|
||||
<string name="bottom_notifications_count">%1$d pemberitahuan</string>
|
||||
<string name="bottom_notifications_clear">Kosongkan</string>
|
||||
<string name="bottom_page_screenshots">Tangkapan skrin</string>
|
||||
<string name="stats_fps_needs_hud">FPS memerlukan HUD dihidupkan</string>
|
||||
<string name="stats_speed">Kelajuan</string>
|
||||
@@ -2838,6 +2831,8 @@
|
||||
<string name="display_position_top">Atas</string>
|
||||
<string name="display_position_bottom">Bawah</string>
|
||||
<string name="display_position_center">Tengah (lalai)</string>
|
||||
<string name="display_position_left">Kiri</string>
|
||||
<string name="display_position_right">Kanan</string>
|
||||
<string name="setup_tab_storage">Storan</string>
|
||||
<string name="storage_measuring">Mengukur storan…</string>
|
||||
<string name="storage_measuring_hint">Mengambil beberapa saat pada kali pertama.</string>
|
||||
@@ -3630,4 +3625,7 @@
|
||||
<string name="input_reset_positions">Tetapkan semula kedudukan</string>
|
||||
<string name="input_show_label">Tunjukkan label</string>
|
||||
<string name="input_layout_import_failed">Susun atur ini tidak dapat diimport</string>
|
||||
|
||||
<string name="theme_white_red">Salji Merah Skarlet</string>
|
||||
|
||||
</resources>
|
||||
@@ -1777,13 +1777,6 @@
|
||||
<string name="bottom_downloads_shader">Laster ned shaderbuffer</string>
|
||||
<string name="bottom_downloads_paused">Pauset</string>
|
||||
<string name="bottom_downloads_queued">Venter på å starte</string>
|
||||
<string name="bottom_page_notifications">Varslinger</string>
|
||||
<string name="bottom_notifications_listener">DroidDeck nedre skjerm</string>
|
||||
<string name="bottom_notifications_why">Vis meldinger og andre varslinger her mens du spiller. De vises bare på denne skjermen og lagres ikke. Android spør én gang: siden for varseltilgang åpnes på denne skjermen; slå på DroidDeck der, så kommer denne siden tilbake av seg selv.</string>
|
||||
<string name="bottom_notifications_allow">Tillat varseltilgang</string>
|
||||
<string name="bottom_notifications_none">Ingen varslinger.</string>
|
||||
<string name="bottom_notifications_count">%1$d varslinger</string>
|
||||
<string name="bottom_notifications_clear">Fjern</string>
|
||||
<string name="bottom_page_screenshots">Skjermbilder</string>
|
||||
<string name="stats_fps_needs_hud">FPS krever at HUD-en er på</string>
|
||||
<string name="stats_speed">Fart</string>
|
||||
@@ -2867,6 +2860,8 @@
|
||||
<string name="display_position_top">Øverst</string>
|
||||
<string name="display_position_bottom">Nederst</string>
|
||||
<string name="display_position_center">Midten (standard)</string>
|
||||
<string name="display_position_left">Venstre</string>
|
||||
<string name="display_position_right">Høyre</string>
|
||||
<string name="setup_tab_storage">Lagring</string>
|
||||
<string name="storage_measuring">Måler lagring…</string>
|
||||
<string name="storage_measuring_hint">Første gang tar noen sekunder.</string>
|
||||
@@ -3660,4 +3655,7 @@
|
||||
<string name="input_reset_positions">Tilbakestill plasseringer</string>
|
||||
<string name="input_show_label">Vis etikett</string>
|
||||
<string name="input_layout_import_failed">Kunne ikke importere dette oppsettet</string>
|
||||
|
||||
<string name="theme_white_red">Skarlagenssnø</string>
|
||||
|
||||
</resources>
|
||||
@@ -1777,13 +1777,6 @@
|
||||
<string name="bottom_downloads_shader">Shadercache downloaden</string>
|
||||
<string name="bottom_downloads_paused">Gepauzeerd</string>
|
||||
<string name="bottom_downloads_queued">Wacht om te starten</string>
|
||||
<string name="bottom_page_notifications">Meldingen</string>
|
||||
<string name="bottom_notifications_listener">DroidDeck onderste scherm</string>
|
||||
<string name="bottom_notifications_why">Toon berichten en andere meldingen hier terwijl je speelt. Ze blijven alleen op dit scherm en worden niet opgeslagen. Android vraagt dit één keer: de pagina voor meldingstoegang opent op dit scherm; zet DroidDeck daar aan en deze pagina komt vanzelf terug.</string>
|
||||
<string name="bottom_notifications_allow">Meldingstoegang toestaan</string>
|
||||
<string name="bottom_notifications_none">Geen meldingen.</string>
|
||||
<string name="bottom_notifications_count">%1$d meldingen</string>
|
||||
<string name="bottom_notifications_clear">Wissen</string>
|
||||
<string name="bottom_page_screenshots">Screenshots</string>
|
||||
<string name="stats_fps_needs_hud">Voor FPS moet de HUD aan staan</string>
|
||||
<string name="stats_speed">Snelheid</string>
|
||||
@@ -2867,6 +2860,8 @@
|
||||
<string name="display_position_top">Boven</string>
|
||||
<string name="display_position_bottom">Onder</string>
|
||||
<string name="display_position_center">Midden (standaard)</string>
|
||||
<string name="display_position_left">Links</string>
|
||||
<string name="display_position_right">Rechts</string>
|
||||
<string name="setup_tab_storage">Opslag</string>
|
||||
<string name="storage_measuring">Opslag meten…</string>
|
||||
<string name="storage_measuring_hint">De eerste keer duurt een paar seconden.</string>
|
||||
@@ -3660,4 +3655,7 @@
|
||||
<string name="input_reset_positions">Posities herstellen</string>
|
||||
<string name="input_show_label">Label tonen</string>
|
||||
<string name="input_layout_import_failed">Deze indeling kan niet worden geïmporteerd</string>
|
||||
|
||||
<string name="theme_white_red">Scharlakenrode sneeuw</string>
|
||||
|
||||
</resources>
|
||||
@@ -1825,13 +1825,6 @@
|
||||
<string name="bottom_downloads_shader">Pobieranie pamięci podręcznej shaderów</string>
|
||||
<string name="bottom_downloads_paused">Wstrzymano</string>
|
||||
<string name="bottom_downloads_queued">Oczekuje na rozpoczęcie</string>
|
||||
<string name="bottom_page_notifications">Powiadomienia</string>
|
||||
<string name="bottom_notifications_listener">Dolny ekran DroidDeck</string>
|
||||
<string name="bottom_notifications_why">Wyświetlaj tu wiadomości i inne powiadomienia podczas gry. Pozostają tylko na tym ekranie i nie są zapisywane. Android zapyta raz: na tym ekranie otworzy się jego strona dostępu do powiadomień; włącz tam DroidDeck, a ta strona wróci sama.</string>
|
||||
<string name="bottom_notifications_allow">Zezwól na dostęp do powiadomień</string>
|
||||
<string name="bottom_notifications_none">Brak powiadomień.</string>
|
||||
<string name="bottom_notifications_count">Powiadomienia: %1$d</string>
|
||||
<string name="bottom_notifications_clear">Wyczyść</string>
|
||||
<string name="bottom_page_screenshots">Zrzuty ekranu</string>
|
||||
<string name="stats_fps_needs_hud">FPS wymaga włączonego HUD</string>
|
||||
<string name="stats_speed">Prędkość</string>
|
||||
@@ -2925,6 +2918,8 @@
|
||||
<string name="display_position_top">Góra</string>
|
||||
<string name="display_position_bottom">Dół</string>
|
||||
<string name="display_position_center">Środek (domyślnie)</string>
|
||||
<string name="display_position_left">Lewo</string>
|
||||
<string name="display_position_right">Prawo</string>
|
||||
<string name="setup_tab_storage">Pamięć</string>
|
||||
<string name="storage_measuring">Mierzenie pamięci…</string>
|
||||
<string name="storage_measuring_hint">Pierwszy raz trwa kilka sekund.</string>
|
||||
@@ -3738,4 +3733,7 @@
|
||||
<string name="input_reset_positions">Resetuj położenie</string>
|
||||
<string name="input_show_label">Pokaż etykietę</string>
|
||||
<string name="input_layout_import_failed">Nie można zaimportować tego układu</string>
|
||||
|
||||
<string name="theme_white_red">Szkarłatny śnieg</string>
|
||||
|
||||
</resources>
|
||||
@@ -1793,13 +1793,6 @@
|
||||
<string name="bottom_downloads_shader">A transferir a cache de shaders</string>
|
||||
<string name="bottom_downloads_paused">Em pausa</string>
|
||||
<string name="bottom_downloads_queued">A aguardar início</string>
|
||||
<string name="bottom_page_notifications">Notificações</string>
|
||||
<string name="bottom_notifications_listener">Ecrã inferior do DroidDeck</string>
|
||||
<string name="bottom_notifications_why">Mostre aqui mensagens e outras notificações enquanto joga. Ficam apenas neste ecrã e não são guardadas. O Android pergunta uma vez: a página de acesso às notificações abre neste ecrã; ative lá o DroidDeck e esta página volta automaticamente.</string>
|
||||
<string name="bottom_notifications_allow">Permitir acesso às notificações</string>
|
||||
<string name="bottom_notifications_none">Sem notificações.</string>
|
||||
<string name="bottom_notifications_count">%1$d notificações</string>
|
||||
<string name="bottom_notifications_clear">Limpar</string>
|
||||
<string name="bottom_page_screenshots">Capturas de ecrã</string>
|
||||
<string name="stats_fps_needs_hud">O FPS requer o HUD ativado</string>
|
||||
<string name="stats_speed">Velocidade</string>
|
||||
@@ -2894,6 +2887,8 @@
|
||||
<string name="stores_target_free_unknown">Espaço livre desconhecido</string>
|
||||
<string name="display_picture_position">Posição da imagem</string>
|
||||
<string name="display_position_top">Topo</string>
|
||||
<string name="display_position_left">Esquerda</string>
|
||||
<string name="display_position_right">Direita</string>
|
||||
<string name="display_position_bottom">Fundo</string>
|
||||
<string name="display_position_center">Centro (predefinido)</string>
|
||||
<string name="setup_tab_storage">Armazenamento</string>
|
||||
@@ -3699,4 +3694,7 @@
|
||||
<string name="input_reset_positions">Repor posições</string>
|
||||
<string name="input_show_label">Mostrar etiqueta</string>
|
||||
<string name="input_layout_import_failed">Não foi possível importar este esquema</string>
|
||||
|
||||
<string name="theme_white_red">Neve Escarlate</string>
|
||||
|
||||
</resources>
|
||||
@@ -1793,13 +1793,6 @@
|
||||
<string name="bottom_downloads_shader">Baixando o cache de shaders</string>
|
||||
<string name="bottom_downloads_paused">Pausado</string>
|
||||
<string name="bottom_downloads_queued">Aguardando para começar</string>
|
||||
<string name="bottom_page_notifications">Notificações</string>
|
||||
<string name="bottom_notifications_listener">Tela inferior do DroidDeck</string>
|
||||
<string name="bottom_notifications_why">Mostre aqui mensagens e outras notificações enquanto você joga. Elas ficam só nesta tela e não são salvas. O Android pergunta uma vez: a página de acesso a notificações dele abre nesta tela; ative o DroidDeck lá e esta página volta sozinha.</string>
|
||||
<string name="bottom_notifications_allow">Permitir acesso a notificações</string>
|
||||
<string name="bottom_notifications_none">Nenhuma notificação.</string>
|
||||
<string name="bottom_notifications_count">%1$d notificações</string>
|
||||
<string name="bottom_notifications_clear">Limpar</string>
|
||||
<string name="bottom_page_screenshots">Capturas de tela</string>
|
||||
<string name="stats_fps_needs_hud">O FPS precisa do HUD ativado</string>
|
||||
<string name="stats_speed">Velocidade</string>
|
||||
@@ -2894,6 +2887,8 @@
|
||||
<string name="stores_target_free_unknown">Espaço livre desconhecido</string>
|
||||
<string name="display_picture_position">Posição da imagem</string>
|
||||
<string name="display_position_top">Topo</string>
|
||||
<string name="display_position_left">Esquerda</string>
|
||||
<string name="display_position_right">Direita</string>
|
||||
<string name="display_position_bottom">Base</string>
|
||||
<string name="display_position_center">Centro (padrão)</string>
|
||||
<string name="setup_tab_storage">Armazenamento</string>
|
||||
@@ -3699,4 +3694,7 @@
|
||||
<string name="input_reset_positions">Redefinir posições</string>
|
||||
<string name="input_show_label">Mostrar rótulo</string>
|
||||
<string name="input_layout_import_failed">Não foi possível importar este layout</string>
|
||||
|
||||
<string name="theme_white_red">Neve Escarlate</string>
|
||||
|
||||
</resources>
|
||||
@@ -1801,13 +1801,6 @@
|
||||
<string name="bottom_downloads_shader">Se descarcă cache-ul de shadere</string>
|
||||
<string name="bottom_downloads_paused">Întrerupt</string>
|
||||
<string name="bottom_downloads_queued">Așteaptă să înceapă</string>
|
||||
<string name="bottom_page_notifications">Notificări</string>
|
||||
<string name="bottom_notifications_listener">Ecranul de jos DroidDeck</string>
|
||||
<string name="bottom_notifications_why">Afișează aici mesajele și alte notificări în timp ce te joci. Rămân doar pe acest ecran și nu sunt salvate. Android întreabă o singură dată: pagina sa de acces la notificări se deschide pe acest ecran; activează DroidDeck acolo, iar această pagină revine singură.</string>
|
||||
<string name="bottom_notifications_allow">Permite accesul la notificări</string>
|
||||
<string name="bottom_notifications_none">Nicio notificare.</string>
|
||||
<string name="bottom_notifications_count">Notificări: %1$d</string>
|
||||
<string name="bottom_notifications_clear">Șterge</string>
|
||||
<string name="bottom_page_screenshots">Capturi de ecran</string>
|
||||
<string name="stats_fps_needs_hud">FPS necesită HUD-ul activat</string>
|
||||
<string name="stats_speed">Viteză</string>
|
||||
@@ -2896,6 +2889,8 @@
|
||||
<string name="display_position_top">Sus</string>
|
||||
<string name="display_position_bottom">Jos</string>
|
||||
<string name="display_position_center">Centru (implicit)</string>
|
||||
<string name="display_position_left">Stânga</string>
|
||||
<string name="display_position_right">Dreapta</string>
|
||||
<string name="setup_tab_storage">Stocare</string>
|
||||
<string name="storage_measuring">Se măsoară stocarea…</string>
|
||||
<string name="storage_measuring_hint">Prima dată durează câteva secunde.</string>
|
||||
@@ -3699,4 +3694,7 @@
|
||||
<string name="input_reset_positions">Resetează pozițiile</string>
|
||||
<string name="input_show_label">Afișează eticheta</string>
|
||||
<string name="input_layout_import_failed">Acest aranjament nu a putut fi importat</string>
|
||||
|
||||
<string name="theme_white_red">Zăpadă stacojie</string>
|
||||
|
||||
</resources>
|
||||
@@ -1888,13 +1888,6 @@
|
||||
<string name="bottom_downloads_shader">Загрузка кэша шейдеров</string>
|
||||
<string name="bottom_downloads_paused">Приостановлено</string>
|
||||
<string name="bottom_downloads_queued">Ожидает начала</string>
|
||||
<string name="bottom_page_notifications">Уведомления</string>
|
||||
<string name="bottom_notifications_listener">Нижний экран DroidDeck</string>
|
||||
<string name="bottom_notifications_why">Показывает сообщения и другие уведомления здесь, пока вы играете. Они отображаются только на этом экране и не сохраняются. Android спросит один раз: на этом экране откроется страница доступа к уведомлениям; включите там DroidDeck, и эта страница вернется сама.</string>
|
||||
<string name="bottom_notifications_allow">Разрешить доступ к уведомлениям</string>
|
||||
<string name="bottom_notifications_none">Нет уведомлений.</string>
|
||||
<string name="bottom_notifications_count">Уведомлений: %1$d</string>
|
||||
<string name="bottom_notifications_clear">Очистить</string>
|
||||
<string name="bottom_page_screenshots">Скриншоты</string>
|
||||
<string name="stats_fps_needs_hud">Для FPS нужно включить HUD</string>
|
||||
<string name="stats_speed">Скорость</string>
|
||||
@@ -3008,6 +3001,8 @@
|
||||
<string name="display_position_top">Сверху</string>
|
||||
<string name="display_position_bottom">Снизу</string>
|
||||
<string name="display_position_center">По центру (по умолчанию)</string>
|
||||
<string name="display_position_left">Слева</string>
|
||||
<string name="display_position_right">Справа</string>
|
||||
<string name="setup_tab_storage">Хранилище</string>
|
||||
<string name="storage_measuring">Измерение хранилища…</string>
|
||||
<string name="storage_measuring_hint">В первый раз занимает несколько секунд.</string>
|
||||
@@ -3821,4 +3816,7 @@
|
||||
<string name="input_reset_positions">Сбросить положение</string>
|
||||
<string name="input_show_label">Показывать подпись</string>
|
||||
<string name="input_layout_import_failed">Не удалось импортировать эту раскладку</string>
|
||||
|
||||
<string name="theme_white_red">Алый снег</string>
|
||||
|
||||
</resources>
|
||||
@@ -1777,13 +1777,6 @@
|
||||
<string name="bottom_downloads_shader">Laddar ned shadercache</string>
|
||||
<string name="bottom_downloads_paused">Pausad</string>
|
||||
<string name="bottom_downloads_queued">Väntar på att starta</string>
|
||||
<string name="bottom_page_notifications">Aviseringar</string>
|
||||
<string name="bottom_notifications_listener">DroidDecks nedre skärm</string>
|
||||
<string name="bottom_notifications_why">Visa meddelanden och andra aviseringar här medan du spelar. De visas bara på den här skärmen och sparas inte. Android frågar en gång: sidan för aviseringsåtkomst öppnas på den här skärmen; slå på DroidDeck där så kommer den här sidan tillbaka av sig själv.</string>
|
||||
<string name="bottom_notifications_allow">Tillåt aviseringsåtkomst</string>
|
||||
<string name="bottom_notifications_none">Inga aviseringar.</string>
|
||||
<string name="bottom_notifications_count">%1$d aviseringar</string>
|
||||
<string name="bottom_notifications_clear">Rensa</string>
|
||||
<string name="bottom_page_screenshots">Skärmbilder</string>
|
||||
<string name="stats_fps_needs_hud">FPS kräver att HUD:en är på</string>
|
||||
<string name="stats_speed">Hastighet</string>
|
||||
@@ -2867,6 +2860,8 @@
|
||||
<string name="display_position_top">Överst</string>
|
||||
<string name="display_position_bottom">Nederst</string>
|
||||
<string name="display_position_center">Mitten (standard)</string>
|
||||
<string name="display_position_left">Vänster</string>
|
||||
<string name="display_position_right">Höger</string>
|
||||
<string name="setup_tab_storage">Lagring</string>
|
||||
<string name="storage_measuring">Mäter lagring…</string>
|
||||
<string name="storage_measuring_hint">Första gången tar några sekunder.</string>
|
||||
@@ -3660,4 +3655,7 @@
|
||||
<string name="input_reset_positions">Återställ positioner</string>
|
||||
<string name="input_show_label">Visa etikett</string>
|
||||
<string name="input_layout_import_failed">Det gick inte att importera layouten</string>
|
||||
|
||||
<string name="theme_white_red">Scharlakansröd snö</string>
|
||||
|
||||
</resources>
|
||||
@@ -1753,13 +1753,6 @@
|
||||
<string name="bottom_downloads_shader">กำลังดาวน์โหลดแคชเชเดอร์</string>
|
||||
<string name="bottom_downloads_paused">หยุดชั่วคราว</string>
|
||||
<string name="bottom_downloads_queued">กำลังรอเริ่ม</string>
|
||||
<string name="bottom_page_notifications">การแจ้งเตือน</string>
|
||||
<string name="bottom_notifications_listener">หน้าจอล่างของ DroidDeck</string>
|
||||
<string name="bottom_notifications_why">แสดงข้อความและการแจ้งเตือนอื่นๆ ที่นี่ขณะที่คุณเล่น การแจ้งเตือนจะแสดงบนหน้าจอนี้เท่านั้นและจะไม่ถูกบันทึก Android จะถามเพียงครั้งเดียว: หน้าการเข้าถึงการแจ้งเตือนจะเปิดขึ้นบนหน้าจอนี้ เปิด DroidDeck ที่นั่น แล้วหน้านี้จะกลับมาเอง</string>
|
||||
<string name="bottom_notifications_allow">อนุญาตการเข้าถึงการแจ้งเตือน</string>
|
||||
<string name="bottom_notifications_none">ไม่มีการแจ้งเตือน</string>
|
||||
<string name="bottom_notifications_count">การแจ้งเตือน %1$d รายการ</string>
|
||||
<string name="bottom_notifications_clear">ล้าง</string>
|
||||
<string name="bottom_page_screenshots">ภาพหน้าจอ</string>
|
||||
<string name="stats_fps_needs_hud">ต้องเปิด HUD เพื่อแสดง FPS</string>
|
||||
<string name="stats_speed">ความเร็ว</string>
|
||||
@@ -2838,6 +2831,8 @@
|
||||
<string name="display_position_top">บน</string>
|
||||
<string name="display_position_bottom">ล่าง</string>
|
||||
<string name="display_position_center">กลาง (ค่าเริ่มต้น)</string>
|
||||
<string name="display_position_left">ซ้าย</string>
|
||||
<string name="display_position_right">ขวา</string>
|
||||
<string name="setup_tab_storage">พื้นที่จัดเก็บ</string>
|
||||
<string name="storage_measuring">กำลังวัดพื้นที่…</string>
|
||||
<string name="storage_measuring_hint">ครั้งแรกใช้เวลาสองสามวินาที</string>
|
||||
@@ -3630,4 +3625,7 @@
|
||||
<string name="input_reset_positions">รีเซ็ตตำแหน่ง</string>
|
||||
<string name="input_show_label">แสดงป้ายกำกับ</string>
|
||||
<string name="input_layout_import_failed">ไม่สามารถนำเข้าการจัดวางนี้ได้</string>
|
||||
|
||||
<string name="theme_white_red">หิมะสีแดงชาด</string>
|
||||
|
||||
</resources>
|
||||
@@ -1769,13 +1769,6 @@
|
||||
<string name="bottom_downloads_shader">Gölgelendirici önbelleği indiriliyor</string>
|
||||
<string name="bottom_downloads_paused">Duraklatıldı</string>
|
||||
<string name="bottom_downloads_queued">Başlamayı bekliyor</string>
|
||||
<string name="bottom_page_notifications">Bildirimler</string>
|
||||
<string name="bottom_notifications_listener">DroidDeck alt ekranı</string>
|
||||
<string name="bottom_notifications_why">Oynarken mesajları ve diğer bildirimleri burada görün. Yalnızca bu ekranda kalırlar ve kaydedilmezler. Android bunu bir kez sorar: bildirim erişimi sayfası bu ekranda açılır; orada DroidDeck\'i açın, bu sayfa kendiliğinden geri gelir.</string>
|
||||
<string name="bottom_notifications_allow">Bildirim erişimine izin ver</string>
|
||||
<string name="bottom_notifications_none">Bildirim yok.</string>
|
||||
<string name="bottom_notifications_count">%1$d bildirim</string>
|
||||
<string name="bottom_notifications_clear">Temizle</string>
|
||||
<string name="bottom_page_screenshots">Ekran görüntüleri</string>
|
||||
<string name="stats_fps_needs_hud">FPS için HUD açık olmalı</string>
|
||||
<string name="stats_speed">Hız</string>
|
||||
@@ -2867,6 +2860,8 @@
|
||||
<string name="display_position_top">Üst</string>
|
||||
<string name="display_position_bottom">Alt</string>
|
||||
<string name="display_position_center">Orta (varsayılan)</string>
|
||||
<string name="display_position_left">Sol</string>
|
||||
<string name="display_position_right">Sağ</string>
|
||||
<string name="setup_tab_storage">Depolama</string>
|
||||
<string name="storage_measuring">Depolama ölçülüyor…</string>
|
||||
<string name="storage_measuring_hint">İlk seferde birkaç saniye sürer.</string>
|
||||
@@ -3660,4 +3655,7 @@
|
||||
<string name="input_reset_positions">Konumları sıfırla</string>
|
||||
<string name="input_show_label">Etiketi göster</string>
|
||||
<string name="input_layout_import_failed">Bu düzen içe aktarılamadı</string>
|
||||
|
||||
<string name="theme_white_red">Kızıl Kar</string>
|
||||
|
||||
</resources>
|
||||
@@ -1825,13 +1825,6 @@
|
||||
<string name="bottom_downloads_shader">Завантаження кешу шейдерів</string>
|
||||
<string name="bottom_downloads_paused">Призупинено</string>
|
||||
<string name="bottom_downloads_queued">Очікує початку</string>
|
||||
<string name="bottom_page_notifications">Сповіщення</string>
|
||||
<string name="bottom_notifications_listener">Нижній екран DroidDeck</string>
|
||||
<string name="bottom_notifications_why">Показуйте тут повідомлення та інші сповіщення під час гри. Вони лишаються лише на цьому екрані й не зберігаються. Android запитає один раз: сторінка доступу до сповіщень відкриється на цьому екрані; увімкніть там DroidDeck, і ця сторінка повернеться сама.</string>
|
||||
<string name="bottom_notifications_allow">Дозволити доступ до сповіщень</string>
|
||||
<string name="bottom_notifications_none">Немає сповіщень.</string>
|
||||
<string name="bottom_notifications_count">Сповіщень: %1$d</string>
|
||||
<string name="bottom_notifications_clear">Очистити</string>
|
||||
<string name="bottom_page_screenshots">Знімки екрана</string>
|
||||
<string name="stats_fps_needs_hud">Для FPS потрібно ввімкнути HUD</string>
|
||||
<string name="stats_speed">Швидкість</string>
|
||||
@@ -2925,6 +2918,8 @@
|
||||
<string name="display_position_top">Угорі</string>
|
||||
<string name="display_position_bottom">Унизу</string>
|
||||
<string name="display_position_center">По центру (типово)</string>
|
||||
<string name="display_position_left">Ліворуч</string>
|
||||
<string name="display_position_right">Праворуч</string>
|
||||
<string name="setup_tab_storage">Сховище</string>
|
||||
<string name="storage_measuring">Вимірювання сховища…</string>
|
||||
<string name="storage_measuring_hint">Уперше триває кілька секунд.</string>
|
||||
@@ -3738,4 +3733,7 @@
|
||||
<string name="input_reset_positions">Скинути розташування</string>
|
||||
<string name="input_show_label">Показувати підпис</string>
|
||||
<string name="input_layout_import_failed">Не вдалося імпортувати цю розкладку</string>
|
||||
|
||||
<string name="theme_white_red">Багряний сніг</string>
|
||||
|
||||
</resources>
|
||||
@@ -1753,13 +1753,6 @@
|
||||
<string name="bottom_downloads_shader">Đang tải bộ nhớ đệm shader</string>
|
||||
<string name="bottom_downloads_paused">Đã tạm dừng</string>
|
||||
<string name="bottom_downloads_queued">Đang chờ bắt đầu</string>
|
||||
<string name="bottom_page_notifications">Thông báo</string>
|
||||
<string name="bottom_notifications_listener">Màn hình dưới DroidDeck</string>
|
||||
<string name="bottom_notifications_why">Hiển thị tin nhắn và các thông báo khác ở đây khi bạn chơi. Chúng chỉ ở trên màn hình này và không được lưu. Android chỉ hỏi một lần: trang quyền truy cập thông báo sẽ mở trên màn hình này; hãy bật DroidDeck ở đó và trang này sẽ tự quay lại.</string>
|
||||
<string name="bottom_notifications_allow">Cho phép truy cập thông báo</string>
|
||||
<string name="bottom_notifications_none">Không có thông báo.</string>
|
||||
<string name="bottom_notifications_count">%1$d thông báo</string>
|
||||
<string name="bottom_notifications_clear">Xóa</string>
|
||||
<string name="bottom_page_screenshots">Ảnh chụp màn hình</string>
|
||||
<string name="stats_fps_needs_hud">Cần bật HUD để xem FPS</string>
|
||||
<string name="stats_speed">Tốc độ</string>
|
||||
@@ -2838,6 +2831,8 @@
|
||||
<string name="display_position_top">Trên</string>
|
||||
<string name="display_position_bottom">Dưới</string>
|
||||
<string name="display_position_center">Giữa (mặc định)</string>
|
||||
<string name="display_position_left">Trái</string>
|
||||
<string name="display_position_right">Phải</string>
|
||||
<string name="setup_tab_storage">Lưu trữ</string>
|
||||
<string name="storage_measuring">Đang đo dung lượng…</string>
|
||||
<string name="storage_measuring_hint">Lần đầu mất vài giây.</string>
|
||||
@@ -3630,4 +3625,7 @@
|
||||
<string name="input_reset_positions">Đặt lại vị trí</string>
|
||||
<string name="input_show_label">Hiện nhãn</string>
|
||||
<string name="input_layout_import_failed">Không thể nhập bố cục này</string>
|
||||
|
||||
<string name="theme_white_red">Tuyết đỏ thẫm</string>
|
||||
|
||||
</resources>
|
||||
@@ -1831,13 +1831,6 @@
|
||||
<string name="bottom_downloads_shader">正在下载着色器缓存</string>
|
||||
<string name="bottom_downloads_paused">已暂停</string>
|
||||
<string name="bottom_downloads_queued">等待开始</string>
|
||||
<string name="bottom_page_notifications">通知</string>
|
||||
<string name="bottom_notifications_listener">DroidDeck 底部屏幕</string>
|
||||
<string name="bottom_notifications_why">在游戏时于此处显示消息和其他通知。通知只显示在此屏幕上,不会被保存。Android 只会询问一次:其通知使用权页面会在此屏幕上打开,在那里开启 DroidDeck 后,会自动返回此页面。</string>
|
||||
<string name="bottom_notifications_allow">允许通知使用权</string>
|
||||
<string name="bottom_notifications_none">没有通知。</string>
|
||||
<string name="bottom_notifications_count">%1$d 条通知</string>
|
||||
<string name="bottom_notifications_clear">清除</string>
|
||||
<string name="bottom_page_screenshots">截图</string>
|
||||
<string name="stats_fps_needs_hud">显示 FPS 需要开启 HUD</string>
|
||||
<string name="stats_speed">速度</string>
|
||||
@@ -2842,6 +2835,8 @@
|
||||
<string name="display_position_top">置顶</string>
|
||||
<string name="display_position_bottom">置底</string>
|
||||
<string name="display_position_center">居中(默认)</string>
|
||||
<string name="display_position_left">左侧</string>
|
||||
<string name="display_position_right">右侧</string>
|
||||
<string name="setup_tab_storage">存储</string>
|
||||
<string name="storage_measuring">正在统计存储…</string>
|
||||
<string name="storage_measuring_hint">首次统计需要几秒钟。</string>
|
||||
@@ -3634,4 +3629,7 @@
|
||||
<string name="input_reset_positions">重置位置</string>
|
||||
<string name="input_show_label">显示标签</string>
|
||||
<string name="input_layout_import_failed">无法导入此布局</string>
|
||||
|
||||
<string name="theme_white_red">猩红之雪</string>
|
||||
|
||||
</resources>
|
||||
@@ -1753,13 +1753,6 @@
|
||||
<string name="bottom_downloads_shader">正在下載著色器快取</string>
|
||||
<string name="bottom_downloads_paused">已暫停</string>
|
||||
<string name="bottom_downloads_queued">等待開始</string>
|
||||
<string name="bottom_page_notifications">通知</string>
|
||||
<string name="bottom_notifications_listener">DroidDeck 下方螢幕</string>
|
||||
<string name="bottom_notifications_why">在遊玩時於此處顯示訊息和其他通知。通知只會顯示在這個螢幕上,不會儲存。Android 只會詢問一次:通知存取權頁面會在這個螢幕上開啟,在那裡開啟 DroidDeck 後,就會自動回到此頁面。</string>
|
||||
<string name="bottom_notifications_allow">允許通知存取權</string>
|
||||
<string name="bottom_notifications_none">沒有通知。</string>
|
||||
<string name="bottom_notifications_count">%1$d 則通知</string>
|
||||
<string name="bottom_notifications_clear">清除</string>
|
||||
<string name="bottom_page_screenshots">螢幕截圖</string>
|
||||
<string name="stats_fps_needs_hud">顯示 FPS 需要開啟 HUD</string>
|
||||
<string name="stats_speed">速度</string>
|
||||
@@ -2838,6 +2831,8 @@
|
||||
<string name="display_position_top">置頂</string>
|
||||
<string name="display_position_bottom">置底</string>
|
||||
<string name="display_position_center">置中(預設)</string>
|
||||
<string name="display_position_left">左側</string>
|
||||
<string name="display_position_right">右側</string>
|
||||
<string name="setup_tab_storage">儲存空間</string>
|
||||
<string name="storage_measuring">正在統計儲存空間…</string>
|
||||
<string name="storage_measuring_hint">首次統計需要幾秒鐘。</string>
|
||||
@@ -3633,4 +3628,7 @@
|
||||
<string name="input_reset_positions">重設位置</string>
|
||||
<string name="input_show_label">顯示標籤</string>
|
||||
<string name="input_layout_import_failed">無法匯入此佈局</string>
|
||||
|
||||
<string name="theme_white_red">緋紅之雪</string>
|
||||
|
||||
</resources>
|
||||
@@ -1753,13 +1753,6 @@
|
||||
<string name="bottom_downloads_shader">正在下載著色器快取</string>
|
||||
<string name="bottom_downloads_paused">已暫停</string>
|
||||
<string name="bottom_downloads_queued">等待開始</string>
|
||||
<string name="bottom_page_notifications">通知</string>
|
||||
<string name="bottom_notifications_listener">DroidDeck 底部螢幕</string>
|
||||
<string name="bottom_notifications_why">在遊玩時於此處顯示訊息和其他通知。通知只會顯示在這個螢幕上,不會儲存。Android 只會詢問一次:通知存取權頁面會在這個螢幕上開啟,在那裡開啟 DroidDeck 後,就會自動回到此頁面。</string>
|
||||
<string name="bottom_notifications_allow">允許通知存取權</string>
|
||||
<string name="bottom_notifications_none">沒有通知。</string>
|
||||
<string name="bottom_notifications_count">%1$d 則通知</string>
|
||||
<string name="bottom_notifications_clear">清除</string>
|
||||
<string name="bottom_page_screenshots">螢幕擷取畫面</string>
|
||||
<string name="stats_fps_needs_hud">顯示 FPS 需要開啟 HUD</string>
|
||||
<string name="stats_speed">速度</string>
|
||||
@@ -2838,6 +2831,8 @@
|
||||
<string name="display_position_top">置頂</string>
|
||||
<string name="display_position_bottom">置底</string>
|
||||
<string name="display_position_center">置中(預設)</string>
|
||||
<string name="display_position_left">左側</string>
|
||||
<string name="display_position_right">右側</string>
|
||||
<string name="setup_tab_storage">儲存空間</string>
|
||||
<string name="storage_measuring">正在統計儲存空間…</string>
|
||||
<string name="storage_measuring_hint">首次統計需要幾秒鐘。</string>
|
||||
@@ -3633,4 +3628,7 @@
|
||||
<string name="input_reset_positions">重設位置</string>
|
||||
<string name="input_show_label">顯示標籤</string>
|
||||
<string name="input_layout_import_failed">無法匯入此版面配置</string>
|
||||
|
||||
<string name="theme_white_red">緋紅之雪</string>
|
||||
|
||||
</resources>
|
||||
@@ -1965,13 +1965,6 @@
|
||||
<string name="bottom_downloads_shader">Downloading shader cache</string>
|
||||
<string name="bottom_downloads_paused">Paused</string>
|
||||
<string name="bottom_downloads_queued">Waiting to start</string>
|
||||
<string name="bottom_page_notifications">Notifications</string>
|
||||
<string name="bottom_notifications_listener">DroidDeck bottom screen</string>
|
||||
<string name="bottom_notifications_why">Show messages and other notifications here while you play. They stay on this screen only and are not saved. Android asks once: its notification access page opens on this screen; turn DroidDeck on there and this page comes back by itself.</string>
|
||||
<string name="bottom_notifications_allow">Allow notification access</string>
|
||||
<string name="bottom_notifications_none">No notifications.</string>
|
||||
<string name="bottom_notifications_count">%1$d notifications</string>
|
||||
<string name="bottom_notifications_clear">Clear</string>
|
||||
<string name="bottom_page_screenshots">Screenshots</string>
|
||||
<string name="stats_fps_needs_hud">FPS needs the HUD on</string>
|
||||
<string name="stats_speed">Speed</string>
|
||||
@@ -3113,6 +3106,8 @@
|
||||
<string name="glasses_pro2_experimental">Experimental Pro 2 head tracking. Brightness, volume, and display switching are not supported yet.</string>
|
||||
<string name="display_picture_position">Picture position</string>
|
||||
<string name="display_position_top">Top</string>
|
||||
<string name="display_position_left">Left</string>
|
||||
<string name="display_position_right">Right</string>
|
||||
<string name="display_position_bottom">Bottom</string>
|
||||
<string name="display_position_center">Center (default)</string>
|
||||
<string name="glasses_6dof">6DoF virtual screen</string>
|
||||
@@ -3821,4 +3816,7 @@
|
||||
<string name="input_reset_positions">Reset positions</string>
|
||||
<string name="input_show_label">Show label</string>
|
||||
<string name="input_layout_import_failed">Could not import this layout</string>
|
||||
|
||||
<string name="theme_white_red">Scarlet Snow</string>
|
||||
<string name="theme_white_red_detail">White surfaces with red accents.</string>
|
||||
</resources>
|
||||
@@ -0,0 +1,26 @@
|
||||
package com.droiddeck.launcher.gpu
|
||||
|
||||
import android.content.Context
|
||||
import java.io.File
|
||||
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 = [28], manifest = Config.NONE)
|
||||
class SystemVulkanPruneAbsentRuntimeTest {
|
||||
@Test fun pruningWithoutTheRuntimeDoesNotThrowAndLeavesFilesAlone() {
|
||||
val context: Context = RuntimeEnvironment.getApplication()
|
||||
val base = File(context.filesDir, "system_vulkan").apply { mkdirs() }
|
||||
val stage = File(base, ".stage-" + java.util.UUID.randomUUID()).apply { mkdir() }
|
||||
val version = File(base, "a".repeat(64)).apply { mkdir() }
|
||||
|
||||
SystemVulkanDriver.pruneUnused(context, true)
|
||||
|
||||
assertTrue(stage.exists())
|
||||
assertTrue(version.exists())
|
||||
}
|
||||
}
|
||||
@@ -17,6 +17,19 @@ class GesturePrefsTest {
|
||||
assertEquals(TouchGestureConfig.RELATIVE_MOUSE_AUTO, TouchGestureConfig.fromJson("{}").relativeMouseMode)
|
||||
}
|
||||
|
||||
@Test fun autoNeverGoesRelativeForDirectTouchWithoutAMouse() {
|
||||
val auto = TouchGestureConfig.RELATIVE_MOUSE_AUTO
|
||||
assertEquals(TouchGestureConfig.RELATIVE_MOUSE_OFF, TouchGestureConfig.gamescopeRelativeMouse(auto, mouseAttached = false, touchpad = false))
|
||||
assertEquals(auto, TouchGestureConfig.gamescopeRelativeMouse(auto, mouseAttached = true, touchpad = false))
|
||||
assertEquals(auto, TouchGestureConfig.gamescopeRelativeMouse(auto, mouseAttached = false, touchpad = true))
|
||||
}
|
||||
|
||||
@Test fun aChosenRelativeMouseModeIsSentAsIs() {
|
||||
for (mode in listOf(TouchGestureConfig.RELATIVE_MOUSE_ON, TouchGestureConfig.RELATIVE_MOUSE_OFF))
|
||||
for (mouse in listOf(false, true)) for (pad in listOf(false, true))
|
||||
assertEquals(mode, TouchGestureConfig.gamescopeRelativeMouse(mode, mouse, pad))
|
||||
}
|
||||
|
||||
@Test fun removedOptionsAreIgnoredWhenLoadingExistingSettings() {
|
||||
val old = """{"holdMouseButtonWhileTouchingEnabled":true,"holdMouseButtonWhileTouchingAction":"right_click",
|
||||
"showClickHighlight":true,"showCursorInTouchscreenMode":true,"tapAction":"key_E","longPressDelay":650}"""
|
||||
|
||||
@@ -0,0 +1,23 @@
|
||||
package com.droiddeck.launcher.input
|
||||
|
||||
import com.droiddeck.launcher.session.SessionPrefs
|
||||
import org.junit.Assert.assertEquals
|
||||
import org.junit.Test
|
||||
|
||||
class TouchRouterTest {
|
||||
@Test fun gesturesAndOffAreNotTouchpadRoutes() {
|
||||
for (mode in listOf(SessionPrefs.TOUCH_GESTURES, SessionPrefs.TOUCH_OFF)) {
|
||||
assertEquals(TouchRouter.Route.GESTURES.takeIf { mode == SessionPrefs.TOUCH_GESTURES } ?: TouchRouter.Route.OFF,
|
||||
TouchRouter.decide(TouchRouter.Snapshot(false, mode, false, false, false)))
|
||||
assertEquals(TouchRouter.Route.GESTURES.takeIf { mode == SessionPrefs.TOUCH_GESTURES } ?: TouchRouter.Route.OFF,
|
||||
TouchRouter.decide(TouchRouter.Snapshot(false, mode, false, true, true)))
|
||||
}
|
||||
}
|
||||
|
||||
@Test fun autoUsesTouchpadOnlyWhenRouterSelectsTouchpad() {
|
||||
assertEquals(TouchRouter.Route.TOUCHPAD,
|
||||
TouchRouter.decide(TouchRouter.Snapshot(false, SessionPrefs.TOUCH_AUTO, false, false, false)))
|
||||
assertEquals(TouchRouter.Route.DIRECT,
|
||||
TouchRouter.decide(TouchRouter.Snapshot(false, SessionPrefs.TOUCH_AUTO, false, true, false)))
|
||||
}
|
||||
}
|
||||
@@ -1,38 +0,0 @@
|
||||
package com.droiddeck.launcher.session
|
||||
|
||||
import org.junit.Assert.assertEquals
|
||||
import org.junit.Assert.assertFalse
|
||||
import org.junit.Assert.assertTrue
|
||||
import org.junit.Test
|
||||
|
||||
class BottomNotificationsTest {
|
||||
private val own = "com.droiddeck.launcher"
|
||||
|
||||
@Test fun messagesFromOtherAppsAreKept() {
|
||||
assertTrue(BottomNotifications.keep(own, "com.discord", ongoing = false, groupSummary = false, title = "Kurt", text = "ready?"))
|
||||
}
|
||||
|
||||
@Test fun ownOngoingSummaryAndEmptyNotificationsAreSkipped() {
|
||||
assertFalse(BottomNotifications.keep(own, own, ongoing = false, groupSummary = false, title = "Session", text = "running"))
|
||||
assertFalse(BottomNotifications.keep(own, "com.spotify", ongoing = true, groupSummary = false, title = "Song", text = "Artist"))
|
||||
assertFalse(BottomNotifications.keep(own, "com.discord", ongoing = false, groupSummary = true, title = "2 messages", text = ""))
|
||||
assertFalse(BottomNotifications.keep(own, "com.discord", ongoing = false, groupSummary = false, title = " ", text = ""))
|
||||
}
|
||||
|
||||
@Test fun anUpdatedNotificationMovesToTheTopOnce() {
|
||||
val list = mutableListOf(
|
||||
BottomNotification("a", "App", "one", "", 1L),
|
||||
BottomNotification("b", "App", "two", "", 2L),
|
||||
)
|
||||
BottomNotifications.add(list, BottomNotification("b", "App", "two again", "", 3L))
|
||||
assertEquals(listOf("b", "a"), list.map { it.key })
|
||||
assertEquals("two again", list.first().title)
|
||||
}
|
||||
|
||||
@Test fun theListKeepsTheNewestThirty() {
|
||||
val list = mutableListOf<BottomNotification>()
|
||||
repeat(35) { BottomNotifications.add(list, BottomNotification("k$it", "App", "t$it", "", it.toLong())) }
|
||||
assertEquals(30, list.size)
|
||||
assertEquals("k34", list.first().key)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,53 @@
|
||||
package com.droiddeck.launcher.session
|
||||
|
||||
import android.content.Intent
|
||||
import org.junit.After
|
||||
import org.junit.Assert.assertEquals
|
||||
import org.junit.Assert.assertNull
|
||||
import org.junit.Before
|
||||
import org.junit.Test
|
||||
|
||||
class SessionLoadingDecisionTest {
|
||||
private var previousSessionId: String? = null
|
||||
private var previousRelaunch: Intent? = null
|
||||
|
||||
@Before
|
||||
fun setUp() {
|
||||
previousSessionId = SessionState.sessionId
|
||||
previousRelaunch = SessionState.relaunch
|
||||
SessionState.clearRelaunch()
|
||||
}
|
||||
|
||||
@After
|
||||
fun tearDown() {
|
||||
SessionState.sessionId = previousSessionId
|
||||
SessionState.relaunch = previousRelaunch
|
||||
SessionState.clearRelaunch()
|
||||
if (previousRelaunch != null) SessionState.queueRelaunch(previousRelaunch!!)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun loadingTimesOutOnlyWhenNoSessionStarted() {
|
||||
assertEquals(false, SessionLoadingDecision.shouldReturnToLauncher(100L, 10_099L, false, SessionPhase.IDLE))
|
||||
assertEquals(true, SessionLoadingDecision.shouldReturnToLauncher(100L, 10_100L, false, SessionPhase.IDLE))
|
||||
assertEquals(false, SessionLoadingDecision.shouldReturnToLauncher(100L, 10_100L, true, SessionPhase.IDLE))
|
||||
assertEquals(false, SessionLoadingDecision.shouldReturnToLauncher(100L, 10_100L, false, SessionPhase.PREPARING))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun relaunchRequiresTheCurrentSessionToHaveRequestedTheStop() {
|
||||
SessionState.sessionId = "session-a"
|
||||
val next = Intent().apply { action = "next" }
|
||||
SessionState.queueRelaunch(next)
|
||||
assertNull(SessionState.takeRelaunch())
|
||||
SessionState.queueRelaunch(next)
|
||||
SessionState.confirmRelaunchStop()
|
||||
assertEquals(next, SessionState.takeRelaunch())
|
||||
assertNull(SessionState.takeRelaunch())
|
||||
|
||||
SessionState.queueRelaunch(next)
|
||||
SessionState.confirmRelaunchStop()
|
||||
SessionState.sessionId = "session-b"
|
||||
assertNull(SessionState.takeRelaunch())
|
||||
}
|
||||
}
|
||||
@@ -15,16 +15,12 @@ class GogCdnTest {
|
||||
{"endpoint_name":"lumen","url_format":"https://cdn.gog.com/{path}","parameters":{}}
|
||||
]}"""
|
||||
|
||||
@Test fun allHostsAreParsedInOrderAndTakenInTurn() {
|
||||
@Test fun allHostsAreParsedInOrder() {
|
||||
val all = GogDownloadManager.parseCdnUrls(answer)
|
||||
assertEquals(3, all.size)
|
||||
assertEquals("https://gog-cdn-fastly.gog.com/token=nva=1~token=t", all[0])
|
||||
assertEquals("https://gog-cdn-akamai.gog.com/s", all[1])
|
||||
assertEquals("https://cdn.gog.com", all[2])
|
||||
val set = all.joinToString("\n")
|
||||
assertEquals(all[0], GogDownloadManager.firstCdn(set))
|
||||
val picks = (0 until 6).map { GogDownloadManager.pickCdn(set) }.toSet()
|
||||
assertEquals(all.toSet(), picks)
|
||||
assertEquals(0, GogDownloadManager.parseCdnUrls("not json").size)
|
||||
}
|
||||
}
|
||||
@@ -26,6 +26,45 @@ import java.util.concurrent.atomic.AtomicBoolean
|
||||
class GogMaintenanceTest {
|
||||
@get:Rule val temporary = TemporaryFolder()
|
||||
|
||||
@Test fun ownedDlcDepotIsSelected() {
|
||||
val allowed = GogContent.installProducts("1", JSONArray("[{\"gameIDs\":[\"2\"]}]"), emptyList(), "unused") { setOf("2") }
|
||||
assertEquals(setOf("1", "2"), allowed)
|
||||
}
|
||||
|
||||
@Test fun unownedDlcDepotIsSkipped() {
|
||||
val allowed = GogContent.installProducts("1", JSONArray("[{\"productId\":\"2\"}]"), emptyList(), "unused") { emptySet() }
|
||||
assertEquals(setOf("1"), allowed)
|
||||
}
|
||||
|
||||
@Test fun ownershipFetchFailureFallsBackToBaseOnly() {
|
||||
val allowed = GogContent.installProducts("1", JSONArray("[{\"productId\":\"2\"}]"), emptyList(), "unused") { null }
|
||||
assertEquals(setOf("1"), allowed)
|
||||
}
|
||||
|
||||
@Test fun installManifestCredentialsAndTempExecutableAreExtractedBeforeSanitizing() {
|
||||
val manifest = JSONObject("""{"clientId":"client","clientSecret":"secret","products":[{"temp_executable":"bin/game.exe"}]}""")
|
||||
assertEquals("client" to "secret", GogContent.clientCredentials(manifest))
|
||||
assertEquals("bin/game.exe", GogContent.tempExecutable(GogContent.sanitize(manifest)))
|
||||
assertFalse(GogContent.sanitize(manifest).toString().contains("secret"))
|
||||
}
|
||||
|
||||
@Test fun emptyProductIdDepotUsesBaseAndDlcFilesKeepTheirProduct() {
|
||||
val build = GogBuild("1", "", 2, "", "https://example.invalid/build")
|
||||
val snapshot = GogContent.snapshot(GogInstallRecord("1", "depot"), build, "unused", ownership = { setOf("2") }, metadata = { url, _ ->
|
||||
when (url) {
|
||||
build.link -> JSONObject("{\"depots\":[{\"gameIDs\":[\"1\"],\"manifest\":\"aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa\"},{\"gameIDs\":[\"2\"],\"manifest\":\"bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb\"},{\"productId\":\"\",\"manifest\":\"cccccccccccccccccccccccccccccccc\"}]}")
|
||||
"https://gog-cdn-fastly.gog.com/content-system/v2/meta/aa/aa/aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa" -> JSONObject("{\"depot\":{\"items\":[{\"path\":\"base.bin\",\"chunks\":[{\"size\":1}]}]}}")
|
||||
"https://gog-cdn-fastly.gog.com/content-system/v2/meta/bb/bb/bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb" -> JSONObject("{\"depot\":{\"items\":[{\"path\":\"dlc.bin\",\"chunks\":[{\"size\":1}]}]}}")
|
||||
"https://gog-cdn-fastly.gog.com/content-system/v2/meta/cc/cc/cccccccccccccccccccccccccccccccc" -> JSONObject("{\"depot\":{\"items\":[{\"path\":\"empty.bin\",\"chunks\":[{\"size\":1}]}]}}")
|
||||
else -> error("Unexpected metadata: $url")
|
||||
}
|
||||
})
|
||||
assertEquals(setOf("1", "2"), snapshot.products.toSet())
|
||||
assertEquals("1", snapshot.files.getJSONObject(0).getString("product"))
|
||||
assertEquals("2", snapshot.files.getJSONObject(1).getString("product"))
|
||||
assertEquals("1", snapshot.files.getJSONObject(2).getString("product"))
|
||||
}
|
||||
|
||||
@Test fun selectsPublicBranchAndTreatsBuildIdsAsOpaque() {
|
||||
val builds = JSONObject("""{"items":[
|
||||
{"os":"windows","branch":"beta","build_id":"99","generation":2,"link":"https://example.invalid/beta"},
|
||||
@@ -213,7 +252,7 @@ class GogMaintenanceTest {
|
||||
fun fixture(name: String) = JSONObject(javaClass.classLoader!!.getResource("gog/$name")!!.readText())
|
||||
val build = GogBuild("3301", "", 1, "", "https://example.invalid/repository.json")
|
||||
val requested = ArrayList<String>()
|
||||
val snapshot = GogContent.snapshot(GogInstallRecord("1207658940", "depot"), build, "unused") { url, _ ->
|
||||
val snapshot = GogContent.snapshot(GogInstallRecord("1207658940", "depot"), build, "unused", metadata = { url, _ ->
|
||||
requested.add(url)
|
||||
when {
|
||||
url == build.link -> fixture("rayman-gen1-repository.json")
|
||||
@@ -221,7 +260,7 @@ class GogMaintenanceTest {
|
||||
url.endsWith("ec88140c-2589-41a0-bbf3-8fc1f215fe5a.json") -> fixture("rayman-gen1-english-excerpt.json")
|
||||
else -> error("Unexpected metadata: $url")
|
||||
}
|
||||
}
|
||||
})
|
||||
assertEquals("3301", snapshot.build)
|
||||
assertEquals(1, snapshot.generation)
|
||||
assertEquals(4, snapshot.files.length())
|
||||
@@ -269,6 +308,17 @@ class GogMaintenanceTest {
|
||||
assertThrows(IllegalArgumentException::class.java) { GogFiles.inside(folder, "link/game.exe") }
|
||||
}
|
||||
|
||||
@Test fun resumedInstallSpaceCountsOnlyFilesStillMissing() {
|
||||
val folder = temporary.newFolder()
|
||||
val done = File(folder, "done.bin").apply { writeText("verified") }
|
||||
val files = JSONArray()
|
||||
.put(JSONObject().put("path", done.name).put("size", done.length()).put("md5", GogFiles.hash(done)))
|
||||
.put(JSONObject().put("path", "missing.bin").put("size", 12L).put("md5", "0".repeat(32)))
|
||||
val missingOnly = GogInstallRecord("1", "depot", files = JSONArray().put(files.getJSONObject(1)))
|
||||
fun required(target: GogInstallRecord) = GogEstimate.calculate(folder, GogInstallRecord("1", "depot"), target, JSONArray(), reuseChunks = false).required
|
||||
assertEquals(required(missingOnly), required(GogInstallRecord("1", "depot", files = files)))
|
||||
}
|
||||
|
||||
@Test fun sameSizeCorruptionFailsVerificationAndLanguageVariantsRemainDistinct() {
|
||||
val folder = temporary.newFolder()
|
||||
val file = File(folder, "game.bin").apply { writeText("original") }
|
||||
|
||||
@@ -1,43 +0,0 @@
|
||||
package com.droiddeck.launcher.stores.gog
|
||||
|
||||
import java.io.File
|
||||
import java.security.MessageDigest
|
||||
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
|
||||
import org.robolectric.shadows.ShadowStatFs
|
||||
import com.droiddeck.launcher.stores.StoreInstallRoot
|
||||
|
||||
@RunWith(RobolectricTestRunner::class)
|
||||
@Config(sdk = [33])
|
||||
class GogResumeSpaceTest {
|
||||
@Test fun remainingWorkRejectsMeasuredZeroButNotUnknownSpace() {
|
||||
val root = File(RuntimeEnvironment.getApplication().filesDir, "gog-zero").apply { mkdirs() }
|
||||
ShadowStatFs.registerStats(root.path, 1024, 0, 0)
|
||||
val files = listOf(GogDownloadManager.DepotFile("missing").apply { totalSize = 4 })
|
||||
val needed = GogDownloadManager.remainingInstallBytes(files, root)
|
||||
assertEquals(4L, needed)
|
||||
assertTrue(StoreInstallRoot.insufficientSpace(needed, StoreInstallRoot.availableBytes(root)))
|
||||
assertFalse(StoreInstallRoot.insufficientSpace(needed, StoreInstallRoot.SPACE_UNKNOWN))
|
||||
}
|
||||
|
||||
@Test fun remainingSpaceExcludesVerifiedFilesButIncludesCorruptAndPartialFiles() {
|
||||
val root = File(RuntimeEnvironment.getApplication().filesDir, "gog-resume").apply { mkdirs() }
|
||||
val data = byteArrayOf(1, 2, 3, 4)
|
||||
val hash = MessageDigest.getInstance("MD5").digest(data).joinToString("") { "%02x".format(it) }
|
||||
val files = listOf("good", "corrupt", "partial", "missing").map {
|
||||
GogDownloadManager.DepotFile(it).apply { totalSize = 4; md5 = hash }
|
||||
}
|
||||
File(root, "good").writeBytes(data)
|
||||
File(root, "corrupt").writeBytes(ByteArray(4))
|
||||
File(root, "partial").writeBytes(byteArrayOf(1))
|
||||
assertEquals(12L, GogDownloadManager.remainingInstallBytes(files, root))
|
||||
for (file in files) File(root, file.relativePath).writeBytes(data)
|
||||
assertEquals(0L, GogDownloadManager.remainingInstallBytes(files, root))
|
||||
}
|
||||
}
|
||||
@@ -16,7 +16,7 @@ class BottomDashboardTest {
|
||||
}
|
||||
|
||||
@Test fun withNothingPickedTheStatsOpen() {
|
||||
val ids = listOf("telemetry", "downloads", "notifications", "screenshots", "chat")
|
||||
val ids = listOf("telemetry", "downloads", "screenshots", "chat")
|
||||
assertEquals("telemetry", pickBottomPage(null, ids))
|
||||
assertEquals("telemetry", pickBottomPage("gone", ids))
|
||||
assertEquals("chat", pickBottomPage("chat", ids))
|
||||
|
||||
+32
-1
@@ -9,6 +9,7 @@ import gzip
|
||||
import json
|
||||
import os
|
||||
import re
|
||||
import shlex
|
||||
import shutil
|
||||
import subprocess
|
||||
import struct
|
||||
@@ -98,6 +99,29 @@ def encode_request(request: dict) -> str:
|
||||
return encoded
|
||||
|
||||
|
||||
def receiver_call(adb: str, serial: str, method: str, arg: str | None, request: dict | None, timeout: float) -> dict:
|
||||
"""The agent bridge through a broadcast, for shells without the `content` command."""
|
||||
call_id = uuid.uuid4().hex[:16]
|
||||
remote = f"/sdcard/Android/data/{PACKAGE}/files/agent-calls/{call_id}.json"
|
||||
command = ["shell", "am", "broadcast", "-a", f"{PACKAGE}.AGENT_CALL", "-p", PACKAGE,
|
||||
"--es", "method", method, "--es", "id", call_id]
|
||||
if arg is not None:
|
||||
command += ["--es", "arg", shlex.quote(arg)]
|
||||
if request is not None:
|
||||
command += ["--es", "request", encode_request(request)]
|
||||
started = run_adb(adb, serial, *command, timeout=timeout + 30)
|
||||
if started.returncode != 0:
|
||||
raise ControlError((started.stdout + started.stderr).decode(errors="replace").strip() or "ADB broadcast failed", "ADB_FAILED")
|
||||
deadline = time.monotonic() + timeout
|
||||
while time.monotonic() < deadline:
|
||||
result = run_adb(adb, serial, "shell", f"cat {remote} 2>/dev/null && rm -f {remote}", timeout=30)
|
||||
text = result.stdout.decode(errors="replace").strip()
|
||||
if result.returncode == 0 and text:
|
||||
return json.loads(text)
|
||||
time.sleep(0.25)
|
||||
raise ControlError(f"No answer to {method} within {timeout:g} s", "TIMEOUT")
|
||||
|
||||
|
||||
def provider_call(adb: str, serial: str, method: str, arg: str | None = None, request: dict | None = None,
|
||||
timeout: float = 30) -> dict:
|
||||
command = ["shell", "content", "call", "--uri", AUTHORITY, "--method", method]
|
||||
@@ -108,6 +132,8 @@ def provider_call(adb: str, serial: str, method: str, arg: str | None = None, re
|
||||
command.extend(["--extra", f"request:s:{encoded}"])
|
||||
result = run_adb(adb, serial, *command, timeout=timeout)
|
||||
output = (result.stdout + result.stderr).decode(errors="replace")
|
||||
if "content: inaccessible or not found" in output:
|
||||
return receiver_call(adb, serial, method, arg, request, timeout)
|
||||
response = _content_result(output)
|
||||
if result.returncode != 0 and response.get("ok") is not False:
|
||||
raise ControlError(output.strip() or "ADB content call failed", "ADB_FAILED")
|
||||
@@ -237,10 +263,15 @@ def displays(adb: str, serial: str) -> list[dict]:
|
||||
device = re.search(r"mPrimaryDisplayDevice=(.+)", block)
|
||||
size = re.search(r"real (\d+) x (\d+)", block)
|
||||
name = device.group(1).strip() if device else None
|
||||
physical = devices.get(name) if name else None
|
||||
if name and not physical:
|
||||
# Some ROMs (Retroid Pocket Duo) print the device as "Built-in Screen(local:<id>)"
|
||||
inline = re.search(r"\(local:(\d+)\)$", name)
|
||||
physical = inline.group(1) if inline else None
|
||||
out.append({
|
||||
"display": logical,
|
||||
"name": name,
|
||||
"physicalId": devices.get(name) if name else None,
|
||||
"physicalId": physical,
|
||||
"size": [int(size.group(1)), int(size.group(2))] if size else None,
|
||||
})
|
||||
return out
|
||||
|
||||
@@ -927,6 +927,33 @@ def steam_shortcut_state(agent_dir, game_id, timeout=6):
|
||||
time.sleep(min(0.5, max(0, deadline - time.monotonic())))
|
||||
|
||||
|
||||
def steam_ready(agent_dir):
|
||||
expression = """(() => {
|
||||
const store = globalThis.appStore;
|
||||
return !!store?.m_bIsInitialized && typeof globalThis.SteamClient?.Apps?.ContinueGameAction === 'function';
|
||||
})()"""
|
||||
return cdp_eval(agent_dir, expression, await_promise=False, timeout=3).get("value") is True
|
||||
|
||||
|
||||
def steam_clear_pending_interstitial(agent_dir, app_id):
|
||||
if not isinstance(app_id, str) or not app_id.isascii() or not app_id.isdigit() or int(app_id) <= 0:
|
||||
raise AgentError("INVALID_APP_ID", "appId must be a positive decimal Steam app id")
|
||||
expression = """(async () => {
|
||||
const apps = globalThis.SteamClient?.Apps;
|
||||
if (typeof apps?.GetActiveGameActions !== 'function' || typeof apps.ContinueGameAction !== 'function') return false;
|
||||
const active = await apps.GetActiveGameActions() || [];
|
||||
for (const action of active) {
|
||||
const appid = String(action?.strAppID ?? action?.gameid ?? action?.nAppID ?? '');
|
||||
if (appid === %s && action?.strTaskName === 'ShowInterstitials') {
|
||||
apps.ContinueGameAction(action.nGameActionID, appid, 'ShowInterstitials');
|
||||
return true;
|
||||
}
|
||||
}
|
||||
return false;
|
||||
})()""" % json.dumps(app_id)
|
||||
return cdp_eval(agent_dir, expression, timeout=3).get("value") is True
|
||||
|
||||
|
||||
def game_pids(app_id):
|
||||
wanted = [("SteamAppId=%s" % app_id).encode(), ("SteamGameId=%s" % app_id).encode()]
|
||||
me = os.getpid()
|
||||
@@ -1261,6 +1288,19 @@ def main(argv):
|
||||
return 1
|
||||
print(state)
|
||||
return {"ready": 0, "missing": 4, "not-shortcut": 3}.get(state, 1)
|
||||
if argv and argv[0] == "--steam-ready":
|
||||
try:
|
||||
return 0 if steam_ready(Path(base) / "agent") else 1
|
||||
except (AgentError, OSError, ValueError):
|
||||
return 1
|
||||
if argv and argv[0] == "--steam-clear-pending-interstitial":
|
||||
if len(argv) != 2:
|
||||
print("usage: droiddeck-agent --steam-clear-pending-interstitial <appid>", file=sys.stderr)
|
||||
return 2
|
||||
try:
|
||||
return 0 if steam_clear_pending_interstitial(Path(base) / "agent", argv[1]) else 1
|
||||
except (AgentError, OSError, ValueError):
|
||||
return 1
|
||||
serve(Path(base) / "agent", focus="--no-focus" not in argv)
|
||||
return 0
|
||||
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
#!/bin/bash
|
||||
set -u
|
||||
export FLATPAK_BWRAP=/usr/local/bin/droiddeck-bwrap
|
||||
unset MESA_LOADER_DRIVER_OVERRIDE GALLIUM_DRIVER LIBGL_KOPPER_DRI2 VK_ICD_FILENAMES VK_DRIVER_FILES
|
||||
case ":${XDG_DATA_DIRS:-}:" in
|
||||
*"/flatpak/exports/share:"*) ;;
|
||||
*) export XDG_DATA_DIRS=$HOME/.local/share/flatpak/exports/share:${XDG_DATA_DIRS:-/usr/local/share:/usr/share} ;;
|
||||
|
||||
@@ -333,12 +333,12 @@ def engine_fixes(exe, args):
|
||||
return env, extra, notes
|
||||
|
||||
|
||||
def win_components(compat, app_id):
|
||||
def win_components(compat, app_id, proton=None, env=None):
|
||||
if not compat:
|
||||
return ""
|
||||
try:
|
||||
module = runpy.run_path(str(Path(__file__).resolve().parent / "droiddeck-wincomponents"))
|
||||
names, notes = module["apply"](compat.rstrip("/"), app_id)
|
||||
names, notes = module["apply"](compat.rstrip("/"), app_id, proton=proton, env=env or os.environ)
|
||||
for note in notes:
|
||||
print("droiddeck-game-env: " + note, file=sys.stderr)
|
||||
return module["overrides_value"](names)
|
||||
@@ -859,6 +859,60 @@ WINE_DLL_DIRS = ("/lib/wine/i386-windows", "/lib/wine/x86_64-windows", "/lib/win
|
||||
"/lib/wine/fakedlls", "/lib64/wine", "/lib64/wine/fakedlls", "/lib64/wine/x86_64-windows")
|
||||
|
||||
|
||||
FOREIGN_SUFFIX = ".foreign-arch"
|
||||
PE = runpy.run_path(str(Path(__file__).resolve().parent / "droiddeck-wine-pe"))
|
||||
pe_machine = PE["pe_machine"]
|
||||
|
||||
|
||||
def repair_foreign_system_dlls(prefix, proton, env=None):
|
||||
dist = Path(proton).parent / "files/lib/wine"
|
||||
windows = Path(prefix) / "pfx/drive_c/windows"
|
||||
machines_by_folder = PE["proton_pe_machines"](proton, env or {})
|
||||
if machines_by_folder is None:
|
||||
return []
|
||||
repaired = []
|
||||
for folder, (libdir, machines) in machines_by_folder.items():
|
||||
builtins = dist / libdir
|
||||
try:
|
||||
builtin_names = {name.lower(): name for name in os.listdir(builtins)}
|
||||
except OSError:
|
||||
continue
|
||||
try:
|
||||
names = sorted(os.listdir(windows / folder))
|
||||
except OSError:
|
||||
continue
|
||||
for name in names:
|
||||
path = windows / folder / name
|
||||
builtin_name = builtin_names.get(name.lower())
|
||||
source = builtins / builtin_name if builtin_name else builtins / name
|
||||
if path.is_symlink() or not name.lower().endswith(".dll") or not source.is_file() or not path.is_file():
|
||||
continue
|
||||
machine = pe_machine(path)
|
||||
if machine is None or PE["machine_compatible"](path, machines, machine):
|
||||
continue
|
||||
link = windows / folder / (name + ".bl-new")
|
||||
backup = windows / folder / (name + FOREIGN_SUFFIX)
|
||||
serial = 1
|
||||
while os.path.lexists(backup):
|
||||
backup = windows / folder / (name + FOREIGN_SUFFIX + ".%d" % serial)
|
||||
serial += 1
|
||||
try:
|
||||
os.symlink(os.path.relpath(source, path.parent), link)
|
||||
os.replace(path, backup)
|
||||
try:
|
||||
os.replace(link, path)
|
||||
except OSError:
|
||||
if not os.path.lexists(path) and os.path.lexists(backup):
|
||||
os.replace(backup, path)
|
||||
raise
|
||||
except OSError:
|
||||
if link.is_symlink():
|
||||
link.unlink()
|
||||
continue
|
||||
repaired.append("%s/%s (machine 0x%x)" % (folder, name, machine))
|
||||
return repaired
|
||||
|
||||
|
||||
def is_wine_dll_link(path):
|
||||
try:
|
||||
return os.path.islink(path) and os.path.dirname(os.readlink(path)).endswith(WINE_DLL_DIRS)
|
||||
@@ -1156,6 +1210,42 @@ def main():
|
||||
env = game_gpu(os.environ.copy())
|
||||
mic_enabled = env.get("BL_MIC_ENABLED")
|
||||
debug_dir = env.get("BL_DEBUG_DIR", "")
|
||||
prefix_path = env.get("STEAM_COMPAT_DATA_PATH", "").rstrip("/")
|
||||
app_id = game_id(env)
|
||||
components = ""
|
||||
waiting = bool(app_id and len(command) > 1 and command[1] == "waitforexitandrun")
|
||||
config = {}
|
||||
launch_config = None
|
||||
launch_config_valid = True
|
||||
effective_env = env
|
||||
if waiting:
|
||||
path = Path.home() / ".config/droiddeck/game-environment.json"
|
||||
try:
|
||||
with path.open(encoding="utf-8") as source:
|
||||
config = json.load(source)
|
||||
launch_config = config
|
||||
except FileNotFoundError:
|
||||
launch_config = {"version": 1}
|
||||
except (OSError, ValueError, TypeError, AttributeError):
|
||||
launch_config_valid = False
|
||||
if launch_config_valid:
|
||||
try:
|
||||
effective_env = apply_config(env, launch_config, app_id)
|
||||
except (OSError, ValueError, TypeError, AttributeError):
|
||||
launch_config_valid = False
|
||||
if not launch_config_valid:
|
||||
print("droiddeck-game-env: invalid configuration; using inherited environment", file=sys.stderr)
|
||||
if waiting:
|
||||
components = win_components(prefix_path, app_id, command[0], effective_env)
|
||||
if prefix_path and command:
|
||||
try:
|
||||
fixed = repair_foreign_system_dlls(prefix_path, command[0], effective_env)
|
||||
except OSError as error:
|
||||
fixed = []
|
||||
print("droiddeck-game-env: prefix system DLL check skipped (%s)" % error, file=sys.stderr)
|
||||
if fixed:
|
||||
print("droiddeck-game-env: replaced foreign-architecture system DLLs with this Proton's own: %s" % ", ".join(fixed),
|
||||
file=sys.stderr)
|
||||
scripts = mark_install_scripts(command, env)
|
||||
if scripts and install_scripts_done(scripts, env):
|
||||
print("droiddeck-game-env: install scripts already run in this prefix; evaluator skipped", file=sys.stderr)
|
||||
@@ -1164,8 +1254,6 @@ def main():
|
||||
digest = scripts_digest(scripts) if scripts else None
|
||||
except OSError:
|
||||
digest = None
|
||||
app_id = game_id(env)
|
||||
waiting = bool(app_id and len(command) > 1 and command[1] == "waitforexitandrun")
|
||||
diagnostic_app_id = app_id if waiting and os.environ.get("DROIDDECK_ENHANCED_DIAGNOSTICS") == "1" else None
|
||||
dirt_prefix = None
|
||||
if app_id:
|
||||
@@ -1183,7 +1271,6 @@ def main():
|
||||
for note in notes:
|
||||
print("droiddeck-game-env: " + note, file=sys.stderr)
|
||||
command += extra
|
||||
components = win_components(env.get("STEAM_COMPAT_DATA_PATH", ""), app_id)
|
||||
if components:
|
||||
own = fixes.get("WINEDLLOVERRIDES")
|
||||
fixes["WINEDLLOVERRIDES"] = components + ";" + own if own else components
|
||||
@@ -1191,22 +1278,21 @@ def main():
|
||||
if dirt_prefix:
|
||||
own = fixes.get("WINEDLLOVERRIDES")
|
||||
fixes["WINEDLLOVERRIDES"] = own + ";ws2_32=n,b" if own else "ws2_32=n,b"
|
||||
path = Path.home() / ".config/droiddeck/game-environment.json"
|
||||
config = {}
|
||||
try:
|
||||
with path.open(encoding="utf-8") as source:
|
||||
config = json.load(source)
|
||||
if waiting:
|
||||
if waiting:
|
||||
if launch_config_valid:
|
||||
if "vblank_mode" in config.get("shared", {}):
|
||||
fixes.pop("vblank_mode", None)
|
||||
env = apply_config(env, config, app_id, fixes)
|
||||
else:
|
||||
env = apply_config(env, launch_config, app_id, fixes)
|
||||
else:
|
||||
path = Path.home() / ".config/droiddeck/game-environment.json"
|
||||
try:
|
||||
with path.open(encoding="utf-8") as source:
|
||||
config = json.load(source)
|
||||
env = apply_config(env, sync_config(config, app_id), app_id)
|
||||
except FileNotFoundError:
|
||||
if waiting:
|
||||
env = apply_config(env, {"version": 1}, app_id, fixes)
|
||||
except (OSError, ValueError, TypeError, AttributeError):
|
||||
print("droiddeck-game-env: invalid configuration; using inherited environment", file=sys.stderr)
|
||||
except FileNotFoundError:
|
||||
pass
|
||||
except (OSError, ValueError, TypeError, AttributeError):
|
||||
print("droiddeck-game-env: invalid configuration; using inherited environment", file=sys.stderr)
|
||||
if waiting:
|
||||
before_debug = env
|
||||
env = apply_game_debug_logs(env, config, debug_dir)
|
||||
|
||||
@@ -902,21 +902,21 @@ case $mode in
|
||||
bl_session_pressure() {
|
||||
bl_memory_read
|
||||
[ "$_bl_game" = 1 ] && bl_low_ram && bl_memory_critical || return 0
|
||||
local _wait=$_bl_tier_backoff _urgent=0 _before _pids _targets
|
||||
[ "$_bl_available" -ge $((_bl_headroom / 2)) ] || _urgent=1 _wait=30
|
||||
local _wait=$_bl_tier_backoff _before _pids _targets
|
||||
[ "$_bl_available" -ge $((_bl_headroom / 2)) ] || [ "$_bl_tier_pushed" -lt 0 ] || _wait=30
|
||||
[ "$_bl_tier_at" -lt 0 ] || [ $((SECONDS - _bl_tier_at)) -ge "$_wait" ] || return 0
|
||||
if [ "$_bl_tier_at" -ge 0 ] && [ "$_bl_tier_pushed" -gt 0 ]; then
|
||||
bl_tier_readback
|
||||
if [ "$_bl_tier_back" -le $((_bl_tier_pushed / 2)) ]; then
|
||||
_bl_tier_backoff=30
|
||||
elif [ "$_urgent" = 0 ]; then
|
||||
else
|
||||
_bl_tier_backoff=$((_bl_tier_backoff * 2 > 300 ? 300 : _bl_tier_backoff * 2))
|
||||
_bl_tier_at=$SECONDS _bl_tier_pushed=0
|
||||
_bl_tier_at=$SECONDS _bl_tier_pushed=-1
|
||||
step "zRAM: the game read back most of its last page-out, next try in ${_bl_tier_backoff} s"
|
||||
return 0
|
||||
fi
|
||||
fi
|
||||
_bl_tier_at=$SECONDS
|
||||
_bl_tier_at=$SECONDS _bl_tier_pushed=0
|
||||
bl_zram_pick game
|
||||
bl_zram_usage "${_pids[@]}" > /dev/null
|
||||
[ "$_bl_rss" -ge $((256 * 1024)) ] || return 0
|
||||
@@ -1244,7 +1244,14 @@ case $mode in
|
||||
sleep 1
|
||||
done
|
||||
[ "${#game}" -gt 10 ] && shortcut=1
|
||||
[ -n "$shortcut" ] || sleep 4
|
||||
_bl_ready_end=$((SECONDS + 30))
|
||||
while [ "$waited" -lt 30 ] && [ "$((_bl_ready_end - SECONDS))" -gt 0 ]; do
|
||||
timeout "$((_bl_ready_end - SECONDS))" /usr/local/bin/droiddeck-agent --steam-ready >/dev/null 2>&1 && break
|
||||
[ "$((_bl_ready_end - SECONDS))" -gt 1 ] || break
|
||||
sleep 1
|
||||
waited=$((waited + 1))
|
||||
done
|
||||
waited=0
|
||||
echo "== steam: interface up; launching the requested game $game"
|
||||
_bl_hand_off
|
||||
[ -n "$shortcut" ] || return 0
|
||||
@@ -1264,7 +1271,9 @@ case $mode in
|
||||
elif _bl_starting; then
|
||||
echo "== steam: the requested game started while waiting; not launching again"
|
||||
elif [ "$(_bl_focused)" = 769 ]; then
|
||||
echo "== steam: launching the requested game again $game (overview status $status)"
|
||||
echo "== steam: clearing the pending launch interstitial for $game (overview status $status)"
|
||||
/usr/local/bin/droiddeck-agent --steam-clear-pending-interstitial "$(((game >> 32) & 0xffffffff))" >/dev/null 2>&1 && return 0
|
||||
echo "== steam: launching the requested game again $game"
|
||||
_bl_hand_off
|
||||
else
|
||||
echo "== steam: the game took focus while waiting; not launching again"
|
||||
|
||||
@@ -760,18 +760,50 @@ class Watcher:
|
||||
def __init__(self, files):
|
||||
self.files = files
|
||||
self.fd = -1
|
||||
self.libc = None
|
||||
wanted = {os.path.dirname(f) for f in files}
|
||||
self.missing = {d for d in wanted if not os.path.isdir(d)}
|
||||
self.watched = set()
|
||||
try:
|
||||
libc = ctypes.CDLL(None, use_errno=True)
|
||||
fd = libc.inotify_init1(os.O_NONBLOCK | os.O_CLOEXEC)
|
||||
dirs = sorted({os.path.dirname(f) for f in files if os.path.isdir(os.path.dirname(f))})
|
||||
dirs = sorted((wanted - self.missing) | {self.existing_parent(d) for d in self.missing} - {""})
|
||||
if fd >= 0 and dirs and all(libc.inotify_add_watch(fd, d.encode(), self.MASK) >= 0 for d in dirs):
|
||||
self.watched = set(dirs)
|
||||
self.fd = fd
|
||||
self.libc = libc
|
||||
elif fd >= 0:
|
||||
os.close(fd)
|
||||
except (OSError, AttributeError):
|
||||
self.fd = -1
|
||||
self.stamp = self.snapshot()
|
||||
|
||||
@staticmethod
|
||||
def existing_parent(path):
|
||||
while path and not os.path.isdir(path):
|
||||
parent = os.path.dirname(path)
|
||||
if parent == path:
|
||||
return ""
|
||||
path = parent
|
||||
return path
|
||||
|
||||
def watch_new_dirs(self):
|
||||
appeared = False
|
||||
progressed = True
|
||||
while progressed:
|
||||
progressed = False
|
||||
for d in sorted(self.missing):
|
||||
parent = d if os.path.isdir(d) else self.existing_parent(d)
|
||||
if not parent or parent in self.watched:
|
||||
continue
|
||||
if self.libc.inotify_add_watch(self.fd, parent.encode(), self.MASK) >= 0:
|
||||
self.watched.add(parent)
|
||||
progressed = True
|
||||
appeared = appeared or parent == d
|
||||
for d in [d for d in self.missing if d in self.watched]:
|
||||
self.missing.discard(d)
|
||||
return appeared
|
||||
|
||||
def snapshot(self):
|
||||
stamp = []
|
||||
for f in self.files:
|
||||
@@ -803,8 +835,9 @@ class Watcher:
|
||||
at += 16 + size
|
||||
except BlockingIOError:
|
||||
pass
|
||||
appeared = self.watch_new_dirs() if self.missing else False
|
||||
wanted = {os.path.basename(f) for f in self.files}
|
||||
return any(n in wanted or n.startswith("appmanifest_") for n in names)
|
||||
return appeared or any(n in wanted or n.startswith("appmanifest_") for n in names)
|
||||
|
||||
|
||||
class XMessage(ctypes.Structure):
|
||||
@@ -1084,6 +1117,7 @@ class Helper:
|
||||
installed = COMPAT["installed_apps"](self.steam_root)
|
||||
self.installed = installed
|
||||
picks = COMPAT["valve_picks"](self.steam_root)
|
||||
tools = COMPAT["tool_platforms"](self.steam_root)
|
||||
prior_auto = state.get("auto", {})
|
||||
if not isinstance(prior_auto, dict):
|
||||
prior_auto = {}
|
||||
@@ -1091,7 +1125,7 @@ class Helper:
|
||||
native_choices = set(prior_native if isinstance(prior_native, (list, tuple, set)) else ())
|
||||
picked = COMPAT["picked_apps"](state)
|
||||
runnable = {t["name"].lower() for t in live}
|
||||
apps = sorted(set(installed) | set(picks) | set(prior_auto) | native_choices, key=COMPAT["app_order"])
|
||||
apps = sorted(set(installed) | set(picks) | set(prior_auto) | native_choices | set(tools), key=COMPAT["app_order"])
|
||||
asked = [app for app, request in COMPAT["game_requests"]().items() if COMPAT["valid_game_request"](app, request)]
|
||||
apps = sorted(set(apps) | set(asked), key=COMPAT["app_order"])
|
||||
current = dict(page.compat("many(%s)" % json.dumps(apps)) or {})
|
||||
@@ -1105,6 +1139,7 @@ class Helper:
|
||||
if isinstance(state.get("auto"), dict):
|
||||
state["auto"] = prior_auto
|
||||
native_choices -= refused
|
||||
tools = {app: platforms for app, platforms in tools.items() if app not in refused}
|
||||
current["0"] = top["selected"]
|
||||
native = COMPAT["native_targets"](self.steam_root, apps)
|
||||
raw_mapping = COMPAT["read_config_mapping"](
|
||||
@@ -1137,7 +1172,7 @@ class Helper:
|
||||
changes, auto = COMPAT["plan_mapping"](
|
||||
current, installed, listed, default,
|
||||
COMPAT["followers"](state, current), native, picks, overrides,
|
||||
prior_auto.keys(), native_choices)
|
||||
prior_auto.keys(), native_choices, tools)
|
||||
changes = dict(moved, **changes)
|
||||
for app, tool in sorted(changes.items(), key=lambda item: COMPAT["app_order"](item[0])):
|
||||
page.compat("set(%d, %s)" % (int(app), json.dumps(tool if tool is not None else "")))
|
||||
@@ -1301,7 +1336,8 @@ def main():
|
||||
failures += 1
|
||||
time.sleep(min(2 * max(failures, 1), 10))
|
||||
os.makedirs(COMPAT["COMPAT_DIR"], exist_ok=True)
|
||||
watcher = Watcher([COMPAT["state_path"]("request.json"), COMPAT["state_path"]("games.json")] +
|
||||
watcher = Watcher([COMPAT["state_path"]("request.json"), COMPAT["state_path"]("games.json"),
|
||||
os.path.join(steam_root, "appcache", "appinfo.vdf")] +
|
||||
[os.path.join(lib, "steamapps", "libraryfolders.vdf") for lib in (steam_root, COMPAT["LIBRARY"] + "-sd")])
|
||||
steamos = os.environ.get("BL_STEAMDECK") == "1" and os.environ.get("BL_STEAM_UI") != "desktop"
|
||||
helper = Helper(steam_root, port, steamos)
|
||||
|
||||
@@ -2,11 +2,17 @@
|
||||
import json
|
||||
import os
|
||||
import re
|
||||
import runpy
|
||||
import shutil
|
||||
import sys
|
||||
import time
|
||||
from pathlib import Path
|
||||
|
||||
PE = runpy.run_path(str(Path(__file__).resolve().parent / "droiddeck-wine-pe"))
|
||||
PE_MACHINES = PE["PE_MACHINES"]
|
||||
pe_machine = PE["pe_machine"]
|
||||
machine_compatible = PE["machine_compatible"]
|
||||
|
||||
STORE = Path("/opt/droiddeck/wincomponents")
|
||||
SELECTION = Path.home() / ".config/droiddeck/wincomponents.json"
|
||||
PENDING = Path.home() / ".config/droiddeck/wincomponents-pending.json"
|
||||
@@ -473,6 +479,15 @@ def save_state(compat, state):
|
||||
os.replace(temp, compat / STATE)
|
||||
|
||||
|
||||
def foreign_component(rel, path, machines):
|
||||
parts = Path(rel).parts
|
||||
folder = parts[2] if len(parts) > 2 and parts[:2] == ("drive_c", "windows") else None
|
||||
if not machines or folder not in PE_MACHINES or not rel.lower().endswith(".dll"):
|
||||
return False
|
||||
machine = pe_machine(path)
|
||||
return machine is not None and not machine_compatible(path, machines[folder][1], machine)
|
||||
|
||||
|
||||
def pending_for(app_id, pending=PENDING):
|
||||
try:
|
||||
with open(pending, encoding="utf-8") as source:
|
||||
@@ -514,7 +529,8 @@ def wait_for_pending(app_id, pending=PENDING, store=STORE, timeout=PENDING_WAIT,
|
||||
return "wincomp: %s waited %dms for %s" % (app_id, waited, ",".join(asked))
|
||||
|
||||
|
||||
def apply(compat_path, app_id, selection=SELECTION, store=STORE, pending=PENDING, wait=wait_for_pending):
|
||||
def apply(compat_path, app_id, selection=SELECTION, store=STORE, pending=PENDING, wait=wait_for_pending,
|
||||
proton=None, env=None):
|
||||
compat = Path(compat_path)
|
||||
pfx = compat / "pfx"
|
||||
waited = wait(app_id, pending, store) if wait else None
|
||||
@@ -527,6 +543,9 @@ def apply(compat_path, app_id, selection=SELECTION, store=STORE, pending=PENDING
|
||||
installed = []
|
||||
values = []
|
||||
held = set()
|
||||
skipped = set()
|
||||
placed_stems = set()
|
||||
machines = PE["proton_pe_machines"](proton, env) if proton else None
|
||||
saved_overrides = state.get("overrides") if isinstance(state.get("overrides"), dict) else {}
|
||||
saved_requires = state.get("requires") if isinstance(state.get("requires"), dict) else {}
|
||||
recorded = [c for c in state.get("components", []) if isinstance(c, str) and valid_id(c)]
|
||||
@@ -566,9 +585,17 @@ def apply(compat_path, app_id, selection=SELECTION, store=STORE, pending=PENDING
|
||||
kept_requires[cid] = list(requires)
|
||||
values += [(cid, value) for value in registry]
|
||||
for rel, src in files:
|
||||
if place(src, pfx / rel, None if rel in state["files"] else compat / BACKUP / rel):
|
||||
foreign = foreign_component(rel, src, machines)
|
||||
if foreign:
|
||||
skipped.add(Path(rel).stem.lower())
|
||||
changed = False
|
||||
else:
|
||||
changed = place(src, pfx / rel, None if rel in state["files"] else compat / BACKUP / rel)
|
||||
placed_stems.add(Path(rel).stem.lower())
|
||||
if changed:
|
||||
copied += 1
|
||||
keep[rel] = cid
|
||||
if not foreign:
|
||||
keep[rel] = cid
|
||||
removed = restored = 0
|
||||
for rel, cid in state["files"].items():
|
||||
if rel in keep:
|
||||
@@ -579,6 +606,10 @@ def apply(compat_path, app_id, selection=SELECTION, store=STORE, pending=PENDING
|
||||
path = existing(pfx / rel)
|
||||
saved = compat / BACKUP / rel
|
||||
if saved.is_file():
|
||||
if foreign_component(rel, saved, machines):
|
||||
if path is not None and not path.is_symlink():
|
||||
path.unlink()
|
||||
continue
|
||||
target = path or (pfx / rel)
|
||||
os.replace(saved, target)
|
||||
restored += 1
|
||||
@@ -590,6 +621,14 @@ def apply(compat_path, app_id, selection=SELECTION, store=STORE, pending=PENDING
|
||||
(compat / "config_info").unlink()
|
||||
except FileNotFoundError:
|
||||
pass
|
||||
def override_name(entry):
|
||||
name = entry.partition("=")[0].strip().lower()
|
||||
return name[:-4] if name.endswith(".dll") else name
|
||||
|
||||
dropped = skipped - placed_stems
|
||||
overrides = [entry for entry in overrides if override_name(entry) not in dropped]
|
||||
for cid in kept_overrides:
|
||||
kept_overrides[cid] = [entry for entry in kept_overrides[cid] if override_name(entry) not in dropped]
|
||||
compat.mkdir(parents=True, exist_ok=True)
|
||||
new_state = {"files": keep, "components": installed + sorted(held), "overrides": kept_overrides,
|
||||
"requires": kept_requires, "registry": state.get("registry", {})}
|
||||
|
||||
+94
@@ -0,0 +1,94 @@
|
||||
#!/usr/bin/python3
|
||||
from pathlib import Path
|
||||
|
||||
|
||||
PE_MACHINES = {
|
||||
"system32": ("aarch64-windows", frozenset((0xAA64, 0xA641))),
|
||||
"syswow64": ("i386-windows", frozenset((0x14C,))),
|
||||
}
|
||||
|
||||
|
||||
def pe_machine(path):
|
||||
try:
|
||||
with open(path, "rb") as image:
|
||||
head = image.read(64)
|
||||
if head[:2] != b"MZ" or len(head) < 64:
|
||||
return None
|
||||
image.seek(int.from_bytes(head[60:64], "little"))
|
||||
header = image.read(6)
|
||||
except OSError:
|
||||
return None
|
||||
if header[:4] != b"PE\0\0" or len(header) < 6:
|
||||
return None
|
||||
return int.from_bytes(header[4:6], "little")
|
||||
|
||||
|
||||
def pe_has_arm64ec_metadata(path):
|
||||
try:
|
||||
with open(path, "rb") as image:
|
||||
image.seek(60)
|
||||
raw = image.read(4)
|
||||
if len(raw) != 4:
|
||||
return False
|
||||
pe_offset = int.from_bytes(raw, "little")
|
||||
image.seek(pe_offset)
|
||||
if image.read(4) != b"PE\0\0":
|
||||
return False
|
||||
coff = image.read(20)
|
||||
if len(coff) != 20:
|
||||
return False
|
||||
section_count = int.from_bytes(coff[2:4], "little")
|
||||
if section_count > 96:
|
||||
return False
|
||||
optional_size = int.from_bytes(coff[16:18], "little")
|
||||
optional_offset = pe_offset + 24
|
||||
image.seek(optional_offset)
|
||||
optional = image.read(optional_size)
|
||||
if len(optional) != optional_size or int.from_bytes(optional[:2], "little") != 0x20B:
|
||||
return False
|
||||
directory_count = int.from_bytes(optional[108:112], "little")
|
||||
directory_offset = 112 + 10 * 8
|
||||
if directory_count <= 10 or directory_offset + 8 > len(optional):
|
||||
return False
|
||||
load_config_rva = int.from_bytes(optional[directory_offset:directory_offset + 4], "little")
|
||||
load_config_size = int.from_bytes(optional[directory_offset + 4:directory_offset + 8], "little")
|
||||
if not load_config_rva or load_config_size < 0xD0:
|
||||
return False
|
||||
section_offset = optional_offset + optional_size
|
||||
for index in range(section_count):
|
||||
image.seek(section_offset + index * 40)
|
||||
section = image.read(40)
|
||||
if len(section) != 40:
|
||||
return False
|
||||
virtual_address = int.from_bytes(section[12:16], "little")
|
||||
raw_size = int.from_bytes(section[16:20], "little")
|
||||
raw_offset = int.from_bytes(section[20:24], "little")
|
||||
relative = load_config_rva - virtual_address
|
||||
if 0 <= relative <= raw_size - 0xD0:
|
||||
image.seek(raw_offset + relative)
|
||||
load_config = image.read(0xD0)
|
||||
if len(load_config) != 0xD0 or int.from_bytes(load_config[:4], "little") < 0xD0:
|
||||
return False
|
||||
return int.from_bytes(load_config[0xC8:0xD0], "little") != 0
|
||||
except (OSError, OverflowError, ValueError):
|
||||
return False
|
||||
return False
|
||||
|
||||
|
||||
def machine_compatible(path, machines, machine=None):
|
||||
machine = pe_machine(path) if machine is None else machine
|
||||
if machine == 0x8664 and pe_has_arm64ec_metadata(path):
|
||||
return 0xA641 in machines
|
||||
return machine in machines
|
||||
|
||||
|
||||
def proton_pe_machines(proton, env=None):
|
||||
dist = Path(proton).parent / "files/lib/wine"
|
||||
if (dist / "aarch64-unix").is_dir() and (env or {}).get("PROTON_USE_ARM64", "1") == "1":
|
||||
return PE_MACHINES
|
||||
if (dist / "x86_64-unix").is_dir():
|
||||
return {
|
||||
"system32": ("x86_64-windows", frozenset((0x8664,))),
|
||||
"syswow64": PE_MACHINES["syswow64"],
|
||||
}
|
||||
return None
|
||||
@@ -734,6 +734,7 @@ def libraries(steam_root):
|
||||
|
||||
|
||||
def installed_apps(steam_root):
|
||||
entries = appinfo_entries(os.path.join(steam_root, "appcache", "appinfo.vdf"))
|
||||
apps = set()
|
||||
for library in libraries(steam_root):
|
||||
steamapps = os.path.join(library, "steamapps")
|
||||
@@ -743,7 +744,7 @@ def installed_apps(steam_root):
|
||||
continue
|
||||
for name in names:
|
||||
m = re.fullmatch(r"appmanifest_(\d+)\.acf", name)
|
||||
if m and m.group(1) not in NOT_GAMES:
|
||||
if m and m.group(1) not in NOT_GAMES and entries.get(m.group(1), {}).get("type") != "tool":
|
||||
apps.add(m.group(1))
|
||||
return sorted(apps, key=int)
|
||||
|
||||
@@ -927,6 +928,12 @@ def valve_picks(steam_root):
|
||||
return sorted(picks, key=int)
|
||||
|
||||
|
||||
def tool_platforms(steam_root):
|
||||
entries = appinfo_entries(os.path.join(steam_root, "appcache", "appinfo.vdf"))
|
||||
return {app: entry["oslist"] for app, entry in entries.items()
|
||||
if entry["type"] == "tool" and entry["oslist"] & {"linux", "windows"}}
|
||||
|
||||
|
||||
def native_targets(steam_root, apps):
|
||||
wanted = {str(app) for app in apps if str(app) not in NOT_GAMES}
|
||||
if not wanted:
|
||||
@@ -1064,10 +1071,11 @@ NATIVE_CHOICE = object()
|
||||
|
||||
|
||||
def plan_mapping(current, installed, runnable, default, auto, native=None, picks=(), overrides=None, managed=None,
|
||||
native_choices=()):
|
||||
native_choices=(), tools=None):
|
||||
tools = tools or {}
|
||||
runnable = {n.lower() for n in runnable}
|
||||
installed = set(installed)
|
||||
picks = set(picks)
|
||||
picks = set(picks) | {app for app, platforms in tools.items() if "linux" not in platforms}
|
||||
native = native or {}
|
||||
overrides = overrides or {}
|
||||
managed = set(auto) if managed is None else set(managed)
|
||||
@@ -1075,8 +1083,12 @@ def plan_mapping(current, installed, runnable, default, auto, native=None, picks
|
||||
changes, kept = {}, {}
|
||||
if current.get("0", "") != default:
|
||||
changes["0"] = default
|
||||
for app in sorted((set(current) | installed | picks | set(auto) | set(overrides) | native_choices) - {"0"}, key=app_order):
|
||||
for app in sorted((set(current) | installed | picks | set(auto) | set(overrides) | native_choices | set(tools)) - {"0"}, key=app_order):
|
||||
name = current.get(app, "")
|
||||
if app in tools and "linux" in tools[app] and "windows" not in tools[app]:
|
||||
if name and (name.lower() in runnable or "proton" in name.lower()):
|
||||
changes[app] = None
|
||||
continue
|
||||
if app in overrides:
|
||||
target = overrides[app]
|
||||
if target is DEFERRED_GAME_TOOL:
|
||||
@@ -1560,7 +1572,7 @@ def register_default(config_path, apps=(), protect=(), default=None, catalog=Non
|
||||
current.setdefault(app, "")
|
||||
changes, auto = plan_mapping(
|
||||
current, apps, names, default, followers(state, current), native, picks,
|
||||
overrides, managed, native_choices)
|
||||
overrides, managed, native_choices, tool_platforms(steam_root))
|
||||
set_mappings(tokens, mapping, changes)
|
||||
text = serialize(tokens)
|
||||
if text != serialize(tokenize(original)):
|
||||
|
||||
@@ -152,6 +152,16 @@ Prototype (inert unless `PROOT_FASTPATH` is set; see `docs/development/proot-per
|
||||
natively reserve 256 GiB above 64 GiB for guest memory: under PRoot the reservation failed and
|
||||
the first guest instruction faulted forever. 48-bit address spaces and 32-bit programs are
|
||||
unchanged in practice.
|
||||
- `0027-guest-seccomp-sigsys.patch` - PRoot treated every seccomp `SIGSYS` as Android's app policy
|
||||
refusing a call: it emulated the few it knows and answered the rest with `ENOSYS`, so a filter
|
||||
the guest installs itself never reached its own handler. Firefox's content sandbox traps
|
||||
`openat` and opens files through a broker in the parent; every font open came back `ENOSYS`,
|
||||
and each tab crashed in `gfxFontGroup::GetDefaultFont`. Android's policy traps with
|
||||
`SECCOMP_RET_DATA` 0, while Firefox's and Chromium's filters tag their traps, so a tagged
|
||||
`SIGSYS` is now delivered to the guest. A trap from the fast path's trampoline still gets
|
||||
`ENOSYS`, because its arguments are host paths; the fast path then stops for that process and
|
||||
repeats the call through a plain `svc`, which the guest's filter traps with the guest path.
|
||||
`bench/guest-trap.c` installs a tagged `openat` trap and checks its handler answers the call.
|
||||
|
||||
Old kernel fixes:
|
||||
|
||||
|
||||
@@ -0,0 +1,49 @@
|
||||
// Run inside a session, with and without the fast path: a seccomp filter the guest installs
|
||||
// itself must reach its own SIGSYS handler, which answers the trapped openat.
|
||||
#define _GNU_SOURCE
|
||||
#include <errno.h>
|
||||
#include <fcntl.h>
|
||||
#include <linux/filter.h>
|
||||
#include <linux/seccomp.h>
|
||||
#include <signal.h>
|
||||
#include <stddef.h>
|
||||
#include <stdio.h>
|
||||
#include <string.h>
|
||||
#include <sys/prctl.h>
|
||||
#include <sys/syscall.h>
|
||||
#include <ucontext.h>
|
||||
#include <unistd.h>
|
||||
|
||||
#define ANSWER 4242
|
||||
static volatile int traps, data;
|
||||
|
||||
static void on_sigsys(int sig, siginfo_t *info, void *context) {
|
||||
(void)sig;
|
||||
ucontext_t *uc = context;
|
||||
traps++;
|
||||
data = info->si_errno;
|
||||
uc->uc_mcontext.regs[0] = ANSWER;
|
||||
}
|
||||
|
||||
int main(void) {
|
||||
struct sigaction sa;
|
||||
memset(&sa, 0, sizeof sa);
|
||||
sa.sa_sigaction = on_sigsys;
|
||||
sa.sa_flags = SA_SIGINFO;
|
||||
sigaction(SIGSYS, &sa, NULL);
|
||||
struct sock_filter code[] = {
|
||||
BPF_STMT(BPF_LD | BPF_W | BPF_ABS, offsetof(struct seccomp_data, nr)),
|
||||
BPF_JUMP(BPF_JMP | BPF_JEQ | BPF_K, SYS_openat, 0, 1),
|
||||
BPF_STMT(BPF_RET | BPF_K, SECCOMP_RET_TRAP | 7),
|
||||
BPF_STMT(BPF_RET | BPF_K, SECCOMP_RET_ALLOW),
|
||||
};
|
||||
struct sock_fprog filter = {.len = sizeof code / sizeof *code, .filter = code};
|
||||
if (prctl(PR_SET_NO_NEW_PRIVS, 1, 0, 0, 0) || prctl(PR_SET_SECCOMP, SECCOMP_MODE_FILTER, &filter, 0, 0)) {
|
||||
perror("seccomp");
|
||||
return 77;
|
||||
}
|
||||
int fd = open("/etc/hostname", O_RDONLY);
|
||||
int err = errno;
|
||||
printf("open=%d errno=%d traps=%d data=%d\n", fd, fd < 0 ? err : 0, traps, data);
|
||||
return fd == ANSWER && traps == 1 && data == 7 ? 0 : 1;
|
||||
}
|
||||
@@ -49,7 +49,30 @@ static size_t rootlen;
|
||||
static long long ttl_ns = 2000000000LL;
|
||||
static unsigned long hits, misses;
|
||||
|
||||
static long sc(long nr, long a, long b, long c, long d, long e) { return fp_sys(nr, a, b, c, d, e, 0); }
|
||||
// A seccomp filter the guest installs itself (Firefox's content sandbox) can trap a trampoline
|
||||
// syscall that the host allows; proot answers those with ENOSYS. The call is then repeated
|
||||
// through libc, where the guest's own SIGSYS handler sees the guest path, and the process stops
|
||||
// using the fast path.
|
||||
static __thread int fp_refused;
|
||||
static int optional_syscall(long nr) {
|
||||
return nr == SYS_statx || nr == SYS_memfd_create || nr == SYS_faccessat2 || nr == SYS_close_range ||
|
||||
nr == SYS_openat2 || nr == SYS_renameat2;
|
||||
}
|
||||
static long sc(long nr, long a, long b, long c, long d, long e) {
|
||||
if (fp_refused) return -ENOSYS;
|
||||
long r = fp_sys(nr, a, b, c, d, e, 0);
|
||||
if (r == -ENOSYS && !optional_syscall(nr)) {
|
||||
__atomic_store_n(&fp_on, 0, __ATOMIC_RELAXED);
|
||||
fp_refused = 1;
|
||||
}
|
||||
return r;
|
||||
}
|
||||
static inline int refused(void) {
|
||||
if (!fp_refused) return 0;
|
||||
fp_refused = 0;
|
||||
return 1;
|
||||
}
|
||||
static inline int answered(long r) { return !refused() && r != FP_SLOW; }
|
||||
static void fp_init(void);
|
||||
static pthread_once_t init_once = PTHREAD_ONCE_INIT;
|
||||
static inline int fp_ready(void) {
|
||||
@@ -788,7 +811,7 @@ static int ret(long r) {
|
||||
if (!real_##name) real_##name = (type)dlsym(RTLD_NEXT, #name)
|
||||
|
||||
#define ANSWER(r, dirfd, path, real_call) \
|
||||
do { long r_ = (r); if (r_ != FP_SLOW) { hits++; return ret(r_); } missed(__func__, dirfd, path); return real_call; } while (0)
|
||||
do { long r_ = (r); if (answered(r_)) { hits++; return ret(r_); } missed(__func__, dirfd, path); return real_call; } while (0)
|
||||
|
||||
|
||||
static long fp_open(int dirfd, const char *path, int flags, mode_t mode) {
|
||||
@@ -805,7 +828,7 @@ static long fp_open(int dirfd, const char *path, int flags, mode_t mode) {
|
||||
|
||||
static int do_openat(int dirfd, const char *path, int flags, mode_t mode, int (*real)(int, const char *, int, ...)) {
|
||||
long r = fp_open(dirfd, path, flags, mode);
|
||||
if (r != FP_SLOW) { hits++; return ret(r); }
|
||||
if (answered(r)) { hits++; return ret(r); }
|
||||
missed(__func__, dirfd, path);
|
||||
return real(dirfd, path, flags, mode);
|
||||
}
|
||||
@@ -870,8 +893,10 @@ int lstat64(const char *p, struct stat64 *st) { return lstat(p, (struct stat *)s
|
||||
int fstat(int fd, struct stat *st) {
|
||||
REAL(fstat, int (*)(int, struct stat *));
|
||||
if (!fp_ready()) return real_fstat(fd, st);
|
||||
long r = sc(SYS_fstat, fd, (long)st, 0, 0, 0);
|
||||
if (!answered(r)) return real_fstat(fd, st);
|
||||
hits++;
|
||||
return ret(sc(SYS_fstat, fd, (long)st, 0, 0, 0));
|
||||
return ret(r);
|
||||
}
|
||||
int fstat64(int fd, struct stat64 *st) { return fstat(fd, (struct stat *)st); }
|
||||
|
||||
@@ -989,7 +1014,7 @@ static long fp_access(int dirfd, const char *path, int mode, int flags) {
|
||||
static int access_common(int dirfd, const char *path, int mode, int flags) {
|
||||
REAL(faccessat, int (*)(int, const char *, int, int));
|
||||
long rs = fp_access(dirfd, path, mode, flags);
|
||||
if (rs != FP_SLOW) { hits++; return ret(rs); }
|
||||
if (answered(rs)) { hits++; return ret(rs); }
|
||||
missed(__func__, dirfd, path);
|
||||
if (flags == 0) return (int)syscall(SYS_faccessat, dirfd, path, mode);
|
||||
return real_faccessat(dirfd, path, mode, flags);
|
||||
@@ -1087,7 +1112,7 @@ char *canonicalize_file_name(const char *path) { return realpath(path, NULL); }
|
||||
char *getcwd(char *buf, size_t size) {
|
||||
REAL(getcwd, char *(*)(char *, size_t));
|
||||
char g[PATH_MAX];
|
||||
if (!fp_ready() || own_cwd(g) == FP_SLOW) { missed(__func__, AT_FDCWD, "."); return real_getcwd(buf, size); }
|
||||
if (!fp_ready() || own_cwd(g) == FP_SLOW || refused()) { missed(__func__, AT_FDCWD, "."); return real_getcwd(buf, size); }
|
||||
hits++;
|
||||
size_t need = strlen(g) + 1;
|
||||
if (buf && size == 0) { errno = EINVAL; return NULL; }
|
||||
@@ -1181,7 +1206,7 @@ static long fp_rename(int olddirfd, const char *old, int newdirfd, const char *n
|
||||
static int rename_common(int olddirfd, const char *old, int newdirfd, const char *new, unsigned flags) {
|
||||
REAL(renameat2, int (*)(int, const char *, int, const char *, unsigned));
|
||||
long rs = fp_rename(olddirfd, old, newdirfd, new, flags);
|
||||
if (rs != FP_SLOW) { hits++; return ret(rs); }
|
||||
if (answered(rs)) { hits++; return ret(rs); }
|
||||
missed(__func__, olddirfd, old);
|
||||
if (!flags) return (int)syscall(SYS_renameat, olddirfd, old, newdirfd, new);
|
||||
return real_renameat2(olddirfd, old, newdirfd, new, flags);
|
||||
@@ -1355,7 +1380,7 @@ static long fp_getxattr(const char *path, const char *name, void *value, size_t
|
||||
static ssize_t getxattr_common(const char *path, const char *name, void *value, size_t size, int follow,
|
||||
ssize_t (*real)(const char *, const char *, void *, size_t)) {
|
||||
long rs = fp_getxattr(path, name, value, size, follow);
|
||||
if (rs != FP_SLOW) { hits++; return rs < 0 ? ret(rs) : rs; }
|
||||
if (answered(rs)) { hits++; return rs < 0 ? ret(rs) : rs; }
|
||||
missed(__func__, AT_FDCWD, path);
|
||||
return real(path, name, value, size);
|
||||
}
|
||||
@@ -1378,7 +1403,7 @@ static long fp_listxattr(const char *path, char *list, size_t size, int follow)
|
||||
static ssize_t listxattr_common(const char *path, char *list, size_t size, int follow,
|
||||
ssize_t (*real)(const char *, char *, size_t)) {
|
||||
long rs = fp_listxattr(path, list, size, follow);
|
||||
if (rs != FP_SLOW) { hits++; return rs < 0 ? ret(rs) : rs; }
|
||||
if (answered(rs)) { hits++; return rs < 0 ? ret(rs) : rs; }
|
||||
missed(__func__, AT_FDCWD, path);
|
||||
return real(path, list, size);
|
||||
}
|
||||
@@ -1581,6 +1606,7 @@ static int accept_common(int fd, struct sockaddr *addr, socklen_t *len, int flag
|
||||
struct sockaddr_storage got;
|
||||
socklen_t got_len = sizeof got;
|
||||
long r = sc(SYS_accept4, fd, addr ? (long)&got : 0, addr ? (long)&got_len : 0, flags, 0);
|
||||
if (refused()) return real_accept4(fd, addr, len, flags);
|
||||
if (r >= 0) inherit_long_name(fd, (int)r);
|
||||
if (!addr) return ret(r);
|
||||
if (r >= 0) report_address((int)r, LONG_PEER, &got, got_len, addr, len);
|
||||
@@ -1595,6 +1621,7 @@ static int name_common(long nr, int fd, struct sockaddr *addr, socklen_t *len, i
|
||||
struct sockaddr_storage got;
|
||||
socklen_t got_len = sizeof got;
|
||||
long r = sc(nr, fd, (long)&got, (long)&got_len, 0, 0);
|
||||
if (refused()) return real(fd, addr, len);
|
||||
if (r == 0) report_address(fd, nr == SYS_getsockname ? LONG_SELF : LONG_PEER, &got, got_len, addr, len);
|
||||
return ret(r);
|
||||
}
|
||||
@@ -1687,7 +1714,7 @@ static int online_cpus(void) {
|
||||
int get_nprocs(void) {
|
||||
REAL(get_nprocs, int (*)(void));
|
||||
int n = fp_ready() ? online_cpus() : -1;
|
||||
if (n > 0) { hits++; return n; }
|
||||
if (n > 0 && !refused()) { hits++; return n; }
|
||||
missed(__func__, AT_FDCWD, "(nprocs)");
|
||||
return real_get_nprocs();
|
||||
}
|
||||
@@ -1743,6 +1770,7 @@ int isatty(int fd) {
|
||||
if (!fp_ready()) return real_isatty(fd);
|
||||
hits++;
|
||||
long r = sc(SYS_ioctl, fd, TCGETS, (long)&k, 0, 0);
|
||||
if (refused()) return real_isatty(fd);
|
||||
if (r < 0) { errno = (int)-r; return 0; }
|
||||
return r == 0;
|
||||
}
|
||||
|
||||
@@ -74,11 +74,14 @@ long fp_svc_dispatch(const long *r) {
|
||||
int saved_errno = errno;
|
||||
long result = svc_blocked(r);
|
||||
uintptr_t frame = (uintptr_t)r;
|
||||
if (result == FP_SLOW && (!svc_busy || frame >= svc_busy)) {
|
||||
if (result == FP_SLOW && __atomic_load_n(&fp_on, __ATOMIC_RELAXED) && (!svc_busy || frame >= svc_busy)) {
|
||||
uintptr_t previous = svc_busy;
|
||||
svc_busy = frame;
|
||||
if (svc_stack_room(frame)) { result = svc_path_call(r); svc_busy = 0; }
|
||||
else svc_busy = previous;
|
||||
if (svc_stack_room(frame)) {
|
||||
result = svc_path_call(r);
|
||||
if (refused()) result = FP_SLOW;
|
||||
svc_busy = 0;
|
||||
} else svc_busy = previous;
|
||||
}
|
||||
if (result == FP_SLOW) {
|
||||
__atomic_fetch_add(&svc_slow, 1, __ATOMIC_RELAXED);
|
||||
|
||||
@@ -0,0 +1,59 @@
|
||||
From 0000000000000000000000000000000000000000 Mon Sep 17 00:00:00 2001
|
||||
From: Kurt Himebauch
|
||||
Date: Mon, 9 Nov 2026 15:30:00 -0500
|
||||
Subject: [PATCH] seccomp: hand a guest filter's own SIGSYS back to the guest
|
||||
|
||||
---
|
||||
--- a/src/tracee/seccomp.h
|
||||
+++ b/src/tracee/seccomp.h
|
||||
@@ -1,3 +1,4 @@
|
||||
+#include <signal.h>
|
||||
#include "tracee/tracee.h"
|
||||
#include "sys/vfs.h"
|
||||
|
||||
@@ -18,5 +19,5 @@
|
||||
|
||||
void restart_syscall_after_seccomp(Tracee* tracee);
|
||||
void set_result_after_seccomp(Tracee *tracee, word_t result);
|
||||
-int handle_seccomp_event(Tracee* tracee);
|
||||
+int handle_seccomp_event(Tracee* tracee, const siginfo_t *siginfo);
|
||||
void fix_and_restart_enosys_syscall(Tracee* tracee);
|
||||
--- a/src/tracee/seccomp.c
|
||||
+++ b/src/tracee/seccomp.c
|
||||
@@ -68,7 +68,7 @@
|
||||
*
|
||||
* Return 0 to swallow signal or SIGSYS to deliver it to process.
|
||||
*/
|
||||
-int handle_seccomp_event(Tracee* tracee)
|
||||
+int handle_seccomp_event(Tracee* tracee, const siginfo_t *siginfo)
|
||||
{
|
||||
int ret;
|
||||
|
||||
@@ -100,6 +100,16 @@
|
||||
|
||||
print_current_regs(tracee, 3, "seccomp SIGSYS");
|
||||
|
||||
+ /* Android's app policy traps with SECCOMP_RET_DATA 0. A filter the guest
|
||||
+ * installed itself (Firefox's and Chromium's sandboxes) tags its traps, and
|
||||
+ * its own SIGSYS handler answers them, usually through a broker process.
|
||||
+ * A trap from the fast path's trampoline still gets ENOSYS: its arguments
|
||||
+ * are host paths, and the fast path repeats the call through libc. */
|
||||
+ if (siginfo->si_errno != 0 && !from_fastpath_stub(tracee)) {
|
||||
+ VERBOSE(tracee, 4, "SIGSYS from the guest's own seccomp filter (data %d)", siginfo->si_errno);
|
||||
+ return SIGSYS;
|
||||
+ }
|
||||
+
|
||||
return handle_seccomp_event_common(tracee);
|
||||
}
|
||||
|
||||
--- a/src/tracee/event.c
|
||||
+++ b/src/tracee/event.c
|
||||
@@ -676,7 +676,7 @@
|
||||
tracee->skip_next_seccomp_signal = false;
|
||||
signal = 0;
|
||||
} else {
|
||||
- signal = handle_seccomp_event(tracee);
|
||||
+ signal = handle_seccomp_event(tracee, &siginfo);
|
||||
}
|
||||
} else {
|
||||
VERBOSE(tracee, 1, "non-seccomp SIGSYS");
|
||||
@@ -101,23 +101,31 @@ def description_excerpt(body):
|
||||
return ""
|
||||
|
||||
|
||||
def build_content(repo, number, title, sha, apk, release, description=None):
|
||||
def commit_subject(message):
|
||||
subject = (message or "").splitlines()[0].strip() if message else ""
|
||||
return plain_title(subject[:300])
|
||||
|
||||
|
||||
def build_content(repo, number, title, sha, apk, release, description=None, commit_message=None):
|
||||
excerpt = description_excerpt(description)
|
||||
subject = commit_subject(commit_message)
|
||||
return (f"**Signed test build** `pr-{number}` · `{sha[:7]}`\n"
|
||||
f"[#{number}](https://github.com/{repo}/pull/{number}): {plain_title(title)}\n"
|
||||
+ (f"Latest commit: {subject}\n" if subject else "")
|
||||
+ (f"\n{excerpt}\n\n" if excerpt else "") +
|
||||
"_Not merged — for testing._\n"
|
||||
f"In the app: **Updates → Test builds → #{number}**\n"
|
||||
f"Download: [standard APK](<{apk}>) · [all variants](<{release}>)")
|
||||
|
||||
|
||||
def retired_content(content, merged):
|
||||
label = "Merged" if merged else "Closed"
|
||||
if content.startswith(("**Merged** —", "**Closed** —")):
|
||||
def retired_content(content, merged=False, superseded_by=None):
|
||||
label = f"Superseded by `{superseded_by[:7]}`" if superseded_by else ("Merged" if merged else "Closed")
|
||||
if content.startswith(("**Merged** —", "**Closed** —", "**Superseded by `")):
|
||||
content = "\n".join(line[2:-2] if line.startswith("~~") and line.endswith("~~") else line
|
||||
for line in content.splitlines()[2:])
|
||||
struck = "\n".join(f"~~{line}~~" if line else "" for line in content.splitlines())
|
||||
return f"**{label}** — this test build is no longer available.\n\n{struck}"
|
||||
note = "This test build has been replaced." if superseded_by else "This test build is no longer available."
|
||||
return f"**{label}** — {note}\n\n{struck}"
|
||||
|
||||
|
||||
def sync(mode, repo, number, discord, sha="", apk="", release=""):
|
||||
@@ -136,6 +144,11 @@ def sync(mode, repo, number, discord, sha="", apk="", release=""):
|
||||
return
|
||||
raise RuntimeError("Signed build report comment is missing")
|
||||
message_id = saved_message(comment["body"], discord.id)
|
||||
state_match = STATE.search(comment["body"])
|
||||
try:
|
||||
old_state = json.loads(state_match.group(1)) if state_match else {}
|
||||
except ValueError:
|
||||
old_state = {}
|
||||
if mode == "retire":
|
||||
if message_id:
|
||||
message = discord.request("GET", f"/messages/{message_id}")
|
||||
@@ -144,16 +157,41 @@ def sync(mode, repo, number, discord, sha="", apk="", release=""):
|
||||
{"content": retired_content(message["content"], pull.get("merged", False)),
|
||||
"allowed_mentions": {"parse": []}, "flags": 4})
|
||||
return
|
||||
content = build_content(repo, number, pull["title"], sha, apk, release, pull.get("body"))
|
||||
try:
|
||||
commit = github(f"repos/{repo}/commits/{sha}") or {}
|
||||
except RuntimeError:
|
||||
commit = {}
|
||||
commit_message = (commit.get("commit") or {}).get("message", "")
|
||||
content = build_content(repo, number, pull["title"], sha, apk, release, pull.get("body"), commit_message)
|
||||
if len(content) > 1800:
|
||||
raise RuntimeError("Discord announcement is too long")
|
||||
payload = {"content": content, "allowed_mentions": {"parse": []}, "flags": 4}
|
||||
message = discord.request("PATCH", f"/messages/{message_id}", payload) if message_id else None
|
||||
old_message = None
|
||||
previous_sha = old_state.get("sha")
|
||||
if message_id and not previous_sha:
|
||||
old_message = discord.request("GET", f"/messages/{message_id}")
|
||||
if old_message:
|
||||
match = re.search(r"`([0-9a-f]{7})`", old_message.get("content", ""))
|
||||
previous_sha = match.group(1) if match else None
|
||||
if message_id and (previous_sha == sha or previous_sha == sha[:7]):
|
||||
message = discord.request("PATCH", f"/messages/{message_id}", payload)
|
||||
else:
|
||||
if message_id and old_state.get("sha"):
|
||||
previous = discord.request("GET", f"/messages/{message_id}")
|
||||
if previous:
|
||||
discord.request("PATCH", f"/messages/{message_id}",
|
||||
{"content": retired_content(previous["content"], superseded_by=sha),
|
||||
"allowed_mentions": {"parse": []}, "flags": 4})
|
||||
elif message_id and old_message:
|
||||
discord.request("PATCH", f"/messages/{message_id}",
|
||||
{"content": retired_content(old_message["content"], superseded_by=sha),
|
||||
"allowed_mentions": {"parse": []}, "flags": 4})
|
||||
message = None
|
||||
if message is None:
|
||||
message = discord.request("POST", "?wait=true", payload)
|
||||
if message is None:
|
||||
raise RuntimeError("Discord webhook no longer exists")
|
||||
state = {"webhook_id": discord.id, "message_id": message["id"]}
|
||||
state = {"webhook_id": discord.id, "message_id": message["id"], "sha": sha}
|
||||
latest = github(f"repos/{repo}/issues/comments/{comment['id']}")
|
||||
body = STATE.sub("", latest["body"]).rstrip() + f"\n<!-- droiddeck-discord: {json.dumps(state)} -->"
|
||||
github(f"repos/{repo}/issues/comments/{comment['id']}", "PATCH", {"body": body})
|
||||
|
||||
@@ -457,6 +457,26 @@ class AgentTest(unittest.TestCase):
|
||||
self.assertEqual(1, agent.main(["--steam-shortcut-state", game_id]))
|
||||
self.assertEqual(2, agent.main(["--steam-shortcut-state"]))
|
||||
|
||||
def test_steam_ready_uses_steams_initialized_store(self):
|
||||
with patch.object(agent, "cdp_eval", return_value={"value": True}) as evaluate:
|
||||
self.assertTrue(agent.steam_ready(self.dir))
|
||||
self.assertIn("m_bIsInitialized", evaluate.call_args.args[1])
|
||||
with patch.object(agent, "cdp_eval", return_value={"value": False}):
|
||||
self.assertFalse(agent.steam_ready(self.dir))
|
||||
|
||||
def test_pending_interstitial_is_continued_by_steam(self):
|
||||
with patch.object(agent, "cdp_eval", return_value={"value": True}) as evaluate:
|
||||
self.assertTrue(agent.steam_clear_pending_interstitial(self.dir, "3551182975"))
|
||||
expression = evaluate.call_args.args[1]
|
||||
self.assertIn("ContinueGameAction", expression)
|
||||
self.assertIn("ShowInterstitials", expression)
|
||||
self.assertIn('"3551182975"', expression)
|
||||
self.assertIn("await apps.GetActiveGameActions()", expression)
|
||||
with patch.object(agent, "cdp_eval", return_value={"value": False}):
|
||||
self.assertFalse(agent.steam_clear_pending_interstitial(self.dir, "3551182975"))
|
||||
with self.assertRaises(agent.AgentError):
|
||||
agent.steam_clear_pending_interstitial(self.dir, "0")
|
||||
|
||||
def test_asleep_command_reports_steams_own_sleep_state(self):
|
||||
with patch.dict(os.environ, {"BL_LAUNCH_DIR": str(self.dir.parent)}):
|
||||
for value, code in (("yes", 0), ("no", 5), ("unknown", 1), (None, 1)):
|
||||
|
||||
@@ -328,6 +328,15 @@ class DroiddeckctlTest(FakeAdbTest):
|
||||
self.assertEqual("222", out["displays"][1]["physicalId"])
|
||||
self.assertEqual([1080, 1240], out["displays"][1]["size"])
|
||||
|
||||
def test_displays_named_with_their_unique_id(self):
|
||||
text = DUMPSYS_DISPLAY.replace("mPrimaryDisplayDevice=Screen-2",
|
||||
"mPrimaryDisplayDevice=Screen-2(local:222)")
|
||||
self.configure({}, dumpsys_display=text)
|
||||
code, out = self.run_ctl("screenshot", str(self.dir / "shot.png"), "--display", "4")
|
||||
self.assertEqual(0, code)
|
||||
shot = [c for c in self.calls() if c["args"][:2] == ["exec-out", "screencap"]][0]
|
||||
self.assertEqual(["exec-out", "screencap", "-p", "-d", "222"], shot["args"])
|
||||
|
||||
def test_unknown_display_is_an_error(self):
|
||||
self.configure({}, dumpsys_display=DUMPSYS_DISPLAY)
|
||||
code, out = self.run_ctl("screenshot", str(self.dir / "x.png"), "--display", "9")
|
||||
@@ -398,6 +407,28 @@ class DroiddeckctlTest(FakeAdbTest):
|
||||
self.assertEqual(4, code)
|
||||
self.assertEqual("NO_FRAMES", out["error"]["code"])
|
||||
|
||||
def test_provider_call_falls_back_to_a_broadcast_without_content(self):
|
||||
calls = []
|
||||
answers = iter([b"", b'{"ok": true, "command": "state"}'])
|
||||
|
||||
def adb(_adb, _serial, *args, timeout=30):
|
||||
calls.append(args)
|
||||
if args[:2] == ("shell", "content"):
|
||||
return ctl.subprocess.CompletedProcess(args, 127, b"", b"/system/bin/sh: content: inaccessible or not found")
|
||||
if args[:2] == ("shell", "am"):
|
||||
return ctl.subprocess.CompletedProcess(args, 0, b"Broadcast completed: result=0", b"")
|
||||
return ctl.subprocess.CompletedProcess(args, 0, next(answers), b"")
|
||||
|
||||
with unittest.mock.patch.object(ctl, "run_adb", adb), unittest.mock.patch.object(ctl.time, "sleep"):
|
||||
answer = ctl.provider_call("adb", "fake-1", "state", arg="a b", request={"x": 1})
|
||||
self.assertEqual({"ok": True, "command": "state"}, answer)
|
||||
broadcast = calls[1]
|
||||
self.assertEqual(("shell", "am", "broadcast", "-a", ctl.PACKAGE + ".AGENT_CALL", "-p", ctl.PACKAGE), broadcast[:7])
|
||||
self.assertIn("'a b'", broadcast)
|
||||
self.assertEqual(ctl.encode_request({"x": 1}), broadcast[broadcast.index("request") + 1])
|
||||
call_id = broadcast[broadcast.index("id") + 1]
|
||||
self.assertIn("agent-calls/%s.json" % call_id, calls[-1][1])
|
||||
|
||||
def test_pacing_turns_timestats_off_when_the_dump_fails(self):
|
||||
calls = []
|
||||
|
||||
|
||||
@@ -0,0 +1,58 @@
|
||||
from pathlib import Path
|
||||
import os
|
||||
import shutil
|
||||
import subprocess
|
||||
import sys
|
||||
import tempfile
|
||||
import unittest
|
||||
|
||||
SOURCE = Path(__file__).resolve().parents[2] / "app/src/main/cpp/fakeinput_steam.cpp"
|
||||
|
||||
# Stands in for an allocator like mimalloc that calls access() while it starts up (Claude Code
|
||||
# probes /sys/devices/system/node/node1 on its first allocation, which comes from fakeinput's
|
||||
# constructor when it is preloaded).
|
||||
PROGRAM = r"""
|
||||
#define _GNU_SOURCE
|
||||
#include <stddef.h>
|
||||
#include <stdio.h>
|
||||
#include <unistd.h>
|
||||
|
||||
extern void *__libc_malloc(size_t);
|
||||
static int started;
|
||||
|
||||
void *malloc(size_t size) {
|
||||
if (!started) {
|
||||
started = 1;
|
||||
access("/sys/devices/system/node/node1", R_OK);
|
||||
}
|
||||
return __libc_malloc(size);
|
||||
}
|
||||
|
||||
int main(void) {
|
||||
puts("ok");
|
||||
return 0;
|
||||
}
|
||||
"""
|
||||
|
||||
|
||||
@unittest.skipUnless(sys.platform == "linux" and shutil.which("gcc") and shutil.which("g++"),
|
||||
"needs Linux, gcc and g++")
|
||||
class FakeinputReentryTest(unittest.TestCase):
|
||||
def test_allocator_calling_hooks_during_first_allocation_survives_preload(self):
|
||||
with tempfile.TemporaryDirectory() as tmp:
|
||||
path = Path(tmp)
|
||||
(path / "program.c").write_text(PROGRAM)
|
||||
subprocess.run(["gcc", "-rdynamic", "-o", str(path / "program"), str(path / "program.c")], check=True)
|
||||
subprocess.run(["g++", "-shared", "-fPIC", "-O2", "-Wno-attributes", "-pthread",
|
||||
"-std=c++17", "-static-libstdc++", "-static-libgcc",
|
||||
"-Wl,--exclude-libs,ALL", "-o", str(path / "libfakeinput.so"),
|
||||
str(SOURCE), "-ldl"], check=True)
|
||||
result = subprocess.run([str(path / "program")],
|
||||
env=dict(os.environ, LD_PRELOAD=str(path / "libfakeinput.so")),
|
||||
capture_output=True, text=True, timeout=10)
|
||||
self.assertEqual(result.returncode, 0, result.stderr)
|
||||
self.assertEqual(result.stdout, "ok\n")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
@@ -708,6 +708,54 @@ class ReadConfigMappingTest(unittest.TestCase):
|
||||
|
||||
|
||||
class GameEnvironmentTest(unittest.TestCase):
|
||||
def test_component_filter_and_prefix_repair_use_per_game_proton_architecture(self):
|
||||
with tempfile.TemporaryDirectory() as tmp:
|
||||
home = Path(tmp)
|
||||
config = home / ".config/droiddeck/game-environment.json"
|
||||
config.parent.mkdir(parents=True)
|
||||
config.write_text(json.dumps({"version": 1, "shared": {"PROTON_USE_ARM64": "1"},
|
||||
"games": {"42": {"PROTON_USE_ARM64": "0"}}}))
|
||||
seen_components, seen_repair, seen_start = [], [], []
|
||||
globals = MODULE["main"].__globals__
|
||||
stubs = {
|
||||
"game_gpu": lambda env: env,
|
||||
"mark_install_scripts": lambda command, env: [],
|
||||
"install_scripts_done": lambda scripts, env: False,
|
||||
"scripts_digest": lambda scripts: None,
|
||||
"win_components": lambda *args: seen_components.append(args[3]) or "",
|
||||
"repair_foreign_system_dlls": lambda *args: seen_repair.append(args[2]) or [],
|
||||
"gl_vsync": lambda env: False,
|
||||
"game_images": lambda env: None,
|
||||
"skip_red_launcher": lambda command: command,
|
||||
"ubisoft_overlay_off": lambda env: None,
|
||||
"project_zomboid_jvm": lambda command: None,
|
||||
"engine_fixes": lambda *args: ({}, [], []),
|
||||
"dirt_ws2_wanted": lambda *args: None,
|
||||
"apply_game_debug_logs": lambda env, config, debug_dir: env,
|
||||
"offline_without_anticheat": lambda command, env: command,
|
||||
"game_logs": lambda command, env, app_id: env,
|
||||
"directaudio_settings": lambda env: env,
|
||||
"vram_cap": lambda env: env,
|
||||
"render_path_defaults": lambda *args: None,
|
||||
"bridge_feature_level": lambda env: None,
|
||||
"force_ssbs": lambda command, env: env,
|
||||
"game_cores": lambda env: None,
|
||||
"cpu_topology": lambda env, command: None,
|
||||
"ego_core_plan": lambda *args: None,
|
||||
"runpy": mock.Mock(run_path=lambda *args: {"launch": lambda command, env: None}),
|
||||
"load_sync": lambda: {},
|
||||
"select_sync": lambda command, env, sync: (command, env),
|
||||
"start": lambda command, env, *args: seen_start.append(env) or 7,
|
||||
}
|
||||
environment = {"HOME": tmp, "STEAM_COMPAT_DATA_PATH": "/compatdata/42", "PROTON_USE_ARM64": "1"}
|
||||
command = ["/stock/proton", "waitforexitandrun", "/game/game.exe"]
|
||||
with patch.dict(os.environ, environment, clear=True), patch.object(sys, "argv", ["game-env", *command]), \
|
||||
patch.dict(globals, stubs):
|
||||
self.assertEqual(MODULE["main"](), 7)
|
||||
self.assertEqual(["0"], [env["PROTON_USE_ARM64"] for env in seen_components])
|
||||
self.assertEqual(["0"], [env["PROTON_USE_ARM64"] for env in seen_repair])
|
||||
self.assertEqual(["0"], [env["PROTON_USE_ARM64"] for env in seen_start])
|
||||
|
||||
def test_optional_xbox_loader_failure_keeps_stock_launch(self):
|
||||
from unittest.mock import patch
|
||||
for error in (FileNotFoundError('missing'), RuntimeError('module initialization failed')):
|
||||
@@ -2585,6 +2633,167 @@ class UbisoftCompositionTest(unittest.TestCase):
|
||||
self.assertEqual(list(prefix.glob(".ubisoft-*.reg")), [])
|
||||
|
||||
|
||||
|
||||
def pe_image(machine):
|
||||
head = bytearray(64)
|
||||
head[:2] = b"MZ"
|
||||
head[60:64] = (64).to_bytes(4, "little")
|
||||
return bytes(head) + b"PE\0\0" + machine.to_bytes(2, "little") + b"\0" * 32
|
||||
|
||||
|
||||
def arm64ec_pe_image():
|
||||
image = bytearray(0x400)
|
||||
image[:2] = b"MZ"
|
||||
image[60:64] = (64).to_bytes(4, "little")
|
||||
image[64:68] = b"PE\0\0"
|
||||
coff = 68
|
||||
image[coff:coff + 2] = (0x8664).to_bytes(2, "little")
|
||||
image[coff + 2:coff + 4] = (1).to_bytes(2, "little")
|
||||
image[coff + 16:coff + 18] = (240).to_bytes(2, "little")
|
||||
optional = coff + 20
|
||||
image[optional:optional + 2] = (0x20B).to_bytes(2, "little")
|
||||
image[optional + 108:optional + 112] = (16).to_bytes(4, "little")
|
||||
directory = optional + 112 + 10 * 8
|
||||
image[directory:directory + 4] = (0x1000).to_bytes(4, "little")
|
||||
image[directory + 4:directory + 8] = (0xD0).to_bytes(4, "little")
|
||||
section = optional + 240
|
||||
image[section + 8:section + 12] = (0x200).to_bytes(4, "little")
|
||||
image[section + 12:section + 16] = (0x1000).to_bytes(4, "little")
|
||||
image[section + 16:section + 20] = (0x200).to_bytes(4, "little")
|
||||
image[section + 20:section + 24] = (0x200).to_bytes(4, "little")
|
||||
image[0x200:0x204] = (0xD0).to_bytes(4, "little")
|
||||
image[0x2C8:0x2D0] = (0x10000000).to_bytes(8, "little")
|
||||
return bytes(image)
|
||||
|
||||
|
||||
class ForeignSystemDllTest(unittest.TestCase):
|
||||
def prefix(self, tmp, arm=True):
|
||||
proton = Path(tmp) / "Proton"
|
||||
dist = proton / "files/lib/wine"
|
||||
if arm:
|
||||
(dist / "aarch64-unix").mkdir(parents=True)
|
||||
else:
|
||||
(dist / "x86_64-unix").mkdir(parents=True)
|
||||
for libdir, machine in (("aarch64-windows", 0xAA64), ("i386-windows", 0x14C)):
|
||||
(dist / libdir).mkdir(parents=True)
|
||||
for name in ("ucrtbase.dll", "rpcrt4.dll"):
|
||||
(dist / libdir / name).write_bytes(pe_image(machine))
|
||||
windows = Path(tmp) / "compat/pfx/drive_c/windows"
|
||||
for folder in ("system32", "syswow64"):
|
||||
(windows / folder).mkdir(parents=True)
|
||||
return proton / "proton", Path(tmp) / "compat", windows
|
||||
|
||||
def run_repair(self, compat, proton):
|
||||
return MODULE["repair_foreign_system_dlls"](str(compat), str(proton))
|
||||
|
||||
def test_x64_dll_in_arm64_system32_is_replaced_by_the_builtin(self):
|
||||
with tempfile.TemporaryDirectory() as tmp:
|
||||
proton, compat, windows = self.prefix(tmp)
|
||||
(windows / "system32/UCRTBASE.DLL").write_bytes(pe_image(0x8664))
|
||||
(windows / "system32/rpcrt4.dll").write_bytes(pe_image(0xAA64))
|
||||
fixed = self.run_repair(compat, proton)
|
||||
self.assertEqual(["system32/UCRTBASE.DLL (machine 0x8664)"], fixed)
|
||||
self.assertTrue((windows / "system32/UCRTBASE.DLL").is_symlink())
|
||||
self.assertEqual(0xAA64, MODULE["pe_machine"](windows / "system32/UCRTBASE.DLL"))
|
||||
self.assertEqual(0x8664, MODULE["pe_machine"](windows / "system32/UCRTBASE.DLL.foreign-arch"))
|
||||
self.assertFalse((windows / "system32/rpcrt4.dll").is_symlink())
|
||||
self.assertEqual([], self.run_repair(compat, proton))
|
||||
|
||||
def test_64_bit_dll_in_syswow64_is_replaced(self):
|
||||
with tempfile.TemporaryDirectory() as tmp:
|
||||
proton, compat, windows = self.prefix(tmp)
|
||||
(windows / "syswow64/ucrtbase.dll").write_bytes(pe_image(0xAA64))
|
||||
self.assertEqual(["syswow64/ucrtbase.dll (machine 0xaa64)"], self.run_repair(compat, proton))
|
||||
self.assertEqual(0x14C, MODULE["pe_machine"](windows / "syswow64/ucrtbase.dll"))
|
||||
|
||||
def test_matching_ec_symlinked_unknown_and_non_pe_files_are_left_alone(self):
|
||||
with tempfile.TemporaryDirectory() as tmp:
|
||||
proton, compat, windows = self.prefix(tmp)
|
||||
system32 = windows / "system32"
|
||||
(system32 / "ucrtbase.dll").write_bytes(pe_image(0xA641))
|
||||
(system32 / "userlib.dll").write_bytes(pe_image(0x8664))
|
||||
(system32 / "rpcrt4.dll").write_bytes(b"not a pe file")
|
||||
self.assertEqual([], self.run_repair(compat, proton))
|
||||
self.assertFalse((system32 / "ucrtbase.dll").is_symlink())
|
||||
self.assertEqual(0x8664, MODULE["pe_machine"](system32 / "userlib.dll"))
|
||||
|
||||
def test_final_arm64ec_image_is_not_repaired_as_a_foreign_x64_dll(self):
|
||||
with tempfile.TemporaryDirectory() as tmp:
|
||||
proton, compat, windows = self.prefix(tmp)
|
||||
dll = windows / "system32/ucrtbase.dll"
|
||||
dll.write_bytes(arm64ec_pe_image())
|
||||
self.assertEqual(0x8664, MODULE["pe_machine"](dll))
|
||||
self.assertTrue(MODULE["PE"]["machine_compatible"](dll, frozenset((0xAA64, 0xA641))))
|
||||
self.assertFalse(MODULE["PE"]["machine_compatible"](dll, frozenset((0x8664,))))
|
||||
self.assertEqual([], self.run_repair(compat, proton))
|
||||
self.assertFalse(dll.is_symlink())
|
||||
|
||||
def test_component_owned_and_native_first_foreign_dlls_are_still_replaced(self):
|
||||
with tempfile.TemporaryDirectory() as tmp:
|
||||
proton, compat, windows = self.prefix(tmp)
|
||||
(windows / "system32/ucrtbase.dll").write_bytes(pe_image(0x8664))
|
||||
(windows / "system32/rpcrt4.dll").write_bytes(pe_image(0x8664))
|
||||
state = {"files": {"drive_c/windows/system32/ucrtbase.dll": {}}}
|
||||
(compat / ".droiddeck-wincomponents.json").write_text(json.dumps(state))
|
||||
reg = 'WINE REGISTRY Version 2\n\n[Software\\\\Wine\\\\DllOverrides] 1\n"RPCRT4.DLL"="native,builtin"\n'
|
||||
(compat / "pfx/user.reg").write_text(reg)
|
||||
env = {"WINEDLLOVERRIDES": "ucrtbase=n,b"}
|
||||
self.assertEqual(["system32/rpcrt4.dll (machine 0x8664)", "system32/ucrtbase.dll (machine 0x8664)"],
|
||||
sorted(MODULE["repair_foreign_system_dlls"](str(compat), str(proton), env)))
|
||||
self.assertEqual(0xAA64, MODULE["pe_machine"](windows / "system32/ucrtbase.dll"))
|
||||
self.assertEqual(0xAA64, MODULE["pe_machine"](windows / "system32/rpcrt4.dll"))
|
||||
|
||||
def test_newest_foreign_copy_is_the_only_backup_and_failed_final_replace_restores_it(self):
|
||||
with tempfile.TemporaryDirectory() as tmp:
|
||||
proton, compat, windows = self.prefix(tmp)
|
||||
dll = windows / "system32/ucrtbase.dll"
|
||||
dll.write_bytes(pe_image(0x8664))
|
||||
backup = windows / "system32/ucrtbase.dll.foreign-arch"
|
||||
backup.write_bytes(pe_image(0x1234))
|
||||
self.assertEqual(["system32/ucrtbase.dll (machine 0x8664)"], self.run_repair(compat, proton))
|
||||
self.assertEqual(0x1234, MODULE["pe_machine"](backup))
|
||||
self.assertEqual(0x8664, MODULE["pe_machine"](backup.with_name(backup.name + ".1")))
|
||||
self.assertEqual(0xAA64, MODULE["pe_machine"](dll))
|
||||
with tempfile.TemporaryDirectory() as tmp:
|
||||
proton, compat, windows = self.prefix(tmp)
|
||||
dll = windows / "system32/ucrtbase.dll"
|
||||
dll.write_bytes(pe_image(0x8664))
|
||||
backup = windows / "system32/ucrtbase.dll.foreign-arch"
|
||||
backup.write_bytes(pe_image(0x1234))
|
||||
real = os.replace
|
||||
def flaky_with_backup(src, dst):
|
||||
if str(src).endswith(".bl-new"):
|
||||
raise OSError("boom")
|
||||
return real(src, dst)
|
||||
with mock.patch("os.replace", side_effect=flaky_with_backup):
|
||||
self.assertEqual([], self.run_repair(compat, proton))
|
||||
self.assertEqual(0x8664, MODULE["pe_machine"](dll))
|
||||
self.assertFalse((windows / "system32/ucrtbase.dll.foreign-arch.1").exists())
|
||||
with tempfile.TemporaryDirectory() as tmp:
|
||||
proton, compat, windows = self.prefix(tmp)
|
||||
dll = windows / "system32/ucrtbase.dll"
|
||||
dll.write_bytes(pe_image(0x8664))
|
||||
real = os.replace
|
||||
def flaky(src, dst):
|
||||
if str(src).endswith(".bl-new"):
|
||||
raise OSError("boom")
|
||||
return real(src, dst)
|
||||
with mock.patch("os.replace", side_effect=flaky):
|
||||
self.assertEqual([], self.run_repair(compat, proton))
|
||||
self.assertEqual(0x8664, MODULE["pe_machine"](dll))
|
||||
self.assertFalse(dll.is_symlink())
|
||||
self.assertFalse((windows / "system32/ucrtbase.dll.foreign-arch").exists())
|
||||
self.assertFalse((windows / "system32/ucrtbase.dll.bl-new").is_symlink())
|
||||
|
||||
def test_non_arm64_proton_and_missing_prefix_are_untouched(self):
|
||||
with tempfile.TemporaryDirectory() as tmp:
|
||||
proton, compat, windows = self.prefix(tmp, arm=False)
|
||||
(windows / "system32/ucrtbase.dll").write_bytes(pe_image(0x8664))
|
||||
self.assertEqual([], self.run_repair(compat, proton))
|
||||
self.assertEqual(0x8664, MODULE["pe_machine"](windows / "system32/ucrtbase.dll"))
|
||||
self.assertEqual([], self.run_repair(Path(tmp) / "missing", proton))
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
|
||||
|
||||
@@ -8,8 +8,12 @@ import unittest
|
||||
from pathlib import Path
|
||||
|
||||
BIN = Path(__file__).resolve().parents[1] / "linuxfs/overlay/usr/local/bin"
|
||||
KEYS = ("TU_DEBUG", "DROIDDECK_GAME_TU_DEBUG")
|
||||
KEYS = ("TU_DEBUG", "DROIDDECK_GAME_TU_DEBUG", "MESA_LOADER_DRIVER_OVERRIDE", "GALLIUM_DRIVER",
|
||||
"LIBGL_KOPPER_DRI2", "VK_ICD_FILENAMES", "VK_DRIVER_FILES")
|
||||
SESSION = {"TU_DEBUG": "sysmem", "DROIDDECK_GAME_TU_DEBUG": ""}
|
||||
GPU_SESSION = {"MESA_LOADER_DRIVER_OVERRIDE": "zink", "GALLIUM_DRIVER": "zink",
|
||||
"LIBGL_KOPPER_DRI2": "true", "VK_ICD_FILENAMES": "/runtime/turnip.json",
|
||||
"VK_DRIVER_FILES": "/runtime/turnip.json"}
|
||||
|
||||
|
||||
class GameGpuLaunchersTest(unittest.TestCase):
|
||||
@@ -50,11 +54,16 @@ class GameGpuLaunchersTest(unittest.TestCase):
|
||||
return self.run_env(["bash", str(BIN / "droiddeck-flatpak-run"), "--command=app", "org.example.App"], session)
|
||||
|
||||
def test_the_wrapper_gives_a_program_the_game_setting(self):
|
||||
self.assertEqual({"TU_DEBUG": "unset", "DROIDDECK_GAME_TU_DEBUG": "unset"}, self.wrapper())
|
||||
self.assertEqual({key: "unset" for key in KEYS}, self.wrapper())
|
||||
self.assertEqual("gmem", self.wrapper(session={"TU_DEBUG": "sysmem", "DROIDDECK_GAME_TU_DEBUG": "gmem"})["TU_DEBUG"])
|
||||
|
||||
def test_non_flatpak_programs_keep_the_session_gpu_path(self):
|
||||
values = self.wrapper(session=GPU_SESSION)
|
||||
self.assertEqual(GPU_SESSION, {key: values[key] for key in GPU_SESSION})
|
||||
|
||||
def test_the_wrapper_keeps_the_session_setting_for_chromium(self):
|
||||
self.assertEqual({"TU_DEBUG": "sysmem", "DROIDDECK_GAME_TU_DEBUG": "unset"}, self.wrapper("--chromium"))
|
||||
values = self.wrapper("--chromium")
|
||||
self.assertEqual(("sysmem", "unset"), (values["TU_DEBUG"], values["DROIDDECK_GAME_TU_DEBUG"]))
|
||||
self.assertEqual("unset", self.wrapper("--for", str(self.app / "files"))["TU_DEBUG"])
|
||||
self.chromium()
|
||||
self.assertEqual("sysmem", self.wrapper("--for", str(self.app / "files"))["TU_DEBUG"])
|
||||
@@ -71,12 +80,13 @@ class GameGpuLaunchersTest(unittest.TestCase):
|
||||
timeout=30).returncode)
|
||||
|
||||
def test_a_flatpak_app_gets_the_game_setting(self):
|
||||
self.assertEqual({"TU_DEBUG": "unset", "DROIDDECK_GAME_TU_DEBUG": "unset"}, self.flatpak())
|
||||
self.assertEqual({key: "unset" for key in KEYS}, self.flatpak(session=dict(SESSION, **GPU_SESSION)))
|
||||
self.assertEqual("nolrz", self.flatpak({"TU_DEBUG": "sysmem", "DROIDDECK_GAME_TU_DEBUG": "nolrz"})["TU_DEBUG"])
|
||||
|
||||
def test_a_chromium_flatpak_keeps_the_session_setting(self):
|
||||
self.chromium()
|
||||
self.assertEqual({"TU_DEBUG": "sysmem", "DROIDDECK_GAME_TU_DEBUG": "unset"}, self.flatpak())
|
||||
values = self.flatpak()
|
||||
self.assertEqual(("sysmem", "unset"), (values["TU_DEBUG"], values["DROIDDECK_GAME_TU_DEBUG"]))
|
||||
|
||||
def test_a_flatpak_without_the_game_setting_is_unchanged(self):
|
||||
self.assertEqual("sysmem", self.flatpak({"TU_DEBUG": "sysmem"})["TU_DEBUG"])
|
||||
|
||||
@@ -32,7 +32,7 @@ step() { :; }
|
||||
bl_zram_usage() { _bl_rss=$rss _bl_swap=$client_swap; }
|
||||
bl_zram_send() { echo "send:$1:$3"; rss=$((rss - reclaimed)); _bl_pageout_complete=${pageout_complete:-1}; }
|
||||
''' + functions + '\nbl_memory_read\n' + body
|
||||
result = run_bash(harness)
|
||||
result = run_bash('NOW=0\n' + harness.replace('SECONDS', 'NOW'))
|
||||
self.assertEqual(0, result.returncode, result.stderr)
|
||||
self.assertEqual('', result.stderr)
|
||||
return result.stdout.splitlines()
|
||||
@@ -168,7 +168,7 @@ bl_session_others() { _bl_session=(321 654); }
|
||||
bl_zram_usage() { _bl_rss=$rss _bl_swap=$swap; }
|
||||
bl_zram_send() { echo "send:$1:$3"; rss=$((rss - pushed)); swap=$((swap + pushed)); _bl_pageout_complete=$complete; }
|
||||
''' + functions + '\nbl_memory_read\n' + body
|
||||
result = run_bash(harness)
|
||||
result = run_bash('NOW=0\n' + harness.replace('SECONDS', 'NOW'))
|
||||
self.assertEqual(0, result.returncode, result.stderr)
|
||||
self.assertEqual('', result.stderr)
|
||||
return result.stdout.splitlines()
|
||||
@@ -229,9 +229,34 @@ echo "backoff:$_bl_tier_backoff"
|
||||
self.assertEqual(['send:1:game', 'send:1:game'], self.run_policy('''
|
||||
_bl_tier_backoff=300
|
||||
SECONDS=0; bl_session_pressure
|
||||
swap=0
|
||||
SECONDS=20; bl_session_pressure
|
||||
SECONDS=30; bl_session_pressure
|
||||
'''))
|
||||
|
||||
def test_nearly_out_of_memory_still_backs_off_when_the_game_reads_its_pageout_back(self):
|
||||
self.memory(available=512)
|
||||
self.assertEqual(['send:1:game', 'backoff:60', 'send:1:game'], self.run_policy('''
|
||||
SECONDS=0; bl_session_pressure
|
||||
swap=0
|
||||
SECONDS=30; bl_session_pressure
|
||||
echo "backoff:$_bl_tier_backoff"
|
||||
SECONDS=59; bl_session_pressure
|
||||
SECONDS=60; bl_session_pressure
|
||||
SECONDS=90; bl_session_pressure
|
||||
'''))
|
||||
|
||||
def test_nearly_out_of_memory_retries_a_skipped_pageout_after_30_seconds(self):
|
||||
self.memory(available=512)
|
||||
self.assertEqual(['send:1:game', 'send:1:game'], self.run_policy('''
|
||||
SECONDS=0; bl_session_pressure
|
||||
swap=0
|
||||
SECONDS=30; bl_session_pressure
|
||||
_bl_tier_backoff=300
|
||||
saved=$rss; rss=$((128 * 1024))
|
||||
SECONDS=330; bl_session_pressure
|
||||
rss=$saved
|
||||
SECONDS=359; bl_session_pressure
|
||||
SECONDS=360; bl_session_pressure
|
||||
'''))
|
||||
|
||||
def test_small_swap_headroom_skips_the_pageout(self):
|
||||
|
||||
@@ -14,17 +14,30 @@ class PrDiscordTest(unittest.TestCase):
|
||||
self.discord.request.return_value = {"id": "1557547293375332465"}
|
||||
self.github = patch.object(module, "github").start()
|
||||
self.addCleanup(patch.stopall)
|
||||
self.github.side_effect = lambda path, method="GET", body=None: self.pull if "/pulls/" in path else self.comment
|
||||
def github_request(path, method="GET", body=None):
|
||||
if "/pulls/" in path:
|
||||
return self.pull
|
||||
if "/commits/" in path:
|
||||
return {"commit": {"message": "Fix reconnect behavior\n\nMore details"}}
|
||||
if path.endswith("/issues/comments/10"):
|
||||
if method == "PATCH":
|
||||
self.comment["body"] = body["body"]
|
||||
return self.comment
|
||||
return self.comment
|
||||
self.github.side_effect = github_request
|
||||
patch.object(module, "report_comment", return_value=self.comment).start()
|
||||
|
||||
def publish(self):
|
||||
module.sync("publish", "Droid-Deck/DroidDeck", "410", self.discord, "a" * 40,
|
||||
def publish(self, sha=None):
|
||||
if sha is not None:
|
||||
self.pull["head"]["sha"] = sha
|
||||
sha = sha or "a" * 40
|
||||
module.sync("publish", "Droid-Deck/DroidDeck", "410", self.discord, sha,
|
||||
"https://github.com/Droid-Deck/DroidDeck-CI/releases/download/pr-410/build.apk",
|
||||
"https://github.com/Droid-Deck/DroidDeck-CI/releases/tag/pr-410")
|
||||
|
||||
def save_state(self):
|
||||
self.comment["body"] += '\n<!-- droiddeck-discord: ' + json.dumps({
|
||||
"webhook_id": self.discord.id, "message_id": "1557547293375332465"}) + ' -->'
|
||||
"webhook_id": self.discord.id, "message_id": "1557547293375332465", "sha": "a" * 40}) + ' -->'
|
||||
|
||||
def test_create_confirmed_message_and_persist_reference(self):
|
||||
self.publish()
|
||||
@@ -36,12 +49,76 @@ class PrDiscordTest(unittest.TestCase):
|
||||
self.assertEqual("1557547293375332465", module.saved_message(saved, self.discord.id))
|
||||
self.assertIn("Build download", saved)
|
||||
|
||||
def test_new_build_updates_existing_message(self):
|
||||
def test_same_build_updates_existing_message_without_duplicate(self):
|
||||
self.save_state()
|
||||
self.publish()
|
||||
self.assertEqual("PATCH", self.discord.request.call_args.args[0])
|
||||
saved = self.github.call_args.args[2]["body"]
|
||||
self.assertEqual(1, len(module.STATE.findall(saved)))
|
||||
self.assertIn('"sha": "' + "a" * 40 + '"', saved)
|
||||
|
||||
def test_new_commit_retires_old_message_and_posts_replacement(self):
|
||||
self.save_state()
|
||||
old_content = module.build_content("Droid-Deck/DroidDeck", "410", self.pull["title"], "a" * 40,
|
||||
"apk", "release")
|
||||
self.discord.request.side_effect = [
|
||||
{"content": old_content}, {"id": "1557547293375332465"}, {"id": "1557547293375332466"}]
|
||||
self.publish("b" * 40)
|
||||
calls = self.discord.request.call_args_list
|
||||
self.assertEqual(["GET", "PATCH", "POST"], [call.args[0] for call in calls])
|
||||
self.assertIn("**Superseded by `bbbbbbb`**", calls[1].args[2]["content"])
|
||||
self.assertIn("~~Download:", calls[1].args[2]["content"])
|
||||
self.assertIn("Latest commit: Fix reconnect behavior", calls[2].args[2]["content"])
|
||||
saved = self.github.call_args.args[2]["body"]
|
||||
self.assertEqual("1557547293375332466", module.saved_message(saved, self.discord.id))
|
||||
self.assertIn('"sha": "' + "b" * 40 + '"', saved)
|
||||
|
||||
def test_publish_supersede_rerun_and_close_lifecycle(self):
|
||||
messages = {}
|
||||
created = []
|
||||
next_id = 1
|
||||
|
||||
def discord_request(method, suffix="", body=None):
|
||||
nonlocal next_id
|
||||
if method == "POST":
|
||||
message_id = str(1557547293375332465 + next_id)
|
||||
next_id += 1
|
||||
messages[message_id] = {"id": message_id, "content": body["content"]}
|
||||
created.append(message_id)
|
||||
return messages[message_id]
|
||||
message_id = suffix.split("/")[-1]
|
||||
if method == "GET":
|
||||
return messages.get(message_id)
|
||||
if method == "PATCH":
|
||||
if message_id not in messages:
|
||||
return None
|
||||
messages[message_id].update(body)
|
||||
return messages[message_id]
|
||||
self.fail(f"Unexpected Discord method: {method}")
|
||||
|
||||
self.discord.request.side_effect = discord_request
|
||||
self.publish("a" * 40)
|
||||
first_id = created[-1]
|
||||
self.publish("b" * 40)
|
||||
second_id = created[-1]
|
||||
self.publish("b" * 40)
|
||||
self.assertEqual(2, len(created))
|
||||
self.assertIn("**Superseded by `bbbbbbb`**", messages[first_id]["content"])
|
||||
self.assertIn("`bbbbbbb`", messages[second_id]["content"])
|
||||
|
||||
self.pull.update(state="closed", merged=True)
|
||||
module.sync("retire", "Droid-Deck/DroidDeck", "410", self.discord)
|
||||
self.assertTrue(messages[second_id]["content"].startswith("**Merged**"))
|
||||
self.assertIn("~~Latest commit:", messages[second_id]["content"])
|
||||
|
||||
def test_legacy_state_uses_embedded_short_sha_to_supersede(self):
|
||||
self.save_state()
|
||||
self.comment["body"] = self.comment["body"].replace(', "sha": "' + "a" * 40 + '"', '')
|
||||
old_content = "**Signed test build** `pr-410` · `aaaaaaa`\nold build"
|
||||
self.discord.request.side_effect = [
|
||||
{"content": old_content}, {"id": "1557547293375332465"}, {"id": "1557547293375332466"}]
|
||||
self.publish("b" * 40)
|
||||
self.assertEqual(["GET", "PATCH", "POST"], [call.args[0] for call in self.discord.request.call_args_list])
|
||||
|
||||
def test_deleted_message_is_recreated(self):
|
||||
self.save_state()
|
||||
@@ -78,6 +155,10 @@ class PrDiscordTest(unittest.TestCase):
|
||||
module.sync("retire", "Droid-Deck/DroidDeck", "410", self.discord)
|
||||
self.discord.request.assert_not_called()
|
||||
|
||||
def test_superseded_content_is_idempotent(self):
|
||||
once = module.retired_content("first\n\nlast", superseded_by="a" * 40)
|
||||
self.assertEqual(once, module.retired_content(once, superseded_by="a" * 40))
|
||||
|
||||
def test_old_build_without_saved_id_is_not_guessed(self):
|
||||
self.pull["state"] = "closed"
|
||||
module.sync("retire", "Droid-Deck/DroidDeck", "410", self.discord)
|
||||
@@ -153,6 +234,29 @@ class PrDiscordTest(unittest.TestCase):
|
||||
self.assertLessEqual(len(content), 1800)
|
||||
self.assertLessEqual(len(module.retired_content(content, True)), 2000)
|
||||
|
||||
def test_commit_subject_is_sanitized_and_uses_only_first_line(self):
|
||||
content = module.build_content("Droid-Deck/DroidDeck", "410", "title", "a" * 40,
|
||||
"apk", "release", commit_message="Fix *input* @everyone\nDetails")
|
||||
self.assertIn(r"Latest commit: Fix \*input\* @everyone", content)
|
||||
self.assertNotIn("Details", content)
|
||||
|
||||
def test_commit_lookup_failure_does_not_block_announcement(self):
|
||||
def unavailable_commit(path, method="GET", body=None):
|
||||
if "/commits/" in path:
|
||||
raise RuntimeError("GitHub request failed")
|
||||
return self.pull if "/pulls/" in path else self.comment
|
||||
self.github.side_effect = unavailable_commit
|
||||
self.publish()
|
||||
self.assertEqual("POST", self.discord.request.call_args.args[0])
|
||||
self.assertNotIn("Latest commit:", self.discord.request.call_args.args[2]["content"])
|
||||
|
||||
def test_empty_commit_response_does_not_block_announcement(self):
|
||||
self.github.side_effect = lambda path, method="GET", body=None: (
|
||||
self.pull if "/pulls/" in path else None if "/commits/" in path else self.comment)
|
||||
self.publish()
|
||||
self.assertEqual("POST", self.discord.request.call_args.args[0])
|
||||
self.assertNotIn("Latest commit:", self.discord.request.call_args.args[2]["content"])
|
||||
|
||||
|
||||
original_report = module.report_comment
|
||||
|
||||
|
||||
@@ -41,6 +41,7 @@ class SteamColdLaunchTest(unittest.TestCase):
|
||||
"#!/bin/sh\n"
|
||||
"printf '%s\n' \"$@\" > \"$AGENT_ARGS\"\n"
|
||||
"n=$(cat \"$AGENT_COUNT\" 2>/dev/null || echo 0); n=$((n + 1)); echo $n > \"$AGENT_COUNT\"\n"
|
||||
"[ \"$1\" = --steam-ready ] && exit \"${READY_STATUS:-0}\"\n"
|
||||
"exit \"$AGENT_STATUS\"\n")
|
||||
agent.chmod(0o755)
|
||||
sleep = self.bin / "sleep"
|
||||
@@ -55,12 +56,13 @@ class SteamColdLaunchTest(unittest.TestCase):
|
||||
pgrep.chmod(0o755)
|
||||
self.block = LAUNCH_BLOCK.replace("/usr/local/bin/droiddeck-agent", str(agent))
|
||||
|
||||
def run_launch(self, target, focus="769", helper_status=0, running_after=999):
|
||||
def run_launch(self, target, focus="769", helper_status=0, running_after=999, ready_status=0):
|
||||
env = dict(os.environ, HOME=str(self.root), BL_LAUNCH_DIR=str(self.launch_dir),
|
||||
PATH=str(self.bin) + os.pathsep + os.environ["PATH"],
|
||||
AGENT_ARGS=str(self.agent_args), SLEEP_ARGS=str(self.sleep_args),
|
||||
AGENT_COUNT=str(self.agent_count), AGENT_STATUS=str(helper_status),
|
||||
FOCUS=focus, FOCUS_COUNT=str(self.focus_count), RUNNING_AFTER=str(running_after))
|
||||
FOCUS=focus, FOCUS_COUNT=str(self.focus_count), RUNNING_AFTER=str(running_after),
|
||||
READY_STATUS=str(ready_status))
|
||||
script = self.block + "\nprintf 'ARG:%s\n' \"$@\"\nwait\n"
|
||||
url = target if target.startswith("steam://") else "steam://rungameid/" + target
|
||||
result = subprocess.run(["bash", "-c", script, "cold-launch", url],
|
||||
@@ -72,28 +74,40 @@ class SteamColdLaunchTest(unittest.TestCase):
|
||||
return [l for l in result.stdout.splitlines() if "launching the requested game" in l]
|
||||
|
||||
def test_a_shortcut_that_takes_focus_is_handed_over_once_without_waiting_for_the_overview(self):
|
||||
result = self.run_launch(SHORTCUT, focus="769 769 4242")
|
||||
result = self.run_launch(SHORTCUT, focus="769 4242")
|
||||
self.assertEqual(SHORTCUT, (self.launch_dir / "steam-game").read_text().strip())
|
||||
self.assertEqual(1, len(self.hand_offs(result)), result.stdout)
|
||||
self.assertFalse(self.agent_args.exists(), result.stdout)
|
||||
self.assertEqual(["1"], self.sleep_args.read_text().split())
|
||||
self.assertEqual(["--steam-ready"], self.agent_args.read_text().splitlines())
|
||||
self.assertFalse(self.sleep_args.exists())
|
||||
|
||||
def test_a_shortcut_that_never_takes_focus_is_handed_over_again_after_the_overview_wait(self):
|
||||
result = self.run_launch(SHORTCUT, focus="769", helper_status=0)
|
||||
self.assertEqual(2, len(self.hand_offs(result)), result.stdout)
|
||||
self.assertIn("launching the requested game again", result.stdout)
|
||||
self.assertEqual(["--wait-steam-shortcut-overview", SHORTCUT, "45"], self.agent_args.read_text().splitlines())
|
||||
self.assertEqual(1, len(self.hand_offs(result)), result.stdout)
|
||||
self.assertIn("clearing the pending launch interstitial", result.stdout)
|
||||
self.assertEqual(["--steam-clear-pending-interstitial", "3551182975"], self.agent_args.read_text().splitlines())
|
||||
self.assertEqual(["1"] * 20, self.sleep_args.read_text().split())
|
||||
|
||||
def test_a_timed_out_overview_still_gets_one_more_hand_off(self):
|
||||
result = self.run_launch(SHORTCUT, focus="769", helper_status=1)
|
||||
self.assertEqual(2, len(self.hand_offs(result)), result.stdout)
|
||||
self.assertIn("overview status 1", result.stdout)
|
||||
self.assertIn("launching the requested game again", result.stdout)
|
||||
|
||||
def test_a_steam_that_never_reports_ready_still_gets_the_launch(self):
|
||||
result = self.run_launch("413150", ready_status=1)
|
||||
self.assertEqual("413150", (self.launch_dir / "steam-game").read_text().strip())
|
||||
self.assertEqual(1, len(self.hand_offs(result)), result.stdout)
|
||||
self.assertEqual(["1"] * 30, self.sleep_args.read_text().split())
|
||||
|
||||
def test_a_focus_change_does_not_end_the_readiness_wait(self):
|
||||
result = self.run_launch("413150", focus="769 4242", ready_status=1)
|
||||
self.assertEqual(1, len(self.hand_offs(result)), result.stdout)
|
||||
self.assertEqual(["1"] * 30, self.sleep_args.read_text().split())
|
||||
|
||||
def test_a_game_still_starting_without_a_window_is_not_launched_twice(self):
|
||||
result = self.run_launch(SHORTCUT, running_after=1)
|
||||
self.assertEqual(1, len(self.hand_offs(result)), result.stdout)
|
||||
self.assertFalse(self.agent_args.exists())
|
||||
self.assertIn("--steam-ready", self.agent_args.read_text())
|
||||
self.assertEqual(["-f", "reaper SteamLaunch AppId=3551182975( |$)"],
|
||||
(self.root / "pgrep-args").read_text().splitlines())
|
||||
self.assertIn("already starting", result.stdout)
|
||||
@@ -108,15 +122,15 @@ class SteamColdLaunchTest(unittest.TestCase):
|
||||
focus = " ".join(["769"] * 23) + " 4242"
|
||||
result = self.run_launch(SHORTCUT, focus=focus, helper_status=0)
|
||||
self.assertEqual(1, len(self.hand_offs(result)), result.stdout)
|
||||
self.assertIn("took focus while waiting", result.stdout)
|
||||
self.assertNotIn("clearing the pending launch interstitial", result.stdout)
|
||||
self.assertNotIn("launching the requested game again", result.stdout)
|
||||
|
||||
def test_native_app_id_keeps_the_short_delay_and_is_handed_over_once(self):
|
||||
result = self.run_launch("413150")
|
||||
self.assertEqual("413150", (self.launch_dir / "steam-game").read_text().strip())
|
||||
self.assertEqual(1, len(self.hand_offs(result)), result.stdout)
|
||||
self.assertEqual("4", self.sleep_args.read_text().strip())
|
||||
self.assertFalse(self.agent_args.exists())
|
||||
self.assertFalse(self.sleep_args.exists())
|
||||
self.assertIn("--steam-ready", self.agent_args.read_text())
|
||||
|
||||
def test_a_restart_for_a_game_uses_the_same_hand_off(self):
|
||||
loop = SOURCE[SOURCE.index(' if [ -s "$HOME/.bl-restart-game" ]; then'):SOURCE.index(' if [ -f "$HOME/.bl-restart-once" ]; then')]
|
||||
|
||||
@@ -0,0 +1,215 @@
|
||||
import json
|
||||
import os
|
||||
from pathlib import Path
|
||||
import select
|
||||
import tempfile
|
||||
import unittest
|
||||
from unittest import mock
|
||||
|
||||
from tools.tests import test_droiddeck_fex as fixtures
|
||||
from tools.tests import test_steam_compat_live as live
|
||||
from tools.tests.test_steam_compat_mapping import config_text
|
||||
|
||||
|
||||
COMPAT = live.COMPAT
|
||||
TOOL = COMPAT["TOOL"]
|
||||
|
||||
|
||||
class ToolPlatformsTest(unittest.TestCase):
|
||||
def setUp(self):
|
||||
temporary = tempfile.TemporaryDirectory()
|
||||
self.addCleanup(temporary.cleanup)
|
||||
self.root = Path(temporary.name)
|
||||
self.steam = self.root / "Steam"
|
||||
self.state = self.root / "state"
|
||||
self.config = self.steam / "config/config.vdf"
|
||||
self.config.parent.mkdir(parents=True)
|
||||
self.state.mkdir()
|
||||
(self.steam / "appcache").mkdir()
|
||||
(self.steam / "steamapps").mkdir()
|
||||
self.entries = {
|
||||
976730: ("windows", "64", "game"),
|
||||
1826330: ("linux", "", "tool"),
|
||||
1161040: ("linux", "", "tool"),
|
||||
900001: ("windows", "64", "tool"),
|
||||
900002: ("linux", "arm64", "tool"),
|
||||
900003: ("osx", "64", "tool"),
|
||||
900004: ("", "", "tool"),
|
||||
900005: ("linux,windows", "64", "tool"),
|
||||
}
|
||||
self.write_appinfo()
|
||||
self.catalog = [{"name": TOOL, "display": "Proton", "dir": "Proton", "valve": True}]
|
||||
self.config.write_text(config_text('"0" { "name" "%s" }' % TOOL))
|
||||
patcher = mock.patch.dict(COMPAT["register_default"].__globals__, COMPAT_DIR=str(self.state))
|
||||
patcher.start()
|
||||
self.addCleanup(patcher.stop)
|
||||
|
||||
def write_appinfo(self, magic=0x07564429):
|
||||
(self.steam / "appcache/appinfo.vdf").write_bytes(fixtures.AppInfoTest.appinfo(self.entries, magic))
|
||||
|
||||
def platforms(self):
|
||||
return COMPAT["tool_platforms"](str(self.steam))
|
||||
|
||||
def plan(self, current, auto=None, **kwargs):
|
||||
return COMPAT["plan_mapping"](current, ["976730"], [TOOL], TOOL, auto or {},
|
||||
tools=self.platforms(), **kwargs)
|
||||
|
||||
def test_metadata_selects_tools_across_cache_formats(self):
|
||||
for magic in (0x07564427, 0x07564428, 0x07564429):
|
||||
with self.subTest(magic=magic):
|
||||
self.write_appinfo(magic)
|
||||
self.assertEqual(set(self.platforms()), {"1826330", "1161040", "900001", "900002", "900005"})
|
||||
|
||||
def test_tools_are_not_installed_games(self):
|
||||
for app in self.entries:
|
||||
(self.steam / ("steamapps/appmanifest_%s.acf" % app)).write_text("")
|
||||
self.assertEqual(COMPAT["installed_apps"](str(self.steam)), ["976730"])
|
||||
|
||||
def test_linux_tools_clear_proton_and_windows_tools_get_default_before_install(self):
|
||||
current = {"0": TOOL, "976730": TOOL, "1826330": TOOL, "1161040": "proton_experimental"}
|
||||
changes, auto = self.plan(current, {"1826330": TOOL, "976730": TOOL})
|
||||
self.assertEqual(changes, {"1826330": None, "1161040": None, "900001": TOOL})
|
||||
self.assertNotIn("1826330", auto)
|
||||
self.assertEqual(auto["900001"], TOOL)
|
||||
self.assertEqual(auto["976730"], TOOL)
|
||||
|
||||
def test_removed_ge_proton_is_cleared_with_or_without_saved_ownership(self):
|
||||
for saved in ({}, {"1826330": "GE-Proton11-7"}):
|
||||
with self.subTest(saved=saved):
|
||||
changes, auto = self.plan({"0": TOOL, "1826330": "GE-Proton11-7"}, saved)
|
||||
self.assertIsNone(changes["1826330"])
|
||||
self.assertNotIn("1826330", auto)
|
||||
|
||||
def test_removed_ge_proton_game_selection_survives(self):
|
||||
changes, auto = self.plan({"0": TOOL, "976730": "GE-Proton11-7"})
|
||||
self.assertNotIn("976730", changes)
|
||||
self.assertNotIn("976730", auto)
|
||||
|
||||
def test_saved_native_selections_survive(self):
|
||||
for name in (COMPAT["FEX_TOOL"], "custom-native"):
|
||||
with self.subTest(name=name):
|
||||
changes, auto = self.plan({"0": TOOL, "1826330": name}, {"1826330": name})
|
||||
self.assertNotIn("1826330", changes)
|
||||
self.assertNotIn("1826330", auto)
|
||||
|
||||
def test_main_watches_cache_updates_and_replacements(self):
|
||||
class StopWatching(BaseException):
|
||||
pass
|
||||
|
||||
watchers = []
|
||||
|
||||
def capture(files):
|
||||
watcher = live.LIVE["Watcher"](files)
|
||||
watchers.append(watcher)
|
||||
raise StopWatching()
|
||||
|
||||
namespace = live.LIVE["main"].__globals__
|
||||
with mock.patch.dict(namespace, Watcher=capture), mock.patch.dict(os.environ, BL_CDP_PORT="1"), \
|
||||
mock.patch.object(live.LIVE["sys"], "argv", ["compat", str(self.steam)]):
|
||||
with self.assertRaises(StopWatching):
|
||||
live.LIVE["main"]()
|
||||
watcher = watchers[0]
|
||||
if watcher.fd >= 0:
|
||||
self.addCleanup(os.close, watcher.fd)
|
||||
self.assertFalse(watcher.changed())
|
||||
self.entries[900006] = ("linux", "", "tool")
|
||||
self.write_appinfo()
|
||||
self.assertTrue(watcher.changed())
|
||||
self.assertIn("900006", self.platforms())
|
||||
cache = self.steam / "appcache/appinfo.vdf"
|
||||
replacement = cache.with_suffix(".tmp")
|
||||
replacement.write_bytes(cache.read_bytes())
|
||||
replacement.replace(cache)
|
||||
self.assertTrue(watcher.changed())
|
||||
|
||||
def test_watcher_follows_an_appcache_created_after_startup(self):
|
||||
root = self.root / "late"
|
||||
(root / "state").mkdir(parents=True)
|
||||
watcher = live.LIVE["Watcher"]([str(root / "state/request.json"), str(root / "appcache/appinfo.vdf")])
|
||||
self.assertGreaterEqual(watcher.fd, 0)
|
||||
self.addCleanup(os.close, watcher.fd)
|
||||
self.assertFalse(watcher.changed())
|
||||
(root / "appcache").mkdir()
|
||||
self.assertTrue(watcher.changed())
|
||||
(root / "appcache/appinfo.vdf").write_bytes(b"x")
|
||||
self.assertTrue(watcher.changed())
|
||||
|
||||
def test_watcher_follows_each_missing_directory_level(self):
|
||||
root = self.root / "levels"
|
||||
root.mkdir()
|
||||
target = root / "sd/steamapps/appmanifest_1.acf"
|
||||
watcher = live.LIVE["Watcher"]([str(target)])
|
||||
self.assertGreaterEqual(watcher.fd, 0)
|
||||
self.addCleanup(os.close, watcher.fd)
|
||||
(root / "sd").mkdir()
|
||||
self.assertTrue(select.select([watcher.fd], [], [], 1)[0])
|
||||
self.assertFalse(watcher.changed())
|
||||
(root / "sd/steamapps").mkdir()
|
||||
self.assertTrue(select.select([watcher.fd], [], [], 1)[0])
|
||||
self.assertTrue(watcher.changed())
|
||||
target.write_bytes(b"x")
|
||||
self.assertTrue(watcher.changed())
|
||||
|
||||
def test_linux_tool_requests_cannot_restore_windows_platform(self):
|
||||
changes, auto = self.plan({"0": TOOL, "1826330": TOOL}, overrides={"1826330": TOOL})
|
||||
self.assertIsNone(changes["1826330"])
|
||||
self.assertNotIn("1826330", auto)
|
||||
|
||||
def test_linux_execution_tools_and_custom_selections_survive(self):
|
||||
current = {"0": TOOL, "1826330": COMPAT["FEX_TOOL"], "900002": "custom-native",
|
||||
"900001": "custom-windows", "900005": TOOL}
|
||||
changes, auto = self.plan(current)
|
||||
self.assertEqual(changes, {"976730": TOOL})
|
||||
self.assertNotIn("1826330", auto)
|
||||
|
||||
def test_missing_or_malformed_cache_does_not_invent_platforms(self):
|
||||
cache = self.steam / "appcache/appinfo.vdf"
|
||||
cache.unlink()
|
||||
self.assertEqual(self.platforms(), {})
|
||||
cache.write_bytes(b"invalid")
|
||||
self.assertEqual(self.platforms(), {})
|
||||
|
||||
def test_offline_repair_is_idempotent_and_preserves_game_and_neighbor_fields(self):
|
||||
self.config.write_text(config_text(
|
||||
'"0" { "name" "%s" } "976730" { "name" "custom-game" } '
|
||||
'"1826330" { "name" "%s" }' % (TOOL, TOOL)))
|
||||
for _ in range(2):
|
||||
COMPAT["register_default"](str(self.config), ["976730"], catalog=self.catalog,
|
||||
directory=str(self.state), default=TOOL)
|
||||
self.assertEqual(COMPAT["read_config_mapping"](str(self.config)),
|
||||
{"0": TOOL, "976730": "custom-game", "900001": TOOL})
|
||||
self.assertIn('"outside"', self.config.read_text())
|
||||
state = json.loads((self.state / "state.json").read_text())
|
||||
self.assertNotIn("1826330", state["auto"])
|
||||
|
||||
def reconcile(self, page):
|
||||
helper = live.LIVE["Helper"](str(self.steam), 1)
|
||||
helper.page = page
|
||||
with mock.patch.dict(COMPAT, tool_catalog=lambda root: self.catalog):
|
||||
helper.reconcile()
|
||||
return json.loads((self.state / "state.json").read_text())
|
||||
|
||||
def test_live_repair_covers_uninstalled_dependencies_and_stays_idempotent(self):
|
||||
page = live.FakePage({"0": TOOL, "1826330": TOOL, "976730": "custom-game"}, [TOOL])
|
||||
state = self.reconcile(page)
|
||||
self.assertEqual(page.sets, [("900001", TOOL), ("1826330", "")])
|
||||
self.assertNotIn("1826330", state["auto"])
|
||||
page.sets.clear()
|
||||
self.reconcile(page)
|
||||
self.assertEqual(page.sets, [])
|
||||
self.assertEqual(page.mapping["976730"], "custom-game")
|
||||
|
||||
def test_live_removed_ge_proton_is_cleared_and_stays_cleared(self):
|
||||
page = live.FakePage({"0": TOOL, "1826330": "GE-Proton11-7"}, [TOOL])
|
||||
(self.state / "state.json").write_text(json.dumps({"auto": {"1826330": "GE-Proton11-7"}}))
|
||||
state = self.reconcile(page)
|
||||
self.assertIn(("1826330", ""), page.sets)
|
||||
self.assertNotIn("1826330", state["auto"])
|
||||
page.sets.clear()
|
||||
self.reconcile(page)
|
||||
self.assertEqual(page.sets, [])
|
||||
|
||||
def test_live_refused_tools_are_not_written(self):
|
||||
page = live.FakePage({"0": TOOL, "1826330": TOOL}, [TOOL], refused={"1826330", "900001"})
|
||||
self.reconcile(page)
|
||||
self.assertEqual(page.sets, [])
|
||||
@@ -35,8 +35,134 @@ class WinComponentsTest(unittest.TestCase):
|
||||
def pick(self, *ids):
|
||||
self.selection.write_text(json.dumps({"version": 1, "games": {"2571976725": list(ids)}}))
|
||||
|
||||
def apply(self):
|
||||
return MODULE["apply"](self.compat, "2571976725", self.selection, self.store)
|
||||
def apply(self, proton=None, env=None):
|
||||
return MODULE["apply"](self.compat, "2571976725", self.selection, self.store, proton=proton, env=env)
|
||||
|
||||
def proton(self, arm=True):
|
||||
root = Path(self.tmp.name) / ("arm-proton" if arm else "x64-proton")
|
||||
(root / "files/lib/wine" / ("aarch64-unix" if arm else "x86_64-unix")).mkdir(parents=True)
|
||||
return root / "proton"
|
||||
|
||||
def pe_image(self, machine):
|
||||
head = bytearray(64)
|
||||
head[:2] = b"MZ"
|
||||
head[60:64] = (64).to_bytes(4, "little")
|
||||
return bytes(head) + b"PE\0\0" + machine.to_bytes(2, "little") + b"\0" * 32
|
||||
|
||||
def arm64ec_pe_image(self):
|
||||
image = bytearray(0x400)
|
||||
image[:2] = b"MZ"
|
||||
image[60:64] = (64).to_bytes(4, "little")
|
||||
image[64:68] = b"PE\0\0"
|
||||
coff = 68
|
||||
image[coff:coff + 2] = (0x8664).to_bytes(2, "little")
|
||||
image[coff + 2:coff + 4] = (1).to_bytes(2, "little")
|
||||
image[coff + 16:coff + 18] = (240).to_bytes(2, "little")
|
||||
optional = coff + 20
|
||||
image[optional:optional + 2] = (0x20B).to_bytes(2, "little")
|
||||
image[optional + 108:optional + 112] = (16).to_bytes(4, "little")
|
||||
directory = optional + 112 + 10 * 8
|
||||
image[directory:directory + 4] = (0x1000).to_bytes(4, "little")
|
||||
image[directory + 4:directory + 8] = (0xD0).to_bytes(4, "little")
|
||||
section = optional + 240
|
||||
image[section + 8:section + 12] = (0x200).to_bytes(4, "little")
|
||||
image[section + 12:section + 16] = (0x1000).to_bytes(4, "little")
|
||||
image[section + 16:section + 20] = (0x200).to_bytes(4, "little")
|
||||
image[section + 20:section + 24] = (0x200).to_bytes(4, "little")
|
||||
image[0x200:0x204] = (0xD0).to_bytes(4, "little")
|
||||
image[0x2C8:0x2D0] = (0x10000000).to_bytes(8, "little")
|
||||
return bytes(image)
|
||||
|
||||
def test_foreign_component_dll_is_skipped_and_override_dropped(self):
|
||||
proton = self.proton()
|
||||
self.component("vc", ["ucrtbase"], {"system32/ucrtbase.dll": self.pe_image(0x8664)})
|
||||
self.pick("vc")
|
||||
names, _ = self.apply(proton)
|
||||
self.assertEqual(names, [])
|
||||
self.assertFalse((self.pfx / "system32/ucrtbase.dll").exists())
|
||||
names, notes = self.apply(proton)
|
||||
self.assertEqual(names, [])
|
||||
self.assertEqual(notes, [])
|
||||
|
||||
def test_component_final_arm64ec_dll_is_placed_and_keeps_its_override(self):
|
||||
proton = self.proton()
|
||||
self.component("vc", ["ucrtbase"], {"system32/ucrtbase.dll": self.arm64ec_pe_image()})
|
||||
self.pick("vc")
|
||||
names, _ = self.apply(proton)
|
||||
self.assertEqual(names, ["ucrtbase"])
|
||||
self.assertEqual(MODULE["pe_machine"](self.pfx / "system32/ucrtbase.dll"), 0x8664)
|
||||
|
||||
def test_compatible_copy_keeps_override_when_other_architecture_copy_is_skipped(self):
|
||||
proton = self.proton()
|
||||
self.component("vc", ["ucrtbase"], {
|
||||
"system32/ucrtbase.dll": self.pe_image(0x8664),
|
||||
"syswow64/ucrtbase.dll": self.pe_image(0x14C),
|
||||
})
|
||||
self.pick("vc")
|
||||
names, _ = self.apply(proton)
|
||||
self.assertEqual(names, ["ucrtbase"])
|
||||
self.assertFalse((self.pfx / "system32/ucrtbase.dll").exists())
|
||||
self.assertEqual(MODULE["pe_machine"](self.pfx / "syswow64/ucrtbase.dll"), 0x14C)
|
||||
|
||||
def test_existing_foreign_component_dll_is_removed_for_repair(self):
|
||||
proton = self.proton()
|
||||
self.component("vc", ["ucrtbase"], {"system32/ucrtbase.dll": self.pe_image(0x8664)})
|
||||
self.pick("vc")
|
||||
self.pfx.joinpath("system32").mkdir(parents=True)
|
||||
self.pfx.joinpath("system32/ucrtbase.dll").write_bytes(self.pe_image(0x8664))
|
||||
self.compat.joinpath(".droiddeck-wincomponents.json").write_text(json.dumps({
|
||||
"files": {"drive_c/windows/system32/ucrtbase.dll": "vc"}, "components": ["vc"]}))
|
||||
self.apply(proton)
|
||||
self.assertFalse((self.pfx / "system32/ucrtbase.dll").exists())
|
||||
self.assertEqual([], GAME_ENV["repair_foreign_system_dlls"](str(self.compat), str(proton)))
|
||||
self.assertEqual([], GAME_ENV["repair_foreign_system_dlls"](str(self.compat), str(proton)))
|
||||
|
||||
def test_foreign_component_restore_does_not_override_repair_with_native_override(self):
|
||||
proton = self.proton()
|
||||
builtin = Path(self.tmp.name) / "arm-proton/files/lib/wine/aarch64-windows/ucrtbase.dll"
|
||||
builtin.parent.mkdir(parents=True, exist_ok=True)
|
||||
builtin.write_bytes(self.pe_image(0xAA64))
|
||||
system32 = self.pfx / "system32"
|
||||
system32.mkdir(parents=True)
|
||||
dll = system32 / "ucrtbase.dll"
|
||||
dll.write_bytes(self.pe_image(0x8664))
|
||||
backup = self.compat / ".droiddeck-wincomponents-backup/drive_c/windows/system32/ucrtbase.dll"
|
||||
backup.parent.mkdir(parents=True)
|
||||
backup.write_bytes(self.pe_image(0x8664))
|
||||
self.compat.joinpath(".droiddeck-wincomponents.json").write_text(json.dumps({
|
||||
"files": {"drive_c/windows/system32/ucrtbase.dll": "vc"}, "components": ["vc"]}))
|
||||
self.compat.joinpath("pfx/user.reg").parent.mkdir(parents=True, exist_ok=True)
|
||||
self.compat.joinpath("pfx/user.reg").write_text(
|
||||
'WINE REGISTRY Version 2\n\n[Software\\\\Wine\\\\DllOverrides] 1\n"ucrtbase"="native,builtin"\n')
|
||||
fixed = GAME_ENV["repair_foreign_system_dlls"](str(self.compat), str(proton))
|
||||
self.assertEqual(["system32/ucrtbase.dll (machine 0x8664)"], fixed)
|
||||
self.apply(proton)
|
||||
self.assertTrue(dll.is_symlink())
|
||||
self.assertEqual(0xAA64, MODULE["pe_machine"](dll))
|
||||
|
||||
def test_matching_component_dll_is_placed(self):
|
||||
proton = self.proton()
|
||||
self.component("vc", ["ucrtbase"], {"system32/ucrtbase.dll": self.pe_image(0xAA64)})
|
||||
self.pick("vc")
|
||||
names, _ = self.apply(proton)
|
||||
self.assertEqual(names, ["ucrtbase"])
|
||||
self.assertEqual(MODULE["pe_machine"](self.pfx / "system32/ucrtbase.dll"), 0xAA64)
|
||||
|
||||
def test_x64_proton_places_x64_component_dll(self):
|
||||
proton = self.proton(arm=False)
|
||||
self.component("vc", ["ucrtbase"], {"system32/ucrtbase.dll": self.pe_image(0x8664)})
|
||||
self.pick("vc")
|
||||
names, _ = self.apply(proton)
|
||||
self.assertEqual(names, ["ucrtbase"])
|
||||
self.assertEqual(MODULE["pe_machine"](self.pfx / "system32/ucrtbase.dll"), 0x8664)
|
||||
|
||||
def test_arm_proton_with_x64_selection_places_x64_component_dll(self):
|
||||
proton = self.proton()
|
||||
self.component("vc", ["ucrtbase"], {"system32/ucrtbase.dll": self.pe_image(0x8664)})
|
||||
self.pick("vc")
|
||||
names, _ = self.apply(proton, {"PROTON_USE_ARM64": "0"})
|
||||
self.assertEqual(names, ["ucrtbase"])
|
||||
self.assertEqual(MODULE["pe_machine"](self.pfx / "system32/ucrtbase.dll"), 0x8664)
|
||||
|
||||
def test_places_files_before_the_prefix_exists_and_returns_overrides(self):
|
||||
self.component("openal", ["openal32"], {"syswow64/OpenAL32.dll": b"x86", "system32/OpenAL32.dll": b"x64"})
|
||||
@@ -235,7 +361,7 @@ class WinComponentsTest(unittest.TestCase):
|
||||
self.pick("openal")
|
||||
globals_ = GAME_ENV["win_components"].__globals__
|
||||
real = runpy.run_path(str(BIN / "droiddeck-wincomponents"))
|
||||
real["apply"].__defaults__ = (self.selection, self.store, self.selection.parent / "no-pending.json", None)
|
||||
real["apply"].__defaults__ = (self.selection, self.store, self.selection.parent / "no-pending.json", None, None, None)
|
||||
with mock.patch.dict(globals_, {"runpy": mock.Mock(run_path=lambda _path: real)}):
|
||||
self.assertEqual(GAME_ENV["win_components"](str(self.compat) + "/", "2571976725"), "openal32=n,b")
|
||||
self.assertEqual(GAME_ENV["win_components"]("", "2571976725"), "")
|
||||
|
||||
Reference in new issue
Block a user