Stores: Epic overlay off by default, switches saved on disk, registry writes checked

With the overlay pointer set Metalstorm exits while loading; without it
the game signs in and runs. The overlay now defaults to off: the
sidecar's epic block carries a version, and a block from before it (the
old default wrote overlay=true unasked) reads as off, in the app and in
droiddeck-store-launch alike. The overlay is fetched only for a game that
turns it on.

The card's switches read the sidecar fresh, write it and read it back,
and show what was read back. droiddeck-store-launch waits for a
wineserver still holding the prefix before appending to user.reg - one
that exits rewrites the file and drops the line - reads the value back
and logs what the file holds. Its docstring is raw. The game-PATH
xdg-open logs each call as scheme://host only and accepts
steam://openurl.
This commit is contained in:
The412Banner committed 2026-10-09 00:09:39 -04:00
1 parent 1d1de10b2d
commit a844483080
9 files changed
+165 -30

No files matched your search

@@ -60,7 +60,7 @@ class StoreGameSidecar(
put("title", title)
if (exe.isNotEmpty()) put("exe", exe)
if (state != STATE_INSTALLED) put("state", state)
if (store == Store.EPIC) put("epic", JSONObject().put("eos", epic.eos).put("offline", epic.offline).put("overlay", epic.overlay))
if (store == Store.EPIC) put("epic", JSONObject().put("v", EpicOptions.VERSION).put("eos", epic.eos).put("offline", epic.offline).put("overlay", epic.overlay))
if (!launcher.isNullOrEmpty()) put("launcher", launcher)
if (args.isNotEmpty()) put("args", JSONArray(args))
if (env.isNotEmpty()) put("env", JSONObject(env as Map<*, *>))
@@ -87,6 +87,18 @@ class StoreGameSidecar(
fun file(folder: File): File = File(folder, FILE_NAME)
/**
* Changes an Epic game's launch choices on disk: reads the sidecar fresh (never a copy held
* by a screen), applies [change], writes it, and reads it back. The written sidecar, or null
* when there is none or the write did not take. Blocking.
*/
fun updateEpic(folder: File, change: (EpicOptions) -> EpicOptions): StoreGameSidecar? {
val current = read(folder) ?: return null
val next = current.copy(epic = change(current.epic))
next.write(folder)
return read(folder)?.takeIf { it.epic == next.epic }
}
/** The sidecar in [folder], or null when there is none or it cannot be read. */
fun read(folder: File): StoreGameSidecar? {
val f = file(folder)
@@ -122,7 +134,10 @@ class StoreGameSidecar(
extra = extra,
state = state,
epic = o.optJSONObject("epic")?.let { e ->
EpicOptions(eos = e.optBoolean("eos", true), offline = e.optBoolean("offline", false), overlay = e.optBoolean("overlay", true))
// Before v2 the overlay was on by default and written so without anyone choosing
// it; the overlay crashed games, so those read as off. From v2 it is a choice.
val chosen = e.optInt("v", 1) >= EpicOptions.VERSION
EpicOptions(eos = e.optBoolean("eos", true), offline = e.optBoolean("offline", false), overlay = chosen && e.optBoolean("overlay", false))
} ?: EpicOptions(),
)
}
@@ -136,9 +151,15 @@ class StoreGameSidecar(
/**
* An Epic game's launch choices. [eos]: pass a fresh exchange code (Epic sign-in). [offline]: start
* with the Epic identity arguments only, no code - for a game that will not start when sign-in fails.
* [overlay]: point the game's prefix at Epic's EOS overlay.
* [overlay]: point the game's prefix at Epic's EOS overlay - off by default: with it on, a game can
* exit while loading on this stack (Metalstorm did, twice; off, it signs in and runs).
*/
data class EpicOptions(val eos: Boolean = true, val offline: Boolean = false, val overlay: Boolean = true) {
data class EpicOptions(val eos: Boolean = true, val offline: Boolean = false, val overlay: Boolean = false) {
/** Whether a launch asks for a code at all. */
val wantsCode: Boolean get() = eos && !offline
companion object {
/** v2: the overlay is off unless chosen (Metalstorm exits while loading with it on). */
const val VERSION = 2
}
}
@@ -62,8 +62,10 @@ object StoreLaunchRequests {
val code = StoreLaunch.epicCode(app, id)
// The overlay is placed once and shared; the launch writes the prefix's pointer.
val overlay = com.droiddeck.launcher.stores.epic.EpicOverlay.installedVersion(app)
Log.i(TAG, "epic overlay installed=${overlay ?: "no"} prefix=${request.optString("prefix").ifEmpty { "?" }}")
if (overlay == null) com.droiddeck.launcher.stores.epic.EpicOverlay.ensureAsync(app)
val wanted = StoreInstallRoot.gameFolders(app).firstNotNullOfOrNull { f -> StoreGameSidecar.read(f)?.takeIf { it.store == Store.EPIC && it.id == id } }?.epic?.overlay == true
Log.i(TAG, "epic overlay installed=${overlay ?: "no"} wanted=$wanted prefix=${request.optString("prefix").ifEmpty { "?" }}")
// Fetched only for a game that has the overlay on (656 MB; off by default).
if (overlay == null && wanted) com.droiddeck.launcher.stores.epic.EpicOverlay.ensureAsync(app)
JSONObject().put("ok", true).put("code", code.written).put("reason", code.reason).put("overlay", overlay ?: "")
}
}
@@ -51,8 +51,6 @@ object EpicBackend : StoreBackend, EpicLaunchSupport {
when (result) {
is EpicLibrary.SyncResult.Ok -> {
StoresState.library[store] = result.games.map { EpicLibrary.toCatalogItem(app, it) }; StoresState.problems.remove(store)
// The EOS overlay, once, so a game's first launch already has it.
EpicOverlay.ensureAsync(app)
}
is EpicLibrary.SyncResult.Failed -> StoresState.problems[store] = result.message
EpicLibrary.SyncResult.NotLoggedIn -> StoresState.problems[store] = "Epic session expired: sign in again."
@@ -508,6 +508,7 @@ internal fun LaunchSettings(s: FrontEndState, a: FrontEndActions, host: MenuHost
/** The Epic card: sign-in, offline and overlay switches, read from and written to the game's sidecar. */
@Composable
private fun EpicLaunchCard(host: MenuHost, folder: java.io.File) {
val appContext = androidx.compose.ui.platform.LocalContext.current.applicationContext
var sidecar by remember(folder) { mutableStateOf(com.droiddeck.launcher.stores.StoreGameSidecar.read(folder)) }
val options = sidecar?.epic ?: com.droiddeck.launcher.stores.EpicOptions()
val value = buildList {
@@ -515,19 +516,25 @@ private fun EpicLaunchCard(host: MenuHost, folder: java.io.File) {
else if (options.eos) add(stringResource(R.string.epic_card_eos))
if (options.overlay) add(stringResource(R.string.epic_card_overlay))
}.ifEmpty { listOf(stringResource(R.string.epic_card_none)) }.joinToString(" · ")
fun set(next: com.droiddeck.launcher.stores.EpicOptions) {
val current = sidecar ?: return
val updated = current.copy(epic = next)
sidecar = updated
Thread({ runCatching { updated.write(folder) } }, "epic-options").start()
// Each switch goes straight to the sidecar on disk - both launch paths read it there - and the
// card shows what was read back, so a write that did not take is never shown as done.
fun set(change: (com.droiddeck.launcher.stores.EpicOptions) -> com.droiddeck.launcher.stores.EpicOptions) {
Thread({
val written = runCatching { com.droiddeck.launcher.stores.StoreGameSidecar.updateEpic(folder, change) }
.onFailure { android.util.Log.w("EpicLaunchCard", "could not write ${folder.name}: ${it.message}") }.getOrNull()
if (written != null) android.util.Log.i("EpicLaunchCard", "epic options ${written.id} eos=${written.epic.eos} offline=${written.epic.offline} overlay=${written.epic.overlay}")
if (written?.epic?.overlay == true) com.droiddeck.launcher.stores.epic.EpicOverlay.ensureAsync(appContext)
else android.util.Log.w("EpicLaunchCard", "epic options not saved for ${folder.name}")
com.droiddeck.launcher.stores.StoresState.post { sidecar = written ?: com.droiddeck.launcher.stores.StoreGameSidecar.read(folder) }
}, "epic-options").start()
}
SettingCard(stringResource(R.string.epic_card_title), value, "card:epic", Modifier.fillMaxSize()) {
host.open = if (host.open == "epic") null else "epic"
}
AnchoredMenu(host.open == "epic", onDismiss = { if (host.open == "epic") host.open = null }, title = stringResource(R.string.epic_card_title)) { first ->
MenuItem(stringResource(R.string.epic_eos), checked = options.eos, focusRequester = first) { set(options.copy(eos = !options.eos)) }
MenuItem(stringResource(R.string.epic_offline), checked = options.offline) { set(options.copy(offline = !options.offline)) }
MenuItem(stringResource(R.string.epic_overlay), checked = options.overlay) { set(options.copy(overlay = !options.overlay)) }
MenuItem(stringResource(R.string.epic_eos), checked = options.eos, focusRequester = first) { set { it.copy(eos = !it.eos) } }
MenuItem(stringResource(R.string.epic_offline), checked = options.offline) { set { it.copy(offline = !it.offline) } }
MenuItem(stringResource(R.string.epic_overlay), checked = options.overlay) { set { it.copy(overlay = !it.overlay) } }
}
}
@@ -47,14 +47,34 @@ class StoreGameSidecarTest {
@Test fun epicLaunchChoicesRoundTripAndDefaultOn() {
val plain = StoreGameSidecar.parse(StoreGameSidecar(Store.EPIC, "m", "Metalstorm", "M.exe").toJson().toString())!!
assertEquals(EpicOptions(eos = true, offline = false, overlay = true), plain.epic)
assertEquals(EpicOptions(eos = true, offline = false, overlay = false), plain.epic)
assertTrue(plain.epic.wantsCode)
val set = StoreGameSidecar(Store.EPIC, "m", "Metalstorm", "M.exe", epic = EpicOptions(eos = true, offline = true, overlay = false))
val back = StoreGameSidecar.parse(set.toJson().toString())!!
assertEquals(EpicOptions(eos = true, offline = true, overlay = false), back.epic)
assertFalse(back.epic.wantsCode)
// A sidecar from before the choices existed reads as all on.
// A sidecar from before the choices existed reads as the defaults: sign-in on, overlay off.
assertEquals(EpicOptions(), StoreGameSidecar.parse("""{"store":"epic","id":"m","title":"M","exe":"M.exe"}""")!!.epic)
// The old default wrote overlay=true without a version: nobody chose it, so it reads as off.
assertFalse(StoreGameSidecar.parse("""{"store":"epic","id":"m","title":"M","exe":"M.exe","epic":{"eos":true,"offline":false,"overlay":true}}""")!!.epic.overlay)
// Chosen from v2 on, it stays.
val on = StoreGameSidecar.parse(StoreGameSidecar(Store.EPIC, "m", "M", "M.exe", epic = EpicOptions(overlay = true)).toJson().toString())!!
assertTrue(on.epic.overlay)
}
@Test fun aSwitchOnTheEpicCardChangesTheSidecarOnDisk() {
val folder = java.io.File(org.robolectric.RuntimeEnvironment.getApplication().filesDir, "Games/Stores/Epic/Metalstorm").apply { mkdirs() }
StoreGameSidecar(Store.EPIC, "m", "Metalstorm", "Metalstorm.exe", launcher = ".droiddeck-launch.bat").write(folder)
val written = StoreGameSidecar.updateEpic(folder) { it.copy(overlay = true) }!!
assertTrue(written.epic.overlay)
// What the Steam-launch helper reads: the file itself.
val onDisk = org.json.JSONObject(java.io.File(folder, StoreGameSidecar.FILE_NAME).readText())
assertTrue(onDisk.getJSONObject("epic").getBoolean("overlay"))
assertEquals(EpicOptions.VERSION, onDisk.getJSONObject("epic").getInt("v"))
assertTrue(onDisk.getJSONObject("epic").getBoolean("eos"))
// Everything else in it stays.
assertEquals(".droiddeck-launch.bat", StoreGameSidecar.read(folder)!!.launcher)
assertEquals(null, StoreGameSidecar.updateEpic(java.io.File(folder, "missing")) { it })
}
@Test fun anInstallUnderWayHasNoExeYetAndIsNotInstalled() {
+18
View File
@@ -144,6 +144,24 @@ without it and Setup says "Stores engine not built").
(`token`, `__token__`, `f_token`, `hdnts`, access/refresh/id tokens, `code=`) and Authorization /
Cookie headers are blanked outside URLs too. `StoreLogTest` covers a GOG secure link and an Epic
Akamai URL. The engine's own logcat lines (tag `EpicNative` etc.) are written on the native side.
- **Epic overlay off by default** (device A/B, Metalstorm: overlay pointer set → exits while
loading, twice; off → signs in to its Starform account and runs at 118 fps). `EpicOptions.overlay`
defaults to false; the sidecar's `epic` block carries `"v": 2`, and an older block (the old default
wrote `"overlay": true` unasked) reads as off - in the app and in `droiddeck-store-launch` alike.
The 656 MB overlay is fetched only for a game that turns it on (the card's switch, or a launch of
such a game), no longer after every library sync.
- **The card's switches are saved where both launch paths read them:** each toggle reads the
sidecar fresh from disk, writes it and reads it back (`StoreGameSidecar.updateEpic`), and the card
shows what was read back; the app logs `epic options <id> eos= offline= overlay=`.
- **Registry lines that a running wineserver would drop:** `droiddeck-store-launch` waits up to 8 s
for a wineserver still holding the prefix to exit before appending to `user.reg` (it rewrites the
file from memory when it exits, which is how `OverlayPath` vanished), reads the value back, and
logs `overlay-set`, `overlay-off`, `overlay-not-written` or `overlay-still-set`, plus
`server-running` when one never left. The helper's docstring is raw (no SyntaxWarning).
- **Browser hand-off diagnostics:** the game-PATH `xdg-open` logs every call as `scheme://host`
only and accepts `steam://openurl/<address>`. On the device it was never called while EOS waited
on its browser flow, so how EOS opens the browser under this Proton is still to be seen in the
next log.
- **The exchange code no longer reaches the game's environment** (device: Metalstorm's Player.log
dumped `DD_AUTH: … -AUTH_PASSWORD=<code>`). The launcher read the code into a variable the game
inherited. It now clears the variable on the same line that starts the game - cmd has expanded it
@@ -1,5 +1,5 @@
#!/usr/bin/python3
"""Before Proton starts a store game, asks the app for what that store needs at launch.
r"""Before Proton starts a store game, asks the app for what that store needs at launch.
Run by droiddeck-proton / droiddeck-proton-wrap (steam-compatibility) for every real game launch,
whoever started it: the Games tab, a pinned shortcut, or the Steam client's own Play button. It
@@ -39,6 +39,7 @@ EPIC_CODE = ".droiddeck-epic-code"
TIMEOUT = 5.0
STALE_SECONDS = 300
OVERLAY_DIR = "/root/.local/share/droiddeck/epic-overlay"
SERVER_WAIT = 8.0
OVERLAY_DLL = "EOSOVH-Win64-Shipping.dll"
EOS_KEY = "[Software\\\\Epic Games\\\\EOS]"
BROWSER_KEY = "[Software\\\\Wine\\\\WineBrowser]"
@@ -171,24 +172,74 @@ def remove_value(reg, key, name):
def options(sidecar):
"""An Epic game's launch choices from its sidecar, as the Games tab's Epic card sets them."""
e = sidecar.get("epic") if isinstance(sidecar.get("epic"), dict) else {}
return {"eos": e.get("eos", True) is not False, "offline": e.get("offline", False) is True, "overlay": e.get("overlay", True) is not False}
# The overlay is off unless chosen (v2 on): the old default wrote overlay=true without a version,
# and with it on a game could exit while loading. The app's EpicOptions reads it the same way.
chosen = isinstance(e.get("v"), int) and e.get("v") >= 2
return {"eos": e.get("eos", True) is not False, "offline": e.get("offline", False) is True, "overlay": chosen and e.get("overlay") is True}
def provision_prefix(compat_data, overlay_dir=OVERLAY_DIR, overlay=True):
def server_socket(pfx):
"""The wineserver socket for prefix [pfx], as Wine names it (server-<dev>-<inode> under /tmp/.wine-<uid>)."""
try:
st = os.stat(pfx)
except OSError:
return None
base = os.environ.get("BL_WINE_SERVER_BASE") or "/tmp/.wine-%d" % os.getuid()
return Path(base) / ("server-%x-%x" % (st.st_dev, st.st_ino)) / "socket"
def wait_for_server(pfx, timeout=SERVER_WAIT):
"""
Waits for a wineserver still holding this prefix (an earlier verb's, or the last launch's) to
exit: it rewrites user.reg from its own memory when it does, which drops a line appended
meanwhile. True when none is left.
"""
sock = server_socket(pfx)
if sock is None:
return True
deadline = time.monotonic() + timeout
while sock.exists() and time.monotonic() < deadline:
time.sleep(0.1)
return not sock.exists()
def has_value(reg, key, name, value=None):
"""Whether the last word on `key` in user.reg sets "name" (to [value], when given)."""
if not reg.is_file():
return False
inside, last = False, None
for row in reg.read_text(errors="replace").splitlines():
if row.startswith("["):
inside = row.startswith(key + " ") or row == key
elif inside and row.startswith('"%s"=' % name):
last = row
if last is None:
return False
return value is None or last == '"%s"="%s"' % (name, reg_escape(value))
def provision_prefix(compat_data, overlay_dir=OVERLAY_DIR, overlay=False, wait=SERVER_WAIT):
"""The EOS overlay pointer (or its removal, when [overlay] is off) and Wine's browser, in the game's prefix."""
if not compat_data:
return "no-prefix"
reg = Path(compat_data) / "pfx" / "user.reg"
pfx = Path(compat_data) / "pfx"
reg = pfx / "user.reg"
if not reg.is_file():
return "prefix-not-created"
# A wineserver still up for this prefix would write its own copy of user.reg over these lines.
busy = not wait_for_server(pfx, wait)
set_value(reg, BROWSER_KEY, "Browsers", "xdg-open")
if not overlay:
remove_value(reg, EOS_KEY, "OverlayPath")
return "overlay-off"
if overlay_version(overlay_dir) is None:
state = "overlay-off" if not has_value(reg, EOS_KEY, "OverlayPath") else "overlay-still-set"
elif overlay_version(overlay_dir) is None:
return "overlay-missing"
set_value(reg, EOS_KEY, "OverlayPath", windows_path(overlay_dir))
return "overlay-set"
else:
target = windows_path(overlay_dir)
set_value(reg, EOS_KEY, "OverlayPath", target)
# Read back, so the log says what the file holds, not what was meant.
state = "overlay-set" if has_value(reg, EOS_KEY, "OverlayPath", target) else "overlay-not-written"
return state + (" server-running" if busy else "")
def main(argv):
@@ -20,9 +20,23 @@ def hand_over(url, launch_dir):
os.rename(tmp, req / (rid + ".json"))
def where(url):
"""scheme://host only, for the log: no path, no query."""
from urllib.parse import urlsplit
try:
u = urlsplit(url)
return "%s://%s" % (u.scheme, u.hostname or "")
except ValueError:
return "?"
def main(argv):
url = argv[0] if argv else ""
# The Steam client's own form (steam://openurl/<address>), in case a launcher passes it on.
if url.startswith("steam://openurl/"):
url = url[len("steam://openurl/"):]
launch_dir = os.environ.get("BL_LAUNCH_DIR", "")
print("droiddeck xdg-open: %s" % where(url), file=sys.stderr, flush=True)
if url.startswith(("https://", "http://")) and launch_dir:
hand_over(url, launch_dir)
return 0
+8 -4
View File
@@ -128,18 +128,18 @@ class Prefix(unittest.TestCase):
def test_the_overlay_pointer_names_the_shared_copy_through_z(self):
(self.overlay / store.OVERLAY_DLL).write_bytes(b'MZ')
(self.overlay / 'droiddeck-version').write_text('1.0.42')
self.assertEqual('overlay-set', store.provision_prefix(str(self.compat), str(self.overlay)))
self.assertEqual('overlay-set', store.provision_prefix(str(self.compat), str(self.overlay), overlay=True))
text = self.reg.read_text()
self.assertIn('[Software\\\\Epic Games\\\\EOS] ', text)
self.assertIn('"OverlayPath"="Z:' + str(self.overlay).replace('/', '\\\\') + '"', text)
self.assertIn('"Browsers"="xdg-open"', text)
# A second launch adds nothing: the last word already says so.
store.provision_prefix(str(self.compat), str(self.overlay))
store.provision_prefix(str(self.compat), str(self.overlay), overlay=True)
self.assertEqual(text, self.reg.read_text())
self.assertEqual('1.0.42', store.overlay_version(str(self.overlay)))
def test_no_overlay_yet_or_no_prefix_yet(self):
self.assertEqual('overlay-missing', store.provision_prefix(str(self.compat), str(self.overlay)))
self.assertEqual('overlay-missing', store.provision_prefix(str(self.compat), str(self.overlay), overlay=True))
self.assertNotIn('OverlayPath', self.reg.read_text())
self.assertEqual('prefix-not-created', store.provision_prefix(self.tmp.name + '/compatdata/1', str(self.overlay)))
self.assertEqual('no-prefix', store.provision_prefix('', str(self.overlay)))
@@ -150,7 +150,10 @@ class Choices(unittest.TestCase):
with tempfile.TemporaryDirectory() as root:
folder = game(root)
sidecar = json.loads((folder / '.droiddeck-store.json').read_text())
self.assertEqual({'eos': True, 'offline': False, 'overlay': True}, store.options(sidecar))
self.assertEqual({'eos': True, 'offline': False, 'overlay': False}, store.options(sidecar))
# The old default (no version) never turns it on; a v2 choice does.
self.assertFalse(store.options({'epic': {'eos': True, 'offline': False, 'overlay': True}})['overlay'])
self.assertTrue(store.options({'epic': {'v': 2, 'overlay': True}})['overlay'])
sidecar['epic'] = {'eos': True, 'offline': True, 'overlay': False}
(folder / '.droiddeck-store.json').write_text(json.dumps(sidecar))
(folder / '.droiddeck-epic-code').write_text('left over')
@@ -185,6 +188,7 @@ class Browser(unittest.TestCase):
reqs = list((Path(root) / 'stores/req').glob('*.json'))
self.assertEqual(1, len(reqs))
self.assertEqual({'op': 'open-url', 'url': 'https://www.epicgames.com/activate'}, json.loads(reqs[0].read_text()))
self.assertEqual('https://www.epicgames.com', xdg.where('https://www.epicgames.com/activate?code=SECRET'))
class Launchers(unittest.TestCase):