mirror of
https://github.com/Droid-Deck/DroidDeck.git
synced 2026-10-14 19:27:09 -04:00
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:
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
|
||||
|
||||
@@ -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())!!
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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')
|
||||
|
||||
Reference in new issue
Block a user