Compare commits

...
22 Commits
Author SHA1 Message Date
MaxsTechReviewandKurt Himebauch 13c8edb3d5 fix(game-env): repair foreign-architecture system DLLs in ARM64 prefixes (#2318)
Co-authored-by: Kurt Himebauch <[email protected]>
2026-11-10 06:24:03 -05:00
MaxsTechReview 1ec2b696b7 fix(flatpak): stop inheriting host Zink overrides (#2391)
* fix(flatpak): stop inheriting host zink overrides

* Check every cleared driver override in the Flatpak launcher test
2026-11-10 02:55:13 -05:00
MaxsTechReview 621fd8d43e fix(gog): install owned DLC (#2348) 2026-11-09 23:58:24 -05:00
LunaDL 17936530aa feat(display): add left and right picture alignment (#2384) 2026-11-09 23:41:25 -05:00
LunaDL ae3e9509f4 Feat: Add new theme color for launcher: Scarlet snow (#2262) 2026-11-09 20:29:14 -05:00
MaxsTechReview 6bcd431ca6 fix(session): wait for Steam readiness before launch (#2362)
* fix(session): wait for Steam readiness before launch

* fix(session): keep the launch fallback when Steam readiness or the interstitial is unavailable

* fix(session): cap the Steam readiness wait by elapsed time and bound each check

* fix(session): keep waiting for Steam readiness through focus changes and never pass a zero timeout
2026-11-09 18:23:24 -05:00
MaxsTechReview 836340f3a2 fix(session): no endless loading screen after a game exits back to Steam (#2359)
* fix(session): avoid stale relaunch loading overlay

* fix(session): keep the loading screen while the runtime installs
2026-11-09 17:09:27 -05:00
Kurt Himebauch 1b19837fc6 fix(native): a guest's own seccomp filter gets its SIGSYS, so Firefox tabs stop crashing (#2360) 2026-11-09 16:42:38 -05:00
Kurt Himebauch c85b84a964 fix(input): fakeinput no longer aborts programs whose allocator calls a hooked function at startup (#2361)
* fix(input): fakeinput no longer aborts programs whose allocator calls a hooked function at startup

* fix(ctl): screenshot finds display ids that the device name carries inline

* fix(input): fakeinput registers its fork handlers before other threads see its config
2026-11-09 16:41:26 -05:00
MaxsTechReview 75a0230304 fix(input): Auto relative mouse stays off for direct touch without a mouse (#2305)
* fix(input): Auto relative mouse stays off for direct touch without a mouse

* fix(input): re-check relative mouse when an input device changes

* fix(input): reapply relative mouse after agent readiness

* fix(input): only the current session clears the agent-ready hook and readiness resets relative mouse retries
2026-11-09 15:02:12 -05:00
Kurt Himebauch 7c89c14a84 fix(dual-screen): drop the Dashboard notifications page and its notification listener (#2342)
* fix(dual-screen): drop the Dashboard notifications page and its notification listener

* test(session): freeze the clock in the memory-pressure policy harness
2026-11-09 12:48:00 -05:00
Kurt Himebauch b837611efe fix(ctl): a full disk no longer crashes the app when an agent call cannot write its answer (#2325) 2026-11-09 12:33:26 -05:00
Kurt Himebauch bf434b2652 fix(hud): steady frame graph on the bottom screen dashboard (#2345) 2026-11-09 12:30:41 -05:00
GiovanniCarbinatti 35941bb9e7 chore: update README.md (#2311) 2026-11-09 06:45:24 -05:00
MaxsTechReview 96a12465e0 fix(session): stop re-compressing game memory every 30 s when the game reads it straight back (#2312)
* fix(session): stop re-compressing game memory every 30 s when the game reads it straight back

On devices with 13 GB or less, the low-memory page-out skipped its
read-back backoff whenever memory was nearly exhausted. When the game
was actively using what was compressed, it read it straight back, so
the session pushed and re-read gigabytes every 30 seconds, pinning all
cores and freezing the game for 5-10 s each time. The read-back check
now applies on that path too: an effective page-out still repeats every
30 s, an ineffective one backs off as on the normal path.

* zRAM: keep the read-back backoff on the nearly-out-of-memory path

* zRAM: retry a skipped page-out after 30 s when nearly out of memory
2026-11-09 02:08:40 -05:00
Kurt Himebauch 88d6b70881 fix(ctl): reach the agent bridge by broadcast when the shell has no content command (#2321) 2026-11-09 00:00:20 -05:00
MaxsTechReview 29823bb1bf fix(steam): Linux-only tools keep their native platform (#2313)
* fix(steam): reconcile tool dependencies by supported platform

* fix(steam): refresh tool metadata and clear stale GE mappings

* fix(steam): clear any Proton mapping from Linux-only tools

* fix(steam): watch folders the client creates after the helper starts

* Watch each missing directory level as it appears

* Make the watcher level test wait on inotify readiness
2026-11-08 22:22:22 -05:00
MaxsTechReview 92a7124589 fix(gpu): skip system Vulkan pruning when the runtime pack is absent (#2316)
fix: skip pruning system Vulkan runtimes when the runtime pack is not installed
2026-11-08 22:07:39 -05:00
Kurt Himebauch bf95a40e3e fix(ci): post new Discord notice for each PR build (#2314)
Post a fresh signed test-build notice for each new PR commit, retire the
previous notice, and include the latest commit subject.
2026-11-08 21:19:22 -05:00
Kurt Himebauch 1355cb3a2b fix(ci): handle empty commit lookup responses 2026-11-08 21:13:53 -05:00
Kurt Himebauch 51407155fc fix(ci): keep commit lookup failures from blocking notices 2026-11-08 21:12:58 -05:00
Kurt Himebauch b4a953c659 fix(ci): post new Discord notice for each PR build 2026-11-08 21:08:22 -05:00
95 changed files with 2111 additions and 1090 deletions

No files matched your search

+3 -3
View File
@@ -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).
+8 -8
View File
@@ -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"
+27 -16
View File
@@ -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),
)
+5 -7
View File
@@ -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>
+4 -7
View File
@@ -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>
+5 -7
View File
@@ -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>
+5 -7
View File
@@ -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>
+5 -7
View File
@@ -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>
+5 -7
View File
@@ -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>
+5 -7
View File
@@ -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>
+5 -7
View File
@@ -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>
+5 -7
View File
@@ -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>
+5 -7
View File
@@ -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>
+5 -7
View File
@@ -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>
+5 -7
View File
@@ -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>
+5 -7
View File
@@ -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>
+5 -7
View File
@@ -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>
+5 -7
View File
@@ -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>
+5 -7
View File
@@ -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>
+5 -7
View File
@@ -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>
+5 -7
View File
@@ -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>
+5 -7
View File
@@ -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>
+5 -7
View File
@@ -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>
+5 -7
View File
@@ -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>
+5 -7
View File
@@ -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>
+5 -7
View File
@@ -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>
+5 -7
View File
@@ -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>
+5 -7
View File
@@ -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>
+5 -7
View File
@@ -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>
+5 -7
View File
@@ -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>
+5 -7
View File
@@ -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>
+5 -7
View File
@@ -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>
+5 -7
View File
@@ -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>
+5 -7
View File
@@ -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>
+5 -7
View File
@@ -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>
+5 -7
View File
@@ -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
View File
@@ -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
View File
@@ -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)):
+10
View File
@@ -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:
+49
View File
@@ -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;
}
+38 -10
View File
@@ -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;
}
+6 -3
View File
@@ -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");
+46 -8
View File
@@ -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})
+20
View File
@@ -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)):
+31
View File
@@ -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 = []
+58
View File
@@ -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()
+209
View File
@@ -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()
+15 -5
View File
@@ -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"])
+28 -3
View File
@@ -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):
+109 -5
View File
@@ -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
+26 -12
View File
@@ -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')]
+215
View File
@@ -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, [])
+129 -3
View File
@@ -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"), "")