Compare commits

..
10 changed files with 166 additions and 58 deletions

No files matched your search

@@ -1601,6 +1601,7 @@ class SessionActivity : ComponentActivity(), SurfaceHolder.Callback {
intent.getStringExtra(SessionService.EXTRA_STEAM_UI),
intent.getStringExtra(SessionService.EXTRA_STEAM_URL),
intent.getStringArrayExtra(SessionService.EXTRA_PROGRAM_ARGS),
shownOnDisplayId,
)
}
applyFrameGen()
@@ -1651,6 +1652,7 @@ class SessionActivity : ComponentActivity(), SurfaceHolder.Callback {
override fun onConfigurationChanged(newConfig: android.content.res.Configuration) {
super.onConfigurationChanged(newConfig)
if (started) SessionService.setActivityDisplay(this, shownOnDisplayId)
cancelTouchGestures()
applyFollowOrientation(newConfig)
applyUnfoldedControls()
@@ -2274,9 +2276,13 @@ class SessionActivity : ComponentActivity(), SurfaceHolder.Callback {
return true
}
private val shownOnDisplayId: Int get() =
if (Build.VERSION.SDK_INT >= 30) display?.displayId ?: windowManager.defaultDisplay.displayId
else windowManager.defaultDisplay.displayId
private fun refreshSecondScreenDisplays() {
if (!::displayManager.isInitialized) return
val primaryId = display?.displayId ?: windowManager.defaultDisplay.displayId
val primaryId = shownOnDisplayId
val candidates = SecondScreenDisplays.available(displayManager, primaryId)
.filter { glassesScreen == null || it.id != glasses.displayId }
val oldIds = secondScreenDisplays.map { it.id }.toSet()
@@ -2799,7 +2805,7 @@ class SessionActivity : ComponentActivity(), SurfaceHolder.Callback {
refreshSecondScreenDisplays()
if (secondScreenMode != SecondScreenMode.NONE && secondScreenPresentation == null) showSecondScreen(secondScreenMode)
startedScreens++
SessionService.setActivityVisible(this, true)
SessionService.setActivityVisible(this, true, shownOnDisplayId)
Glasses.rescan()
updateGlassesScreen()
updateHandCamera()
@@ -32,7 +32,6 @@ object GameEnvironmentOptions {
Option("MESA_EXTENSION_MAX_YEAR", "", 0, Type.TEXT),
Option("MESA_GL_VERSION_OVERRIDE", "", 0, Type.TEXT),
Option("PULSE_LATENCY_MSEC", "", 0, Type.NUMBER),
Option("DROIDDECK_ANTICHEAT_OFFLINE", "1", R.string.game_env_anticheat_offline, Type.TOGGLE, listOf("0", "1")),
Option("WINE_LARGE_ADDRESS_AWARE", "0", 0, Type.TOGGLE, listOf("0", "1")),
Option("WINEDLLOVERRIDES", "", 0, Type.TEXT),
Option("GALLIUM_HUD", "simple", 0, Type.MULTIPLE, listOf("simple", "fps", "frametime")),
@@ -22,6 +22,8 @@ import android.os.IBinder
import android.os.Handler
import android.os.Looper
import android.os.PowerManager
import android.hardware.display.DisplayManager
import android.view.Display
import android.util.Log
import com.droiddeck.launcher.R
import com.droiddeck.launcher.SessionActivity
@@ -76,6 +78,7 @@ class SessionService : Service() {
private var signInHold = false
private val hasVisibleSurface get() = sessionHasVisibleSurface(activityVisible || signInHold, screenOn, xrActivityVisible)
private var screenOn = true
private var sessionDisplayId = Display.DEFAULT_DISPLAY
private var manualPauseRequested = false
private var steamSleepToken: String? = null
private var nativeSleepToken: String? = null
@@ -95,8 +98,8 @@ class SessionService : Service() {
private val screenReceiver = object : BroadcastReceiver() {
override fun onReceive(context: Context?, intent: Intent?) {
when (intent?.action) {
Intent.ACTION_SCREEN_OFF -> screenOn = false
Intent.ACTION_SCREEN_ON -> screenOn = true
Intent.ACTION_SCREEN_OFF -> screenOn = readScreenOn(false)
Intent.ACTION_SCREEN_ON -> screenOn = readScreenOn(true)
else -> return
}
if (screenOn) resumeSteamSleepOnReturn()
@@ -104,17 +107,49 @@ class SessionService : Service() {
updateSuspendPolicy()
}
}
private var displayListenerRegistered = false
private val displayListener = object : DisplayManager.DisplayListener {
override fun onDisplayAdded(displayId: Int) {}
override fun onDisplayRemoved(displayId: Int) {
if (displayId != sessionDisplayId) return
sessionDisplayId = Display.DEFAULT_DISPLAY
refreshScreenOn()
}
override fun onDisplayChanged(displayId: Int) {
if (displayId == sessionDisplayId && displayId != Display.DEFAULT_DISPLAY) refreshScreenOn()
}
}
private fun readScreenOn(interactive: Boolean? = null): Boolean {
val id = sessionDisplayId
val state = if (id == Display.DEFAULT_DISPLAY) null
else (getSystemService(Context.DISPLAY_SERVICE) as? DisplayManager)?.getDisplay(id)?.state
return sessionScreenOn(id == Display.DEFAULT_DISPLAY,
interactive ?: (getSystemService(Context.POWER_SERVICE) as? PowerManager)?.isInteractive ?: true, state)
}
private fun refreshScreenOn() {
val on = readScreenOn()
if (on == screenOn) return
screenOn = on
if (on) resumeSteamSleepOnReturn()
suspendAttemptFailed = false
updateSuspendPolicy()
}
@Volatile private var sessionGen = 0
override fun onCreate() {
super.onCreate()
val power = getSystemService(Context.POWER_SERVICE) as? PowerManager
screenOn = power?.isInteractive ?: true
screenOn = readScreenOn()
registerReceiver(screenReceiver, IntentFilter().apply {
addAction(Intent.ACTION_SCREEN_OFF)
addAction(Intent.ACTION_SCREEN_ON)
})
screenReceiverRegistered = true
(getSystemService(Context.DISPLAY_SERVICE) as? DisplayManager)?.let {
it.registerDisplayListener(displayListener, Handler(Looper.getMainLooper()))
displayListenerRegistered = true
}
}
override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int {
@@ -163,12 +198,19 @@ class SessionService : Service() {
ACTION_ACTIVITY_VISIBLE -> {
if (!SessionState.pipActive) pipTask = false
activityVisible = true
sessionDisplayId = intent.getIntExtra(EXTRA_DISPLAY_ID, sessionDisplayId)
screenOn = readScreenOn()
suspendDownloadNow = false
resumeSteamSleepOnReturn()
suspendAttemptFailed = false
updateSuspendPolicy()
return START_NOT_STICKY
}
ACTION_ACTIVITY_DISPLAY -> {
sessionDisplayId = intent.getIntExtra(EXTRA_DISPLAY_ID, sessionDisplayId)
refreshScreenOn()
return START_NOT_STICKY
}
ACTION_ACTIVITY_HIDDEN -> {
activityVisible = false
suspendAttemptFailed = false
@@ -232,7 +274,8 @@ class SessionService : Service() {
activityVisible = true
xrActivityVisible = false
signInHold = false
screenOn = (getSystemService(Context.POWER_SERVICE) as? PowerManager)?.isInteractive ?: true
sessionDisplayId = intent?.getIntExtra(EXTRA_DISPLAY_ID, Display.DEFAULT_DISPLAY) ?: Display.DEFAULT_DISPLAY
screenOn = readScreenOn()
manualPauseRequested = false
suspendDownloadNow = false
steamDownloadMonitor.reset()
@@ -1022,7 +1065,7 @@ class SessionService : Service() {
private fun resumeSession(panelVisible: Boolean = true) {
if (panelVisible) activityVisible = true
screenOn = (getSystemService(Context.POWER_SERVICE) as? PowerManager)?.isInteractive ?: screenOn
screenOn = readScreenOn()
manualPauseRequested = false
suspendDownloadNow = false
if (steamSleepToken == null && (nativeSleepToken == null || nativeSleepFallback || nativeSleepIdle)) requestSteamWake()
@@ -1248,7 +1291,7 @@ class SessionService : Service() {
suspendOperationPending = true
SessionEvents.record(if (shouldSuspend) "session.suspend_requested" else "session.resume_requested", mapOf(
"policy" to suspendPolicy, "activityVisible" to activityVisible,
"screenOn" to screenOn, "steamSleep" to (steamSleepToken != null),
"screenOn" to screenOn, "display" to sessionDisplayId, "steamSleep" to (steamSleepToken != null),
"xrActivityVisible" to xrActivityVisible,
"nativeSleep" to (nativeSleepToken != null),
))
@@ -1512,6 +1555,10 @@ class SessionService : Service() {
unregisterReceiver(screenReceiver)
screenReceiverRegistered = false
}
if (displayListenerRegistered) {
(getSystemService(Context.DISPLAY_SERVICE) as? DisplayManager)?.unregisterDisplayListener(displayListener)
displayListenerRegistered = false
}
super.onDestroy()
}
@@ -1699,6 +1746,8 @@ class SessionService : Service() {
private const val ACTION_SUSPEND_POLICY_CHANGED = "com.droiddeck.launcher.SUSPEND_POLICY_CHANGED"
private const val ACTION_ACTIVITY_VISIBLE = "com.droiddeck.launcher.ACTIVITY_VISIBLE"
private const val ACTION_ACTIVITY_HIDDEN = "com.droiddeck.launcher.ACTIVITY_HIDDEN"
private const val ACTION_ACTIVITY_DISPLAY = "com.droiddeck.launcher.ACTIVITY_DISPLAY"
private const val EXTRA_DISPLAY_ID = "displayId"
private const val ACTION_XR_ACTIVITY_VISIBLE = "com.droiddeck.launcher.XR_ACTIVITY_VISIBLE"
private const val ACTION_XR_ACTIVITY_HIDDEN = "com.droiddeck.launcher.XR_ACTIVITY_HIDDEN"
private const val ACTION_SIGN_IN_HOLD = "com.droiddeck.launcher.SIGN_IN_HOLD"
@@ -1735,8 +1784,10 @@ class SessionService : Service() {
fun start(
context: Context, mode: String = MODE_STEAM, program: String? = null,
steamUi: String? = null, steamUrl: String? = null, programArgs: Array<String>? = null,
displayId: Int = Display.DEFAULT_DISPLAY,
) {
val intent = Intent(context, SessionService::class.java).putExtra(EXTRA_MODE, mode)
.putExtra(EXTRA_DISPLAY_ID, displayId)
if (program != null) intent.putExtra(EXTRA_PROGRAM, program)
if (programArgs != null) intent.putExtra(EXTRA_PROGRAM_ARGS, programArgs)
if (steamUi != null) intent.putExtra(EXTRA_STEAM_UI, steamUi)
@@ -1757,10 +1808,15 @@ class SessionService : Service() {
context.startService(Intent(context, SessionService::class.java).setAction(ACTION_PIP_BEGIN))
}
fun setActivityVisible(context: Context, visible: Boolean) {
fun setActivityVisible(context: Context, visible: Boolean, displayId: Int = Display.DEFAULT_DISPLAY) {
if (!SessionState.running) return
val action = if (visible) ACTION_ACTIVITY_VISIBLE else ACTION_ACTIVITY_HIDDEN
context.startService(Intent(context, SessionService::class.java).setAction(action))
context.startService(Intent(context, SessionService::class.java).setAction(action).putExtra(EXTRA_DISPLAY_ID, displayId))
}
fun setActivityDisplay(context: Context, displayId: Int) {
if (!SessionState.running) return
context.startService(Intent(context, SessionService::class.java).setAction(ACTION_ACTIVITY_DISPLAY).putExtra(EXTRA_DISPLAY_ID, displayId))
}
fun setXrActivityVisible(context: Context, visible: Boolean) {
@@ -1,4 +1,10 @@
package com.droiddeck.launcher.session
import android.view.Display
internal fun sessionHasVisibleSurface(panelVisible: Boolean, screenOn: Boolean, xrVisible: Boolean): Boolean =
xrVisible || (panelVisible && screenOn)
internal fun sessionScreenOn(onDefaultDisplay: Boolean, interactive: Boolean, displayState: Int?): Boolean =
if (onDefaultDisplay || displayState == null || displayState == Display.STATE_UNKNOWN) interactive
else displayState == Display.STATE_ON || displayState == Display.STATE_VR || displayState == Display.STATE_ON_SUSPEND
-1
View File
@@ -90,7 +90,6 @@
<string name="game_env_no_fsync">Set to 1 to keep this game off droiddeck-fsync; it uses droiddeck-esync where a pack exists, otherwise droiddeck-ntsync.</string>
<string name="game_env_fsync_first">Set to 1 to run this game on droiddeck-fsync, or 0 to put droiddeck-esync first where a pack fits its Proton.</string>
<string name="game_env_xalia">Set to 0 to disable Proton’s gamepad-navigation helper for this game.</string>
<string name="game_env_anticheat_offline">On by default. Starts an Easy Anti-Cheat game without its anti-cheat when the game supports offline play. Turn off to launch through Easy Anti-Cheat as usual. Online modes that require Easy Anti-Cheat remain unavailable.</string>
<string name="game_env_hud">DXVK overlay for D3D9–11. fps displays frame rate; unset hides it.</string>
<string name="game_env_dxvk_config">DXVK settings separated by semicolons. Recent builds use dxvk.maxFrameRate for frame limiting; prefer the session frame limiter for ordinary play.</string>
<string name="game_env_vkd3d_limit">D3D12 frame-rate limit. 0 disables the limit.</string>
@@ -1,5 +1,6 @@
package com.droiddeck.launcher.session
import android.view.Display
import org.junit.Assert.assertFalse
import org.junit.Assert.assertTrue
import org.junit.Test
@@ -20,6 +21,34 @@ class SessionVisibilityTest {
assertFalse(sessionHasVisibleSurface(false, false, false))
}
@Test fun phoneDisplayFollowsTheInteractiveState() {
assertTrue(sessionScreenOn(true, true, null))
assertFalse(sessionScreenOn(true, false, null))
assertFalse(sessionScreenOn(true, false, Display.STATE_ON))
}
@Test fun externalDisplayKeepsTheSessionAwakeWhileThePhoneScreenIsOff() {
assertTrue(sessionScreenOn(false, false, Display.STATE_ON))
assertTrue(sessionScreenOn(false, false, Display.STATE_ON_SUSPEND))
assertTrue(sessionScreenOn(false, false, Display.STATE_VR))
}
@Test fun externalDisplayTurnedOffSuspendsEvenWithThePhoneScreenOn() {
assertFalse(sessionScreenOn(false, true, Display.STATE_OFF))
assertFalse(sessionScreenOn(false, true, Display.STATE_DOZE))
assertFalse(sessionScreenOn(false, true, Display.STATE_DOZE_SUSPEND))
}
@Test fun missingExternalDisplayFallsBackToThePhone() {
assertTrue(sessionScreenOn(false, true, null))
assertFalse(sessionScreenOn(false, false, null))
}
@Test fun unknownExternalDisplayStateFallsBackToThePhone() {
assertTrue(sessionScreenOn(false, true, Display.STATE_UNKNOWN))
assertFalse(sessionScreenOn(false, false, Display.STATE_UNKNOWN))
}
@Test fun losingXrVisibilityReturnsToNormalSuspendRules() {
assertTrue(sessionHasVisibleSurface(false, false, true))
assertFalse(sessionHasVisibleSurface(false, false, false))
@@ -126,28 +126,6 @@ def skip_red_launcher(command):
return command
def offline_without_anticheat(command, env):
"""Start the configured game executable when the player opts into launching without EAC."""
if env.get("DROIDDECK_ANTICHEAT_OFFLINE") != "1" or len(command) <= 2:
return command
folder = os.path.dirname(command[2])
try:
with open(os.path.join(folder, "EasyAntiCheat", "settings.json"), encoding="utf-8-sig") as source:
executable = json.load(source).get("executable")
except (OSError, ValueError, AttributeError):
return command
if not isinstance(executable, str) or not executable:
return command
relative = os.path.normpath(executable.replace("\\", "/"))
game = os.path.join(folder, relative)
if os.path.isabs(relative) or relative.startswith("..") or not os.path.isfile(game):
return command
if os.path.normcase(os.path.abspath(game)) == os.path.normcase(os.path.abspath(command[2])):
return command
print("droiddeck-game-env: started without Easy Anti-Cheat (offline only)", file=sys.stderr)
return command[:2] + [game] + command[3:]
def engine_fixes(exe, args):
"""Environment and extra arguments a game needs because of what it is built with."""
game = Game(exe, args)
@@ -348,8 +326,6 @@ def main():
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)
if app_id and len(command) > 1 and command[1] == "waitforexitandrun":
command = offline_without_anticheat(command, env)
if mic_enabled == "0":
env["BANNER_AUDIO_DIRECT_MIC"] = "0"
env = force_ssbs(command, env)
@@ -271,6 +271,23 @@ def provision_prefix(compat_data, wait=SERVER_WAIT):
return state + (" server-running" if busy else "")
def create_prefix(compat_data, proton, timeout=120):
if not compat_data or not proton or not Path(proton).is_file():
return "no-proton"
reg = Path(compat_data) / "pfx/user.reg"
if reg.is_file():
return "present"
try:
Path(compat_data).mkdir(parents=True, exist_ok=True)
subprocess.run([proton, "run", "cmd.exe", "/d", "/c", "exit"], stdin=subprocess.DEVNULL,
stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL, timeout=timeout)
except subprocess.TimeoutExpired:
return "timeout"
except OSError:
return "failed"
return "created" if reg.is_file() else "failed"
CLOUD_TIMEOUT = 15.0
CLOUD_STORES = ("gog", "epic")
STORE_GAME = 10
@@ -502,6 +519,10 @@ def main(argv):
compat_data = os.environ.get("STEAM_COMPAT_DATA_PATH", "").rstrip("/")
prefix_id = os.path.basename(compat_data) if compat_data else ""
launch_dir = os.environ.get("BL_LAUNCH_DIR", "")
if launch_dir and cloud_on(sidecar):
made = create_prefix(compat_data, os.environ.get("BL_STORE_PROTON", ""))
if made != "present":
log("cloud %s %s prefix=%s" % (store, game, made))
if store == "gog":
prepare_gog(sidecar, compat_data, launch_dir)
log("gog registry: " + apply_gog_registry(folder, compat_data, os.environ.get("BL_STORE_PROTON", "")))
-21
View File
@@ -201,27 +201,6 @@ class GameEnvironmentTest(unittest.TestCase):
(other / "Other.exe").touch()
self.assertEqual(MODULE["engine_fixes"](str(other / "Other.exe"), []), ({}, [], []))
def test_easy_anti_cheat_games_start_offline_only_when_opted_in(self):
with tempfile.TemporaryDirectory() as tmp:
game = Path(tmp) / "ELDEN RING/Game"
(game / "EasyAntiCheat").mkdir(parents=True)
for name in ("start_protected_game.exe", "eldenring.exe"):
(game / name).touch()
(game / "EasyAntiCheat/settings.json").write_text('\ufeff{ "executable" : "eldenring.exe" }', encoding="utf-8")
command = ["proton", "waitforexitandrun", str(game / "start_protected_game.exe"), "-arg"]
offline = MODULE["offline_without_anticheat"]
self.assertEqual(offline(command, {}), command)
self.assertEqual(offline(command, {"DROIDDECK_ANTICHEAT_OFFLINE": "0"}), command)
self.assertEqual(offline(command, {"DROIDDECK_ANTICHEAT_OFFLINE": "1"}),
["proton", "waitforexitandrun", str(game / "eldenring.exe"), "-arg"])
(game / "EasyAntiCheat/settings.json").write_text('{ "executable" : "../../escape.exe" }')
(Path(tmp) / "escape.exe").touch()
self.assertEqual(offline(command, {"DROIDDECK_ANTICHEAT_OFFLINE": "1"}), command)
(game / "EasyAntiCheat/settings.json").write_text('{ "executable" : "missing.exe" }')
self.assertEqual(offline(command, {"DROIDDECK_ANTICHEAT_OFFLINE": "1"}), command)
plain = ["proton", "waitforexitandrun", str(Path(tmp) / "Other.exe")]
self.assertEqual(offline(plain, {"DROIDDECK_ANTICHEAT_OFFLINE": "1"}), plain)
def test_deep_rock_gdk_avoids_the_raytracing_pipeline_crash(self):
game = "/game/FSD/Binaries/WinGDK/FSD-WinGDK-Shipping.exe"
env, extra, notes = MODULE["engine_fixes"](game, [])
+37
View File
@@ -226,6 +226,43 @@ class CloudHooks(unittest.TestCase):
self.assertEqual(store.CLOUD_TIMEOUT, asked[1][1])
self.assertEqual('cloud-up', asked[2][0]['op'])
def test_a_first_launch_creates_the_prefix_before_the_cloud_download(self):
with tempfile.TemporaryDirectory() as root:
folder = game(root, store_id='epic', ident='fa4240e5')
sidecar = json.loads((folder / store.SIDECAR).read_text())
sidecar['epic'] = {'eos': False, 'offline': True}
(folder / store.SIDECAR).write_text(json.dumps(sidecar))
proton = Path(root) / 'proton'
proton.write_text('selected Proton')
compat = Path(root) / 'compatdata/1'
order = []
def run(argv, **kwargs):
order.append(argv)
(compat / 'pfx').mkdir(parents=True)
(compat / 'pfx/user.reg').write_text('WINE REGISTRY Version 2\n')
return __import__('subprocess').CompletedProcess(argv, 0)
env = {'BL_LAUNCH_DIR': root, 'STEAM_COMPAT_DATA_PATH': str(compat), 'BL_STORE_PROTON': str(proton)}
with patch.dict(os.environ, env), patch.object(store.subprocess, 'run', side_effect=run), \
patch.object(store, 'ask', side_effect=lambda channel, request, timeout=5.0: order.append(request) or {'result': 'ok'}):
self.assertEqual(store.STORE_GAME, store.main([str(folder / store.LAUNCHER)]))
self.assertEqual([str(proton), 'run', 'cmd.exe', '/d', '/c', 'exit'], order[0])
self.assertEqual({'op': 'cloud-down', 'store': 'epic', 'id': 'fa4240e5'}, order[-1])
self.assertEqual('present', store.create_prefix(str(compat), str(proton)))
self.assertEqual(2, len(order))
def test_a_prefix_that_is_not_made_leaves_the_deferred_download(self):
with tempfile.TemporaryDirectory() as root:
proton = Path(root) / 'proton'
proton.write_text('selected Proton')
with patch.object(store.subprocess, 'run', return_value=__import__('subprocess').CompletedProcess([], 1)):
self.assertEqual('failed', store.create_prefix(root + '/compatdata/1', str(proton)))
with patch.object(store.subprocess, 'run', side_effect=__import__('subprocess').TimeoutExpired('proton', 1)):
self.assertEqual('timeout', store.create_prefix(root + '/compatdata/1', str(proton)))
self.assertEqual('no-proton', store.create_prefix(root + '/compatdata/1', root + '/missing'))
self.assertEqual('no-proton', store.create_prefix('', str(proton)))
def test_cloud_off_or_amazon_runs_proton_as_before(self):
with tempfile.TemporaryDirectory() as root:
folder = game(root, store_id='gog', ident='1')