Files
DroidDeck/tools/tests/test_store_launch.py
The412Banner 41422407c4 Stores: cloud saves recognise GOG games, wait for a new prefix, never overwrite blindly
On device nothing ran for ELDERBORN: a GOG game without arguments has
no launcher .bat, Steam runs its exe, and droiddeck-store-launch only
looked for the .bat. It now recognises the exe too, looking through the
folders above it for the sidecar, and every path logs a result line.

A first launch has no prefix yet: the download is answered as deferred
and runs once Proton has made the prefix's user folder, within a minute,
without holding the launch.

Uploads are safe now. CloudPlan decides per file against this device's
baseline - the local and cloud state at the last sync: no upload before
a download has completed once, the first download lets the cloud win,
and a file changed on both sides is a conflict that Manage saves offers
to settle with Keep cloud or Keep local. The cloud copy is backed up
before an upload replaces it, as the local folder is before a download.
2026-10-09 06:29:36 -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()