Compare commits

...
5 Commits
Author SHA1 Message Date
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
53 changed files with 577 additions and 49 deletions

No files matched your search

+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() {
@@ -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
@@ -2435,6 +2436,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
@@ -2447,6 +2449,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()
@@ -2471,8 +2479,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)
@@ -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
@@ -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
}
@@ -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
@@ -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),
)
+3
View File
@@ -3809,4 +3809,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>
@@ -3665,4 +3665,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>
+3
View File
@@ -3653,4 +3653,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>
+3
View File
@@ -3731,4 +3731,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>
+3
View File
@@ -3653,4 +3653,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>
+3
View File
@@ -3653,4 +3653,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>
+3
View File
@@ -3653,4 +3653,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>
+3
View File
@@ -3692,4 +3692,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>
+3
View File
@@ -3653,4 +3653,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>
+3
View File
@@ -3692,4 +3692,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>
+3
View File
@@ -3653,4 +3653,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>
+3
View File
@@ -3653,4 +3653,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>
+3
View File
@@ -3623,4 +3623,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>
+3
View File
@@ -3692,4 +3692,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>
+3
View File
@@ -3623,4 +3623,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>
+3
View File
@@ -3623,4 +3623,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>
+3
View File
@@ -3623,4 +3623,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>
+3
View File
@@ -3653,4 +3653,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>
+3
View File
@@ -3653,4 +3653,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>
+3
View File
@@ -3731,4 +3731,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>
@@ -3692,4 +3692,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>
+3
View File
@@ -3692,4 +3692,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>
+3
View File
@@ -3692,4 +3692,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>
+3
View File
@@ -3814,4 +3814,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>
+3
View File
@@ -3653,4 +3653,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>
+3
View File
@@ -3623,4 +3623,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>
+3
View File
@@ -3653,4 +3653,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>
+3
View File
@@ -3731,4 +3731,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>
+3
View File
@@ -3623,4 +3623,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>
@@ -3627,4 +3627,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>
@@ -3626,4 +3626,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>
@@ -3626,4 +3626,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>
+3
View File
@@ -3814,4 +3814,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,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())
}
}
+6 -1
View File
@@ -263,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
@@ -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"
+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");
+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)):
+9
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")
+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()
+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')]