Stores: an Epic card in a game's launch settings

Epic games get an Epic card in the Games tab's launch settings: Epic
sign-in (EOS) on, Launch offline off, Epic overlay on. The choices live
in the game's sidecar and survive an update, so both launch paths read
the same thing - the app before its own launches and droiddeck-store-
launch for one from the Steam client: no code when sign-in is off or
offline, the overlay pointer written or removed per the switch.
This commit is contained in:
The412Banner committed 2026-10-08 23:39:42 -04:00
1 parent 97a0f1553f
commit dd2b2b08c3
9 files changed
+162 -16

No files matched your search

@@ -38,6 +38,8 @@ class StoreGameSidecar(
/** Per-store identifiers the launcher needs again (Epic namespace / catalog id, Amazon entitlement). */
val extra: Map<String, String> = emptyMap(),
val state: String = STATE_INSTALLED,
/** An Epic game's launch choices (the Games tab's Epic card); both launch paths read them from here. */
val epic: EpicOptions = EpicOptions(),
) {
val isInstalled: Boolean get() = state == STATE_INSTALLED
@@ -48,8 +50,8 @@ class StoreGameSidecar(
exe: String = this.exe, launcher: String? = this.launcher, args: List<String> = this.args,
env: Map<String, String> = this.env, installVersion: String = this.installVersion,
installedAt: Long = this.installedAt, cover: String? = this.cover, hero: String? = this.hero,
extra: Map<String, String> = this.extra, state: String = this.state,
) = StoreGameSidecar(store, id, title, exe, launcher, args, env, installVersion, installedAt, cover, hero, extra, state)
extra: Map<String, String> = this.extra, state: String = this.state, epic: EpicOptions = this.epic,
) = StoreGameSidecar(store, id, title, exe, launcher, args, env, installVersion, installedAt, cover, hero, extra, state, epic)
fun toJson(): JSONObject = JSONObject().apply {
put("version", VERSION)
@@ -58,6 +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 (!launcher.isNullOrEmpty()) put("launcher", launcher)
if (args.isNotEmpty()) put("args", JSONArray(args))
if (env.isNotEmpty()) put("env", JSONObject(env as Map<*, *>))
@@ -118,6 +121,9 @@ class StoreGameSidecar(
hero = o.optString("hero", "").ifEmpty { null },
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))
} ?: EpicOptions(),
)
}
@@ -126,3 +132,13 @@ class StoreGameSidecar(
path.isNotEmpty() && !path.startsWith("/") && !path.contains(":") && path.split('/').none { it == ".." || it.isEmpty() }
}
}
/**
* 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.
*/
data class EpicOptions(val eos: Boolean = true, val offline: Boolean = false, val overlay: Boolean = true) {
/** Whether a launch asks for a code at all. */
val wantsCode: Boolean get() = eos && !offline
}
@@ -42,7 +42,9 @@ object StoreInstalls {
fun complete(context: Context, folder: File, sidecar: StoreGameSidecar, onStep: ((Int, Int) -> Unit)? = null) {
val app = context.applicationContext
onStep?.invoke(0, STEPS)
val finished = StoreLaunch.writeLauncher(folder, sidecar.copy(state = StoreGameSidecar.STATE_INSTALLED))
// The user's launch choices survive an update or a repair.
val kept = StoreGameSidecar.read(folder)?.epic ?: sidecar.epic
val finished = StoreLaunch.writeLauncher(folder, sidecar.copy(state = StoreGameSidecar.STATE_INSTALLED, epic = kept))
finished.write(folder)
if (StoreGameSidecar.read(folder)?.isInstalled != true) throw java.io.IOException("the install record could not be written in ${folder.name}")
onStep?.invoke(1, STEPS)
@@ -93,8 +93,12 @@ object StoreLaunch {
val app = context.applicationContext
val result = try {
val folder = StoreInstallRoot.gameFolders(app).firstOrNull { f -> StoreGameSidecar.read(f)?.let { it.store == Store.EPIC && it.id == id } == true }
val options = folder?.let { StoreGameSidecar.read(it)?.epic }
if (folder == null) CodeResult(false, "not-installed")
else {
else if (options != null && !options.wantsCode) {
File(folder, EPIC_CODE).delete()
CodeResult(false, if (options.offline) "offline" else "eos-off")
} else {
val file = File(folder, EPIC_CODE)
file.delete()
val support = StoresState.backend(Store.EPIC) as? EpicLaunchSupport
@@ -475,6 +475,13 @@ internal fun LaunchSettings(s: FrontEndState, a: FrontEndActions, host: MenuHost
}
}
if (controller != null) add { m -> SettingCard(stringResource(R.string.setup_card_controls), stringResource(R.string.setup_card_controls_hint), "card:controls", m, controller.onMapping) }
// An Epic game's own launch choices, kept in its sidecar so a launch from the Steam client
// honours them too (droiddeck-store-launch reads the same file).
if (game?.source == com.droiddeck.launcher.stores.Store.EPIC.id && game.gameFiles != null) add { m ->
Box(m) {
EpicLaunchCard(host, game.gameFiles!!)
}
}
if (wincompKey != null) add { m ->
SettingCard(
stringResource(R.string.wincomp_title),
@@ -498,6 +505,32 @@ 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) {
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 {
if (options.offline) add(stringResource(R.string.epic_card_offline))
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()
}
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)) }
}
}
@Composable
private fun SettingCard(label: String, value: String, id: String, modifier: Modifier, onClick: () -> Unit) {
val colors = MaterialTheme.colorScheme
+8
View File
@@ -2012,6 +2012,14 @@
<string name="stores_free">Free</string>
<string name="stores_resume">Resume</string>
<string name="stores_show_mature">Show mature content</string>
<string name="epic_card_title">Epic</string>
<string name="epic_card_eos">EOS</string>
<string name="epic_card_offline">Offline</string>
<string name="epic_card_overlay">Overlay</string>
<string name="epic_card_none">Off</string>
<string name="epic_eos">Epic sign-in (EOS)</string>
<string name="epic_offline">Launch offline</string>
<string name="epic_overlay">Epic overlay</string>
<string name="stores_eyebrow_installed">installed</string>
<string name="stores_eyebrow_library">in your library</string>
<string name="stores_eyebrow_store">store</string>
@@ -45,6 +45,18 @@ class StoreGameSidecarTest {
assertTrue(back.isInstalled)
}
@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)
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.
assertEquals(EpicOptions(), StoreGameSidecar.parse("""{"store":"epic","id":"m","title":"M","exe":"M.exe"}""")!!.epic)
}
@Test fun anInstallUnderWayHasNoExeYetAndIsNotInstalled() {
val started = StoreGameSidecar(Store.EPIC, "Metalstorm", "Metalstorm", exe = "", state = StoreGameSidecar.STATE_INSTALLING)
val back = StoreGameSidecar.parse(started.toJson().toString())!!
+7
View File
@@ -144,6 +144,13 @@ 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 launch card** (Games tab › Launch settings, Epic games only): "Epic sign-in (EOS)" on,
"Launch offline" off, "Epic overlay" on, stored in the game's sidecar (`"epic": {"eos", "offline",
"overlay"}`, kept across updates) so both launch paths honour them alike - the app's
`StoreLaunch.epicCode` and `droiddeck-store-launch` for a launch from the Steam client: no code
when sign-in is off or offline, the overlay pointer written or removed per the switch. The card
reads e.g. "EOS · Overlay". Bannerlator's forced ownership token (`-epicovt`) is not ported, so
there is no switch for it.
- **EOS overlay** (device: Metalstorm's EOS grant answered `corrective_action_required`, the overlay
was not configured, and EOS fell back to a browser flow that never finished). Epic's own overlay
component ("EpicOnlineServicesOverlay", the mechanism from Legendary's `lfs/eos.py`, credits in
@@ -146,14 +146,42 @@ def overlay_version(overlay_dir=OVERLAY_DIR):
return "unknown"
def provision_prefix(compat_data, overlay_dir=OVERLAY_DIR):
"""The EOS overlay pointer and Wine's browser, in the game's prefix. Returns what was done."""
def remove_value(reg, key, name):
"""Drops every `"name"=` line under `key` in user.reg; True when one was there."""
if not reg.is_file():
return False
rows, inside, dropped = [], False, False
for row in reg.read_text(errors="replace").splitlines(keepends=True):
if row.startswith("["):
inside = row.startswith(key + " ") or row.rstrip("\n") == key
elif inside and row.startswith('"%s"=' % name):
dropped = True
continue
rows.append(row)
if dropped:
tmp = reg.with_name(reg.name + ".dd-new")
tmp.write_text("".join(rows))
os.replace(tmp, reg)
return dropped
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}
def provision_prefix(compat_data, overlay_dir=OVERLAY_DIR, overlay=True):
"""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"
if not reg.is_file():
return "prefix-not-created"
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:
return "overlay-missing"
set_value(reg, EOS_KEY, "OverlayPath", windows_path(overlay_dir))
@@ -169,19 +197,27 @@ def main(argv):
if store != "epic":
return 0
drop_stale(folder)
launch_dir = os.environ.get("BL_LAUNCH_DIR", "")
if not launch_dir:
log("epic launch id=%s code=no reason=no-channel" % game)
return 0
choice = options(sidecar)
compat_data = os.environ.get("STEAM_COMPAT_DATA_PATH", "").rstrip("/")
prefix_id = os.path.basename(compat_data) if compat_data else ""
answer = ask(Path(launch_dir) / "stores", {"op": "epic-code", "store": store, "id": game, "prefix": prefix_id})
if answer is None:
log("epic launch id=%s code=no reason=timeout" % game)
launch_dir = os.environ.get("BL_LAUNCH_DIR", "")
if not (choice["eos"] and not choice["offline"]):
# Sign-in off, or offline: no code, and none left over from before.
try:
(folder / EPIC_CODE).unlink()
except OSError:
pass
log("epic launch id=%s code=no reason=%s" % (game, "offline" if choice["offline"] else "eos-off"))
elif not launch_dir:
log("epic launch id=%s code=no reason=no-channel" % game)
else:
got = bool(answer.get("code"))
log("epic launch id=%s code=%s reason=%s" % (game, "yes" if got else "no", answer.get("reason", "")))
done = provision_prefix(compat_data)
answer = ask(Path(launch_dir) / "stores", {"op": "epic-code", "store": store, "id": game, "prefix": prefix_id})
if answer is None:
log("epic launch id=%s code=no reason=timeout" % game)
else:
got = bool(answer.get("code"))
log("epic launch id=%s code=%s reason=%s" % (game, "yes" if got else "no", answer.get("reason", "")))
done = provision_prefix(compat_data, overlay=choice["overlay"])
log("epic overlay installed=%s prefix=%s %s" % (overlay_version() or "no", prefix_id or "?", done))
return 0
+28
View File
@@ -145,6 +145,34 @@ class Prefix(unittest.TestCase):
self.assertEqual('no-prefix', store.provision_prefix('', str(self.overlay)))
class Choices(unittest.TestCase):
def test_the_sidecars_epic_choices_are_honoured(self):
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))
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')
compat = Path(root) / 'compatdata/1'
(compat / 'pfx').mkdir(parents=True)
reg = compat / 'pfx/user.reg'
reg.write_text('WINE REGISTRY Version 2\n\n[Software\\\\Epic Games\\\\EOS] 1\n"OverlayPath"="Z:\\\\x"\n"Other"="kept"\n')
os.environ['BL_LAUNCH_DIR'] = str(Path(root) / 'session')
os.environ['STEAM_COMPAT_DATA_PATH'] = str(compat)
try:
self.assertEqual(0, store.main([str(folder / '.droiddeck-launch.bat')]))
finally:
del os.environ['BL_LAUNCH_DIR']
del os.environ['STEAM_COMPAT_DATA_PATH']
# Offline: no request was made and no code is left; overlay off: the pointer is gone.
self.assertFalse((Path(root) / 'session/stores/req').exists())
self.assertFalse((folder / '.droiddeck-epic-code').exists())
text = reg.read_text()
self.assertNotIn('OverlayPath', text)
self.assertIn('"Other"="kept"', text)
class Browser(unittest.TestCase):
def test_a_web_address_is_handed_to_the_app(self):
xdg = load_path(BIN.parent / 'lib/droiddeck/browser/xdg-open', 'xdg_open')