Files
DroidDeck/tools/tests/test_store_launch.py
T
The412Banner e99d5f9505 Stores: GOG, Epic Games and Amazon Games libraries, installs and cloud saves (#476)
A Stores section (rail, under Games; Setup › Stores gate) with each store's sign-in,
library, storefront shelves and a download queue. Each store has two tabs, Store and
Library, and Library filters to All or Installed; libraries open from a saved copy
and refresh in the background, with install sizes on every card. Games install to internal storage,
or a card chosen at Install time, and are registered in the Steam client as non-Steam
games, so they launch through Steam and droiddeck-proton from either the front end or
the client.

- Downloads: GOG, Epic and Amazon engines in Rust (app/src/main/rust/stores, built by
  cargo-ndk, nothing compiled in the tree). One adaptive fetch core with a latency
  brake, stalled-host handoff and every CDN a store offers. Per-stage progress,
  pause/resume, two-step cancel that deletes, failed downloads kept for retry, a
  foreground service with Wi-Fi and wake locks and notification controls.
- Games tab: store source chips (Steam, GOG, Epic, Amazon, Custom) and store art.
- Epic: per-launch exchange code from the app and from Steam's Play button,
  "Epic sign-in (EOS)", "Launch offline", and a game's web links opened in Android's
  browser for EOS consent. The EOS overlay crashed games and is left out.
- Cloud saves (GOG, Epic): pulled before launch, pushed after the game or the session
  ends, no upload before a first download, conflicts never overwritten, backups both
  ways.
- Logs: every store line redacted; the engine log goes to daily files.
2026-10-09 11:08:17 -04:00

263 lines
13 KiB
Python

"""droiddeck-store-launch: a launch from anywhere - the Steam client's own Play button included -
asks the app for an Epic game's sign-in code before Proton starts. These pin how it recognises a
store game, how it asks, and that both Proton launchers call it."""
import importlib.machinery
import importlib.util
import json
import os
from pathlib import Path
import tempfile
import threading
import time
import unittest
BIN = Path(__file__).resolve().parents[1] / 'linuxfs/overlay/usr/local/bin'
def load(name):
loader = importlib.machinery.SourceFileLoader(name.replace('-', '_'), str(BIN / name))
spec = importlib.util.spec_from_loader(loader.name, loader)
module = importlib.util.module_from_spec(spec)
loader.exec_module(module)
return module
store = load('droiddeck-store-launch')
def load_path(path, name):
loader = importlib.machinery.SourceFileLoader(name, str(path))
spec = importlib.util.spec_from_loader(name, loader)
module = importlib.util.module_from_spec(spec)
loader.exec_module(module)
return module
compat = load('steam-compatibility')
def game(root, store_id='epic', ident='8b6a0e14'):
folder = Path(root) / 'Games/Stores/Epic/Metalstorm'
folder.mkdir(parents=True)
(folder / '.droiddeck-store.json').write_text(json.dumps({'store': store_id, 'id': ident, 'title': 'Metalstorm', 'exe': 'Metalstorm.exe'}))
(folder / '.droiddeck-launch.bat').write_text('@echo off\r\n')
return folder
class Detection(unittest.TestCase):
def test_the_launcher_among_steams_arguments_names_the_game(self):
with tempfile.TemporaryDirectory() as root:
folder = game(root)
found = store.find_store_game(['/usr/bin/steam-runtime', '"%s/.droiddeck-launch.bat"' % folder, '-flag'])
self.assertIsNotNone(found)
self.assertEqual(folder, found[0])
self.assertEqual('epic', found[1]['store'])
self.assertEqual('8b6a0e14', found[1]['id'])
def test_anything_else_is_left_alone(self):
with tempfile.TemporaryDirectory() as root:
folder = game(root)
# The exe of a store game is one too (a GOG game without a launcher); a stray file is not.
self.assertEqual(folder, store.find_store_game([str(folder / 'Metalstorm.exe')])[0])
self.assertIsNone(store.find_store_game([str(Path(root) / 'elsewhere/readme.txt')]))
(folder / '.droiddeck-store.json').unlink()
self.assertIsNone(store.find_store_game([str(folder / '.droiddeck-launch.bat')]))
def test_only_epic_asks_for_a_code(self):
with tempfile.TemporaryDirectory() as root:
folder = game(root, store_id='gog')
# Cloud saves off: nothing else to ask for either.
sc = json.loads((folder / '.droiddeck-store.json').read_text()); sc['cloud'] = False
(folder / '.droiddeck-store.json').write_text(json.dumps(sc))
os.environ['BL_LAUNCH_DIR'] = str(Path(root) / 'session')
try:
self.assertEqual(0, store.main([str(folder / '.droiddeck-launch.bat')]))
finally:
del os.environ['BL_LAUNCH_DIR']
self.assertFalse((Path(root) / 'session/stores').exists())
class Asking(unittest.TestCase):
def test_a_request_is_answered_through_the_resp_folder(self):
with tempfile.TemporaryDirectory() as root:
channel = Path(root) / 'stores'
def app():
req = channel / 'req'
for _ in range(100):
names = [p for p in req.glob('*.json')] if req.exists() else []
if names:
request = json.loads(names[0].read_text())
self.assertEqual({'op': 'epic-code', 'store': 'epic', 'id': 'x'}, request)
names[0].unlink()
tmp = channel / 'resp' / (names[0].name + '.tmp')
tmp.write_text(json.dumps({'ok': True, 'code': True, 'reason': 'ok'}))
os.rename(tmp, channel / 'resp' / names[0].name)
return
time.sleep(0.02)
t = threading.Thread(target=app)
t.start()
answer = store.ask(channel, {'op': 'epic-code', 'store': 'epic', 'id': 'x'}, timeout=3)
t.join()
self.assertEqual({'ok': True, 'code': True, 'reason': 'ok'}, answer)
def test_no_answer_withdraws_the_request(self):
with tempfile.TemporaryDirectory() as root:
channel = Path(root) / 'stores'
self.assertIsNone(store.ask(channel, {'op': 'epic-code'}, timeout=0.2))
self.assertEqual([], list((channel / 'req').iterdir()))
def test_a_stale_code_is_dropped_before_asking(self):
with tempfile.TemporaryDirectory() as root:
folder = game(root)
code = folder / '.droiddeck-epic-code'
code.write_text('old')
self.assertFalse(store.drop_stale(folder, now=code.stat().st_mtime + 60))
self.assertTrue(store.drop_stale(folder, now=code.stat().st_mtime + store.STALE_SECONDS + 1))
self.assertFalse(code.exists())
class Prefix(unittest.TestCase):
def setUp(self):
self.tmp = tempfile.TemporaryDirectory()
root = Path(self.tmp.name)
self.compat = root / 'compatdata/3785150007'
(self.compat / 'pfx').mkdir(parents=True)
self.reg = self.compat / 'pfx/user.reg'
self.reg.write_text('WINE REGISTRY Version 2\n\n[Software\\\\Wine] 1\n"Version"="win10"\n')
def tearDown(self):
self.tmp.cleanup()
def test_wines_browser_is_the_hand_off_by_its_path(self):
self.assertEqual('browser-set', store.provision_prefix(str(self.compat)))
text = self.reg.read_text()
self.assertIn('[Software\\\\Wine\\\\WineBrowser] ', text)
self.assertIn('"Browsers"="/usr/local/bin/droiddeck-open-url,xdg-open"', text)
# A second launch adds nothing: the last word already says so.
store.provision_prefix(str(self.compat))
self.assertEqual(text, self.reg.read_text())
def test_an_earlier_builds_overlay_pointer_is_removed(self):
self.reg.write_text(self.reg.read_text() + '\n[Software\\\\Epic Games\\\\EOS] 2\n"OverlayPath"="Z:\\\\x"\n"Other"="kept"\n')
self.assertEqual('browser-set old-overlay-removed', store.provision_prefix(str(self.compat)))
text = self.reg.read_text()
self.assertNotIn('OverlayPath', text)
self.assertIn('"Other"="kept"', text)
def test_no_prefix_yet(self):
self.assertEqual('prefix-not-created', store.provision_prefix(self.tmp.name + '/compatdata/1'))
self.assertEqual('no-prefix', store.provision_prefix(''))
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}, store.options(sidecar))
# An earlier build's overlay field is read past.
self.assertEqual({'eos': True, 'offline': False}, store.options({'epic': {'v': 2, 'overlay': True}}))
sidecar['epic'] = {'eos': True, 'offline': True}
sidecar['cloud'] = 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; the old overlay 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 / 'droiddeck-open-url', 'open_url')
with tempfile.TemporaryDirectory() as root:
os.environ['BL_LAUNCH_DIR'] = root
try:
self.assertEqual(0, xdg.main(['https://www.epicgames.com/activate']))
finally:
del os.environ['BL_LAUNCH_DIR']
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 ExeTarget(unittest.TestCase):
def test_a_game_started_by_its_exe_is_found_from_a_subfolder(self):
# Most GOG games have no launcher .bat: Steam runs the exe, which may sit in a subfolder.
with tempfile.TemporaryDirectory() as root:
folder = game(root, store_id='gog', ident='1207664643')
(folder / 'bin/x64').mkdir(parents=True)
exe = folder / 'bin/x64/ELDERBORN.exe'
exe.write_text('MZ')
found = store.find_store_game(['"%s"' % exe])
self.assertEqual(folder, found[0])
self.assertEqual('gog', found[1]['store'])
self.assertIsNone(store.find_store_game(['/root/Games/Other/x.exe']))
class CloudHooks(unittest.TestCase):
def test_a_store_game_with_cloud_saves_asks_before_and_after(self):
with tempfile.TemporaryDirectory() as root:
folder = game(root, store_id='gog', ident='1207664643')
bat = str(folder / '.droiddeck-launch.bat')
os.environ['BL_LAUNCH_DIR'] = str(Path(root) / 'session')
try:
original = store.ask
asked = []
store.ask = lambda channel, request, timeout=5.0: asked.append((request, timeout)) or {'ok': True, 'files': 2, 'reason': 'ok'}
# 10: the launcher waits for Proton and calls --exited afterwards.
self.assertEqual(store.STORE_GAME, store.main([bat]))
self.assertEqual(0, store.main(['--exited', bat]))
finally:
store.ask = original
del os.environ['BL_LAUNCH_DIR']
self.assertEqual({'op': 'cloud-down', 'store': 'gog', 'id': '1207664643'}, asked[0][0])
self.assertEqual(store.CLOUD_TIMEOUT, asked[0][1])
self.assertEqual('cloud-up', asked[1][0]['op'])
def test_cloud_off_or_amazon_runs_proton_as_before(self):
with tempfile.TemporaryDirectory() as root:
folder = game(root, store_id='gog', ident='1')
sidecar = json.loads((folder / '.droiddeck-store.json').read_text())
sidecar['cloud'] = False
(folder / '.droiddeck-store.json').write_text(json.dumps(sidecar))
self.assertEqual(0, store.main([str(folder / '.droiddeck-launch.bat')]))
self.assertFalse(store.cloud_on({'store': 'amazon', 'id': 'x'}))
self.assertTrue(store.cloud_on({'store': 'epic', 'id': 'x'}))
def test_both_launchers_wait_for_proton_only_for_a_store_game(self):
for sh in (compat.LAUNCHER_SH, compat.EXTRA_WRAPPER_SH):
self.assertIn('bl_run ${BL_TASKSET:-} /usr/local/bin/droiddeck-game-env', sh)
self.assertNotIn('exec ${BL_TASKSET:-} /usr/local/bin/droiddeck-game-env', sh)
self.assertIn('[ $? -eq 10 ] && BL_STORE_EXIT=1', compat.BL_STORE_SETUP)
self.assertIn('droiddeck-store-launch --exited "${BL_STORE_ARGS[@]}"', compat.BL_STORE_SETUP)
self.assertIn('exec "$@"', compat.BL_STORE_SETUP)
class Launchers(unittest.TestCase):
def test_both_proton_launchers_ask_before_proton_starts(self):
sh = compat.LAUNCHER_SH
self.assertIn('bl_store_launch() {', sh)
self.assertLess(sh.index('bl_store_launch "$@"'), sh.index('droiddeck-game-env "$depot/proton"'))
self.assertIn('bl_store_launch "$@"', compat.EXTRA_WRAPPER_SH)
self.assertIn('bl_store_launch() {', compat.BL_STORE_SETUP)
self.assertIn('waitforexitandrun', compat.BL_STORE_SETUP)
if __name__ == '__main__':
unittest.main()