mirror of
https://github.com/Droid-Deck/DroidDeck.git
synced 2026-11-11 02:51:10 +00:00
* perf(epic): keep the hidden launcher from loading its store and Fab pages, and compress it while games run The hidden Epic Games Launcher that stays up for sign-in while a game runs pre-warmed its store and Fab pages on a display nobody sees. Under FEX each page used about one core and grew past a gigabyte, crashing and reloading in a loop. The hidden instance now skips the store pre-warm, gets a blank page in place of Fab, and is paged out to zRAM with the Steam client once a game has focus. The visible launcher is unchanged. * test(zram): keep the scan fixture PIDs above the kernel PID range
1229 lines
61 KiB
Python
1229 lines
61 KiB
Python
import http.client
|
|
import io
|
|
import json
|
|
import os
|
|
import runpy
|
|
import shutil
|
|
import socket
|
|
import signal
|
|
import subprocess
|
|
import sys
|
|
import tarfile
|
|
import tempfile
|
|
import threading
|
|
import time
|
|
import unittest
|
|
from pathlib import Path
|
|
from unittest import mock
|
|
|
|
BIN = Path(__file__).resolve().parents[1] / "linuxfs/overlay/usr/local/bin"
|
|
EPIC = runpy.run_path(str(BIN / "droiddeck-epic"))
|
|
G = EPIC["main"].__globals__
|
|
STORE = runpy.run_path(str(BIN / "droiddeck-store-launch"))
|
|
S = STORE["main"].__globals__
|
|
|
|
|
|
def tar_add(archive, name, data=None, link=None, kind=None, mode=0o644):
|
|
info = tarfile.TarInfo(name)
|
|
if kind == "dir":
|
|
info.type = tarfile.DIRTYPE
|
|
info.mode = 0o755
|
|
archive.addfile(info)
|
|
elif link is not None:
|
|
info.type = tarfile.SYMTYPE if kind != "hard" else tarfile.LNKTYPE
|
|
info.linkname = link
|
|
archive.addfile(info)
|
|
else:
|
|
info.size = len(data)
|
|
info.mode = mode
|
|
archive.addfile(info, io.BytesIO(data))
|
|
|
|
|
|
def ar(members):
|
|
out = bytearray(b"!<arch>\n")
|
|
for name, data in members:
|
|
out += ("%-16s%-12s%-6s%-6s%-8s%-10d`\n" % (name + "/", 0, 0, 0, "100644", len(data))).encode()
|
|
out += data
|
|
if len(data) % 2:
|
|
out += b"\n"
|
|
return bytes(out)
|
|
|
|
|
|
class EpicTestCase(unittest.TestCase):
|
|
def setUp(self):
|
|
self.tmp = Path(tempfile.mkdtemp())
|
|
self.addCleanup(shutil.rmtree, self.tmp, True)
|
|
self.home = self.tmp / "home"
|
|
state = self.home / ".local/share/droiddeck-epic"
|
|
data = self.home / ".local/share/com.epicgames.eglv2"
|
|
runtime = self.tmp / "run"
|
|
runtime.mkdir()
|
|
patches = {
|
|
"HOME": str(self.home),
|
|
"STATE": str(state),
|
|
"CURRENT": str(state / "current.json"),
|
|
"APPS": str(state / "app"),
|
|
"RUNTIMES": str(state / "runtime"),
|
|
"LOGS": str(state / "logs"),
|
|
"SHIMS": str(state / "bin"),
|
|
"FEX_CONFIG": str(state / "fex"),
|
|
"FEX_CACHE": str(self.home / ".cache/droiddeck-epic"),
|
|
"DATA": str(data),
|
|
"SETTINGS": str(data / "settings/app-settings.json"),
|
|
"API_FILE": str(data / "api.json"),
|
|
"AUTH_FILE": str(data / "auth" / "auth.json"),
|
|
"PROTON_ROOT": str(data / "compat/runtime"),
|
|
"INSTALLATIONS": str(data / "installations.jsonl"),
|
|
"V1_MANIFESTS": str(self.home / ".local/share/Epic/EpicGamesLauncher/Data/Manifests"),
|
|
"AUTO_SIGN_IN": str(state / "auto-sign-in"),
|
|
"SIGNED_OUT": str(state / "signed-out"),
|
|
"WEB_COOKIES": str(data / "cookies"),
|
|
}
|
|
for name, value in patches.items():
|
|
patcher = mock.patch.dict(G, {name: value})
|
|
patcher.start()
|
|
self.addCleanup(patcher.stop)
|
|
env = mock.patch.dict(os.environ, {"XDG_RUNTIME_DIR": str(runtime)})
|
|
env.start()
|
|
self.addCleanup(env.stop)
|
|
self.state = state
|
|
self.data = data
|
|
|
|
|
|
class PreloadTest(EpicTestCase):
|
|
def test_launcher_runtime_preloads_the_webkit_input_fix(self):
|
|
source = self.tmp / "libwebkitinput.so"
|
|
source.write_bytes(b"scroll")
|
|
files = self.tmp / "files"
|
|
with mock.patch.dict(G, {"WEBKIT_INPUT_SOURCE": str(source)}):
|
|
G["install_preload"](str(files))
|
|
self.assertEqual((files / "lib/x86_64-linux-gnu/droiddeck/libwebkitinput.so").read_bytes(), b"scroll")
|
|
self.assertEqual((files / "etc/ld.so.preload").read_text(), "/usr/lib/x86_64-linux-gnu/droiddeck/libwebkitinput.so\n")
|
|
with mock.patch.dict(G, {"WEBKIT_INPUT_SOURCE": str(self.tmp / "missing.so")}):
|
|
G["install_preload"](str(files))
|
|
self.assertFalse((files / "lib/x86_64-linux-gnu/droiddeck/libwebkitinput.so").exists())
|
|
self.assertEqual((files / "etc/ld.so.preload").read_text(), "")
|
|
|
|
|
|
class RuntimeExtractTest(EpicTestCase):
|
|
def test_runtime_extract_rewrites_links_and_skips_i386(self):
|
|
tarball = self.tmp / "rt.tar.gz"
|
|
with tarfile.open(tarball, "w:gz") as archive:
|
|
tar_add(archive, "metadata", b"[Runtime]\n")
|
|
tar_add(archive, "files", kind="dir")
|
|
tar_add(archive, "files/lib/x86_64-linux-gnu/libc.so.6", b"libc", mode=0o755)
|
|
tar_add(archive, "files/lib/ld-linux-x86-64.so.2", link="/usr/lib/x86_64-linux-gnu/ld-linux-x86-64.so.2")
|
|
tar_add(archive, "files/etc/alias", link="/etc/real")
|
|
tar_add(archive, "files/bin/rel", link="../lib/x86_64-linux-gnu/libc.so.6")
|
|
tar_add(archive, "files/lib/i386-linux-gnu/libc.so.6", b"i386")
|
|
tar_add(archive, "files/share/doc/readme", b"doc")
|
|
tar_add(archive, "files/lib/hard", link="files/lib/x86_64-linux-gnu/libc.so.6", kind="hard")
|
|
tar_add(archive, "../escape", b"x")
|
|
tar_add(archive, "other/file", b"x")
|
|
staging, count = G["extract_runtime"](str(tarball), str(self.tmp / "rt"))
|
|
files = Path(staging) / "files"
|
|
self.assertEqual(b"libc", (files / "lib/x86_64-linux-gnu/libc.so.6").read_bytes())
|
|
self.assertTrue(os.access(files / "lib/x86_64-linux-gnu/libc.so.6", os.X_OK))
|
|
self.assertEqual("x86_64-linux-gnu/ld-linux-x86-64.so.2", os.readlink(files / "lib/ld-linux-x86-64.so.2"))
|
|
self.assertEqual("real", os.readlink(files / "etc/alias"))
|
|
self.assertEqual("../lib/x86_64-linux-gnu/libc.so.6", os.readlink(files / "bin/rel"))
|
|
self.assertEqual(".", os.readlink(files / "usr"))
|
|
self.assertEqual(b"libc", (files / "lib/hard").read_bytes())
|
|
self.assertFalse((files / "lib/i386-linux-gnu").exists())
|
|
self.assertFalse((files / "share/doc").exists())
|
|
self.assertFalse((self.tmp / "escape").exists())
|
|
self.assertFalse((Path(staging) / "other").exists())
|
|
self.assertTrue((Path(staging) / "metadata").is_file())
|
|
self.assertEqual(6, count)
|
|
|
|
def test_runtime_without_libc_is_refused(self):
|
|
tarball = self.tmp / "rt.tar.gz"
|
|
with tarfile.open(tarball, "w:gz") as archive:
|
|
tar_add(archive, "files/bin/sh", b"sh")
|
|
with self.assertRaises(G["Failure"]):
|
|
G["extract_runtime"](str(tarball), str(self.tmp / "rt"))
|
|
self.assertFalse((self.tmp / "rt.staging").exists())
|
|
|
|
def test_gles_comes_out_of_the_deb(self):
|
|
data = io.BytesIO()
|
|
with tarfile.open(fileobj=data, mode="w:xz") as archive:
|
|
tar_add(archive, "./usr/lib/x86_64-linux-gnu/libGLESv2.so.2.1.0", b"gles")
|
|
tar_add(archive, "./usr/lib/x86_64-linux-gnu/libGLESv2.so.2", link="libGLESv2.so.2.1.0")
|
|
tar_add(archive, "./usr/share/doc/libgles2/copyright", b"c")
|
|
deb = self.tmp / "gles.deb"
|
|
deb.write_bytes(ar([("debian-binary", b"2.0\n"), ("control.tar.xz", b"x"), ("data.tar.xz", data.getvalue())]))
|
|
files = self.tmp / "files"
|
|
G["install_gles"](str(deb), str(files))
|
|
lib = files / "lib/x86_64-linux-gnu"
|
|
self.assertEqual(b"gles", (lib / "libGLESv2.so.2").read_bytes())
|
|
self.assertEqual("libGLESv2.so.2.1.0", os.readlink(lib / "libGLESv2.so.2"))
|
|
self.assertFalse((files / "share").exists())
|
|
bad = self.tmp / "bad.deb"
|
|
bad.write_bytes(b"not an archive")
|
|
with self.assertRaises(G["Failure"]):
|
|
G["install_gles"](str(bad), str(files))
|
|
|
|
def test_install_marks_ready_and_prunes_old_versions(self):
|
|
tarball = self.tmp / "rt.tar.gz"
|
|
with tarfile.open(tarball, "w:gz") as archive:
|
|
tar_add(archive, "files/lib/x86_64-linux-gnu/libc.so.6", b"libc")
|
|
data = io.BytesIO()
|
|
with tarfile.open(fileobj=data, mode="w:gz") as archive:
|
|
tar_add(archive, "./usr/lib/x86_64-linux-gnu/libGLESv2.so.2", b"gles")
|
|
deb = self.tmp / "gles.deb"
|
|
deb.write_bytes(ar([("data.tar.gz", data.getvalue())]))
|
|
old = self.state / "app/0.1"
|
|
old.mkdir(parents=True)
|
|
|
|
def extract_app(image, dest):
|
|
os.makedirs(os.path.join(dest, "usr/bin"))
|
|
Path(dest, "AppRun").write_text("run")
|
|
Path(dest, "usr/bin/EpicGamesLauncher").write_text("bin")
|
|
|
|
with mock.patch.dict(G, {"extract_app": extract_app, "fex_tool": lambda request=False: {"bin": "/fex", "server": "/s"}}), \
|
|
mock.patch("sys.stdout", new_callable=io.StringIO) as out:
|
|
image = self.tmp / "image"
|
|
image.write_bytes(b"x")
|
|
code = G["install"]({"app": str(image), "app-version": "0.95.2+g1", "runtime": str(tarball),
|
|
"runtime-version": "4.0.1", "gles": str(deb)})
|
|
self.assertEqual(0, code)
|
|
report = json.loads(out.getvalue())
|
|
self.assertTrue(report["installed"])
|
|
self.assertEqual({"app": "0.95.2+g1", "runtime": "4.0.1"}, json.loads((self.state / "current.json").read_text()))
|
|
self.assertFalse(old.exists())
|
|
files = self.state / "runtime/4.0.1/files"
|
|
self.assertEqual("", (files / "etc/ld.so.preload").read_text())
|
|
self.assertTrue((self.state / "runtime/4.0.1/.droiddeck-ready").is_file())
|
|
self.assertTrue((self.state / "app/0.95.2+g1/.droiddeck-ready").is_file())
|
|
|
|
|
|
class ArgumentsTest(EpicTestCase):
|
|
def test_client_arguments_win_and_paths_become_windows_paths(self):
|
|
ovt = self.tmp / "ovt"
|
|
ovt.mkdir()
|
|
client = ["-AUTH_LOGIN=unused", "-AUTH_PASSWORD=abc", "-AUTH_TYPE=exchangecode", "-epicapp=Fig",
|
|
"-epicovt=%s/token.ovt" % ovt, '-epicusername="Some One"', "-nosplash"]
|
|
own = ["-EpicPortal", "-epicapp=Fig", "-nosplash", "-windowed"]
|
|
merged = G["merge_args"](client, own)
|
|
self.assertIn('-epicovt="Z:%s\\token.ovt"' % str(ovt).replace("/", "\\"), merged)
|
|
self.assertEqual(1, merged.count("-nosplash"))
|
|
self.assertNotIn("-EpicPortal", merged)
|
|
self.assertEqual("-windowed", merged[-1])
|
|
self.assertEqual("-AUTH_PASSWORD=abc", merged[1])
|
|
|
|
def test_command_line_escapes_cmd_specials_outside_quotes(self):
|
|
line = G["command_line"](["-a=b&c", '-n="A & B"', "two words", "-p=50%", "^x", "-z=a|b>c<d"])
|
|
self.assertEqual('-a=b^&c -n="A & B" "two words" -p=50% ^^x -z=a^|b^>c^<d', line)
|
|
|
|
def test_a_stray_quote_cannot_unescape_the_rest_of_the_line(self):
|
|
self.assertEqual('"-a=x &y" b^&c -n=A^&B', G["command_line"](['-a="x &y', "b&c", '-n=A&B"']))
|
|
|
|
def test_args_file_is_written_with_crlf(self):
|
|
folder = self.tmp / "game"
|
|
folder.mkdir()
|
|
line = G["write_args"](str(folder), {"args": ["-x"]}, ["-epicapp=Fig"])
|
|
self.assertEqual("-epicapp=Fig -x", line)
|
|
self.assertEqual(b"-epicapp=Fig -x\r\n", (folder / ".droiddeck-epic-args").read_bytes())
|
|
self.assertEqual(0o600, (folder / ".droiddeck-epic-args").stat().st_mode & 0o777)
|
|
self.assertEqual([".droiddeck-epic-args"], os.listdir(folder))
|
|
|
|
def test_paths_convert_both_ways(self):
|
|
self.assertEqual("Z:\\root\\a b\\c.exe", G["windows_path"]("/root/a b/c.exe"))
|
|
self.assertEqual("/root/a b/c.exe", G["native_path"]("Z:\\root\\a b\\c.exe"))
|
|
self.assertEqual("/root/x", G["native_path"]("z:\\root\\x"))
|
|
|
|
def test_game_launches_are_told_apart_from_helpers(self):
|
|
game = {"args": ["-EpicPortal", "-epicapp=Fig"]}
|
|
self.assertTrue(G["is_game"](game))
|
|
self.assertTrue(G["is_game"](game, "fig"))
|
|
self.assertFalse(G["is_game"](game, "Other"))
|
|
self.assertFalse(G["is_game"]({"args": ["/quiet"]}))
|
|
|
|
def test_deep_link_quotes_the_app_id(self):
|
|
self.assertEqual("com.epicgames.launcher://apps/ns%3Aitem%3AFig?action=launch&silent=true",
|
|
G["deep_link"]("ns", "item", "Fig"))
|
|
|
|
|
|
class SignInTest(EpicTestCase):
|
|
AUTH = ("https://www.epicgames.com/id/authorize?client_id=c85f&response_type=code"
|
|
"&redirect_uri=http%3A%2F%2F127.0.0.1%3A41234%2Flauncher%2Fauthorized&state=s")
|
|
|
|
def test_only_epic_loopback_sign_ins_count(self):
|
|
self.assertTrue(G["sign_in_url"](self.AUTH))
|
|
self.assertFalse(G["sign_in_url"](self.AUTH.replace("www.epicgames.com", "example.com")))
|
|
self.assertFalse(G["sign_in_url"](self.AUTH.replace("127.0.0.1%3A41234", "example.com%3A80")))
|
|
self.assertFalse(G["sign_in_url"](self.AUTH.replace("%3A41234", "")))
|
|
self.assertFalse(G["sign_in_url"]("https://store.epicgames.com/p/fig"))
|
|
|
|
def test_xdg_open_hands_sign_in_and_links_to_the_app(self):
|
|
launch = self.tmp / "launch"
|
|
with mock.patch.dict(os.environ, {"BL_LAUNCH_DIR": str(launch)}):
|
|
self.assertEqual(0, G["shim_open"]([self.AUTH]))
|
|
self.assertEqual(0, G["shim_open"](["https://store.epicgames.com/p/fig"]))
|
|
self.assertEqual(1, G["shim_open"](["mailto:x@example.com"]))
|
|
self.assertEqual(0, G["shim_open"](["steam://rungameid/2379304779"]))
|
|
self.assertEqual(1, G["shim_open"](["steam://rungameid/0"]))
|
|
requests = [json.loads(p.read_text()) for p in (launch / "stores/req").glob("*.json")]
|
|
self.assertEqual(["epic-sign-in", "open-url", "steam-run"], sorted(r["op"] for r in requests))
|
|
self.assertEqual(["2379304779"], [r["id"] for r in requests if r["op"] == "steam-run"])
|
|
|
|
def test_the_xdg_open_command_forwards_steam_links(self):
|
|
launch = self.tmp / "cli-launch"
|
|
env = dict(os.environ, HOME=str(self.home), BL_LAUNCH_DIR=str(launch))
|
|
def xdg_open(url):
|
|
return subprocess.run([sys.executable, str(BIN / "droiddeck-epic"), "xdg-open", url], env=env,
|
|
stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL, timeout=30).returncode
|
|
self.assertEqual(0, xdg_open("steam://rungameid/2379304779"))
|
|
self.assertEqual(0, xdg_open("steam://run/606150"))
|
|
self.assertEqual(1, xdg_open("steam://rungameid/0"))
|
|
requests = [json.loads(p.read_text()) for p in (launch / "stores/req").glob("*.json")]
|
|
self.assertEqual(["steam-run", "steam-run"], [r["op"] for r in requests])
|
|
self.assertEqual(["2379304779", "606150"], sorted(r["id"] for r in requests))
|
|
|
|
|
|
class SignInHandoffTest(EpicTestCase):
|
|
def requests(self, launch):
|
|
return [json.loads(p.read_text()) for p in sorted((launch / "stores/req").glob("*.json"))]
|
|
|
|
def test_the_app_answers_whether_it_has_an_epic_account(self):
|
|
asked = []
|
|
for reply, ready in (({"ok": True}, True), ({"ok": False, "reason": "no-account"}, False), (None, False)):
|
|
with mock.patch.dict(G, {"ask_app": lambda request, timeout, reply=reply: asked.append(request) or reply}):
|
|
self.assertEqual(ready, G["handoff_ready"]())
|
|
self.assertEqual([{"op": "epic-client-handoff"}] * 3, asked)
|
|
|
|
def test_carrying_the_sign_in_needs_a_signed_out_launcher_and_an_account(self):
|
|
marker = Path(G["AUTO_SIGN_IN"])
|
|
marker.parent.mkdir(parents=True)
|
|
marker.write_text("1\n")
|
|
with mock.patch.dict(G, {"signed_in": lambda: True, "handoff_ready": lambda: True}):
|
|
self.assertFalse(G["carry_sign_in"]())
|
|
self.assertFalse(marker.exists())
|
|
with mock.patch.dict(G, {"signed_in": lambda: False, "handoff_ready": lambda: False}):
|
|
self.assertFalse(G["carry_sign_in"]())
|
|
with mock.patch.dict(G, {"signed_in": lambda: False, "handoff_ready": lambda: True}):
|
|
self.assertTrue(G["carry_sign_in"]())
|
|
|
|
def test_a_fresh_marker_makes_the_sign_in_automatic(self):
|
|
launch = self.tmp / "launch"
|
|
marker = Path(G["AUTO_SIGN_IN"])
|
|
with mock.patch.dict(os.environ, {"BL_LAUNCH_DIR": str(launch)}):
|
|
G["shim_open"]([SignInTest.AUTH])
|
|
marker.parent.mkdir(parents=True)
|
|
marker.write_text("1\n")
|
|
G["shim_open"]([SignInTest.AUTH])
|
|
old = time.time() - G["AUTO_SIGN_IN_FRESH"] - 5
|
|
os.utime(marker, (old, old))
|
|
G["shim_open"]([SignInTest.AUTH])
|
|
self.assertEqual([None, True, None], [r.get("auto") for r in self.requests(launch)])
|
|
|
|
def test_the_accessibility_bus_is_only_opened_for_a_carried_sign_in(self):
|
|
base = {"NO_AT_BRIDGE": "1", "GTK_A11Y": "none"}
|
|
with mock.patch.dict(G, {"KEPT_ENV": ("NO_AT_BRIDGE", "GTK_A11Y"), "CLIENT_ENV": {}, "fex_environment": lambda fex, files, base: {}}):
|
|
plain = G["client_environment"](base, None, None, ":0", "bus", "broker")
|
|
carried = G["client_environment"](base, None, None, ":0", "bus", "broker", "unix:abstract=a11y")
|
|
self.assertEqual(("1", "none", None), (plain.get("NO_AT_BRIDGE"), plain.get("GTK_A11Y"), plain.get("AT_SPI_BUS_ADDRESS")))
|
|
self.assertEqual((None, None, "unix:abstract=a11y"),
|
|
(carried.get("NO_AT_BRIDGE"), carried.get("GTK_A11Y"), carried.get("AT_SPI_BUS_ADDRESS")))
|
|
|
|
def test_only_the_hidden_launcher_skips_the_store_and_fab_pages(self):
|
|
with mock.patch.dict(G, {"fex_environment": lambda fex, files, base: {}}):
|
|
visible = G["client_environment"]({}, None, None, ":0", "bus", "broker")
|
|
hidden = G["client_environment"]({}, None, None, ":2", "bus", "broker", hidden=True)
|
|
self.assertNotIn("DROIDDECK_EPIC_HIDDEN", visible)
|
|
self.assertNotIn("EGL_DISABLE_STORE_PREWARM", visible)
|
|
self.assertEqual(("1", "1"), (hidden.get("DROIDDECK_EPIC_HIDDEN"), hidden.get("EGL_DISABLE_STORE_PREWARM")))
|
|
self.assertEqual(visible["WEBKIT_SKIA_ENABLE_CPU_RENDERING"], hidden["WEBKIT_SKIA_ENABLE_CPU_RENDERING"])
|
|
|
|
def session(self):
|
|
session = G["Session"]("visible")
|
|
session.carried = True
|
|
session.a11y = "unix:abstract=a11y"
|
|
return session
|
|
|
|
def test_the_welcome_screen_is_passed_then_sign_in_is_pressed_once(self):
|
|
pressed = []
|
|
screens = [{"Get started": (":1.2", "/a")}, {"Sign in": (":1.2", "/b")}, {"Sign in": (":1.2", "/b")}]
|
|
session = self.session()
|
|
with mock.patch.dict(G, {
|
|
"signed_in": lambda: False,
|
|
"a11y_buttons": lambda address: screens.pop(0),
|
|
"a11y_press": lambda address, target: pressed.append(target) or True,
|
|
}):
|
|
for now in (10.0, 11.0, 12.5, 15.0):
|
|
session.carry_on(now)
|
|
self.assertEqual([("/a"), ("/b")], [target[1] for target in pressed])
|
|
self.assertTrue(session.sign_in_pressed)
|
|
self.assertTrue(Path(G["AUTO_SIGN_IN"]).exists())
|
|
self.assertEqual(1, len(screens))
|
|
|
|
def test_the_welcome_screen_is_pressed_a_limited_number_of_times(self):
|
|
pressed = []
|
|
session = self.session()
|
|
with mock.patch.dict(G, {
|
|
"signed_in": lambda: False,
|
|
"a11y_buttons": lambda address: {"Get started": (":1.2", "/a")},
|
|
"a11y_press": lambda address, target: pressed.append(target) or True,
|
|
}):
|
|
for step in range(10):
|
|
session.carry_on(step * 3.0)
|
|
self.assertEqual(G["FIRST_RUN_PRESSES"], len(pressed))
|
|
|
|
def test_signing_in_ends_the_carry_and_drops_the_marker(self):
|
|
marker = Path(G["AUTO_SIGN_IN"])
|
|
marker.parent.mkdir(parents=True)
|
|
marker.write_text("1\n")
|
|
session = self.session()
|
|
with mock.patch.dict(G, {"signed_in": lambda: True}):
|
|
session.carry_on(1.0)
|
|
self.assertFalse(session.carried)
|
|
self.assertFalse(marker.exists())
|
|
|
|
def test_no_accessibility_bus_means_no_buttons(self):
|
|
self.assertEqual({}, G["a11y_buttons"](None))
|
|
with mock.patch.dict(G, {"a11y_connect": lambda address: None}):
|
|
self.assertEqual({}, G["a11y_buttons"]("unix:abstract=a11y"))
|
|
self.assertFalse(G["a11y_press"]("unix:abstract=a11y", (":1.2", "/a")))
|
|
|
|
|
|
class AuthTest(EpicTestCase):
|
|
def write_auth(self, value):
|
|
path = Path(G["AUTH_FILE"])
|
|
path.parent.mkdir(parents=True, exist_ok=True)
|
|
path.write_text(json.dumps(value))
|
|
|
|
def test_only_a_prod_refresh_token_counts_as_signed_in(self):
|
|
self.assertFalse(G["signed_in"]())
|
|
for value in ({"foo": 1}, {"prod": "x"}, {"prod": {"refresh_token": ""}}, {"gamedev": {"refresh_token": "t"}}):
|
|
self.write_auth(value)
|
|
self.assertFalse(G["signed_in"](), value)
|
|
self.write_auth({"prod": {"refresh_token": "t"}})
|
|
self.assertTrue(G["signed_in"]())
|
|
|
|
|
|
class SignOutTest(EpicTestCase):
|
|
def requests(self, launch):
|
|
return [json.loads(p.read_text()) for p in sorted((launch / "stores/req").glob("*.json"))]
|
|
|
|
def follow(self, states, times):
|
|
launch = self.tmp / "launch"
|
|
session = G["Session"]("visible")
|
|
states = iter(states)
|
|
with mock.patch.dict(os.environ, {"BL_LAUNCH_DIR": str(launch)}), \
|
|
mock.patch.dict(G, {"signed_in": lambda: next(states)}):
|
|
for now in times:
|
|
session.follow_sign_out(now)
|
|
return self.requests(launch) if (launch / "stores/req").is_dir() else []
|
|
|
|
def test_signing_the_launcher_out_signs_droiddeck_out_after_the_grace(self):
|
|
grace = G["SIGN_OUT_GRACE"]
|
|
posted = self.follow([True, False, False, False, False], [0.0, 1.0, 1.0 + grace / 2, 1.0 + grace, 9.0 + grace])
|
|
self.assertEqual([{"op": "epic-signed-out"}], posted)
|
|
|
|
def test_a_short_token_gap_is_not_a_sign_out(self):
|
|
grace = G["SIGN_OUT_GRACE"]
|
|
self.assertEqual([], self.follow([True, False, True, False], [0.0, 1.0, 1.0 + grace / 2, 1.0 + grace]))
|
|
|
|
def test_a_launcher_that_was_never_signed_in_posts_nothing(self):
|
|
self.assertEqual([], self.follow([False] * 3, [0.0, 10.0, 20.0]))
|
|
|
|
def write_signed_in(self):
|
|
auth = Path(G["AUTH_FILE"])
|
|
auth.parent.mkdir(parents=True, exist_ok=True)
|
|
auth.write_text(json.dumps({"prod": {"refresh_token": "t"}}))
|
|
Path(G["WEB_COOKIES"]).write_text("c")
|
|
marker = Path(G["SIGNED_OUT"])
|
|
marker.parent.mkdir(parents=True, exist_ok=True)
|
|
marker.write_text("1\n")
|
|
return auth, marker
|
|
|
|
def test_droiddecks_sign_out_drops_the_launchers_sign_in_once(self):
|
|
auth, marker = self.write_signed_in()
|
|
self.assertTrue(G["forget_sign_in"]())
|
|
self.assertFalse(auth.exists() or marker.exists() or Path(G["WEB_COOKIES"]).exists())
|
|
auth.write_text(json.dumps({"prod": {"refresh_token": "t"}}))
|
|
self.assertFalse(G["forget_sign_in"]())
|
|
self.assertTrue(auth.exists())
|
|
|
|
def test_the_open_launcher_restarts_signed_out(self):
|
|
auth, marker = self.write_signed_in()
|
|
session = G["Session"]("visible")
|
|
session.broker = mock.Mock(closing=False, game=None, owner=None)
|
|
session.client = mock.Mock(poll=mock.Mock(return_value=None))
|
|
session.was_signed_in = True
|
|
ended = []
|
|
restarted = mock.Mock(poll=mock.Mock(return_value=0), returncode=0)
|
|
with mock.patch.object(session, "end_client", side_effect=lambda: ended.append(auth.exists())), \
|
|
mock.patch.object(session, "start_client", side_effect=lambda: setattr(session, "client", restarted)), \
|
|
mock.patch.dict(G, {"signed_in": lambda: False}):
|
|
self.assertEqual(0, session.watch())
|
|
self.assertEqual([True], ended)
|
|
self.assertFalse(auth.exists() or marker.exists())
|
|
self.assertIs(restarted, session.client)
|
|
self.assertFalse(session.was_signed_in)
|
|
|
|
def test_the_open_launcher_waits_for_its_game_before_restarting(self):
|
|
self.write_signed_in()
|
|
session = G["Session"]("visible")
|
|
session.broker = mock.Mock(closing=False, game=object(), owner=None)
|
|
session.client = mock.Mock(poll=mock.Mock(side_effect=[None, None, 0]), returncode=0)
|
|
with mock.patch.object(session, "end_client") as end, mock.patch.object(session, "start_client") as start, \
|
|
mock.patch.dict(G, {"signed_in": lambda: True}):
|
|
self.assertEqual(0, session.watch())
|
|
end.assert_not_called()
|
|
start.assert_not_called()
|
|
self.assertTrue(Path(G["SIGNED_OUT"]).exists())
|
|
|
|
def test_the_hidden_launcher_closes_and_forgets_the_sign_in(self):
|
|
auth, marker = self.write_signed_in()
|
|
session = G["Session"]("hidden")
|
|
session.broker = mock.Mock(closing=False, game=None, owner=None, running=[])
|
|
session.client = mock.Mock(poll=mock.Mock(return_value=None))
|
|
with mock.patch.object(session, "end_client") as end:
|
|
self.assertEqual(0, session.watch())
|
|
self.assertTrue(auth.exists())
|
|
session.stop()
|
|
end.assert_called_once()
|
|
self.assertFalse(auth.exists() or marker.exists())
|
|
|
|
|
|
class DisplayTest(EpicTestCase):
|
|
def test_hidden_display_comes_from_a_headless_gamescope(self):
|
|
session = G["Session"]("hidden")
|
|
session.rundir = str(self.tmp / "display-run")
|
|
os.makedirs(session.rundir)
|
|
spawned = []
|
|
|
|
def spawn(command, env, log=None, group=False):
|
|
spawned.append((command, env))
|
|
marker = command[-1]
|
|
Path(marker).write_text(":2")
|
|
|
|
session.spawn = spawn
|
|
display = session.start_display({"DISPLAY": ":0", "WAYLAND_DISPLAY": "gamescope-0", "HOME": "/root"}, "unix:path=/b")
|
|
self.assertEqual(":2", display)
|
|
command, env = spawned[0]
|
|
self.assertEqual(["gamescope", "--backend", "headless"], command[:3])
|
|
self.assertNotIn("DISPLAY", env)
|
|
self.assertNotIn("WAYLAND_DISPLAY", env)
|
|
self.assertEqual("unix:path=/b", env["DBUS_SESSION_BUS_ADDRESS"])
|
|
|
|
|
|
class InstallationsTest(EpicTestCase):
|
|
def add_game(self, guid="G1", app="Fig", store="epic", extra=None):
|
|
folder = self.tmp / ("game-" + app)
|
|
folder.mkdir()
|
|
(folder / ".droiddeck-store.json").write_text(json.dumps({"store": store, "id": app, "extra": extra or {}}))
|
|
manifests = Path(G["V1_MANIFESTS"])
|
|
manifests.mkdir(parents=True, exist_ok=True)
|
|
(manifests / (guid + ".item")).write_text(json.dumps({
|
|
"InstallationGuid": guid, "InstallLocation": str(folder), "CatalogNamespace": "ns", "CatalogItemId": "it",
|
|
"AppName": app, "AppVersionString": "v1", "InstallTags": ["en"], "SidecarConfigRevision": 1,
|
|
"SidecarDeploymentId": "dep", "LaunchExecutable": "Fig.exe", "bIsIncompleteInstall": False,
|
|
}))
|
|
return folder
|
|
|
|
def lines(self):
|
|
return [json.loads(line) for line in Path(G["INSTALLATIONS"]).read_text().splitlines()]
|
|
|
|
def test_missing_droiddeck_install_is_registered_as_windows(self):
|
|
folder = self.add_game()
|
|
self.assertTrue(G["sync_installations"]())
|
|
header, entry = self.lines()
|
|
self.assertEqual(1, header["entries"])
|
|
self.assertEqual("Windows", entry["platform"])
|
|
self.assertEqual(str(folder), entry["dir"])
|
|
self.assertEqual(["", "en"], entry["tags"])
|
|
self.assertEqual({"revision": 1, "deploymentId": "dep"}, entry["sidecar"])
|
|
self.assertFalse(G["sync_installations"]())
|
|
|
|
def test_imported_linux_record_is_switched_to_windows_and_others_kept(self):
|
|
folder = self.add_game()
|
|
other = {"installationId": "X", "dir": "/elsewhere", "platform": "Linux", "artifactId": "Other"}
|
|
mine = {"installationId": "G1", "dir": str(folder), "platform": "Linux", "artifactId": "Fig", "sidecar": {"revision": 0}}
|
|
Path(G["INSTALLATIONS"]).parent.mkdir(parents=True, exist_ok=True)
|
|
Path(G["INSTALLATIONS"]).write_text("\n".join(json.dumps(v) for v in (
|
|
{"version": 1, "writtenAt": "t", "entries": 3}, mine, dict(mine, sidecar={"revision": 1}), other)) + "\n")
|
|
self.assertTrue(G["sync_installations"]())
|
|
header, first, second, kept = self.lines()
|
|
self.assertEqual(3, header["entries"])
|
|
self.assertEqual(["Windows", "Windows"], [first["platform"], second["platform"]])
|
|
self.assertEqual({"revision": 1}, second["sidecar"])
|
|
self.assertEqual(other, kept)
|
|
|
|
def write_records(self, *records):
|
|
Path(G["INSTALLATIONS"]).parent.mkdir(parents=True, exist_ok=True)
|
|
Path(G["INSTALLATIONS"]).write_text("\n".join(json.dumps(v) for v in (
|
|
{"version": 1, "writtenAt": "t", "entries": len(records)},) + records) + "\n")
|
|
|
|
def test_imported_record_is_switched_after_the_launcher_consumed_the_item(self):
|
|
folder = self.add_game()
|
|
shutil.rmtree(G["V1_MANIFESTS"])
|
|
self.write_records({"installationId": "G1", "dir": str(folder), "platform": "Linux", "artifactId": "Fig"})
|
|
self.assertTrue(G["sync_installations"]())
|
|
self.assertEqual("Windows", self.lines()[1]["platform"])
|
|
self.assertFalse(G["sync_installations"]())
|
|
|
|
def test_games_the_launcher_installed_keep_their_platform(self):
|
|
folder = self.add_game(extra={"clientManaged": "true"})
|
|
record = {"installationId": "G1", "dir": str(folder), "platform": "Linux", "artifactId": "Fig"}
|
|
self.write_records(record)
|
|
self.assertFalse(G["sync_installations"]())
|
|
self.assertEqual(record, self.lines()[1])
|
|
|
|
def test_a_moved_game_points_the_launcher_at_its_new_folder(self):
|
|
folder = self.add_game()
|
|
old = {"installationId": "G1", "dir": "/gone/Fig", "platform": "Windows", "artifactId": "Fig"}
|
|
self.write_records(old, dict(old, version="v2"))
|
|
self.assertTrue(G["sync_installations"]())
|
|
header, first, second = self.lines()
|
|
self.assertEqual(2, header["entries"])
|
|
self.assertEqual([str(folder), str(folder)], [first["dir"], second["dir"]])
|
|
self.assertEqual("v2", second["version"])
|
|
self.assertFalse(G["sync_installations"]())
|
|
|
|
def test_an_imported_record_under_another_id_follows_the_move_and_platform(self):
|
|
folder = self.add_game()
|
|
self.write_records({"installationId": "L1", "dir": "/gone/Fig", "platform": "Linux", "artifactId": "Fig"})
|
|
self.assertTrue(G["sync_installations"]())
|
|
entry = self.lines()[1]
|
|
self.assertEqual(("L1", str(folder), "Windows"), (entry["installationId"], entry["dir"], entry["platform"]))
|
|
|
|
def test_a_record_whose_folder_still_exists_is_not_moved(self):
|
|
folder = self.add_game()
|
|
other = self.tmp / "copy"
|
|
other.mkdir()
|
|
(other / ".droiddeck-store.json").write_text(json.dumps({"store": "epic", "id": "Fig"}))
|
|
record = {"installationId": "G1", "dir": str(other), "platform": "Windows", "artifactId": "Fig"}
|
|
self.write_records(record)
|
|
self.assertFalse(G["sync_installations"]())
|
|
self.assertNotEqual(str(folder), self.lines()[1]["dir"])
|
|
|
|
def test_a_launcher_install_of_the_same_game_is_not_redirected(self):
|
|
self.add_game()
|
|
own = self.tmp / "launcher-Fig"
|
|
own.mkdir()
|
|
record = {"installationId": "L1", "dir": str(own), "platform": "Windows", "artifactId": "Fig"}
|
|
self.write_records(record)
|
|
G["sync_installations"]()
|
|
self.assertEqual(record, self.lines()[1])
|
|
|
|
def test_games_not_installed_by_droiddeck_are_left_alone(self):
|
|
self.add_game(store="gog")
|
|
self.assertFalse(G["sync_installations"]())
|
|
self.assertFalse(Path(G["INSTALLATIONS"]).exists())
|
|
|
|
|
|
class SettingsTest(EpicTestCase):
|
|
def test_settings_are_seeded_without_losing_the_rest(self):
|
|
settings = self.data / "settings/app-settings.json"
|
|
settings.parent.mkdir(parents=True)
|
|
settings.write_text(json.dumps({"locale": "de", "cloudSavesEnabled": True}))
|
|
self.assertTrue(G["seed_settings"]())
|
|
saved = json.loads(settings.read_text())
|
|
self.assertEqual("de", saved["locale"])
|
|
self.assertFalse(saved["cloudSavesEnabled"])
|
|
self.assertFalse(saved["minimizeToTray"])
|
|
self.assertFalse(saved["automaticDownloadsEnabled"])
|
|
self.assertFalse(G["seed_settings"]())
|
|
|
|
def test_proton_shim_replaces_every_ge_proton(self):
|
|
real = self.data / "compat/runtime/GE-Proton10-40"
|
|
real.mkdir(parents=True)
|
|
(real / "proton").write_text("#!/usr/bin/env python3\n")
|
|
G["install_shims"]()
|
|
for name in ("GE-Proton10-34", "GE-Proton10-40"):
|
|
shim = self.data / "compat/runtime" / name / "proton"
|
|
self.assertIn("droiddeck-epic", shim.read_text())
|
|
self.assertIn(" proton ", shim.read_text())
|
|
self.assertTrue(os.access(shim, os.X_OK))
|
|
self.assertIn("xdg-open", (self.state / "bin/xdg-open").read_text())
|
|
|
|
|
|
class BrokerTest(EpicTestCase):
|
|
def broker(self, mode="hidden"):
|
|
path = os.path.join(G["run_dir"](), "broker.sock")
|
|
broker = G["Broker"](path, mode, dict(os.environ))
|
|
self.addCleanup(broker.shutdown)
|
|
return broker, path
|
|
|
|
def shim(self, path, args, result):
|
|
def run():
|
|
with mock.patch.dict(os.environ, {"DROIDDECK_EPIC_BROKER": path}):
|
|
result.append(G["shim_proton"](["waitforexitandrun", "/games/Fig/Fig.exe"] + args))
|
|
thread = threading.Thread(target=run, daemon=True)
|
|
thread.start()
|
|
return thread
|
|
|
|
def test_stale_broker_socket_is_removed_before_bind(self):
|
|
path = os.path.join(G["run_dir"](), "broker.sock")
|
|
os.makedirs(os.path.dirname(path), exist_ok=True)
|
|
stale = socket.socket(socket.AF_UNIX, socket.SOCK_STREAM)
|
|
stale.bind(path)
|
|
stale.close()
|
|
broker, path = self.broker()
|
|
self.assertEqual("hidden", G["ask_broker"]({"op": "status"}, path=path)["mode"])
|
|
|
|
def test_hidden_launch_hands_args_and_waits_for_the_game(self):
|
|
broker, path = self.broker()
|
|
conn = socket.socket(socket.AF_UNIX, socket.SOCK_STREAM)
|
|
conn.connect(path)
|
|
G["send"](conn, {"op": "await", "app": "Fig", "timeout": 10})
|
|
time.sleep(0.2)
|
|
helper = []
|
|
self.shim(path, ["/quiet"], helper).join(5)
|
|
self.assertEqual([0], helper)
|
|
result = []
|
|
thread = self.shim(path, ["-epicapp=Fig", "-AUTH_PASSWORD=abc"], result)
|
|
reply = G["receive"](conn, 10)
|
|
conn.close()
|
|
self.assertTrue(reply["ok"])
|
|
self.assertEqual(["-epicapp=Fig", "-AUTH_PASSWORD=abc"], reply["args"])
|
|
time.sleep(0.3)
|
|
self.assertEqual([], result)
|
|
self.assertEqual({"ok": True}, G["ask_broker"]({"op": "exited", "code": 3}, path=path))
|
|
thread.join(5)
|
|
self.assertEqual([3], result)
|
|
self.assertIsNotNone(broker.exited_at)
|
|
|
|
def test_await_times_out_without_a_launch(self):
|
|
broker, path = self.broker()
|
|
conn = socket.socket(socket.AF_UNIX, socket.SOCK_STREAM)
|
|
conn.connect(path)
|
|
G["send"](conn, {"op": "await", "app": "Fig", "timeout": 0.5})
|
|
self.assertEqual({"ok": False, "reason": "timeout"}, G["receive"](conn, 5))
|
|
conn.close()
|
|
|
|
def test_broker_survives_malformed_requests(self):
|
|
broker, path = self.broker()
|
|
for raw in (b"[1, 2]\n", b'{"op": "exited", "code": [1]}\n', b'{"op": "exited", "code": "x"}\n',
|
|
b'{"op": "launch", "args": 5}\n'):
|
|
with socket.socket(socket.AF_UNIX, socket.SOCK_STREAM) as conn:
|
|
conn.connect(path)
|
|
conn.sendall(raw)
|
|
conn.settimeout(5)
|
|
conn.recv(4096)
|
|
self.assertEqual("hidden", G["ask_broker"]({"op": "status"}, path=path)["mode"])
|
|
|
|
def test_shim_survives_a_malformed_reply(self):
|
|
path = os.path.join(G["run_dir"](), "fake.sock")
|
|
os.makedirs(os.path.dirname(path), exist_ok=True)
|
|
server = socket.socket(socket.AF_UNIX, socket.SOCK_STREAM)
|
|
self.addCleanup(server.close)
|
|
server.bind(path)
|
|
server.listen(4)
|
|
replies = [b"[1]\n", b'{"code": "x"}\n']
|
|
|
|
def answer():
|
|
for reply in replies:
|
|
conn, _ = server.accept()
|
|
with conn:
|
|
G["receive"](conn, 5)
|
|
conn.sendall(reply)
|
|
|
|
threading.Thread(target=answer, daemon=True).start()
|
|
with mock.patch.dict(os.environ, {"DROIDDECK_EPIC_BROKER": path}):
|
|
for _ in replies:
|
|
self.assertEqual(1, G["shim_proton"](["waitforexitandrun", "/x.exe", "-epicapp=Fig"]))
|
|
|
|
def test_shim_without_broker_fails(self):
|
|
with mock.patch.dict(os.environ, {"DROIDDECK_EPIC_BROKER": str(self.tmp / "missing.sock")}):
|
|
self.assertEqual(1, G["shim_proton"](["waitforexitandrun", "/x.exe", "-epicapp=Fig"]))
|
|
self.assertEqual(0, G["shim_proton"](["getcompatpath", "/x"]))
|
|
|
|
def test_visible_mode_runs_store_games_through_their_launcher(self):
|
|
folder = self.tmp / "Fig"
|
|
folder.mkdir()
|
|
(folder / ".droiddeck-store.json").write_text(json.dumps({"store": "epic", "id": "Fig", "args": ["-x"]}))
|
|
(folder / ".droiddeck-launch.bat").write_text('if exist "%~dp0.droiddeck-epic-args" goto client\r\n')
|
|
calls = []
|
|
|
|
def popen(command, env=None, cwd=None, start_new_session=False):
|
|
calls.append((command, env.get("DROIDDECK_EPIC_HANDLED"), start_new_session))
|
|
return mock.Mock(wait=mock.Mock(return_value=7))
|
|
|
|
running = []
|
|
with mock.patch.object(subprocess, "Popen", popen):
|
|
code = G["run_game"]({"exe": str(folder / "Fig.exe"), "args": ["-epicapp=Fig"]}, {"PATH": "/usr/bin"}, running)
|
|
self.assertEqual(7, code)
|
|
self.assertEqual(([G["PROTON_RUN"], str(folder / ".droiddeck-launch.bat")], "1", True), calls[0])
|
|
self.assertEqual([], running)
|
|
self.assertEqual(b"-epicapp=Fig -x\r\n", (folder / ".droiddeck-epic-args").read_bytes())
|
|
|
|
def test_visible_games_are_marked_as_playing_while_they_run(self):
|
|
broker, path = self.broker("visible")
|
|
launch = self.tmp / "launch"
|
|
marker = launch / G["PLAYING"]
|
|
seen = []
|
|
|
|
def run_game(request, env, running=None):
|
|
seen.append(marker.is_file())
|
|
return 5
|
|
|
|
result = []
|
|
with mock.patch.dict(os.environ, {"BL_LAUNCH_DIR": str(launch)}), mock.patch.dict(G, {"run_game": run_game}):
|
|
self.shim(path, ["-epicapp=Fig"], result).join(10)
|
|
self.assertEqual([True], seen)
|
|
self.assertEqual([5], result)
|
|
self.assertFalse(marker.exists())
|
|
|
|
def steam_takes(self, path, app, replies):
|
|
def take():
|
|
with socket.socket(socket.AF_UNIX, socket.SOCK_STREAM) as conn:
|
|
conn.connect(path)
|
|
G["send"](conn, {"op": "await", "app": app, "timeout": 10})
|
|
replies.append(G["receive"](conn, 10))
|
|
G["ask_broker"]({"op": "exited", "code": 6}, path=path)
|
|
threading.Thread(target=take, daemon=True).start()
|
|
|
|
def test_a_game_started_in_the_open_launcher_goes_through_steam(self):
|
|
broker, path = self.broker("visible")
|
|
asked, ran, result, taken = [], [], [], []
|
|
|
|
def ask_app(request, timeout):
|
|
asked.append(request)
|
|
self.steam_takes(path, "Fig", taken)
|
|
return {"ok": True}
|
|
|
|
with mock.patch.dict(G, {
|
|
"find_store_game": lambda exe: (str(self.tmp), {"store": "epic", "id": "Fig"}),
|
|
"ask_app": ask_app,
|
|
"run_game": lambda *a, **k: ran.append(a) or 3,
|
|
}):
|
|
self.shim(path, ["-epicapp=Fig", "-AUTH_PASSWORD=x"], result).join(10)
|
|
self.assertEqual([{"op": "epic-play-in-steam", "id": "Fig"}], asked)
|
|
self.assertEqual([], ran)
|
|
self.assertEqual([6], result)
|
|
self.assertEqual(["-epicapp=Fig", "-AUTH_PASSWORD=x"], taken[0]["args"])
|
|
self.assertEqual([], broker.launches)
|
|
|
|
def test_a_game_steam_never_takes_is_dropped_after_the_wait(self):
|
|
broker, path = self.broker("visible")
|
|
result = []
|
|
with mock.patch.dict(G, {
|
|
"find_store_game": lambda exe: (str(self.tmp), {"store": "epic", "id": "Fig"}),
|
|
"ask_app": lambda request, timeout: {"ok": True},
|
|
"run_game": lambda *a, **k: 3,
|
|
"STEAM_CLAIM_WAIT": 0.3,
|
|
}):
|
|
self.shim(path, ["-epicapp=Fig"], result).join(10)
|
|
self.assertEqual([0], result)
|
|
self.assertEqual([], broker.launches)
|
|
|
|
def test_a_launch_steam_already_waits_for_is_handed_straight_to_it(self):
|
|
broker, path = self.broker("visible")
|
|
taken, result, asked = [], [], []
|
|
self.steam_takes(path, "Fig", taken)
|
|
self.assertTrue(G["wait_for"](lambda: broker.awaiting.get("fig"), 5, 0.05))
|
|
with mock.patch.dict(G, {"ask_app": lambda request, timeout: asked.append(request) or {"ok": True},
|
|
"run_game": lambda *a, **k: 3}):
|
|
self.shim(path, ["-epicapp=Fig"], result).join(10)
|
|
self.assertEqual([], asked)
|
|
self.assertEqual([6], result)
|
|
self.assertEqual(["-epicapp=Fig"], taken[0]["args"])
|
|
self.assertEqual({}, dict(broker.awaiting))
|
|
|
|
def test_steam_takes_the_launch_the_open_launcher_already_started(self):
|
|
broker, path = self.broker("visible")
|
|
item = G["Launch"]({"exe": "/games/Fig/Fig.exe", "args": ["-epicapp=Fig", "-x"]})
|
|
broker.launches.append(item)
|
|
info = {"pid": os.getpid(), "broker": path, "mode": "visible", "bus": "unix:path=/nonexistent"}
|
|
with mock.patch.dict(G, {"forward": mock.Mock(side_effect=AssertionError("no deep link"))}):
|
|
reply = G["hand_off"](info, None, "Fig", "ns", "it", "1", time.monotonic() + 10)
|
|
self.assertTrue(reply["ok"])
|
|
self.assertEqual(["-epicapp=Fig", "-x"], reply["args"])
|
|
self.assertTrue(item.claimed)
|
|
self.assertIs(item, broker.game)
|
|
|
|
def test_a_visible_launcher_that_started_nothing_gets_the_deep_link(self):
|
|
broker, path = self.broker("visible")
|
|
info = {"pid": os.getpid(), "broker": path, "mode": "visible", "bus": "unix:path=/x"}
|
|
sent, result = [], []
|
|
|
|
def forward(bus, url):
|
|
sent.append(url)
|
|
self.shim(path, ["-epicapp=Fig"], result)
|
|
return True
|
|
|
|
with mock.patch.dict(G, {"api": lambda: {"port": 1}, "installed": lambda current, app: True,
|
|
"signed_in": lambda: True, "forward": forward,
|
|
"ask_app": mock.Mock(side_effect=AssertionError("offered to Steam"))}):
|
|
reply = G["hand_off"](info, None, "Fig", "ns", "it", "1", time.monotonic() + 10)
|
|
self.assertTrue(reply["ok"])
|
|
self.assertEqual(["-epicapp=Fig"], reply["args"])
|
|
self.assertEqual(1, len(sent))
|
|
G["ask_broker"]({"op": "exited", "code": 2}, path=path)
|
|
self.assertTrue(G["wait_for"](lambda: result, 5, 0.05))
|
|
self.assertEqual([2], result)
|
|
|
|
def test_the_hidden_launcher_is_closed_before_the_visible_one_opens(self):
|
|
broker, path = self.broker()
|
|
proc = subprocess.Popen(["sleep", "30"])
|
|
self.addCleanup(proc.kill)
|
|
info = {"pid": proc.pid, "mode": "hidden", "broker": path}
|
|
stops = []
|
|
|
|
def ask(message, timeout=3.0, path=None):
|
|
stops.append((message, path))
|
|
proc.kill()
|
|
proc.wait()
|
|
return {"ok": True}
|
|
|
|
with mock.patch.dict(G, {"read_json": lambda p: info, "ask_broker": ask}):
|
|
G["retire_hidden"]()
|
|
self.assertEqual([({"op": "stop"}, path)], stops)
|
|
stops.clear()
|
|
with mock.patch.dict(G, {"read_json": lambda p: dict(info, mode="visible", pid=os.getpid()), "ask_broker": ask}):
|
|
G["retire_hidden"]()
|
|
self.assertEqual([], stops)
|
|
|
|
def test_the_open_mark_carries_the_steam_app_id(self):
|
|
launch = self.tmp / "launch"
|
|
with mock.patch.dict(os.environ, {"BL_LAUNCH_DIR": str(launch)}):
|
|
with G["marked"](G["OPEN"], "3000000001"):
|
|
self.assertEqual("3000000001\n", (launch / G["OPEN"]).read_text())
|
|
self.assertFalse((launch / G["OPEN"]).exists())
|
|
|
|
def test_the_steam_app_id_comes_from_the_reaper_above_the_launcher(self):
|
|
reaper = subprocess.Popen(["sh", "-c", "sleep 30; :", "SteamLaunch", "AppId=3000000001", "--"])
|
|
self.addCleanup(reaper.kill)
|
|
children = Path("/proc/%d/task/%d/children" % (reaper.pid, reaper.pid))
|
|
self.assertTrue(G["wait_for"](lambda: children.is_file() and children.read_text().split(), 5, 0.05))
|
|
child = int(children.read_text().split()[0])
|
|
self.assertEqual(reaper.pid, G["parent_pid"](child))
|
|
self.assertEqual("3000000001", G["steam_app_id"](child))
|
|
self.assertEqual("3000000001", G["steam_app_id"](reaper.pid))
|
|
self.assertEqual("", G["steam_app_id"](os.getpid()))
|
|
self.assertEqual(0, G["parent_pid"](self.dead_pid()))
|
|
bad = subprocess.Popen(["sh", "-c", "sleep 30; :", "SteamLaunch", "AppId=x"])
|
|
self.addCleanup(bad.kill)
|
|
self.assertEqual("", G["steam_app_id"](bad.pid))
|
|
|
|
def test_a_game_run_in_the_launcher_gets_steams_overlay_preload_back(self):
|
|
folder = self.tmp / "Fig"
|
|
folder.mkdir()
|
|
envs = []
|
|
|
|
def popen(command, env=None, cwd=None, start_new_session=False):
|
|
envs.append(env)
|
|
return mock.Mock(wait=mock.Mock(return_value=0))
|
|
|
|
with mock.patch.object(subprocess, "Popen", popen):
|
|
G["run_game"]({"exe": str(folder / "Fig.exe"), "args": []}, {"PATH": "/usr/bin", G["STEAM_PRELOAD"]: "/s/overlay.so"})
|
|
G["run_game"]({"exe": str(folder / "Fig.exe"), "args": []}, {"PATH": "/usr/bin"})
|
|
self.assertEqual("/s/overlay.so", envs[0]["LD_PRELOAD"])
|
|
self.assertNotIn(G["STEAM_PRELOAD"], envs[0])
|
|
self.assertNotIn("LD_PRELOAD", envs[1])
|
|
|
|
|
|
def test_a_game_started_by_the_hidden_launcher_does_not_inherit_its_page_switches(self):
|
|
folder = self.tmp / "Fig"
|
|
folder.mkdir()
|
|
envs = []
|
|
|
|
def popen(command, env=None, cwd=None, start_new_session=False):
|
|
envs.append(env)
|
|
return mock.Mock(wait=mock.Mock(return_value=0))
|
|
|
|
with mock.patch.object(subprocess, "Popen", popen):
|
|
G["run_game"]({"exe": str(folder / "Fig.exe"), "args": []}, dict(G["HIDDEN_ENV"], PATH="/usr/bin"))
|
|
self.assertFalse(set(G["HIDDEN_ENV"]) & set(envs[0]))
|
|
self.assertEqual("/usr/bin", envs[0]["PATH"])
|
|
def test_an_owner_gone_ends_a_game_the_open_launcher_handed_to_steam(self):
|
|
broker, _ = self.broker("visible")
|
|
session = G["Session"]("visible")
|
|
session.broker = broker
|
|
polls = iter([None, None, None, 0])
|
|
session.client = mock.Mock(poll=mock.Mock(side_effect=lambda: next(polls, 0)), returncode=0)
|
|
game = G["Launch"]({"args": ["-epicapp=Fig"]})
|
|
broker.game = game
|
|
broker.owner = self.dead_pid()
|
|
result = []
|
|
thread = threading.Thread(target=lambda: result.append(session.watch()), daemon=True)
|
|
thread.start()
|
|
thread.join(10)
|
|
self.assertEqual([0], result)
|
|
self.assertTrue(game.done.is_set())
|
|
self.assertIsNone(broker.game)
|
|
|
|
def test_a_game_steam_cannot_take_runs_in_the_open_launcher(self):
|
|
broker, path = self.broker("visible")
|
|
result = []
|
|
for reply in ({"ok": False, "reason": "not-registered"}, None, [1]):
|
|
with mock.patch.dict(G, {
|
|
"find_store_game": lambda exe: (str(self.tmp), {"store": "epic", "id": "Fig"}),
|
|
"ask_app": lambda request, timeout: reply,
|
|
"run_game": lambda *a, **k: 4,
|
|
}):
|
|
self.shim(path, ["-epicapp=Fig"], result).join(10)
|
|
self.assertEqual([4, 4, 4], result)
|
|
|
|
def test_only_registered_epic_games_are_offered_to_steam(self):
|
|
asked = []
|
|
with mock.patch.dict(G, {"ask_app": lambda request, timeout: asked.append(request) or {"ok": True}}):
|
|
with mock.patch.dict(G, {"find_store_game": lambda exe: None}):
|
|
self.assertFalse(G["play_in_steam"]({"exe": "/games/Fig/Fig.exe"}))
|
|
with mock.patch.dict(G, {"find_store_game": lambda exe: ("/games/Fig", {"store": "gog", "id": "1"})}):
|
|
self.assertFalse(G["play_in_steam"]({"exe": "/games/Fig/Fig.exe"}))
|
|
self.assertEqual([], asked)
|
|
|
|
def test_a_game_the_launcher_just_installed_is_offered_to_steam_by_its_install_record(self):
|
|
records = Path(G["INSTALLATIONS"])
|
|
records.parent.mkdir(parents=True, exist_ok=True)
|
|
lines = [{"version": 1, "entries": 3},
|
|
{"installationId": "A", "dir": "/games/Fig", "artifactId": "Fig"},
|
|
{"installationId": "B", "dir": "/games/Old", "artifactId": "Old"},
|
|
{"installationId": "B", "deleted": True}]
|
|
records.write_text("\n".join(json.dumps(line) for line in lines) + "\n")
|
|
asked = []
|
|
with mock.patch.dict(G, {"find_store_game": lambda exe: None,
|
|
"ask_app": lambda request, timeout: asked.append(request) or {"ok": True}}):
|
|
self.assertTrue(G["play_in_steam"]({"exe": "/games/Fig/bin/Fig.exe"}))
|
|
self.assertFalse(G["play_in_steam"]({"exe": "/games/Old/Old.exe"}))
|
|
self.assertFalse(G["play_in_steam"]({"exe": "/games/Figment/Fig.exe"}))
|
|
self.assertEqual([{"op": "epic-play-in-steam", "id": "Fig"}], asked)
|
|
|
|
def test_the_playing_mark_goes_away_when_the_game_fails(self):
|
|
launch = self.tmp / "launch"
|
|
with mock.patch.dict(os.environ, {"BL_LAUNCH_DIR": str(launch)}):
|
|
with self.assertRaises(OSError), G["playing"]():
|
|
self.assertTrue((launch / G["PLAYING"]).is_file())
|
|
raise OSError("boom")
|
|
self.assertFalse((launch / G["PLAYING"]).exists())
|
|
|
|
def test_a_stopped_session_leaves_no_playing_mark_for_the_next_launcher(self):
|
|
launch = self.tmp / "launch"
|
|
launch.mkdir()
|
|
(launch / G["PLAYING"]).write_text("1\n")
|
|
with mock.patch.dict(os.environ, {"BL_LAUNCH_DIR": str(launch)}):
|
|
G["clear_playing"]()
|
|
G["clear_playing"]()
|
|
self.assertFalse((launch / G["PLAYING"]).exists())
|
|
|
|
def test_a_stopped_visible_session_ends_the_game_and_its_mark(self):
|
|
broker, _ = self.broker("visible")
|
|
launch = self.tmp / "launch"
|
|
launch.mkdir()
|
|
(launch / G["PLAYING"]).write_text("1\n")
|
|
game = subprocess.Popen(["sleep", "60"], start_new_session=True)
|
|
self.addCleanup(game.kill)
|
|
broker.running.append(game)
|
|
session = G["Session"]("visible")
|
|
session.broker = broker
|
|
with mock.patch.dict(os.environ, {"BL_LAUNCH_DIR": str(launch)}):
|
|
session.stop()
|
|
self.assertIsNotNone(game.poll())
|
|
self.assertFalse((launch / G["PLAYING"]).exists())
|
|
|
|
def dead_pid(self):
|
|
proc = subprocess.Popen(["true"])
|
|
proc.wait()
|
|
return proc.pid
|
|
|
|
def test_hidden_session_closes_once_the_game_and_its_owner_are_gone(self):
|
|
broker, _ = self.broker()
|
|
session = G["Session"]("hidden")
|
|
session.broker = broker
|
|
session.client = mock.Mock(poll=mock.Mock(return_value=None))
|
|
game = G["Launch"]({"args": ["-epicapp=Fig"]})
|
|
broker.game = game
|
|
broker.owner = self.dead_pid()
|
|
result = []
|
|
with mock.patch.dict(G, {"EXIT_GRACE": 0.3}), mock.patch.object(broker, "exited", wraps=broker.exited) as exited:
|
|
thread = threading.Thread(target=lambda: result.append(session.watch()), daemon=True)
|
|
thread.start()
|
|
thread.join(5)
|
|
self.assertEqual([0], result)
|
|
self.assertTrue(game.done.is_set())
|
|
self.assertIsNone(broker.game)
|
|
self.assertEqual(1, exited.call_count)
|
|
|
|
def test_exited_without_a_game_changes_nothing(self):
|
|
broker, _ = self.broker()
|
|
broker.exited(0)
|
|
self.assertIsNone(broker.exited_at)
|
|
|
|
def test_hand_off_stops_waiting_when_the_client_dies(self):
|
|
broker, path = self.broker()
|
|
info = {"pid": self.dead_pid(), "broker": path, "bus": "unix:path=/nonexistent"}
|
|
started = time.monotonic()
|
|
reply = G["hand_off"](info, None, "Fig", "ns", "it", "1", time.monotonic() + 30)
|
|
self.assertEqual({"ok": False, "reason": "client-exited"}, reply)
|
|
self.assertLess(time.monotonic() - started, 5)
|
|
|
|
def test_forward_survives_a_hung_dbus_send(self):
|
|
with mock.patch.object(subprocess, "call", side_effect=subprocess.TimeoutExpired("dbus-send", 15)):
|
|
self.assertFalse(G["forward"]("unix:path=/x", "com.epicgames.launcher://apps/x"))
|
|
|
|
|
|
class LaunchTest(EpicTestCase):
|
|
def test_launch_refuses_before_the_client_is_ready(self):
|
|
folder = self.tmp / "Fig"
|
|
folder.mkdir()
|
|
(folder / ".droiddeck-store.json").write_text(json.dumps({"store": "epic", "id": "Fig"}))
|
|
options = {"app": "Fig", "namespace": "ns", "item": "it", "folder": str(folder)}
|
|
with mock.patch("sys.stdout", new_callable=io.StringIO) as out:
|
|
self.assertEqual(1, G["launch"](dict(options)))
|
|
self.assertEqual("old-launcher", json.loads(out.getvalue())["reason"])
|
|
(folder / ".droiddeck-launch.bat").write_text("goto client .droiddeck-epic-args")
|
|
with mock.patch("sys.stdout", new_callable=io.StringIO) as out, \
|
|
mock.patch.dict(G, {"fex_tool": lambda request=False: None}):
|
|
self.assertEqual(1, G["launch"](dict(options)))
|
|
self.assertEqual("not-installed", json.loads(out.getvalue())["reason"])
|
|
|
|
def test_a_start_error_is_reported_and_cleaned_up(self):
|
|
stopped = []
|
|
with mock.patch.object(G["Session"], "run", side_effect=OSError("no dbus-daemon")), \
|
|
mock.patch.object(signal, "signal") as handlers, \
|
|
mock.patch.object(G["Session"], "stop", lambda session: stopped.append(handlers.call_args_list[-2:])):
|
|
self.assertEqual(2, G["serve"]("hidden"))
|
|
self.assertEqual([[mock.call(signal.SIGTERM, signal.SIG_IGN), mock.call(signal.SIGHUP, signal.SIG_IGN)]], stopped)
|
|
|
|
def test_a_failed_spawn_closes_its_log_pipe(self):
|
|
session = G["Session"]("hidden")
|
|
closed = []
|
|
real_close = os.close
|
|
with mock.patch.dict(G, {"capped_log": lambda path: 99999}), \
|
|
mock.patch.object(subprocess, "Popen", side_effect=FileNotFoundError("gamescope")), \
|
|
mock.patch.object(os, "close", lambda fd: closed.append(fd) if fd == 99999 else real_close(fd)):
|
|
with self.assertRaises(OSError):
|
|
session.spawn(["gamescope"], {}, str(self.tmp / "logs/g.log"))
|
|
self.assertEqual([99999], closed)
|
|
self.assertEqual([], session.procs)
|
|
|
|
def test_a_broken_reply_from_the_launcher_api_means_not_known_yet(self):
|
|
with mock.patch.dict(G, {"api_get": mock.Mock(side_effect=http.client.IncompleteRead(b""))}):
|
|
self.assertIsNone(G["installed"]({"port": 1, "machineSecret": "s"}, "Fig"))
|
|
|
|
def test_a_bad_timeout_falls_back_to_the_default(self):
|
|
with mock.patch("sys.stdout", new_callable=io.StringIO) as out:
|
|
self.assertEqual(1, G["launch"]({"timeout": "soon"}))
|
|
self.assertEqual("missing-game", json.loads(out.getvalue())["reason"])
|
|
|
|
def test_a_spawned_client_is_stopped_through_its_own_broker(self):
|
|
asked = []
|
|
proc = mock.Mock(pid=4321, wait=mock.Mock(return_value=0))
|
|
with mock.patch.dict(G, {"ask_broker": lambda message, timeout=5.0, path=None: asked.append(path) or {"ok": True}}):
|
|
G["stop_spawned"](proc, {"pid": 4321, "broker": "/run/spawned.sock"})
|
|
self.assertEqual(["/run/spawned.sock"], asked)
|
|
|
|
def test_a_spawned_client_that_will_not_stop_is_killed(self):
|
|
proc = subprocess.Popen(["python3", "-c", "import signal, time; signal.signal(signal.SIGTERM, signal.SIG_IGN); "
|
|
"print(1, flush=True); time.sleep(60)"], stdout=subprocess.PIPE, start_new_session=True)
|
|
self.addCleanup(proc.stdout.close)
|
|
self.addCleanup(proc.kill)
|
|
proc.stdout.readline()
|
|
with mock.patch.dict(G, {"STOP_WAIT": 0.2}):
|
|
G["stop_spawned"](proc, {"pid": proc.pid, "broker": str(self.tmp / "missing.sock")})
|
|
self.assertEqual(-signal.SIGKILL, proc.poll())
|
|
|
|
def test_installations_listing_is_searched_for_the_app(self):
|
|
listing = {"installations": [{"sandboxId": "ns", "artifact": {"id": "Fig"}}]}
|
|
self.assertTrue(G["holds"](listing, "Fig"))
|
|
self.assertFalse(G["holds"](listing, "Other"))
|
|
|
|
def test_run_command_opens_the_launcher(self):
|
|
with mock.patch.dict(G, {"serve": lambda mode: mode}):
|
|
self.assertEqual("visible", G["main"](["run"]))
|
|
|
|
def test_launch_command_reaches_the_store_launch(self):
|
|
with mock.patch.dict(G, {"launch": lambda options: options.get("app")}):
|
|
self.assertEqual("Fig", G["main"](["launch", "--app", "Fig", "--namespace", "ns", "--item", "it"]))
|
|
|
|
|
|
class StoreLaunchClientTest(unittest.TestCase):
|
|
def setUp(self):
|
|
self.tmp = Path(tempfile.mkdtemp())
|
|
self.addCleanup(shutil.rmtree, self.tmp, True)
|
|
self.folder = self.tmp / "Fig"
|
|
self.folder.mkdir()
|
|
self.sidecar = {"store": "epic", "id": "Fig", "extra": {"namespace": "ns", "catalogItemId": "it"}}
|
|
(self.folder / ".droiddeck-store.json").write_text(json.dumps(self.sidecar))
|
|
|
|
def test_client_is_wanted_once_installed(self):
|
|
state = self.tmp / ".local" / "share" / "droiddeck-epic"
|
|
with mock.patch.dict(os.environ, {"HOME": str(self.tmp)}), mock.patch("os.access", return_value=True):
|
|
self.assertFalse(S["client_wanted"]())
|
|
state.mkdir(parents=True)
|
|
(state / "current.json").write_text("{}")
|
|
self.assertTrue(S["client_wanted"]())
|
|
with mock.patch("os.access", return_value=False):
|
|
self.assertFalse(S["client_wanted"]())
|
|
|
|
def test_offline_game_skips_the_client(self):
|
|
calls = []
|
|
sidecar = dict(self.sidecar, epic={"offline": True})
|
|
(self.folder / ".droiddeck-store.json").write_text(json.dumps(sidecar))
|
|
with mock.patch.dict(os.environ, {"BL_LAUNCH_DIR": str(self.tmp / "launch"), "STEAM_COMPAT_DATA_PATH": ""}), \
|
|
mock.patch.dict(S, {"client_wanted": lambda: calls.append("wanted") or True,
|
|
"ask": lambda channel, request, timeout=0: calls.append(request["op"]) or {"ok": True}}):
|
|
S["main"]([str(self.folder / ".droiddeck-launch.bat")])
|
|
self.assertNotIn("wanted", calls)
|
|
self.assertNotIn("epic-code", calls)
|
|
|
|
def test_through_client_needs_the_args_file(self):
|
|
answers = [b'{"ok": true}\n', b'{"ok": false, "reason": "signed-out"}\n']
|
|
|
|
def run(command, **kwargs):
|
|
self.assertEqual(["launch", "--app", "Fig", "--namespace", "ns", "--item", "it"], command[1:8])
|
|
return subprocess.CompletedProcess(command, 0, stdout=answers.pop(0))
|
|
|
|
with mock.patch.object(subprocess, "run", run):
|
|
self.assertEqual("failed", S["through_client"](self.folder, self.sidecar))
|
|
self.assertEqual("signed-out", S["through_client"](self.folder, self.sidecar))
|
|
self.assertEqual("no-catalog-ids", S["through_client"](self.folder, {"id": "Fig"}))
|
|
|
|
def test_epic_launch_falls_back_to_an_exchange_code(self):
|
|
calls = []
|
|
env = {"BL_LAUNCH_DIR": str(self.tmp / "launch"), "STEAM_COMPAT_DATA_PATH": ""}
|
|
with mock.patch.dict(os.environ, env), \
|
|
mock.patch.dict(S, {"client_wanted": lambda: True,
|
|
"through_client": lambda folder, sidecar, timeout=0: calls.append("client") or "timeout",
|
|
"ask": lambda channel, request, timeout=0: calls.append(request["op"]) or {"ok": True, "code": True}}):
|
|
S["main"]([str(self.folder / ".droiddeck-launch.bat")])
|
|
self.assertEqual(["client", "epic-code", "cloud-down"], calls)
|
|
|
|
def test_epic_launch_through_client_skips_the_code(self):
|
|
calls = []
|
|
|
|
def through(folder, sidecar, timeout=0):
|
|
calls.append("client")
|
|
(folder / ".droiddeck-epic-args").write_text("-epicapp=Fig\r\n")
|
|
return "ok"
|
|
|
|
(self.folder / ".droiddeck-epic-code").write_text("stale")
|
|
with mock.patch.dict(os.environ, {"BL_LAUNCH_DIR": str(self.tmp / "launch"), "STEAM_COMPAT_DATA_PATH": ""}), \
|
|
mock.patch.dict(S, {"client_wanted": lambda: True, "through_client": through,
|
|
"ask": lambda channel, request, timeout=0: calls.append(request["op"]) or {"ok": True}}):
|
|
S["main"]([str(self.folder / ".droiddeck-launch.bat")])
|
|
self.assertEqual(["client", "cloud-down"], calls)
|
|
self.assertFalse((self.folder / ".droiddeck-epic-code").exists())
|
|
self.assertTrue((self.folder / ".droiddeck-epic-args").exists())
|
|
|
|
def test_handled_launch_keeps_the_clients_args(self):
|
|
calls = []
|
|
(self.folder / ".droiddeck-epic-args").write_text("-epicapp=Fig\r\n")
|
|
with mock.patch.dict(os.environ, {"BL_LAUNCH_DIR": str(self.tmp / "launch"), "DROIDDECK_EPIC_HANDLED": "1",
|
|
"STEAM_COMPAT_DATA_PATH": ""}), \
|
|
mock.patch.dict(S, {"client_wanted": lambda: calls.append("wanted") or True,
|
|
"ask": lambda channel, request, timeout=0: calls.append(request["op"]) or {"ok": True}}):
|
|
S["main"]([str(self.folder / ".droiddeck-launch.bat")])
|
|
self.assertEqual(["cloud-down"], calls)
|
|
self.assertTrue((self.folder / ".droiddeck-epic-args").exists())
|
|
|
|
def test_the_games_exit_code_reaches_the_epic_client(self):
|
|
runs = []
|
|
bat = str(self.folder / ".droiddeck-launch.bat")
|
|
with mock.patch.dict(os.environ, {"BL_LAUNCH_DIR": ""}), mock.patch.dict(os.environ), \
|
|
mock.patch("os.access", return_value=True), \
|
|
mock.patch.object(subprocess, "run", lambda command, **kwargs: runs.append(command)):
|
|
os.environ.pop("DROIDDECK_EPIC_HANDLED", None)
|
|
self.assertEqual(0, S["main"](["--exited", "--code", "7", bat]))
|
|
self.assertEqual(0, S["main"](["--exited", bat]))
|
|
self.assertEqual([S["EPIC_CLIENT"], "exited", "--code", "7"], runs[0])
|
|
self.assertEqual("0", runs[1][-1])
|
|
|
|
|
|
if __name__ == "__main__":
|
|
unittest.main()
|