From ecd916d43f5ecc8041fb6fbfd1d39559602dffce Mon Sep 17 00:00:00 2001 From: MaxsTechReview Date: Sun, 8 Nov 2026 18:32:40 -0500 Subject: [PATCH 01/33] fix(input): show the panel tabs under the controls-below-the-game bar (#2302) * fix(input): show the panel tabs under the controls-below-the-game bar * fix(input): take the tab row out of the game, not the controls below it --- .../java/com/droiddeck/launcher/SessionActivity.kt | 13 ++++++++++--- .../droiddeck/launcher/session/SessionDisplay.kt | 4 ++-- .../launcher/session/SessionDisplayTest.kt | 7 +++++++ 3 files changed, 19 insertions(+), 5 deletions(-) diff --git a/app/src/main/java/com/droiddeck/launcher/SessionActivity.kt b/app/src/main/java/com/droiddeck/launcher/SessionActivity.kt index 86e57cd4f..45a831f95 100644 --- a/app/src/main/java/com/droiddeck/launcher/SessionActivity.kt +++ b/app/src/main/java/com/droiddeck/launcher/SessionActivity.kt @@ -3609,7 +3609,10 @@ class SessionActivity : ComponentActivity(), SurfaceHolder.Callback { val width = sessionRoot.width val height = sessionRoot.height val wanted = controlsBelowWanted() - val gameHeight = if (wanted) com.droiddeck.launcher.session.SessionDisplay.unfoldedGameHeight(width, height) + val tabRow = unfoldedColumn?.getChildAt(0)?.height?.let { + it - (com.droiddeck.launcher.ui.BAR_HEIGHT.value * resources.displayMetrics.density + 0.5f).toInt() + } ?: 0 + val gameHeight = if (wanted) com.droiddeck.launcher.session.SessionDisplay.unfoldedGameHeight(width, height, tabRow) else FrameLayout.LayoutParams.MATCH_PARENT val params = surfaceView.layoutParams as FrameLayout.LayoutParams if (params.height != gameHeight) { @@ -3635,8 +3638,12 @@ class SessionActivity : ComponentActivity(), SurfaceHolder.Callback { panel = OnScreenControls(this, padBridge, dock).also { it.updateLayout(primaryInputLayout) } val column = android.widget.LinearLayout(this).apply { orientation = android.widget.LinearLayout.VERTICAL - addView(unfoldedBar(), android.widget.LinearLayout.LayoutParams(android.widget.LinearLayout.LayoutParams.MATCH_PARENT, - (com.droiddeck.launcher.ui.BAR_HEIGHT.value * resources.displayMetrics.density + 0.5f).toInt())) + addView(unfoldedBar().apply { + addOnLayoutChangeListener { _, _, top, _, bottom, _, oldTop, _, oldBottom -> + if (bottom - top != oldBottom - oldTop) post { applyUnfoldedControls() } + } + }, android.widget.LinearLayout.LayoutParams(android.widget.LinearLayout.LayoutParams.MATCH_PARENT, + android.widget.LinearLayout.LayoutParams.WRAP_CONTENT)) addView(panel, android.widget.LinearLayout.LayoutParams(android.widget.LinearLayout.LayoutParams.MATCH_PARENT, 0, 1f)) } sessionRoot.addView(column, sessionRoot.indexOfChild(surfaceView) + 1) diff --git a/app/src/main/java/com/droiddeck/launcher/session/SessionDisplay.kt b/app/src/main/java/com/droiddeck/launcher/session/SessionDisplay.kt index 1cb526c90..9b253d88a 100644 --- a/app/src/main/java/com/droiddeck/launcher/session/SessionDisplay.kt +++ b/app/src/main/java/com/droiddeck/launcher/session/SessionDisplay.kt @@ -69,8 +69,8 @@ object SessionDisplay { const val UNFOLDED_MIN_DP = 600 - fun unfoldedGameHeight(width: Int, height: Int): Int = - minOf(width * 9 / 16, (height * UNFOLDED_MAX_GAME_SHARE).toInt()) and 1.inv() + fun unfoldedGameHeight(width: Int, height: Int, tabRow: Int = 0): Int = + (minOf(width * 9 / 16, (height * UNFOLDED_MAX_GAME_SHARE).toInt()) - tabRow.coerceAtLeast(0)).coerceAtLeast(0) and 1.inv() fun unfoldedControlsWanted( enabled: Boolean, diff --git a/app/src/test/java/com/droiddeck/launcher/session/SessionDisplayTest.kt b/app/src/test/java/com/droiddeck/launcher/session/SessionDisplayTest.kt index 82512c0b9..7f3eec409 100644 --- a/app/src/test/java/com/droiddeck/launcher/session/SessionDisplayTest.kt +++ b/app/src/test/java/com/droiddeck/launcher/session/SessionDisplayTest.kt @@ -60,6 +60,13 @@ class SessionDisplayTest { assertEquals(864, SessionDisplay.unfoldedGameHeight(1920, 1080)) } + @Test fun aTabRowUnderTheBarComesOutOfTheGameNotTheControls() { + assertEquals(1376, SessionDisplay.unfoldedGameHeight(2448, 1848, 0)) + assertEquals(1240, SessionDisplay.unfoldedGameHeight(2448, 1848, 136)) + assertEquals(1376, SessionDisplay.unfoldedGameHeight(2448, 1848, -20)) + assertEquals(0, SessionDisplay.unfoldedGameHeight(200, 100, 500)) + } + private fun unfoldedWanted( enabled: Boolean = true, controller: Boolean = false, From 92742f37c11d359848cee907f5125ce4f83f8b6e Mon Sep 17 00:00:00 2001 From: MaxsTechReview Date: Sun, 8 Nov 2026 18:40:57 -0500 Subject: [PATCH 02/33] fix(preload): answer netlink requests sent with write and writev (#2304) --- tools/linuxfs/preload/netif.c | 28 +++++++++++++ tools/tests/test_netlink_preload.py | 64 +++++++++++++++++++++++++++++ 2 files changed, 92 insertions(+) diff --git a/tools/linuxfs/preload/netif.c b/tools/linuxfs/preload/netif.c index d7f56e32a..aeeeb9eb2 100644 --- a/tools/linuxfs/preload/netif.c +++ b/tools/linuxfs/preload/netif.c @@ -29,6 +29,7 @@ #include #include #include +#include #include #include #include @@ -1655,6 +1656,7 @@ static void reply_attr(struct nl_reply *out, unsigned char *message, int type, return; } attr = (struct rtattr *) (message + offset); + memset(attr, 0, total); attr->rta_type = (unsigned short) type; attr->rta_len = (unsigned short) RTA_LENGTH(len); if (len > 0) memcpy(RTA_DATA(attr), data, len); @@ -2155,3 +2157,29 @@ ssize_t send(int fd, const void *buffer, size_t length, int flags) { } return real(fd, buffer, length, flags); } + +ssize_t write(int fd, const void *buffer, size_t length) { + static ssize_t (*real)(int, const void *, size_t); + ssize_t rc; + if (bl_nl_owns(fd) && deliver(fd, buffer, length, 0, &rc)) return rc; + if (real == NULL) real = (ssize_t (*)(int, const void *, size_t)) dlsym(RTLD_NEXT, "write"); + if (real == NULL) { + errno = ENOSYS; + return -1; + } + return real(fd, buffer, length); +} + +ssize_t writev(int fd, const struct iovec *iov, int iovcnt) { + static ssize_t (*real)(int, const struct iovec *, int); + if (iovcnt >= 0 && bl_nl_owns(fd)) { + struct msghdr msg = { .msg_iov = (struct iovec *) iov, .msg_iovlen = (size_t) iovcnt }; + return sendmsg_request(fd, &msg, 0); + } + if (real == NULL) real = (ssize_t (*)(int, const struct iovec *, int)) dlsym(RTLD_NEXT, "writev"); + if (real == NULL) { + errno = ENOSYS; + return -1; + } + return real(fd, iov, iovcnt); +} diff --git a/tools/tests/test_netlink_preload.py b/tools/tests/test_netlink_preload.py index 64c3b9c97..aedbdbd73 100644 --- a/tools/tests/test_netlink_preload.py +++ b/tools/tests/test_netlink_preload.py @@ -118,6 +118,7 @@ PROGRAM = r""" #include #include #include +#include #include #include @@ -317,6 +318,8 @@ static void *ordinary_traffic(void *argument) { probe_end(counts); require(counts[0] == 0, "ordinary setup avoids netlink lock"); + int pipes[2]; + require(pipe(pipes) == 0, "ordinary pipe setup"); probe_begin(0); const int rounds = 32; for (int round = 0; round < rounds; round++) { @@ -338,6 +341,15 @@ static void *ordinary_traffic(void *argument) { message.msg_name = &source; message.msg_namelen = sizeof(source); require(recvmsg(server, &message, 0) == 1 && buffer == 'z', "ordinary recvmsg"); + require(write(client, "w", 1) == 1, "ordinary write"); + require(read(server, &buffer, 1) == 1 && buffer == 'w', "ordinary read"); + char pair[2]; + struct iovec pieces[2] = {{.iov_base = "v", .iov_len = 1}, {.iov_base = "u", .iov_len = 1}}; + require(writev(client, pieces, 2) == 2, "ordinary writev"); + require(recv(server, pair, 2, MSG_WAITALL) == 2 && pair[0] == 'v' && pair[1] == 'u', + "ordinary writev data"); + require(write(pipes[1], "p", 1) == 1 && read(pipes[0], &buffer, 1) == 1 && buffer == 'p', + "ordinary pipe"); length = sizeof(source); require(getpeername(client, (struct sockaddr *)&source, &length) == 0, "ordinary peer"); const int options[] = {SO_TYPE, SO_DOMAIN, SO_PROTOCOL}; @@ -368,6 +380,8 @@ static void *ordinary_traffic(void *argument) { require(counts[2] == (unsigned)rounds, "ordinary traffic has only requested getpeername"); require(counts[3] == 0, "ordinary traffic avoids restoration fstat"); require(counts[4] == (unsigned)rounds * 3, "ordinary socket options use one syscall each"); + close(pipes[0]); + close(pipes[1]); close(client); close(server); if (server != listener) close(listener); @@ -798,6 +812,52 @@ static void connect_test(void) { close(descriptor); } +static ssize_t link_reply(int descriptor, const char *path, unsigned char *buffer, size_t capacity) { + struct { + struct nlmsghdr header; + struct ifinfomsg info; + } query = { + .header = {.nlmsg_len = sizeof(query), .nlmsg_type = RTM_GETLINK, + .nlmsg_flags = NLM_F_REQUEST | NLM_F_ACK, .nlmsg_seq = 42}, + .info = {.ifi_index = 1} + }; + struct iovec parts[2] = {{.iov_base = &query.header, .iov_len = sizeof(query.header)}, + {.iov_base = &query.info, .iov_len = sizeof(query.info)}}; + struct msghdr message = {.msg_iov = parts, .msg_iovlen = 2}; + ssize_t sent; + if (!strcmp(path, "send")) sent = send(descriptor, &query, sizeof(query), 0); + else if (!strcmp(path, "sendmsg")) sent = sendmsg(descriptor, &message, 0); + else if (!strcmp(path, "write")) sent = write(descriptor, &query, sizeof(query)); + else sent = writev(descriptor, parts, 2); + require(sent == sizeof(query), path); + ssize_t received = read(descriptor, buffer, capacity); + require(received > 0, "read reply"); + return received; +} + +static void write_test(void) { + const char *paths[] = {"send", "sendmsg", "write", "writev"}; + unsigned char expected[4096]; + int descriptor = route_socket(); + ssize_t expected_length = link_reply(descriptor, paths[0], expected, sizeof(expected)); + struct nlmsghdr *header = (struct nlmsghdr *)expected; + require(header->nlmsg_type == RTM_NEWLINK && header->nlmsg_seq == 42 + && header->nlmsg_pid == port_of(descriptor), "send reply"); + require(expected_length > (ssize_t)NLMSG_ALIGN(header->nlmsg_len), "acknowledged reply"); + struct nlmsghdr *acknowledgement = (struct nlmsghdr *)(expected + NLMSG_ALIGN(header->nlmsg_len)); + require(acknowledgement->nlmsg_type == NLMSG_ERROR + && ((struct nlmsgerr *)NLMSG_DATA(acknowledgement))->error == 0, "send acknowledgement"); + for (size_t index = 1; index < sizeof(paths) / sizeof(paths[0]); index++) { + unsigned char buffer[4096]; + ssize_t length = link_reply(descriptor, paths[index], buffer, sizeof(buffer)); + require(length == expected_length, "reply length"); + require(!memcmp(buffer, expected, length), "reply bytes"); + } + require(recv(descriptor, expected, sizeof(expected), MSG_DONTWAIT) == -1 && errno == EAGAIN, + "no extra replies"); + close(descriptor); +} + static void receive_test(void) { int descriptor = route_socket(); send_link(descriptor, 1, 0); @@ -985,6 +1045,7 @@ int main(int argc, char **argv) { else if (!strcmp(argv[1], "nonblocking")) send_timeout_test(1); else if (!strcmp(argv[1], "connect")) connect_test(); else if (!strcmp(argv[1], "receive")) receive_test(); + else if (!strcmp(argv[1], "write")) write_test(); else if (!strcmp(argv[1], "replace")) replacement_test(0); else if (!strcmp(argv[1], "raw_replace")) replacement_test(1); else if (!strcmp(argv[1], "shared")) shared_test(0); @@ -1160,6 +1221,9 @@ class NetlinkPreloadTest(unittest.TestCase): def test_kernel_connect_and_disconnect(self): self.run_program("connect") + def test_write_and_writev_requests_get_the_same_replies_as_send(self): + self.run_program("write") + def test_receive_sources_and_truncation(self): self.run_program("receive") From 6b110581ac167b1476d945db6b38327c63cd399e Mon Sep 17 00:00:00 2001 From: MaxsTechReview Date: Sun, 8 Nov 2026 18:45:53 -0500 Subject: [PATCH 03/33] fix(gog): install scripts' support files reach the prefix (#2294) * fix(gog): install scripts' support files reach the prefix A goggame-.script can copy files into the prefix with supportData actions: default INI files, Plugins.txt and other settings that mods such as one-click editions need. Only setRegistry actions were applied, so those games started without their configuration and ran as the base game. droiddeck-store-launch now applies supportData after the registry, once per script revision: copies from {app} and {supportDir} into {userdocs} and {localappdata}, matching paths without regard to case. Links are copied as links, paths outside those folders are refused, and existing files are replaced only when the action asks for it. * Keep GOG support data copies inside the game and prefix folders * Copy linked GOG support sources as links and keep a player's own links --- docs/development/stores.md | 9 + .../usr/local/bin/droiddeck-store-launch | 117 +++++++++++ tools/tests/test_store_launch.py | 191 +++++++++++++++++- 3 files changed, 316 insertions(+), 1 deletion(-) diff --git a/docs/development/stores.md b/docs/development/stores.md index 78855ea74..278c0cbd8 100644 --- a/docs/development/stores.md +++ b/docs/development/stores.md @@ -244,6 +244,15 @@ self-extractor stops at "Path not found", so the values it installs are applied `witcher.exe` exits during startup. The language comes from the installed product's `goggame-.info`, and a script's own `setRegistry` entries always win over the built-in table. +The script's `supportData` actions run right after: files and folders from the game folder +(`{app}`) or its support files (`{supportDir}`, `support//`) are copied to `{userdocs}` and +`{localappdata}` in the prefix, and `type: folder` without a source creates the folder. Mods such +as one-click editions ship their `Plugins.txt` and INI files this way. Paths match without regard +to case, links are copied as links, and a path outside those folders is refused. A file the +player already has is replaced only when the action says `overwrite`. `.droiddeck-gog-support` in +the prefix holds the actions' hash, so they run once per script revision and a player's later +edits stay. + A GOG game's exe comes from its own manifest first: `goggame-.info`'s play task in category `game` (`isPrimary` first), or a primary `launcher` task for launcher games, whose `path` stays inside the folder; any other `goggame-*.info` in the folder supplies the task only when the diff --git a/tools/linuxfs/overlay/usr/local/bin/droiddeck-store-launch b/tools/linuxfs/overlay/usr/local/bin/droiddeck-store-launch index 131c04906..b2e2b870e 100755 --- a/tools/linuxfs/overlay/usr/local/bin/droiddeck-store-launch +++ b/tools/linuxfs/overlay/usr/local/bin/droiddeck-store-launch @@ -5,6 +5,7 @@ import binascii import json import os import re +import shutil import sys import time import subprocess @@ -352,6 +353,7 @@ def prepare_gog(sidecar, compat_data, launch_dir): GOG_SCRIPTS = "goggame-*.script" GOG_REGISTRY_MARK = ".droiddeck-gog-registry" +GOG_SUPPORT_MARK = ".droiddeck-gog-support" GOG_ROOTS = {"HKEY_LOCAL_MACHINE": "HKEY_LOCAL_MACHINE", "HKLM": "HKEY_LOCAL_MACHINE", "HKEY_CURRENT_USER": "HKEY_CURRENT_USER", "HKCU": "HKEY_CURRENT_USER", "HKEY_CLASSES_ROOT": "HKEY_CLASSES_ROOT", "HKCR": "HKEY_CLASSES_ROOT"} @@ -611,6 +613,120 @@ def apply_gog_registry(folder, compat_data, proton, timeout=90, installation=Non return "import-failed" + note +def gog_support_actions(folder): + found = [] + for script in sorted(Path(folder).glob(GOG_SCRIPTS)): + product = script.name[len("goggame-"):-len(".script")] + try: + actions = json.loads(script.read_text(encoding="utf-8-sig")).get("actions") + except (OSError, ValueError, AttributeError): + continue + for action in actions if isinstance(actions, list) else []: + install = action.get("install") if isinstance(action, dict) else None + if isinstance(install, dict) and install.get("action") == "supportData" \ + and isinstance(install.get("arguments"), dict): + found.append((product, install["arguments"])) + return found + + +def gog_support_bases(folder, product, pfx): + user = Path(pfx) / "drive_c" / "users" / "steamuser" + support = Path(folder) / "support" / product + return {"app": Path(folder), "supportdir": support, "support": support, + "userdocs": user / "Documents", "localappdata": user / "AppData" / "Local"} + + +def gog_inside(path, base): + root = os.path.realpath(base) + real = os.path.realpath(path) + return real == root or real.startswith(root + os.sep) + + +def gog_support_path(text, bases): + match = re.fullmatch(r"\{(\w+)\}(.*)", text) if isinstance(text, str) else None + if not match or match.group(1).lower() not in bases: + return None + parts = [part for part in re.split(r"[\\/]+", match.group(2)) if part] + if any(part in (".", "..") for part in parts): + return None + base = bases[match.group(1).lower()] + path = base + for part in parts: + exact = path / part + if not exact.exists() and path.is_dir(): + exact = next((child for child in path.iterdir() if child.name.lower() == part.lower()), exact) + path = exact + if path != base and not gog_inside(path.parent, base): + return None + return path, base + + +def gog_copy_file(source, target, root): + if not gog_inside(target.parent, root): + raise OSError("outside " + str(root)) + target.parent.mkdir(parents=True, exist_ok=True) + if target.is_symlink(): + target.unlink() + shutil.copy2(source, target, follow_symlinks=False) + + +def gog_support_data(folder, compat_data, proton): + actions = gog_support_actions(folder) + if not actions: + return "none" + pfx = Path(compat_data) / "pfx" if compat_data else None + if not pfx or not (pfx / "drive_c" / "users" / "steamuser").is_dir(): + made = create_prefix(compat_data, proton) + if made not in ("present", "created") or not (pfx / "drive_c" / "users" / "steamuser").is_dir(): + return "no-prefix" + stamp = hashlib.sha256(json.dumps(actions, sort_keys=True).encode("utf-8")).hexdigest() + marker = pfx / GOG_SUPPORT_MARK + try: + if marker.is_file() and marker.read_text() == stamp: + return "ready" + except OSError: + pass + copied = skipped = missing = 0 + for product, arguments in actions: + bases = gog_support_bases(folder, product, pfx) + found = gog_support_path(arguments.get("target"), bases) + origin = gog_support_path(arguments["source"], bases) if "source" in arguments else None + if found is None or ("source" in arguments and origin is None): + skipped += 1 + continue + target, root = found + source = origin[0] if origin else None + try: + if source is None: + target.mkdir(parents=True, exist_ok=True) + elif not source.exists(): + missing += 1 + elif source.is_dir() and not source.is_symlink(): + for top, dirs, files in os.walk(source): + for name in sorted(files + [d for d in dirs if (Path(top) / d).is_symlink()]): + item = Path(top) / name + destination = target / item.relative_to(source) + if (destination.exists() or destination.is_symlink()) and not arguments.get("overwrite"): + continue + gog_copy_file(item, destination, root) + copied += 1 + target.mkdir(parents=True, exist_ok=True) + elif (target.exists() or target.is_symlink()) and not arguments.get("overwrite"): + continue + else: + gog_copy_file(source, target, root) + copied += 1 + except OSError: + skipped += 1 + note = "".join(" %s=%d" % (name, count) for name, count in (("skipped", skipped), ("missing", missing)) if count) + if not skipped: + try: + marker.write_text(stamp) + except OSError: + pass + return "copied=%d%s" % (copied, note) + + def main(argv): if os.environ.get("DROIDDECK_GOG_MAINTENANCE") == "1": return 0 @@ -654,6 +770,7 @@ def main(argv): if store == "gog": prepare_gog(sidecar, compat_data, launch_dir) log("gog registry: " + apply_gog_registry(folder, compat_data, os.environ.get("BL_STORE_PROTON", ""))) + log("gog support data: " + gog_support_data(folder, compat_data, os.environ.get("BL_STORE_PROTON", ""))) if store == "epic": handled = os.environ.get(EPIC_HANDLED) == "1" and (folder / EPIC_ARGS).is_file() if not handled: diff --git a/tools/tests/test_store_launch.py b/tools/tests/test_store_launch.py index 7ea5a1ae7..863eb679a 100644 --- a/tools/tests/test_store_launch.py +++ b/tools/tests/test_store_launch.py @@ -387,6 +387,193 @@ def set_registry(**arguments): return {'install': {'action': 'setRegistry', 'arguments': arguments}, 'name': 'setRegistry'} +def support_data(**arguments): + return {'install': {'action': 'supportData', 'arguments': arguments}, 'name': 'supportData'} + + +class GogSupportData(unittest.TestCase): + def prefix(self, root): + user = Path(root) / 'compat/pfx/drive_c/users/steamuser' + user.mkdir(parents=True) + return user + + def test_support_files_reach_the_prefix_once(self): + with tempfile.TemporaryDirectory() as root: + folder = game(root, store_id='gog', ident='9') + user = self.prefix(root) + support = folder / 'support/9' + (support / 'Docs').mkdir(parents=True) + (support / 'Docs/Fallout4.ini').write_text('[Archive]\n') + (support / 'Plugins.txt').write_text('*LondonWorldSpace.esm\n') + (folder / '__AppData/Saves').mkdir(parents=True) + (folder / '__AppData/Saves/a.txt').write_text('a') + gog_script(folder, '9', [ + set_registry(root='HKLM', subkey='Software\\Game', valueName='a', valueData='b', valueType='string'), + support_data(type='folder', target='{userdocs}/My Games/Fallout4'), + support_data(type='file', overwrite=True, source='{supportDir}/docs/Fallout4.ini', + target='{userdocs}/My Games/Fallout4/Fallout4.ini'), + support_data(type='file', overwrite=True, source='{supportDir}/Plugins.txt', + target='{localappdata}/Fallout4/Plugins.txt'), + support_data(type='folder', overwrite=True, source='{app}/__AppData', target='{localappdata}/Fallout4'), + ]) + with patch.object(store, 'create_prefix') as create: + self.assertEqual('copied=3', store.gog_support_data(folder, root + '/compat', '/proton')) + create.assert_not_called() + self.assertTrue((user / 'Documents/My Games/Fallout4').is_dir()) + self.assertEqual('[Archive]\n', (user / 'Documents/My Games/Fallout4/Fallout4.ini').read_text()) + self.assertEqual('*LondonWorldSpace.esm\n', (user / 'AppData/Local/Fallout4/Plugins.txt').read_text()) + self.assertEqual('a', (user / 'AppData/Local/Fallout4/Saves/a.txt').read_text()) + (user / 'AppData/Local/Fallout4/Plugins.txt').write_text('player order') + self.assertEqual('ready', store.gog_support_data(folder, root + '/compat', '/proton')) + self.assertEqual('player order', (user / 'AppData/Local/Fallout4/Plugins.txt').read_text()) + + def test_a_source_the_build_lacks_does_not_recopy_the_rest_every_launch(self): + with tempfile.TemporaryDirectory() as root: + folder = game(root, store_id='gog', ident='9') + user = self.prefix(root) + (folder / 'support/9').mkdir(parents=True) + (folder / 'support/9/Plugins.txt').write_text('x') + gog_script(folder, '9', [ + support_data(type='file', overwrite=True, source='{supportDir}/Plugins.txt', target='{localappdata}/G/Plugins.txt'), + support_data(type='folder', overwrite=True, source='{app}/__AppData', target='{localappdata}/G'), + ]) + self.assertEqual('copied=1 missing=1', store.gog_support_data(folder, root + '/compat', '/proton')) + (user / 'AppData/Local/G/Plugins.txt').write_text('player order') + self.assertEqual('ready', store.gog_support_data(folder, root + '/compat', '/proton')) + self.assertEqual('player order', (user / 'AppData/Local/G/Plugins.txt').read_text()) + + def test_a_link_never_carries_a_copy_outside(self): + with tempfile.TemporaryDirectory() as root: + folder = game(root, store_id='gog', ident='9') + user = self.prefix(root) + outside = Path(root) / 'outside' + outside.mkdir() + (outside / 'secret.ini').write_text('s') + (folder / '__AppData').mkdir() + (folder / '__AppData/linked').symlink_to(outside) + (folder / 'support/9').mkdir(parents=True) + (folder / 'support/9/a.ini').write_text('a') + gog_script(folder, '9', [ + support_data(type='folder', overwrite=True, source='{app}/__AppData', target='{localappdata}/G'), + support_data(type='file', overwrite=True, source='{supportDir}/a.ini', target='{localappdata}/G/linked/a.ini'), + support_data(type='file', overwrite=True, source='{app}/__AppData/linked/secret.ini', target='{userdocs}/secret.ini'), + ]) + self.assertEqual('copied=1 skipped=2', store.gog_support_data(folder, root + '/compat', '/proton')) + self.assertTrue((user / 'AppData/Local/G/linked').is_symlink()) + self.assertEqual(['secret.ini'], sorted(p.name for p in outside.iterdir())) + self.assertFalse((user / 'Documents/secret.ini').exists()) + + def test_a_linked_source_folder_is_copied_as_a_link(self): + with tempfile.TemporaryDirectory() as root: + folder = game(root, store_id='gog', ident='9') + user = self.prefix(root) + outside = Path(root) / 'outside' + outside.mkdir() + (outside / 'secret.ini').write_text('s') + (folder / 'linked').symlink_to(outside) + gog_script(folder, '9', [support_data(type='folder', overwrite=True, source='{app}/linked', target='{userdocs}/G')]) + store.gog_support_data(folder, root + '/compat', '/proton') + self.assertTrue((user / 'Documents/G').is_symlink()) + self.assertEqual(outside, Path(os.readlink(user / 'Documents/G'))) + + def test_a_players_own_links_stay_without_overwrite(self): + with tempfile.TemporaryDirectory() as root: + folder = game(root, store_id='gog', ident='9') + user = self.prefix(root) + (folder / 'support/9/Saves').mkdir(parents=True) + (folder / 'support/9/a.ini').write_text('a') + (folder / 'support/9/Saves/b.ini').write_text('b') + (user / 'Documents/G').mkdir(parents=True) + (user / 'Documents/a.ini').symlink_to(user / 'mine.ini') + (user / 'Documents/G/b.ini').symlink_to(user / 'gone.ini') + gog_script(folder, '9', [ + support_data(type='file', source='{supportDir}/a.ini', target='{userdocs}/a.ini'), + support_data(type='folder', source='{supportDir}/Saves', target='{userdocs}/G'), + ]) + self.assertEqual('copied=0', store.gog_support_data(folder, root + '/compat', '/proton')) + self.assertEqual(user / 'mine.ini', Path(os.readlink(user / 'Documents/a.ini'))) + self.assertEqual(user / 'gone.ini', Path(os.readlink(user / 'Documents/G/b.ini'))) + + def test_a_changed_script_applies_again(self): + with tempfile.TemporaryDirectory() as root: + folder = game(root, store_id='gog', ident='9') + user = self.prefix(root) + (folder / 'support/9').mkdir(parents=True) + (folder / 'support/9/a.ini').write_text('1') + action = support_data(type='file', overwrite=True, source='{supportDir}/a.ini', target='{userdocs}/a.ini') + gog_script(folder, '9', [action]) + self.assertEqual('copied=1', store.gog_support_data(folder, root + '/compat', '/proton')) + (folder / 'support/9/b.ini').write_text('2') + gog_script(folder, '9', [action, support_data(type='file', overwrite=True, source='{supportDir}/b.ini', + target='{userdocs}/b.ini')]) + self.assertEqual('copied=2', store.gog_support_data(folder, root + '/compat', '/proton')) + self.assertEqual('2', (user / 'Documents/b.ini').read_text()) + + def test_a_players_file_is_kept_unless_the_script_overwrites_it(self): + with tempfile.TemporaryDirectory() as root: + folder = game(root, store_id='gog', ident='9') + user = self.prefix(root) + (folder / 'support/9').mkdir(parents=True) + (folder / 'support/9/a.ini').write_text('default') + (user / 'Documents').mkdir() + (user / 'Documents/a.ini').write_text('mine') + gog_script(folder, '9', [support_data(type='file', source='{supportDir}/a.ini', target='{userdocs}/a.ini')]) + self.assertEqual('copied=0', store.gog_support_data(folder, root + '/compat', '/proton')) + self.assertEqual('mine', (user / 'Documents/a.ini').read_text()) + + def test_paths_outside_the_known_folders_are_refused(self): + with tempfile.TemporaryDirectory() as root: + folder = game(root, store_id='gog', ident='9') + self.prefix(root) + (folder / 'support/9').mkdir(parents=True) + (folder / 'support/9/a.ini').write_text('x') + gog_script(folder, '9', [ + support_data(type='file', overwrite=True, source='{supportDir}/a.ini', target='{userdocs}/../../../../escape.ini'), + support_data(type='file', overwrite=True, source='{supportDir}/a.ini', target='C:/escape.ini'), + support_data(type='folder', target='{windir}/x'), + ]) + self.assertEqual('copied=0 skipped=3', store.gog_support_data(folder, root + '/compat', '/proton')) + self.assertEqual([], list(Path(root).rglob('escape.ini'))) + self.assertFalse((Path(root) / 'compat/pfx' / store.GOG_SUPPORT_MARK).exists()) + + def test_links_in_a_support_folder_are_copied_as_links(self): + with tempfile.TemporaryDirectory() as root: + folder = game(root, store_id='gog', ident='9') + user = self.prefix(root) + outside = Path(root) / 'outside' + outside.mkdir() + (outside / 'secret.txt').write_text('s') + (folder / '__AppData').mkdir() + (folder / '__AppData/linked').symlink_to(outside, target_is_directory=True) + gog_script(folder, '9', [support_data(type='folder', overwrite=True, source='{app}/__AppData', target='{localappdata}/G')]) + self.assertEqual('copied=1', store.gog_support_data(folder, root + '/compat', '/proton')) + self.assertTrue((user / 'AppData/Local/G/linked').is_symlink()) + self.assertEqual('s', (outside / 'secret.txt').read_text()) + + def test_without_support_data_no_prefix_is_made(self): + with tempfile.TemporaryDirectory() as root: + folder = game(root, store_id='gog', ident='9') + gog_script(folder, '9', [set_registry(root='HKLM', subkey='Software\\Game', valueName='a', valueData='b', valueType='string')]) + with patch.object(store, 'create_prefix') as create: + self.assertEqual('none', store.gog_support_data(folder, root + '/compat', '/proton')) + create.assert_not_called() + + def test_a_missing_prefix_is_made_first(self): + with tempfile.TemporaryDirectory() as root: + folder = game(root, store_id='gog', ident='9') + gog_script(folder, '9', [support_data(type='folder', target='{userdocs}/My Games/G')]) + with patch.object(store, 'create_prefix', return_value='failed') as create: + self.assertEqual('no-prefix', store.gog_support_data(folder, root + '/compat', '/proton')) + create.assert_called_once_with(root + '/compat', '/proton') + + def made(compat_data, proton): + self.prefix(root) + return 'created' + with patch.object(store, 'create_prefix', side_effect=made): + self.assertEqual('copied=0', store.gog_support_data(folder, root + '/compat', '/proton')) + self.assertTrue((Path(root) / 'compat/pfx/drive_c/users/steamuser/Documents/My Games/G').is_dir()) + + class GogRegistry(unittest.TestCase): def test_patch_registration_uses_authentic_build_and_the_prefix_registry_executable(self): with tempfile.TemporaryDirectory() as root: @@ -605,6 +792,8 @@ class GogRegistry(unittest.TestCase): folder = game(root, store_id='gog') with patch.dict(os.environ, {'BL_LAUNCH_DIR': root, 'STEAM_COMPAT_DATA_PATH': root + '/compat/', 'BL_STORE_PROTON': '/chosen/proton'}), \ patch.object(store, 'prepare_gog', return_value=False), patch.object(store, 'ask', return_value=None), \ - patch.object(store, 'apply_gog_registry', return_value='ready') as apply: + patch.object(store, 'apply_gog_registry', return_value='ready') as apply, \ + patch.object(store, 'gog_support_data', return_value='ready') as support: store.main([str(folder / store.LAUNCHER)]) apply.assert_called_once_with(folder, root + '/compat', '/chosen/proton') + support.assert_called_once_with(folder, root + '/compat', '/chosen/proton') From 5e438e9248e46be8ab2e5de2af7cbb3b0ecc5a0a Mon Sep 17 00:00:00 2001 From: MaxsTechReview Date: Sun, 8 Nov 2026 19:37:20 -0500 Subject: [PATCH 04/33] fix(gog): owned games the listing leaves out stay owned, and search reaches GOG's catalog (#2296) * fix(gog): owned games the library listing leaves out stay owned Since the GOG library is read from account/getFilteredProducts, a game that listing does not return (one hidden in GOG Galaxy, for example) dropped out of the library, and with it out of the GOG tab, which shows owned games and the shelves. 0.5.0 read every owned id from user/data/games and showed them. The sync now takes the union of the listing and user/data/games. The listing still names most games at once, and an owned id it lacks is fetched like any other. * fix(gog): the GOG tab's search reaches GOG's catalog GogStoreCatalog.search was never wired to the backend, so searching the GOG tab only filtered the owned library and the featured shelves; a game that is neither could not be found. GogBackend now answers search from GOG's catalog, like the other stores. * fix(gog): keep cached owned games when the account list is unavailable --- .../droiddeck/launcher/stores/gog/GogBackend.kt | 2 ++ .../droiddeck/launcher/stores/gog/GogLibrary.kt | 12 ++++++++---- .../launcher/stores/gog/GogListingTest.kt | 17 +++++++++++++++++ 3 files changed, 27 insertions(+), 4 deletions(-) diff --git a/app/src/main/java/com/droiddeck/launcher/stores/gog/GogBackend.kt b/app/src/main/java/com/droiddeck/launcher/stores/gog/GogBackend.kt index fb6b0c890..e36f0fc16 100644 --- a/app/src/main/java/com/droiddeck/launcher/stores/gog/GogBackend.kt +++ b/app/src/main/java/com/droiddeck/launcher/stores/gog/GogBackend.kt @@ -64,6 +64,8 @@ object GogBackend : StoreBackend { }, "gog-sync").start() } + override fun search(context: Context, query: String): List = GogStoreCatalog.search(query) + override fun loadShelves(context: Context, force: Boolean) { val app = context.applicationContext GogStoreCatalog.cachedFeatured(app)?.let { StoresState.post { StoresState.shelves[store] = it } } diff --git a/app/src/main/java/com/droiddeck/launcher/stores/gog/GogLibrary.kt b/app/src/main/java/com/droiddeck/launcher/stores/gog/GogLibrary.kt index 8f48740ee..5b8be2230 100644 --- a/app/src/main/java/com/droiddeck/launcher/stores/gog/GogLibrary.kt +++ b/app/src/main/java/com/droiddeck/launcher/stores/gog/GogLibrary.kt @@ -58,16 +58,17 @@ object GogLibrary { val token = GogAuth.validToken(app) ?: return SyncResult.NotLoggedIn onStatus(app.getString(R.string.stores_sync_game_list)) val listing = ownedListing(token) + val account = ownedIds(token) val listed: List = listing?.games - ?: ownedIds(token)?.map { GogGame(it, "", "", "", "", "", 1, null, "", "") } + ?: account?.map { GogGame(it, "", "", "", "", "", 1, null, "", "") } ?: return SyncResult.Failed(app.getString(R.string.stores_sync_gog_unreachable)) val named = listing != null - StoresState.logLine(if (named) "GOG: listing ${listed.size} games" else "GOG: listing unreadable, ${listed.size} owned ids") - val ownedSet = listing?.owned ?: listed.mapTo(HashSet()) { it.gameId } val cachedList = cached(app) + val cachedIds = cachedList.mapTo(HashSet()) { it.gameId } + val ownedSet = ownedSet(listing?.owned ?: listed.mapTo(HashSet()) { it.gameId }, account, cachedIds) + StoresState.logLine(if (named) "GOG: listing ${listed.size} games, ${ownedSet.size} owned ids" else "GOG: listing unreadable, ${listed.size} owned ids") val stale = System.currentTimeMillis() - p.getLong(LAST_SYNC_KEY, 0L) >= THROTTLE_MS val heavy = force || cachedList.isEmpty() || stale - val cachedIds = cachedList.mapTo(HashSet()) { it.gameId } val skipped = java.util.Collections.newSetFromMap(java.util.concurrent.ConcurrentHashMap()) skipped.addAll(p.getStringSet(SKIPPED_KEY, emptySet()).orEmpty()) val idsToFetch = if (heavy) ownedSet.toList() else ownedSet.filter { it !in cachedIds && it !in skipped } @@ -133,6 +134,9 @@ object GogLibrary { return all.ifEmpty { null }?.let { Listing(it, owned) } } + fun ownedSet(listed: Set, account: List?, cached: Set = emptySet()): Set = + if (account == null) listed + cached else listed + account + private fun ownedIds(token: String): List? { val json = StoreNet.get(OWNED_URL, bearer = token, userAgent = GogAuth.GALAXY_UA) ?: return null val owned = runCatching { JSONObject(json).optJSONArray("owned") }.getOrNull() ?: return null diff --git a/app/src/test/java/com/droiddeck/launcher/stores/gog/GogListingTest.kt b/app/src/test/java/com/droiddeck/launcher/stores/gog/GogListingTest.kt index 5aace971f..86e54aad0 100644 --- a/app/src/test/java/com/droiddeck/launcher/stores/gog/GogListingTest.kt +++ b/app/src/test/java/com/droiddeck/launcher/stores/gog/GogListingTest.kt @@ -24,6 +24,23 @@ class GogListingTest { assertEquals("", shadowrun.description) } + @Test + fun ownedGamesTheListingLeavesOutStayOwned() { + assertEquals(setOf("1", "2", "1086370078"), GogLibrary.ownedSet(setOf("1", "2"), listOf("2", "1086370078"))) + } + + @Test + fun withoutTheAccountListTheListingStillCounts() { + assertEquals(setOf("1", "2"), GogLibrary.ownedSet(setOf("1", "2"), null)) + assertEquals(setOf("3"), GogLibrary.ownedSet(emptySet(), listOf("3"))) + } + + @Test + fun cachedGamesStayWhenTheAccountListIsUnavailable() { + assertEquals(setOf("1", "2", "1086370078"), GogLibrary.ownedSet(setOf("1", "2"), null, setOf("1086370078"))) + assertEquals(setOf("1", "2", "3"), GogLibrary.ownedSet(setOf("1"), listOf("2", "3"), setOf("1086370078"))) + } + @Test fun moviesOtherPlatformsAndGalaxysOwnEntryAreLeftOut() { assertEquals(listOf("1207660413", "1207664663"), GogLibrary.parseListing(PAGE)!!.games.map { it.gameId }) From b4a953c6593824d1a04cb8978ac7e0d536c2e77e Mon Sep 17 00:00:00 2001 From: Kurt Himebauch <136133082+xXJSONDeruloXx@users.noreply.github.com> Date: Sun, 8 Nov 2026 21:08:22 -0500 Subject: [PATCH 05/33] fix(ci): post new Discord notice for each PR build --- tools/release/pr_discord.py | 51 +++++++++++++++--- tools/tests/test_pr_discord.py | 97 ++++++++++++++++++++++++++++++++-- 2 files changed, 135 insertions(+), 13 deletions(-) diff --git a/tools/release/pr_discord.py b/tools/release/pr_discord.py index b577df247..c0ca0b961 100644 --- a/tools/release/pr_discord.py +++ b/tools/release/pr_discord.py @@ -101,23 +101,31 @@ def description_excerpt(body): return "" -def build_content(repo, number, title, sha, apk, release, description=None): +def commit_subject(message): + subject = (message or "").splitlines()[0].strip() if message else "" + return plain_title(subject[:300]) + + +def build_content(repo, number, title, sha, apk, release, description=None, commit_message=None): excerpt = description_excerpt(description) + subject = commit_subject(commit_message) return (f"**Signed test build** `pr-{number}` · `{sha[:7]}`\n" f"[#{number}](https://github.com/{repo}/pull/{number}): {plain_title(title)}\n" + + (f"Latest commit: {subject}\n" if subject else "") + (f"\n{excerpt}\n\n" if excerpt else "") + "_Not merged — for testing._\n" f"In the app: **Updates → Test builds → #{number}**\n" f"Download: [standard APK](<{apk}>) · [all variants](<{release}>)") -def retired_content(content, merged): - label = "Merged" if merged else "Closed" - if content.startswith(("**Merged** —", "**Closed** —")): +def retired_content(content, merged=False, superseded_by=None): + label = f"Superseded by `{superseded_by[:7]}`" if superseded_by else ("Merged" if merged else "Closed") + if content.startswith(("**Merged** —", "**Closed** —", "**Superseded by `")): content = "\n".join(line[2:-2] if line.startswith("~~") and line.endswith("~~") else line for line in content.splitlines()[2:]) struck = "\n".join(f"~~{line}~~" if line else "" for line in content.splitlines()) - return f"**{label}** — this test build is no longer available.\n\n{struck}" + note = "This test build has been replaced." if superseded_by else "This test build is no longer available." + return f"**{label}** — {note}\n\n{struck}" def sync(mode, repo, number, discord, sha="", apk="", release=""): @@ -136,6 +144,11 @@ def sync(mode, repo, number, discord, sha="", apk="", release=""): return raise RuntimeError("Signed build report comment is missing") message_id = saved_message(comment["body"], discord.id) + state_match = STATE.search(comment["body"]) + try: + old_state = json.loads(state_match.group(1)) if state_match else {} + except ValueError: + old_state = {} if mode == "retire": if message_id: message = discord.request("GET", f"/messages/{message_id}") @@ -144,16 +157,38 @@ def sync(mode, repo, number, discord, sha="", apk="", release=""): {"content": retired_content(message["content"], pull.get("merged", False)), "allowed_mentions": {"parse": []}, "flags": 4}) return - content = build_content(repo, number, pull["title"], sha, apk, release, pull.get("body")) + commit = github(f"repos/{repo}/commits/{sha}") + commit_message = commit.get("commit", {}).get("message", "") + content = build_content(repo, number, pull["title"], sha, apk, release, pull.get("body"), commit_message) if len(content) > 1800: raise RuntimeError("Discord announcement is too long") payload = {"content": content, "allowed_mentions": {"parse": []}, "flags": 4} - message = discord.request("PATCH", f"/messages/{message_id}", payload) if message_id else None + old_message = None + previous_sha = old_state.get("sha") + if message_id and not previous_sha: + old_message = discord.request("GET", f"/messages/{message_id}") + if old_message: + match = re.search(r"`([0-9a-f]{7})`", old_message.get("content", "")) + previous_sha = match.group(1) if match else None + if message_id and (previous_sha == sha or previous_sha == sha[:7]): + message = discord.request("PATCH", f"/messages/{message_id}", payload) + else: + if message_id and old_state.get("sha"): + previous = discord.request("GET", f"/messages/{message_id}") + if previous: + discord.request("PATCH", f"/messages/{message_id}", + {"content": retired_content(previous["content"], superseded_by=sha), + "allowed_mentions": {"parse": []}, "flags": 4}) + elif message_id and old_message: + discord.request("PATCH", f"/messages/{message_id}", + {"content": retired_content(old_message["content"], superseded_by=sha), + "allowed_mentions": {"parse": []}, "flags": 4}) + message = None if message is None: message = discord.request("POST", "?wait=true", payload) if message is None: raise RuntimeError("Discord webhook no longer exists") - state = {"webhook_id": discord.id, "message_id": message["id"]} + state = {"webhook_id": discord.id, "message_id": message["id"], "sha": sha} latest = github(f"repos/{repo}/issues/comments/{comment['id']}") body = STATE.sub("", latest["body"]).rstrip() + f"\n" github(f"repos/{repo}/issues/comments/{comment['id']}", "PATCH", {"body": body}) diff --git a/tools/tests/test_pr_discord.py b/tools/tests/test_pr_discord.py index 0533e7094..5c901f6bd 100644 --- a/tools/tests/test_pr_discord.py +++ b/tools/tests/test_pr_discord.py @@ -14,17 +14,30 @@ class PrDiscordTest(unittest.TestCase): self.discord.request.return_value = {"id": "1557547293375332465"} self.github = patch.object(module, "github").start() self.addCleanup(patch.stopall) - self.github.side_effect = lambda path, method="GET", body=None: self.pull if "/pulls/" in path else self.comment + def github_request(path, method="GET", body=None): + if "/pulls/" in path: + return self.pull + if "/commits/" in path: + return {"commit": {"message": "Fix reconnect behavior\n\nMore details"}} + if path.endswith("/issues/comments/10"): + if method == "PATCH": + self.comment["body"] = body["body"] + return self.comment + return self.comment + self.github.side_effect = github_request patch.object(module, "report_comment", return_value=self.comment).start() - def publish(self): - module.sync("publish", "Droid-Deck/DroidDeck", "410", self.discord, "a" * 40, + def publish(self, sha=None): + if sha is not None: + self.pull["head"]["sha"] = sha + sha = sha or "a" * 40 + module.sync("publish", "Droid-Deck/DroidDeck", "410", self.discord, sha, "https://github.com/Droid-Deck/DroidDeck-CI/releases/download/pr-410/build.apk", "https://github.com/Droid-Deck/DroidDeck-CI/releases/tag/pr-410") def save_state(self): self.comment["body"] += '\n' + "webhook_id": self.discord.id, "message_id": "1557547293375332465", "sha": "a" * 40}) + ' -->' def test_create_confirmed_message_and_persist_reference(self): self.publish() @@ -36,12 +49,76 @@ class PrDiscordTest(unittest.TestCase): self.assertEqual("1557547293375332465", module.saved_message(saved, self.discord.id)) self.assertIn("Build download", saved) - def test_new_build_updates_existing_message(self): + def test_same_build_updates_existing_message_without_duplicate(self): self.save_state() self.publish() self.assertEqual("PATCH", self.discord.request.call_args.args[0]) saved = self.github.call_args.args[2]["body"] self.assertEqual(1, len(module.STATE.findall(saved))) + self.assertIn('"sha": "' + "a" * 40 + '"', saved) + + def test_new_commit_retires_old_message_and_posts_replacement(self): + self.save_state() + old_content = module.build_content("Droid-Deck/DroidDeck", "410", self.pull["title"], "a" * 40, + "apk", "release") + self.discord.request.side_effect = [ + {"content": old_content}, {"id": "1557547293375332465"}, {"id": "1557547293375332466"}] + self.publish("b" * 40) + calls = self.discord.request.call_args_list + self.assertEqual(["GET", "PATCH", "POST"], [call.args[0] for call in calls]) + self.assertIn("**Superseded by `bbbbbbb`**", calls[1].args[2]["content"]) + self.assertIn("~~Download:", calls[1].args[2]["content"]) + self.assertIn("Latest commit: Fix reconnect behavior", calls[2].args[2]["content"]) + saved = self.github.call_args.args[2]["body"] + self.assertEqual("1557547293375332466", module.saved_message(saved, self.discord.id)) + self.assertIn('"sha": "' + "b" * 40 + '"', saved) + + def test_publish_supersede_rerun_and_close_lifecycle(self): + messages = {} + created = [] + next_id = 1 + + def discord_request(method, suffix="", body=None): + nonlocal next_id + if method == "POST": + message_id = str(1557547293375332465 + next_id) + next_id += 1 + messages[message_id] = {"id": message_id, "content": body["content"]} + created.append(message_id) + return messages[message_id] + message_id = suffix.split("/")[-1] + if method == "GET": + return messages.get(message_id) + if method == "PATCH": + if message_id not in messages: + return None + messages[message_id].update(body) + return messages[message_id] + self.fail(f"Unexpected Discord method: {method}") + + self.discord.request.side_effect = discord_request + self.publish("a" * 40) + first_id = created[-1] + self.publish("b" * 40) + second_id = created[-1] + self.publish("b" * 40) + self.assertEqual(2, len(created)) + self.assertIn("**Superseded by `bbbbbbb`**", messages[first_id]["content"]) + self.assertIn("`bbbbbbb`", messages[second_id]["content"]) + + self.pull.update(state="closed", merged=True) + module.sync("retire", "Droid-Deck/DroidDeck", "410", self.discord) + self.assertTrue(messages[second_id]["content"].startswith("**Merged**")) + self.assertIn("~~Latest commit:", messages[second_id]["content"]) + + def test_legacy_state_uses_embedded_short_sha_to_supersede(self): + self.save_state() + self.comment["body"] = self.comment["body"].replace(', "sha": "' + "a" * 40 + '"', '') + old_content = "**Signed test build** `pr-410` · `aaaaaaa`\nold build" + self.discord.request.side_effect = [ + {"content": old_content}, {"id": "1557547293375332465"}, {"id": "1557547293375332466"}] + self.publish("b" * 40) + self.assertEqual(["GET", "PATCH", "POST"], [call.args[0] for call in self.discord.request.call_args_list]) def test_deleted_message_is_recreated(self): self.save_state() @@ -78,6 +155,10 @@ class PrDiscordTest(unittest.TestCase): module.sync("retire", "Droid-Deck/DroidDeck", "410", self.discord) self.discord.request.assert_not_called() + def test_superseded_content_is_idempotent(self): + once = module.retired_content("first\n\nlast", superseded_by="a" * 40) + self.assertEqual(once, module.retired_content(once, superseded_by="a" * 40)) + def test_old_build_without_saved_id_is_not_guessed(self): self.pull["state"] = "closed" module.sync("retire", "Droid-Deck/DroidDeck", "410", self.discord) @@ -153,6 +234,12 @@ class PrDiscordTest(unittest.TestCase): self.assertLessEqual(len(content), 1800) self.assertLessEqual(len(module.retired_content(content, True)), 2000) + def test_commit_subject_is_sanitized_and_uses_only_first_line(self): + content = module.build_content("Droid-Deck/DroidDeck", "410", "title", "a" * 40, + "apk", "release", commit_message="Fix *input* @everyone\nDetails") + self.assertIn(r"Latest commit: Fix \*input\* @everyone", content) + self.assertNotIn("Details", content) + original_report = module.report_comment From 51407155fc00c3a67c63ef059969d571f67fad62 Mon Sep 17 00:00:00 2001 From: Kurt Himebauch <136133082+xXJSONDeruloXx@users.noreply.github.com> Date: Sun, 8 Nov 2026 21:12:58 -0500 Subject: [PATCH 06/33] fix(ci): keep commit lookup failures from blocking notices --- tools/release/pr_discord.py | 7 +++++-- tools/tests/test_pr_discord.py | 10 ++++++++++ 2 files changed, 15 insertions(+), 2 deletions(-) diff --git a/tools/release/pr_discord.py b/tools/release/pr_discord.py index c0ca0b961..84fe0265b 100644 --- a/tools/release/pr_discord.py +++ b/tools/release/pr_discord.py @@ -157,8 +157,11 @@ def sync(mode, repo, number, discord, sha="", apk="", release=""): {"content": retired_content(message["content"], pull.get("merged", False)), "allowed_mentions": {"parse": []}, "flags": 4}) return - commit = github(f"repos/{repo}/commits/{sha}") - commit_message = commit.get("commit", {}).get("message", "") + try: + commit = github(f"repos/{repo}/commits/{sha}") + except RuntimeError: + commit = {} + commit_message = (commit.get("commit") or {}).get("message", "") content = build_content(repo, number, pull["title"], sha, apk, release, pull.get("body"), commit_message) if len(content) > 1800: raise RuntimeError("Discord announcement is too long") diff --git a/tools/tests/test_pr_discord.py b/tools/tests/test_pr_discord.py index 5c901f6bd..2a1394d3b 100644 --- a/tools/tests/test_pr_discord.py +++ b/tools/tests/test_pr_discord.py @@ -240,6 +240,16 @@ class PrDiscordTest(unittest.TestCase): self.assertIn(r"Latest commit: Fix \*input\* @everyone", content) self.assertNotIn("Details", content) + def test_commit_lookup_failure_does_not_block_announcement(self): + def unavailable_commit(path, method="GET", body=None): + if "/commits/" in path: + raise RuntimeError("GitHub request failed") + return self.pull if "/pulls/" in path else self.comment + self.github.side_effect = unavailable_commit + self.publish() + self.assertEqual("POST", self.discord.request.call_args.args[0]) + self.assertNotIn("Latest commit:", self.discord.request.call_args.args[2]["content"]) + original_report = module.report_comment From 1355cb3a2ba74b41fde393c2347247da809acd59 Mon Sep 17 00:00:00 2001 From: Kurt Himebauch <136133082+xXJSONDeruloXx@users.noreply.github.com> Date: Sun, 8 Nov 2026 21:13:53 -0500 Subject: [PATCH 07/33] fix(ci): handle empty commit lookup responses --- tools/release/pr_discord.py | 2 +- tools/tests/test_pr_discord.py | 7 +++++++ 2 files changed, 8 insertions(+), 1 deletion(-) diff --git a/tools/release/pr_discord.py b/tools/release/pr_discord.py index 84fe0265b..77a576208 100644 --- a/tools/release/pr_discord.py +++ b/tools/release/pr_discord.py @@ -158,7 +158,7 @@ def sync(mode, repo, number, discord, sha="", apk="", release=""): "allowed_mentions": {"parse": []}, "flags": 4}) return try: - commit = github(f"repos/{repo}/commits/{sha}") + commit = github(f"repos/{repo}/commits/{sha}") or {} except RuntimeError: commit = {} commit_message = (commit.get("commit") or {}).get("message", "") diff --git a/tools/tests/test_pr_discord.py b/tools/tests/test_pr_discord.py index 2a1394d3b..a80efdff7 100644 --- a/tools/tests/test_pr_discord.py +++ b/tools/tests/test_pr_discord.py @@ -250,6 +250,13 @@ class PrDiscordTest(unittest.TestCase): self.assertEqual("POST", self.discord.request.call_args.args[0]) self.assertNotIn("Latest commit:", self.discord.request.call_args.args[2]["content"]) + def test_empty_commit_response_does_not_block_announcement(self): + self.github.side_effect = lambda path, method="GET", body=None: ( + self.pull if "/pulls/" in path else None if "/commits/" in path else self.comment) + self.publish() + self.assertEqual("POST", self.discord.request.call_args.args[0]) + self.assertNotIn("Latest commit:", self.discord.request.call_args.args[2]["content"]) + original_report = module.report_comment From 92a71245890943e822367cba98d336725098725f Mon Sep 17 00:00:00 2001 From: MaxsTechReview Date: Sun, 8 Nov 2026 22:07:39 -0500 Subject: [PATCH 08/33] fix(gpu): skip system Vulkan pruning when the runtime pack is absent (#2316) fix: skip pruning system Vulkan runtimes when the runtime pack is not installed --- .../launcher/gpu/SystemVulkanDriver.java | 1 + .../gpu/SystemVulkanPruneAbsentRuntimeTest.kt | 26 +++++++++++++++++++ 2 files changed, 27 insertions(+) create mode 100644 app/src/test/java/com/droiddeck/launcher/gpu/SystemVulkanPruneAbsentRuntimeTest.kt diff --git a/app/src/main/java/com/droiddeck/launcher/gpu/SystemVulkanDriver.java b/app/src/main/java/com/droiddeck/launcher/gpu/SystemVulkanDriver.java index 25a6e363c..1106ac960 100644 --- a/app/src/main/java/com/droiddeck/launcher/gpu/SystemVulkanDriver.java +++ b/app/src/main/java/com/droiddeck/launcher/gpu/SystemVulkanDriver.java @@ -191,6 +191,7 @@ public final class SystemVulkanDriver { /** Collect abandoned stages, and obsolete versions once older guests can no longer load them. */ public static synchronized void pruneUnused(Context context, boolean guestsStopped) throws IOException { + if (!AddonPack.POWERVR.installed(context)) return; pruneUnused(runtimeDirectory(context), guestsStopped); } diff --git a/app/src/test/java/com/droiddeck/launcher/gpu/SystemVulkanPruneAbsentRuntimeTest.kt b/app/src/test/java/com/droiddeck/launcher/gpu/SystemVulkanPruneAbsentRuntimeTest.kt new file mode 100644 index 000000000..8a740f168 --- /dev/null +++ b/app/src/test/java/com/droiddeck/launcher/gpu/SystemVulkanPruneAbsentRuntimeTest.kt @@ -0,0 +1,26 @@ +package com.droiddeck.launcher.gpu + +import android.content.Context +import java.io.File +import org.junit.Assert.assertTrue +import org.junit.Test +import org.junit.runner.RunWith +import org.robolectric.RobolectricTestRunner +import org.robolectric.RuntimeEnvironment +import org.robolectric.annotation.Config + +@RunWith(RobolectricTestRunner::class) +@Config(sdk = [28], manifest = Config.NONE) +class SystemVulkanPruneAbsentRuntimeTest { + @Test fun pruningWithoutTheRuntimeDoesNotThrowAndLeavesFilesAlone() { + val context: Context = RuntimeEnvironment.getApplication() + val base = File(context.filesDir, "system_vulkan").apply { mkdirs() } + val stage = File(base, ".stage-" + java.util.UUID.randomUUID()).apply { mkdir() } + val version = File(base, "a".repeat(64)).apply { mkdir() } + + SystemVulkanDriver.pruneUnused(context, true) + + assertTrue(stage.exists()) + assertTrue(version.exists()) + } +} From 29823bb1bf4c8ee7b99fecb7e2d892dd177b007d Mon Sep 17 00:00:00 2001 From: MaxsTechReview Date: Sun, 8 Nov 2026 22:22:22 -0500 Subject: [PATCH 09/33] fix(steam): Linux-only tools keep their native platform (#2313) * fix(steam): reconcile tool dependencies by supported platform * fix(steam): refresh tool metadata and clear stale GE mappings * fix(steam): clear any Proton mapping from Linux-only tools * fix(steam): watch folders the client creates after the helper starts * Watch each missing directory level as it appears * Make the watcher level test wait on inotify readiness --- .../usr/local/bin/droiddeck-steam-compat | 46 +++- .../overlay/usr/local/bin/steam-compatibility | 22 +- tools/tests/test_steam_tool_platforms.py | 215 ++++++++++++++++++ 3 files changed, 273 insertions(+), 10 deletions(-) create mode 100644 tools/tests/test_steam_tool_platforms.py diff --git a/tools/linuxfs/overlay/usr/local/bin/droiddeck-steam-compat b/tools/linuxfs/overlay/usr/local/bin/droiddeck-steam-compat index 901caf2f4..e96ce1c55 100755 --- a/tools/linuxfs/overlay/usr/local/bin/droiddeck-steam-compat +++ b/tools/linuxfs/overlay/usr/local/bin/droiddeck-steam-compat @@ -760,18 +760,50 @@ class Watcher: def __init__(self, files): self.files = files self.fd = -1 + self.libc = None + wanted = {os.path.dirname(f) for f in files} + self.missing = {d for d in wanted if not os.path.isdir(d)} + self.watched = set() try: libc = ctypes.CDLL(None, use_errno=True) fd = libc.inotify_init1(os.O_NONBLOCK | os.O_CLOEXEC) - dirs = sorted({os.path.dirname(f) for f in files if os.path.isdir(os.path.dirname(f))}) + dirs = sorted((wanted - self.missing) | {self.existing_parent(d) for d in self.missing} - {""}) if fd >= 0 and dirs and all(libc.inotify_add_watch(fd, d.encode(), self.MASK) >= 0 for d in dirs): + self.watched = set(dirs) self.fd = fd + self.libc = libc elif fd >= 0: os.close(fd) except (OSError, AttributeError): self.fd = -1 self.stamp = self.snapshot() + @staticmethod + def existing_parent(path): + while path and not os.path.isdir(path): + parent = os.path.dirname(path) + if parent == path: + return "" + path = parent + return path + + def watch_new_dirs(self): + appeared = False + progressed = True + while progressed: + progressed = False + for d in sorted(self.missing): + parent = d if os.path.isdir(d) else self.existing_parent(d) + if not parent or parent in self.watched: + continue + if self.libc.inotify_add_watch(self.fd, parent.encode(), self.MASK) >= 0: + self.watched.add(parent) + progressed = True + appeared = appeared or parent == d + for d in [d for d in self.missing if d in self.watched]: + self.missing.discard(d) + return appeared + def snapshot(self): stamp = [] for f in self.files: @@ -803,8 +835,9 @@ class Watcher: at += 16 + size except BlockingIOError: pass + appeared = self.watch_new_dirs() if self.missing else False wanted = {os.path.basename(f) for f in self.files} - return any(n in wanted or n.startswith("appmanifest_") for n in names) + return appeared or any(n in wanted or n.startswith("appmanifest_") for n in names) class XMessage(ctypes.Structure): @@ -1084,6 +1117,7 @@ class Helper: installed = COMPAT["installed_apps"](self.steam_root) self.installed = installed picks = COMPAT["valve_picks"](self.steam_root) + tools = COMPAT["tool_platforms"](self.steam_root) prior_auto = state.get("auto", {}) if not isinstance(prior_auto, dict): prior_auto = {} @@ -1091,7 +1125,7 @@ class Helper: native_choices = set(prior_native if isinstance(prior_native, (list, tuple, set)) else ()) picked = COMPAT["picked_apps"](state) runnable = {t["name"].lower() for t in live} - apps = sorted(set(installed) | set(picks) | set(prior_auto) | native_choices, key=COMPAT["app_order"]) + apps = sorted(set(installed) | set(picks) | set(prior_auto) | native_choices | set(tools), key=COMPAT["app_order"]) asked = [app for app, request in COMPAT["game_requests"]().items() if COMPAT["valid_game_request"](app, request)] apps = sorted(set(apps) | set(asked), key=COMPAT["app_order"]) current = dict(page.compat("many(%s)" % json.dumps(apps)) or {}) @@ -1105,6 +1139,7 @@ class Helper: if isinstance(state.get("auto"), dict): state["auto"] = prior_auto native_choices -= refused + tools = {app: platforms for app, platforms in tools.items() if app not in refused} current["0"] = top["selected"] native = COMPAT["native_targets"](self.steam_root, apps) raw_mapping = COMPAT["read_config_mapping"]( @@ -1137,7 +1172,7 @@ class Helper: changes, auto = COMPAT["plan_mapping"]( current, installed, listed, default, COMPAT["followers"](state, current), native, picks, overrides, - prior_auto.keys(), native_choices) + prior_auto.keys(), native_choices, tools) changes = dict(moved, **changes) for app, tool in sorted(changes.items(), key=lambda item: COMPAT["app_order"](item[0])): page.compat("set(%d, %s)" % (int(app), json.dumps(tool if tool is not None else ""))) @@ -1301,7 +1336,8 @@ def main(): failures += 1 time.sleep(min(2 * max(failures, 1), 10)) os.makedirs(COMPAT["COMPAT_DIR"], exist_ok=True) - watcher = Watcher([COMPAT["state_path"]("request.json"), COMPAT["state_path"]("games.json")] + + watcher = Watcher([COMPAT["state_path"]("request.json"), COMPAT["state_path"]("games.json"), + os.path.join(steam_root, "appcache", "appinfo.vdf")] + [os.path.join(lib, "steamapps", "libraryfolders.vdf") for lib in (steam_root, COMPAT["LIBRARY"] + "-sd")]) steamos = os.environ.get("BL_STEAMDECK") == "1" and os.environ.get("BL_STEAM_UI") != "desktop" helper = Helper(steam_root, port, steamos) diff --git a/tools/linuxfs/overlay/usr/local/bin/steam-compatibility b/tools/linuxfs/overlay/usr/local/bin/steam-compatibility index 071ff0681..5695bd0b1 100755 --- a/tools/linuxfs/overlay/usr/local/bin/steam-compatibility +++ b/tools/linuxfs/overlay/usr/local/bin/steam-compatibility @@ -734,6 +734,7 @@ def libraries(steam_root): def installed_apps(steam_root): + entries = appinfo_entries(os.path.join(steam_root, "appcache", "appinfo.vdf")) apps = set() for library in libraries(steam_root): steamapps = os.path.join(library, "steamapps") @@ -743,7 +744,7 @@ def installed_apps(steam_root): continue for name in names: m = re.fullmatch(r"appmanifest_(\d+)\.acf", name) - if m and m.group(1) not in NOT_GAMES: + if m and m.group(1) not in NOT_GAMES and entries.get(m.group(1), {}).get("type") != "tool": apps.add(m.group(1)) return sorted(apps, key=int) @@ -927,6 +928,12 @@ def valve_picks(steam_root): return sorted(picks, key=int) +def tool_platforms(steam_root): + entries = appinfo_entries(os.path.join(steam_root, "appcache", "appinfo.vdf")) + return {app: entry["oslist"] for app, entry in entries.items() + if entry["type"] == "tool" and entry["oslist"] & {"linux", "windows"}} + + def native_targets(steam_root, apps): wanted = {str(app) for app in apps if str(app) not in NOT_GAMES} if not wanted: @@ -1064,10 +1071,11 @@ NATIVE_CHOICE = object() def plan_mapping(current, installed, runnable, default, auto, native=None, picks=(), overrides=None, managed=None, - native_choices=()): + native_choices=(), tools=None): + tools = tools or {} runnable = {n.lower() for n in runnable} installed = set(installed) - picks = set(picks) + picks = set(picks) | {app for app, platforms in tools.items() if "linux" not in platforms} native = native or {} overrides = overrides or {} managed = set(auto) if managed is None else set(managed) @@ -1075,8 +1083,12 @@ def plan_mapping(current, installed, runnable, default, auto, native=None, picks changes, kept = {}, {} if current.get("0", "") != default: changes["0"] = default - for app in sorted((set(current) | installed | picks | set(auto) | set(overrides) | native_choices) - {"0"}, key=app_order): + for app in sorted((set(current) | installed | picks | set(auto) | set(overrides) | native_choices | set(tools)) - {"0"}, key=app_order): name = current.get(app, "") + if app in tools and "linux" in tools[app] and "windows" not in tools[app]: + if name and (name.lower() in runnable or "proton" in name.lower()): + changes[app] = None + continue if app in overrides: target = overrides[app] if target is DEFERRED_GAME_TOOL: @@ -1560,7 +1572,7 @@ def register_default(config_path, apps=(), protect=(), default=None, catalog=Non current.setdefault(app, "") changes, auto = plan_mapping( current, apps, names, default, followers(state, current), native, picks, - overrides, managed, native_choices) + overrides, managed, native_choices, tool_platforms(steam_root)) set_mappings(tokens, mapping, changes) text = serialize(tokens) if text != serialize(tokenize(original)): diff --git a/tools/tests/test_steam_tool_platforms.py b/tools/tests/test_steam_tool_platforms.py new file mode 100644 index 000000000..899b0b07e --- /dev/null +++ b/tools/tests/test_steam_tool_platforms.py @@ -0,0 +1,215 @@ +import json +import os +from pathlib import Path +import select +import tempfile +import unittest +from unittest import mock + +from tools.tests import test_droiddeck_fex as fixtures +from tools.tests import test_steam_compat_live as live +from tools.tests.test_steam_compat_mapping import config_text + + +COMPAT = live.COMPAT +TOOL = COMPAT["TOOL"] + + +class ToolPlatformsTest(unittest.TestCase): + def setUp(self): + temporary = tempfile.TemporaryDirectory() + self.addCleanup(temporary.cleanup) + self.root = Path(temporary.name) + self.steam = self.root / "Steam" + self.state = self.root / "state" + self.config = self.steam / "config/config.vdf" + self.config.parent.mkdir(parents=True) + self.state.mkdir() + (self.steam / "appcache").mkdir() + (self.steam / "steamapps").mkdir() + self.entries = { + 976730: ("windows", "64", "game"), + 1826330: ("linux", "", "tool"), + 1161040: ("linux", "", "tool"), + 900001: ("windows", "64", "tool"), + 900002: ("linux", "arm64", "tool"), + 900003: ("osx", "64", "tool"), + 900004: ("", "", "tool"), + 900005: ("linux,windows", "64", "tool"), + } + self.write_appinfo() + self.catalog = [{"name": TOOL, "display": "Proton", "dir": "Proton", "valve": True}] + self.config.write_text(config_text('"0" { "name" "%s" }' % TOOL)) + patcher = mock.patch.dict(COMPAT["register_default"].__globals__, COMPAT_DIR=str(self.state)) + patcher.start() + self.addCleanup(patcher.stop) + + def write_appinfo(self, magic=0x07564429): + (self.steam / "appcache/appinfo.vdf").write_bytes(fixtures.AppInfoTest.appinfo(self.entries, magic)) + + def platforms(self): + return COMPAT["tool_platforms"](str(self.steam)) + + def plan(self, current, auto=None, **kwargs): + return COMPAT["plan_mapping"](current, ["976730"], [TOOL], TOOL, auto or {}, + tools=self.platforms(), **kwargs) + + def test_metadata_selects_tools_across_cache_formats(self): + for magic in (0x07564427, 0x07564428, 0x07564429): + with self.subTest(magic=magic): + self.write_appinfo(magic) + self.assertEqual(set(self.platforms()), {"1826330", "1161040", "900001", "900002", "900005"}) + + def test_tools_are_not_installed_games(self): + for app in self.entries: + (self.steam / ("steamapps/appmanifest_%s.acf" % app)).write_text("") + self.assertEqual(COMPAT["installed_apps"](str(self.steam)), ["976730"]) + + def test_linux_tools_clear_proton_and_windows_tools_get_default_before_install(self): + current = {"0": TOOL, "976730": TOOL, "1826330": TOOL, "1161040": "proton_experimental"} + changes, auto = self.plan(current, {"1826330": TOOL, "976730": TOOL}) + self.assertEqual(changes, {"1826330": None, "1161040": None, "900001": TOOL}) + self.assertNotIn("1826330", auto) + self.assertEqual(auto["900001"], TOOL) + self.assertEqual(auto["976730"], TOOL) + + def test_removed_ge_proton_is_cleared_with_or_without_saved_ownership(self): + for saved in ({}, {"1826330": "GE-Proton11-7"}): + with self.subTest(saved=saved): + changes, auto = self.plan({"0": TOOL, "1826330": "GE-Proton11-7"}, saved) + self.assertIsNone(changes["1826330"]) + self.assertNotIn("1826330", auto) + + def test_removed_ge_proton_game_selection_survives(self): + changes, auto = self.plan({"0": TOOL, "976730": "GE-Proton11-7"}) + self.assertNotIn("976730", changes) + self.assertNotIn("976730", auto) + + def test_saved_native_selections_survive(self): + for name in (COMPAT["FEX_TOOL"], "custom-native"): + with self.subTest(name=name): + changes, auto = self.plan({"0": TOOL, "1826330": name}, {"1826330": name}) + self.assertNotIn("1826330", changes) + self.assertNotIn("1826330", auto) + + def test_main_watches_cache_updates_and_replacements(self): + class StopWatching(BaseException): + pass + + watchers = [] + + def capture(files): + watcher = live.LIVE["Watcher"](files) + watchers.append(watcher) + raise StopWatching() + + namespace = live.LIVE["main"].__globals__ + with mock.patch.dict(namespace, Watcher=capture), mock.patch.dict(os.environ, BL_CDP_PORT="1"), \ + mock.patch.object(live.LIVE["sys"], "argv", ["compat", str(self.steam)]): + with self.assertRaises(StopWatching): + live.LIVE["main"]() + watcher = watchers[0] + if watcher.fd >= 0: + self.addCleanup(os.close, watcher.fd) + self.assertFalse(watcher.changed()) + self.entries[900006] = ("linux", "", "tool") + self.write_appinfo() + self.assertTrue(watcher.changed()) + self.assertIn("900006", self.platforms()) + cache = self.steam / "appcache/appinfo.vdf" + replacement = cache.with_suffix(".tmp") + replacement.write_bytes(cache.read_bytes()) + replacement.replace(cache) + self.assertTrue(watcher.changed()) + + def test_watcher_follows_an_appcache_created_after_startup(self): + root = self.root / "late" + (root / "state").mkdir(parents=True) + watcher = live.LIVE["Watcher"]([str(root / "state/request.json"), str(root / "appcache/appinfo.vdf")]) + self.assertGreaterEqual(watcher.fd, 0) + self.addCleanup(os.close, watcher.fd) + self.assertFalse(watcher.changed()) + (root / "appcache").mkdir() + self.assertTrue(watcher.changed()) + (root / "appcache/appinfo.vdf").write_bytes(b"x") + self.assertTrue(watcher.changed()) + + def test_watcher_follows_each_missing_directory_level(self): + root = self.root / "levels" + root.mkdir() + target = root / "sd/steamapps/appmanifest_1.acf" + watcher = live.LIVE["Watcher"]([str(target)]) + self.assertGreaterEqual(watcher.fd, 0) + self.addCleanup(os.close, watcher.fd) + (root / "sd").mkdir() + self.assertTrue(select.select([watcher.fd], [], [], 1)[0]) + self.assertFalse(watcher.changed()) + (root / "sd/steamapps").mkdir() + self.assertTrue(select.select([watcher.fd], [], [], 1)[0]) + self.assertTrue(watcher.changed()) + target.write_bytes(b"x") + self.assertTrue(watcher.changed()) + + def test_linux_tool_requests_cannot_restore_windows_platform(self): + changes, auto = self.plan({"0": TOOL, "1826330": TOOL}, overrides={"1826330": TOOL}) + self.assertIsNone(changes["1826330"]) + self.assertNotIn("1826330", auto) + + def test_linux_execution_tools_and_custom_selections_survive(self): + current = {"0": TOOL, "1826330": COMPAT["FEX_TOOL"], "900002": "custom-native", + "900001": "custom-windows", "900005": TOOL} + changes, auto = self.plan(current) + self.assertEqual(changes, {"976730": TOOL}) + self.assertNotIn("1826330", auto) + + def test_missing_or_malformed_cache_does_not_invent_platforms(self): + cache = self.steam / "appcache/appinfo.vdf" + cache.unlink() + self.assertEqual(self.platforms(), {}) + cache.write_bytes(b"invalid") + self.assertEqual(self.platforms(), {}) + + def test_offline_repair_is_idempotent_and_preserves_game_and_neighbor_fields(self): + self.config.write_text(config_text( + '"0" { "name" "%s" } "976730" { "name" "custom-game" } ' + '"1826330" { "name" "%s" }' % (TOOL, TOOL))) + for _ in range(2): + COMPAT["register_default"](str(self.config), ["976730"], catalog=self.catalog, + directory=str(self.state), default=TOOL) + self.assertEqual(COMPAT["read_config_mapping"](str(self.config)), + {"0": TOOL, "976730": "custom-game", "900001": TOOL}) + self.assertIn('"outside"', self.config.read_text()) + state = json.loads((self.state / "state.json").read_text()) + self.assertNotIn("1826330", state["auto"]) + + def reconcile(self, page): + helper = live.LIVE["Helper"](str(self.steam), 1) + helper.page = page + with mock.patch.dict(COMPAT, tool_catalog=lambda root: self.catalog): + helper.reconcile() + return json.loads((self.state / "state.json").read_text()) + + def test_live_repair_covers_uninstalled_dependencies_and_stays_idempotent(self): + page = live.FakePage({"0": TOOL, "1826330": TOOL, "976730": "custom-game"}, [TOOL]) + state = self.reconcile(page) + self.assertEqual(page.sets, [("900001", TOOL), ("1826330", "")]) + self.assertNotIn("1826330", state["auto"]) + page.sets.clear() + self.reconcile(page) + self.assertEqual(page.sets, []) + self.assertEqual(page.mapping["976730"], "custom-game") + + def test_live_removed_ge_proton_is_cleared_and_stays_cleared(self): + page = live.FakePage({"0": TOOL, "1826330": "GE-Proton11-7"}, [TOOL]) + (self.state / "state.json").write_text(json.dumps({"auto": {"1826330": "GE-Proton11-7"}})) + state = self.reconcile(page) + self.assertIn(("1826330", ""), page.sets) + self.assertNotIn("1826330", state["auto"]) + page.sets.clear() + self.reconcile(page) + self.assertEqual(page.sets, []) + + def test_live_refused_tools_are_not_written(self): + page = live.FakePage({"0": TOOL, "1826330": TOOL}, [TOOL], refused={"1826330", "900001"}) + self.reconcile(page) + self.assertEqual(page.sets, []) From 88d6b70881a9e53eee53ac5ee4d38ba711304788 Mon Sep 17 00:00:00 2001 From: Kurt Himebauch <136133082+xXJSONDeruloXx@users.noreply.github.com> Date: Mon, 9 Nov 2026 00:00:20 -0500 Subject: [PATCH 10/33] fix(ctl): reach the agent bridge by broadcast when the shell has no content command (#2321) --- app/src/main/AndroidManifest.xml | 8 ++++ .../launcher/agent/AgentCallReceiver.kt | 39 +++++++++++++++++++ tools/droiddeckctl | 26 +++++++++++++ tools/tests/test_droiddeckctl.py | 22 +++++++++++ 4 files changed, 95 insertions(+) create mode 100644 app/src/main/java/com/droiddeck/launcher/agent/AgentCallReceiver.kt diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml index 5f010b48d..968aed7d0 100644 --- a/app/src/main/AndroidManifest.xml +++ b/app/src/main/AndroidManifest.xml @@ -352,6 +352,14 @@ + + + + + str: return encoded +def receiver_call(adb: str, serial: str, method: str, arg: str | None, request: dict | None, timeout: float) -> dict: + """The agent bridge through a broadcast, for shells without the `content` command.""" + call_id = uuid.uuid4().hex[:16] + remote = f"/sdcard/Android/data/{PACKAGE}/files/agent-calls/{call_id}.json" + command = ["shell", "am", "broadcast", "-a", f"{PACKAGE}.AGENT_CALL", "-p", PACKAGE, + "--es", "method", method, "--es", "id", call_id] + if arg is not None: + command += ["--es", "arg", shlex.quote(arg)] + if request is not None: + command += ["--es", "request", encode_request(request)] + started = run_adb(adb, serial, *command, timeout=timeout + 30) + if started.returncode != 0: + raise ControlError((started.stdout + started.stderr).decode(errors="replace").strip() or "ADB broadcast failed", "ADB_FAILED") + deadline = time.monotonic() + timeout + while time.monotonic() < deadline: + result = run_adb(adb, serial, "shell", f"cat {remote} 2>/dev/null && rm -f {remote}", timeout=30) + text = result.stdout.decode(errors="replace").strip() + if result.returncode == 0 and text: + return json.loads(text) + time.sleep(0.25) + raise ControlError(f"No answer to {method} within {timeout:g} s", "TIMEOUT") + + def provider_call(adb: str, serial: str, method: str, arg: str | None = None, request: dict | None = None, timeout: float = 30) -> dict: command = ["shell", "content", "call", "--uri", AUTHORITY, "--method", method] @@ -108,6 +132,8 @@ def provider_call(adb: str, serial: str, method: str, arg: str | None = None, re command.extend(["--extra", f"request:s:{encoded}"]) result = run_adb(adb, serial, *command, timeout=timeout) output = (result.stdout + result.stderr).decode(errors="replace") + if "content: inaccessible or not found" in output: + return receiver_call(adb, serial, method, arg, request, timeout) response = _content_result(output) if result.returncode != 0 and response.get("ok") is not False: raise ControlError(output.strip() or "ADB content call failed", "ADB_FAILED") diff --git a/tools/tests/test_droiddeckctl.py b/tools/tests/test_droiddeckctl.py index ad434989a..a626c7868 100644 --- a/tools/tests/test_droiddeckctl.py +++ b/tools/tests/test_droiddeckctl.py @@ -398,6 +398,28 @@ class DroiddeckctlTest(FakeAdbTest): self.assertEqual(4, code) self.assertEqual("NO_FRAMES", out["error"]["code"]) + def test_provider_call_falls_back_to_a_broadcast_without_content(self): + calls = [] + answers = iter([b"", b'{"ok": true, "command": "state"}']) + + def adb(_adb, _serial, *args, timeout=30): + calls.append(args) + if args[:2] == ("shell", "content"): + return ctl.subprocess.CompletedProcess(args, 127, b"", b"/system/bin/sh: content: inaccessible or not found") + if args[:2] == ("shell", "am"): + return ctl.subprocess.CompletedProcess(args, 0, b"Broadcast completed: result=0", b"") + return ctl.subprocess.CompletedProcess(args, 0, next(answers), b"") + + with unittest.mock.patch.object(ctl, "run_adb", adb), unittest.mock.patch.object(ctl.time, "sleep"): + answer = ctl.provider_call("adb", "fake-1", "state", arg="a b", request={"x": 1}) + self.assertEqual({"ok": True, "command": "state"}, answer) + broadcast = calls[1] + self.assertEqual(("shell", "am", "broadcast", "-a", ctl.PACKAGE + ".AGENT_CALL", "-p", ctl.PACKAGE), broadcast[:7]) + self.assertIn("'a b'", broadcast) + self.assertEqual(ctl.encode_request({"x": 1}), broadcast[broadcast.index("request") + 1]) + call_id = broadcast[broadcast.index("id") + 1] + self.assertIn("agent-calls/%s.json" % call_id, calls[-1][1]) + def test_pacing_turns_timestats_off_when_the_dump_fails(self): calls = [] From 96a12465e01f50b88a617f72b4fadacc34f7938f Mon Sep 17 00:00:00 2001 From: MaxsTechReview Date: Mon, 9 Nov 2026 02:08:40 -0500 Subject: [PATCH 11/33] fix(session): stop re-compressing game memory every 30 s when the game reads it straight back (#2312) * fix(session): stop re-compressing game memory every 30 s when the game reads it straight back On devices with 13 GB or less, the low-memory page-out skipped its read-back backoff whenever memory was nearly exhausted. When the game was actively using what was compressed, it read it straight back, so the session pushed and re-read gigabytes every 30 seconds, pinning all cores and freezing the game for 5-10 s each time. The read-back check now applies on that path too: an effective page-out still repeats every 30 s, an ineffective one backs off as on the normal path. * zRAM: keep the read-back backoff on the nearly-out-of-memory path * zRAM: retry a skipped page-out after 30 s when nearly out of memory --- .../overlay/usr/local/bin/droiddeck-session | 10 +++---- tools/tests/test_memory_pressure.py | 27 ++++++++++++++++++- 2 files changed, 31 insertions(+), 6 deletions(-) diff --git a/tools/linuxfs/overlay/usr/local/bin/droiddeck-session b/tools/linuxfs/overlay/usr/local/bin/droiddeck-session index 415f9472f..907364572 100755 --- a/tools/linuxfs/overlay/usr/local/bin/droiddeck-session +++ b/tools/linuxfs/overlay/usr/local/bin/droiddeck-session @@ -902,21 +902,21 @@ case $mode in bl_session_pressure() { bl_memory_read [ "$_bl_game" = 1 ] && bl_low_ram && bl_memory_critical || return 0 - local _wait=$_bl_tier_backoff _urgent=0 _before _pids _targets - [ "$_bl_available" -ge $((_bl_headroom / 2)) ] || _urgent=1 _wait=30 + local _wait=$_bl_tier_backoff _before _pids _targets + [ "$_bl_available" -ge $((_bl_headroom / 2)) ] || [ "$_bl_tier_pushed" -lt 0 ] || _wait=30 [ "$_bl_tier_at" -lt 0 ] || [ $((SECONDS - _bl_tier_at)) -ge "$_wait" ] || return 0 if [ "$_bl_tier_at" -ge 0 ] && [ "$_bl_tier_pushed" -gt 0 ]; then bl_tier_readback if [ "$_bl_tier_back" -le $((_bl_tier_pushed / 2)) ]; then _bl_tier_backoff=30 - elif [ "$_urgent" = 0 ]; then + else _bl_tier_backoff=$((_bl_tier_backoff * 2 > 300 ? 300 : _bl_tier_backoff * 2)) - _bl_tier_at=$SECONDS _bl_tier_pushed=0 + _bl_tier_at=$SECONDS _bl_tier_pushed=-1 step "zRAM: the game read back most of its last page-out, next try in ${_bl_tier_backoff} s" return 0 fi fi - _bl_tier_at=$SECONDS + _bl_tier_at=$SECONDS _bl_tier_pushed=0 bl_zram_pick game bl_zram_usage "${_pids[@]}" > /dev/null [ "$_bl_rss" -ge $((256 * 1024)) ] || return 0 diff --git a/tools/tests/test_memory_pressure.py b/tools/tests/test_memory_pressure.py index 8aa2d1f2c..2c852a7af 100644 --- a/tools/tests/test_memory_pressure.py +++ b/tools/tests/test_memory_pressure.py @@ -229,9 +229,34 @@ echo "backoff:$_bl_tier_backoff" self.assertEqual(['send:1:game', 'send:1:game'], self.run_policy(''' _bl_tier_backoff=300 SECONDS=0; bl_session_pressure -swap=0 SECONDS=20; bl_session_pressure SECONDS=30; bl_session_pressure +''')) + + def test_nearly_out_of_memory_still_backs_off_when_the_game_reads_its_pageout_back(self): + self.memory(available=512) + self.assertEqual(['send:1:game', 'backoff:60', 'send:1:game'], self.run_policy(''' +SECONDS=0; bl_session_pressure +swap=0 +SECONDS=30; bl_session_pressure +echo "backoff:$_bl_tier_backoff" +SECONDS=59; bl_session_pressure +SECONDS=60; bl_session_pressure +SECONDS=90; bl_session_pressure +''')) + + def test_nearly_out_of_memory_retries_a_skipped_pageout_after_30_seconds(self): + self.memory(available=512) + self.assertEqual(['send:1:game', 'send:1:game'], self.run_policy(''' +SECONDS=0; bl_session_pressure +swap=0 +SECONDS=30; bl_session_pressure +_bl_tier_backoff=300 +saved=$rss; rss=$((128 * 1024)) +SECONDS=330; bl_session_pressure +rss=$saved +SECONDS=359; bl_session_pressure +SECONDS=360; bl_session_pressure ''')) def test_small_swap_headroom_skips_the_pageout(self): From 35941bb9e732311c37907318d8fc73d74f08aeea Mon Sep 17 00:00:00 2001 From: GiovanniCarbinatti <83474533+GiovanniCarbinatti@users.noreply.github.com> Date: Mon, 9 Nov 2026 08:45:24 -0300 Subject: [PATCH 12/33] chore: update README.md (#2311) --- README.md | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/README.md b/README.md index 3139abd28..5b329368c 100644 --- a/README.md +++ b/README.md @@ -10,7 +10,7 @@ DroidDeck brings the SteamOS experience to Android: Steam Gaming Mode, full Linu

100% open source. No ads. No telemetry. No proprietary components. No source withheld.

- Download DroidDeck 0.6.9 + Download DroidDeck 0.7.0 Join the DroidDeck Discord

@@ -36,9 +36,9 @@ DroidDeck brings the SteamOS experience to Android: Steam Gaming Mode, full Linu ## Install -1. Android 9 or newer on Adreno 730 or newer, or any 8xx. Adreno 6xx is experimental. Mali, Xclipse, PowerVR and Adreno 710 are unsupported. No root needed. +1. Android 9 or newer on Adreno 730 or newer, or any 8xx. Adreno 6xx, Mali, Xclipse and PowerVR are experimental. Adreno 710 is currently unsupported. No root needed. 2. About 3 GB free for the runtime, plus 1.1 GB for the desktop and emulators. -3. Install the [APK](https://github.com/Droid-Deck/DroidDeck/releases/download/0.6.9/DroidDeck-0.6.9.apk), install the Linux runtime, press **Play** and sign in to Steam. +3. Install the [APK](https://github.com/Droid-Deck/DroidDeck/releases/download/0.7.0/DroidDeck-0.7.0.apk), install the Linux runtime, press **Play** and sign in to Steam. 4. On Android 14 and newer, turn on **Disable child process restrictions** in Developer options. On Android 12 and 13, Steam's first launch shows a **Fix it for me** button that walks you through it. Smart glasses setup is in the [support matrix](docs/smart-glasses-support.md). Release notes are in [docs/releases](docs/releases). From bf434b2652f1eb36d5511e7130e4af6d8cd35177 Mon Sep 17 00:00:00 2001 From: Kurt Himebauch <136133082+xXJSONDeruloXx@users.noreply.github.com> Date: Mon, 9 Nov 2026 12:30:41 -0500 Subject: [PATCH 13/33] fix(hud): steady frame graph on the bottom screen dashboard (#2345) --- .../com/droiddeck/launcher/hud/GameFrames.kt | 6 ++++- .../launcher/ui/BottomTelemetryPage.kt | 4 ++- .../com/droiddeck/launcher/ui/StatsHud.kt | 27 ++++++++++++------- 3 files changed, 26 insertions(+), 11 deletions(-) diff --git a/app/src/main/java/com/droiddeck/launcher/hud/GameFrames.kt b/app/src/main/java/com/droiddeck/launcher/hud/GameFrames.kt index 98dc35074..2aba209de 100644 --- a/app/src/main/java/com/droiddeck/launcher/hud/GameFrames.kt +++ b/app/src/main/java/com/droiddeck/launcher/hud/GameFrames.kt @@ -6,6 +6,7 @@ import kotlin.math.roundToInt internal class FrameStats { private val graph = FloatArray(GRAPH_FRAMES) private var graphHead = 0 + private var graphFrames = 0L private var lastPresentNs = 0L private var lastFpsUpdateNs = 0L private var framesSinceUpdate = 0 @@ -38,6 +39,7 @@ internal class FrameStats { if (lastPresentNs != 0L) { graph[graphHead] = frametimeNs / 1e6f graphHead = (graphHead + 1) % GRAPH_FRAMES + graphFrames++ } val elapsed = timeNs - lastFpsUpdateNs if (elapsed >= FPS_PERIOD_NS) { @@ -68,6 +70,7 @@ internal class FrameStats { fun reset() { graph.fill(0f) graphHead = 0 + graphFrames = 0L lastPresentNs = 0L lastFpsUpdateNs = 0L framesSinceUpdate = 0 @@ -86,8 +89,9 @@ internal class FrameStats { fun frametimeMs(): Float = 1000f / maxOf(fps, 1f) @Synchronized - fun graph(out: FloatArray) { + fun graph(out: FloatArray): Long { for (i in 0 until GRAPH_FRAMES) out[i] = graph[(graphHead + i) % GRAPH_FRAMES] + return graphFrames } @Synchronized diff --git a/app/src/main/java/com/droiddeck/launcher/ui/BottomTelemetryPage.kt b/app/src/main/java/com/droiddeck/launcher/ui/BottomTelemetryPage.kt index 4aee7fc22..08b4ae8ad 100644 --- a/app/src/main/java/com/droiddeck/launcher/ui/BottomTelemetryPage.kt +++ b/app/src/main/java/com/droiddeck/launcher/ui/BottomTelemetryPage.kt @@ -304,7 +304,8 @@ fun BottomTelemetryPage(fps: () -> Float?, runningGame: () -> String? = { null } val now = System.nanoTime() val live = frames.live val stats = frames.stats - val graph = FloatArray(FrameStats.GRAPH_FRAMES).also { if (live) stats.graph(it) } + val graph = FloatArray(FrameStats.GRAPH_FRAMES) + val graphEnd = if (live) stats.graph(graph) else 0L val hz = (frames.refreshMilliHz + 499) / 1000 val data = HudData( fps = fpsNow, avgFps = if (live) stats.averageFps() else null, lows = if (live) stats.lows(now) else FrameStats.Lows.EMPTY, @@ -317,6 +318,7 @@ fun BottomTelemetryPage(fps: () -> Float?, runningGame: () -> String? = { null } title = title, cover = cover, played = playedSeconds?.let(::playedText), clock = clock, fpsHistory = fpsHistory.snapshot(), cpuHistory = cpuHistory.snapshot(), gpuHistory = gpuHistory.snapshot(), historySeconds = maxOf(fpsHistory.spanSeconds(), cpuHistory.spanSeconds(), gpuHistory.spanSeconds()), + graphEnd = graphEnd, coreGroups = coreGroups, gameCores = gameCores, fahrenheit = tempDisplay.fahrenheit, tempColor = { sensor, c -> thresholds[sensor]?.let { Color(HudMetrics.tempColor(c.toFloat(), it, tempDisplay, TEMP_PLAIN)) } ?: Color(TEMP_PLAIN) diff --git a/app/src/main/java/com/droiddeck/launcher/ui/StatsHud.kt b/app/src/main/java/com/droiddeck/launcher/ui/StatsHud.kt index b14dd39dc..0ed81319c 100644 --- a/app/src/main/java/com/droiddeck/launcher/ui/StatsHud.kt +++ b/app/src/main/java/com/droiddeck/launcher/ui/StatsHud.kt @@ -98,6 +98,7 @@ internal class HudData( val tempColor: (HudMetrics.TempSensor, Int) -> Color, val redC: (HudMetrics.TempSensor) -> Int?, val historySeconds: Int = 0, + val graphEnd: Long = 0L, ) { val frametimeMs: Float? get() = fps?.takeIf { it > 0f }?.let { 1000f / it } val hz: Int get() = if (refreshHz > 0) refreshHz else 60 @@ -268,18 +269,26 @@ private fun lowsText(d: HudData, size: TextUnit, accent: Color, deep: Boolean = private val GRAPH_MIN_GAP = 3.dp @Composable -private fun Graph(values: List, lo: Float, hi: Float, color: Color, modifier: Modifier) { +private fun Graph(values: List, lo: Float, hi: Float, color: Color, modifier: Modifier, end: Long = 0L) { Canvas(modifier) { if (values.size < 2 || hi <= lo) return@Canvas val stride = ceil(GRAPH_MIN_GAP.toPx() * (values.size - 1) / size.width).toInt().coerceAtLeast(1) - val points = if (stride == 1) values else values.chunked(stride) { it.average().toFloat() } - if (points.size < 2) return@Canvas + // Buckets start at multiples of stride counted from the first sample ever drawn, so a sample keeps + // its bucket as the window scrolls; counted from the window's start, every new frame re-paired them. + val skip = Math.floorMod(values.size.toLong() - end, stride.toLong()).toInt() val path = Path() - points.forEachIndexed { i, v -> - val x = i / (points.size - 1f) * size.width - val y = size.height - ((v - lo) / (hi - lo)).coerceIn(0f, 1f) * size.height - if (i == 0) path.moveTo(x, y) else path.lineTo(x, y) + var drawn = 0 + var start = skip + while (start < values.size) { + val stop = minOf(start + stride, values.size) + var sum = 0f + for (i in start until stop) sum += values[i] + val x = (start + stop - 1) / 2f / (values.size - 1f) * size.width + val y = size.height - ((sum / (stop - start) - lo) / (hi - lo)).coerceIn(0f, 1f) * size.height + if (drawn++ == 0) path.moveTo(x, y) else path.lineTo(x, y) + start = stop } + if (drawn < 2) return@Canvas drawPath(path, color.copy(alpha = 0.22f), style = Stroke(width = 6.dp.toPx(), cap = StrokeCap.Round, join = StrokeJoin.Round)) drawPath(path, color, style = Stroke(width = 2.2f.dp.toPx(), cap = StrokeCap.Round, join = StrokeJoin.Round)) } @@ -287,7 +296,7 @@ private fun Graph(values: List, lo: Float, hi: Float, color: Color, modif @Composable private fun FrameGraph(d: HudData, accent: Color, height: Dp) { - Graph(d.graph.toList(), 0f, FrameStats.GRAPH_MAX_MS, lerp(accent, Color.White, 0.3f), Modifier.fillMaxWidth().height(height)) + Graph(d.graph.toList(), 0f, FrameStats.GRAPH_MAX_MS, lerp(accent, Color.White, 0.3f), Modifier.fillMaxWidth().height(height), d.graphEnd) } @Composable @@ -589,7 +598,7 @@ private fun ColumnScope.Pacing(d: HudData, accent: Color) { drawLine(color, Offset(0f, y), Offset(size.width, y), 1.dp.toPx(), pathEffect = dash) } } - Graph(d.graph.toList(), 0f, FrameStats.GRAPH_MAX_MS, graphColor, Modifier.fillMaxSize()) + Graph(d.graph.toList(), 0f, FrameStats.GRAPH_MAX_MS, graphColor, Modifier.fillMaxSize(), d.graphEnd) for ((ms, color) in listOf(target to HudInk.dim, target * 2 to HudInk.amber)) { Small("%.1f ms · %d fps".format(ms, (1000f / ms).roundToInt()), 11.sp, color, Modifier.align(Alignment.TopEnd).offset(y = maxHeight * (1f - ms / FrameStats.GRAPH_MAX_MS) - 17.dp)) From b837611efe543336bc3848b4a73e1ac98be3c293 Mon Sep 17 00:00:00 2001 From: Kurt Himebauch <136133082+xXJSONDeruloXx@users.noreply.github.com> Date: Mon, 9 Nov 2026 12:33:26 -0500 Subject: [PATCH 14/33] fix(ctl): a full disk no longer crashes the app when an agent call cannot write its answer (#2325) --- .../main/java/com/droiddeck/launcher/agent/AgentCallReceiver.kt | 2 ++ 1 file changed, 2 insertions(+) diff --git a/app/src/main/java/com/droiddeck/launcher/agent/AgentCallReceiver.kt b/app/src/main/java/com/droiddeck/launcher/agent/AgentCallReceiver.kt index d457bc214..6ef8b2d4b 100644 --- a/app/src/main/java/com/droiddeck/launcher/agent/AgentCallReceiver.kt +++ b/app/src/main/java/com/droiddeck/launcher/agent/AgentCallReceiver.kt @@ -31,6 +31,8 @@ class AgentCallReceiver : BroadcastReceiver() { val dir = File(context.getExternalFilesDir(null), "agent-calls").apply { mkdirs() } File(dir, "$id.json.part").writeText(json) File(dir, "$id.json.part").renameTo(File(dir, "$id.json")) + } catch (e: Exception) { + android.util.Log.w("AgentCallReceiver", "could not write the answer to $method: ${e.message}") } finally { pending.finish() } From 7c89c14a84e1fa2a6d3ff0a1ce149a8c671cce4a Mon Sep 17 00:00:00 2001 From: Kurt Himebauch <136133082+xXJSONDeruloXx@users.noreply.github.com> Date: Mon, 9 Nov 2026 12:48:00 -0500 Subject: [PATCH 15/33] fix(dual-screen): drop the Dashboard notifications page and its notification listener (#2342) * fix(dual-screen): drop the Dashboard notifications page and its notification listener * test(session): freeze the clock in the memory-pressure policy harness --- app/src/main/AndroidManifest.xml | 8 -- .../com/droiddeck/launcher/SessionActivity.kt | 43 ------- .../launcher/session/BottomNotifications.kt | 60 --------- .../launcher/ui/BottomNotificationsPage.kt | 116 ------------------ .../com/droiddeck/launcher/ui/BottomPages.kt | 31 ++++- app/src/main/res/values-ar/strings.xml | 7 -- app/src/main/res/values-b+es+419/strings.xml | 7 -- app/src/main/res/values-bg/strings.xml | 7 -- app/src/main/res/values-cs/strings.xml | 7 -- app/src/main/res/values-da/strings.xml | 7 -- app/src/main/res/values-de/strings.xml | 7 -- app/src/main/res/values-el/strings.xml | 7 -- app/src/main/res/values-es/strings.xml | 7 -- app/src/main/res/values-fi/strings.xml | 7 -- app/src/main/res/values-fr/strings.xml | 7 -- app/src/main/res/values-hi/strings.xml | 7 -- app/src/main/res/values-hu/strings.xml | 7 -- app/src/main/res/values-in/strings.xml | 7 -- app/src/main/res/values-it/strings.xml | 7 -- app/src/main/res/values-ja/strings.xml | 7 -- app/src/main/res/values-ko/strings.xml | 7 -- app/src/main/res/values-ms/strings.xml | 7 -- app/src/main/res/values-nb/strings.xml | 7 -- app/src/main/res/values-nl/strings.xml | 7 -- app/src/main/res/values-pl/strings.xml | 7 -- app/src/main/res/values-pt-rPT/strings.xml | 7 -- app/src/main/res/values-pt/strings.xml | 7 -- app/src/main/res/values-ro/strings.xml | 7 -- app/src/main/res/values-ru/strings.xml | 7 -- app/src/main/res/values-sv/strings.xml | 7 -- app/src/main/res/values-th/strings.xml | 7 -- app/src/main/res/values-tr/strings.xml | 7 -- app/src/main/res/values-uk/strings.xml | 7 -- app/src/main/res/values-vi/strings.xml | 7 -- app/src/main/res/values-zh-rCN/strings.xml | 7 -- app/src/main/res/values-zh-rHK/strings.xml | 7 -- app/src/main/res/values-zh-rTW/strings.xml | 7 -- app/src/main/res/values/strings.xml | 7 -- .../session/BottomNotificationsTest.kt | 38 ------ .../launcher/ui/BottomDashboardTest.kt | 2 +- tools/tests/test_memory_pressure.py | 4 +- 41 files changed, 31 insertions(+), 502 deletions(-) delete mode 100644 app/src/main/java/com/droiddeck/launcher/session/BottomNotifications.kt delete mode 100644 app/src/main/java/com/droiddeck/launcher/ui/BottomNotificationsPage.kt delete mode 100644 app/src/test/java/com/droiddeck/launcher/session/BottomNotificationsTest.kt diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml index 968aed7d0..d5bb0dc0d 100644 --- a/app/src/main/AndroidManifest.xml +++ b/app/src/main/AndroidManifest.xml @@ -91,14 +91,6 @@ - - - - - diff --git a/app/src/main/java/com/droiddeck/launcher/SessionActivity.kt b/app/src/main/java/com/droiddeck/launcher/SessionActivity.kt index 45a831f95..e102b1e12 100644 --- a/app/src/main/java/com/droiddeck/launcher/SessionActivity.kt +++ b/app/src/main/java/com/droiddeck/launcher/SessionActivity.kt @@ -3445,50 +3445,9 @@ class SessionActivity : ComponentActivity(), SurfaceHolder.Callback { private fun bottomPages(): List = sessionBottomPages(BottomPageActions( takeScreenshot = ::captureScreenshot, fps = { if (::hud.isInitialized) hud.liveFps else null }, - openSettings = ::openSettingsBelow, runningGame = { gestureGame }, )) - private fun openSettingsBelow(intent: android.content.Intent, done: () -> Boolean) { - val presentation = secondScreenPresentation - if (presentation == null) { - if (!bottomOutputActive) return - listOf(intent, android.content.Intent(android.provider.Settings.ACTION_NOTIFICATION_LISTENER_SETTINGS)).any { - runCatching { startActivity(android.content.Intent(it)) } - .onFailure { e -> Log.w(TAG, "could not open ${it.action}", e) } - .isSuccess - } - return - } - val options = android.app.ActivityOptions.makeBasic().setLaunchDisplayId(presentation.display.displayId) - val opened = listOf(intent, android.content.Intent(android.provider.Settings.ACTION_NOTIFICATION_LISTENER_SETTINGS)).any { - runCatching { startActivity(android.content.Intent(it).addFlags(android.content.Intent.FLAG_ACTIVITY_NEW_TASK), options.toBundle()) } - .onFailure { e -> Log.w(TAG, "could not open ${it.action} on display ${presentation.display.displayId}", e) } - .isSuccess - } - if (!opened) return - presentation.hide() - var checks = 0 - fun showAgain() { - try { - presentation.show() - } catch (e: Exception) { - Log.w(TAG, "could not show the dashboard again", e) - if (secondScreenPresentation === presentation) closeSecondScreen(reset = true) - } - } - uiHandler.postDelayed(object : Runnable { - override fun run() { - if (secondScreenPresentation !== presentation || presentation.isShowing) return - if (done()) { - showAgain() - return - } - if (++checks < SETTINGS_WATCH_CHECKS) uiHandler.postDelayed(this, SETTINGS_WATCH_MS) else showAgain() - } - }, SETTINGS_WATCH_MS) - } - private fun captureScreenshot(onDone: (Boolean) -> Unit) { val source = if (glassesOutputActive) glassesScreen?.surfaceView else if (bottomOutputActive) bottomGame?.surfaceView else if (::surfaceView.isInitialized) surfaceView else null @@ -5470,8 +5429,6 @@ class SessionActivity : ComponentActivity(), SurfaceHolder.Callback { private const val TEXT_FIELD_TAP_MS = 1_000L private const val PAUSED_FRAME_MAX_EDGE = 1920f private const val PAUSED_FRAME_REDRAW_MS = 150L - private const val SETTINGS_WATCH_MS = 1_000L - private const val SETTINGS_WATCH_CHECKS = 600 private const val BOTTOM_RECOVERY_MS = 1_000L private const val BOTTOM_RECOVERY_TRIES = 3 private const val TAG = "SessionActivity" diff --git a/app/src/main/java/com/droiddeck/launcher/session/BottomNotifications.kt b/app/src/main/java/com/droiddeck/launcher/session/BottomNotifications.kt deleted file mode 100644 index 45e0890c5..000000000 --- a/app/src/main/java/com/droiddeck/launcher/session/BottomNotifications.kt +++ /dev/null @@ -1,60 +0,0 @@ -package com.droiddeck.launcher.session - -import android.app.Notification -import android.content.Context -import android.service.notification.NotificationListenerService -import android.service.notification.StatusBarNotification -import androidx.compose.runtime.mutableStateListOf -import androidx.core.app.NotificationManagerCompat - -data class BottomNotification(val key: String, val app: String, val title: String, val text: String, val postedAt: Long) - -object BottomNotifications { - val items = mutableStateListOf() - - fun granted(context: Context): Boolean = - context.packageName in NotificationManagerCompat.getEnabledListenerPackages(context) - - internal fun keep(own: String, pkg: String, ongoing: Boolean, groupSummary: Boolean, title: String, text: String): Boolean = - pkg != own && !ongoing && !groupSummary && (title.isNotBlank() || text.isNotBlank()) - - internal fun add(list: MutableList, item: BottomNotification) { - list.removeAll { it.key == item.key } - list.add(0, item) - while (list.size > MAX_ITEMS) list.removeAt(list.lastIndex) - } - - fun clear() = items.clear() - - internal fun post(context: Context, sbn: StatusBarNotification) { - val n = sbn.notification ?: return - val extras = n.extras - val title = extras?.getCharSequence(Notification.EXTRA_TITLE)?.toString().orEmpty() - val text = (extras?.getCharSequence(Notification.EXTRA_BIG_TEXT) ?: extras?.getCharSequence(Notification.EXTRA_TEXT))?.toString().orEmpty() - if (!keep(context.packageName, sbn.packageName, sbn.isOngoing, (n.flags and Notification.FLAG_GROUP_SUMMARY) != 0, title, text)) return - val app = runCatching { - val pm = context.packageManager - pm.getApplicationLabel(pm.getApplicationInfo(sbn.packageName, 0)).toString() - }.getOrDefault(sbn.packageName) - add(items, BottomNotification(sbn.key, app, title, text, sbn.postTime)) - } - - internal fun remove(key: String) { - items.removeAll { it.key == key } - } - - private const val MAX_ITEMS = 30 -} - -class BottomNotificationListener : NotificationListenerService() { - override fun onListenerConnected() { - BottomNotifications.clear() - runCatching { activeNotifications }.getOrNull()?.sortedBy { it.postTime }?.forEach { BottomNotifications.post(this, it) } - } - - override fun onNotificationPosted(sbn: StatusBarNotification) = BottomNotifications.post(this, sbn) - - override fun onNotificationRemoved(sbn: StatusBarNotification) = BottomNotifications.remove(sbn.key) - - override fun onListenerDisconnected() = BottomNotifications.clear() -} diff --git a/app/src/main/java/com/droiddeck/launcher/ui/BottomNotificationsPage.kt b/app/src/main/java/com/droiddeck/launcher/ui/BottomNotificationsPage.kt deleted file mode 100644 index 266cb5a59..000000000 --- a/app/src/main/java/com/droiddeck/launcher/ui/BottomNotificationsPage.kt +++ /dev/null @@ -1,116 +0,0 @@ -package com.droiddeck.launcher.ui - -import android.content.Intent -import android.provider.Settings -import androidx.compose.foundation.background -import androidx.compose.foundation.clickable -import androidx.compose.foundation.layout.Arrangement -import androidx.compose.foundation.layout.Box -import androidx.compose.foundation.layout.Column -import androidx.compose.foundation.layout.Row -import androidx.compose.foundation.layout.fillMaxSize -import androidx.compose.foundation.layout.fillMaxWidth -import androidx.compose.foundation.layout.height -import androidx.compose.foundation.layout.padding -import androidx.compose.foundation.lazy.LazyColumn -import androidx.compose.foundation.lazy.items -import androidx.compose.foundation.shape.RoundedCornerShape -import androidx.compose.material3.MaterialTheme -import androidx.compose.material3.Text -import androidx.compose.runtime.Composable -import androidx.compose.runtime.LaunchedEffect -import androidx.compose.runtime.getValue -import androidx.compose.runtime.mutableStateOf -import androidx.compose.runtime.remember -import androidx.compose.runtime.setValue -import androidx.compose.ui.Alignment -import androidx.compose.ui.Modifier -import androidx.compose.ui.draw.clip -import androidx.compose.ui.platform.LocalContext -import androidx.compose.ui.platform.testTag -import androidx.compose.ui.res.stringResource -import androidx.compose.ui.semantics.Role -import androidx.compose.ui.text.font.FontWeight -import androidx.compose.ui.text.style.TextAlign -import androidx.compose.ui.text.style.TextOverflow -import androidx.compose.ui.unit.dp -import androidx.compose.ui.unit.sp -import com.droiddeck.launcher.R -import com.droiddeck.launcher.session.BottomNotifications -import kotlinx.coroutines.delay - -internal fun notificationAccessIntent(context: android.content.Context): Intent = - if (android.os.Build.VERSION.SDK_INT >= android.os.Build.VERSION_CODES.R) { - Intent(Settings.ACTION_NOTIFICATION_LISTENER_DETAIL_SETTINGS).putExtra( - Settings.EXTRA_NOTIFICATION_LISTENER_COMPONENT_NAME, - android.content.ComponentName(context, com.droiddeck.launcher.session.BottomNotificationListener::class.java).flattenToString(), - ) - } else Intent(Settings.ACTION_NOTIFICATION_LISTENER_SETTINGS) - -@Composable -fun BottomNotificationsPage(openSettings: ((Intent, () -> Boolean) -> Unit)?) { - val context = LocalContext.current - val colors = MaterialTheme.colorScheme - val pal = LocalPalette.current - var granted by remember { mutableStateOf(BottomNotifications.granted(context)) } - LaunchedEffect(Unit) { - while (true) { - granted = BottomNotifications.granted(context) - delay(2_000L) - } - } - if (!granted) { - Column(Modifier.fillMaxSize().padding(24.dp), horizontalAlignment = Alignment.CenterHorizontally, - verticalArrangement = Arrangement.spacedBy(16.dp, Alignment.CenterVertically)) { - Text(stringResource(R.string.bottom_notifications_why), fontSize = 15.sp, color = colors.onBackground, textAlign = TextAlign.Center) - BottomPillButton(stringResource(R.string.bottom_notifications_allow), "bottom-notifications-allow") { - val intent = notificationAccessIntent(context) - if (openSettings != null) openSettings(intent) { BottomNotifications.granted(context) } - else runCatching { context.startActivity(intent.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK)) } - } - } - return - } - val shown = BottomNotifications.items - if (shown.isEmpty()) { - Box(Modifier.fillMaxSize(), contentAlignment = Alignment.Center) { - Text(stringResource(R.string.bottom_notifications_none), fontSize = 15.sp, color = colors.onSurfaceVariant) - } - return - } - val timeFormat = remember { android.text.format.DateFormat.getTimeFormat(context) } - Column(Modifier.fillMaxSize(), verticalArrangement = Arrangement.spacedBy(8.dp)) { - Row(Modifier.fillMaxWidth(), verticalAlignment = Alignment.CenterVertically) { - Text(stringResource(R.string.bottom_notifications_count, shown.size), fontSize = 13.sp, color = colors.onSurfaceVariant, - modifier = Modifier.weight(1f)) - BottomPillButton(stringResource(R.string.bottom_notifications_clear), "bottom-notifications-clear") { BottomNotifications.clear() } - } - LazyColumn(Modifier.weight(1f).fillMaxWidth(), verticalArrangement = Arrangement.spacedBy(8.dp)) { - items(shown.toList(), key = { it.key }) { n -> - Column(Modifier.fillMaxWidth().clip(RoundedCornerShape(14.dp)).background(pal.surface).padding(horizontal = 14.dp, vertical = 10.dp), - verticalArrangement = Arrangement.spacedBy(4.dp)) { - Row(verticalAlignment = Alignment.CenterVertically) { - Text(n.app, fontSize = 12.sp, fontWeight = FontWeight.SemiBold, color = pal.signal, maxLines = 1, - overflow = TextOverflow.Ellipsis, modifier = Modifier.weight(1f)) - Text(timeFormat.format(java.util.Date(n.postedAt)), fontSize = 12.sp, color = colors.onSurfaceVariant) - } - if (n.title.isNotBlank()) Text(n.title, fontSize = 15.sp, fontWeight = FontWeight.SemiBold, color = colors.onBackground, - maxLines = 1, overflow = TextOverflow.Ellipsis) - if (n.text.isNotBlank()) Text(n.text, fontSize = 14.sp, color = colors.onBackground, maxLines = 4, overflow = TextOverflow.Ellipsis) - } - } - } - } -} - -@Composable -internal fun BottomPillButton(label: String, tag: String, onClick: () -> Unit) { - val pal = LocalPalette.current - Box( - Modifier.height(40.dp).clip(RoundedCornerShape(20.dp)).background(pal.signal.copy(alpha = 0.16f)) - .clickable(role = Role.Button, onClick = onClick).testTag(tag).padding(horizontal = 16.dp), - contentAlignment = Alignment.Center, - ) { - Text(label, fontSize = 14.sp, fontWeight = FontWeight.SemiBold, color = pal.signal, maxLines = 1) - } -} diff --git a/app/src/main/java/com/droiddeck/launcher/ui/BottomPages.kt b/app/src/main/java/com/droiddeck/launcher/ui/BottomPages.kt index a8884fdec..2d2e401fa 100644 --- a/app/src/main/java/com/droiddeck/launcher/ui/BottomPages.kt +++ b/app/src/main/java/com/droiddeck/launcher/ui/BottomPages.kt @@ -1,17 +1,31 @@ package com.droiddeck.launcher.ui +import androidx.compose.foundation.background +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.material.icons.Icons import androidx.compose.material.icons.outlined.Download import androidx.compose.material.icons.outlined.Forum import androidx.compose.material.icons.outlined.Speed -import androidx.compose.material.icons.outlined.Notifications import androidx.compose.material.icons.outlined.PhotoLibrary +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.platform.testTag +import androidx.compose.ui.semantics.Role +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp import com.droiddeck.launcher.R class BottomPageActions( val takeScreenshot: ((onDone: (Boolean) -> Unit) -> Unit)? = null, val fps: () -> Float? = { null }, - val openSettings: ((intent: android.content.Intent, done: () -> Boolean) -> Unit)? = null, val runningGame: () -> String? = { null }, ) @@ -20,7 +34,18 @@ internal const val BOTTOM_PAGE_TELEMETRY = "telemetry" fun sessionBottomPages(actions: BottomPageActions): List = listOf( BottomPage(BOTTOM_PAGE_TELEMETRY, R.string.bottom_page_telemetry, Icons.Outlined.Speed) { BottomTelemetryPage(actions.fps, actions.runningGame) }, BottomPage("downloads", R.string.bottom_page_downloads, Icons.Outlined.Download) { BottomDownloadsPage() }, - BottomPage("notifications", R.string.bottom_page_notifications, Icons.Outlined.Notifications) { BottomNotificationsPage(actions.openSettings) }, BottomPage("screenshots", R.string.bottom_page_screenshots, Icons.Outlined.PhotoLibrary) { BottomScreenshotsPage(actions.takeScreenshot) }, BottomPage("chat", R.string.bottom_page_chat, Icons.Outlined.Forum) { BottomChatPage() }, ) + +@Composable +internal fun BottomPillButton(label: String, tag: String, onClick: () -> Unit) { + val pal = LocalPalette.current + Box( + Modifier.height(40.dp).clip(RoundedCornerShape(20.dp)).background(pal.signal.copy(alpha = 0.16f)) + .clickable(role = Role.Button, onClick = onClick).testTag(tag).padding(horizontal = 16.dp), + contentAlignment = Alignment.Center, + ) { + Text(label, fontSize = 14.sp, fontWeight = FontWeight.SemiBold, color = pal.signal, maxLines = 1) + } +} diff --git a/app/src/main/res/values-ar/strings.xml b/app/src/main/res/values-ar/strings.xml index 86335b1ca..d6a881d86 100644 --- a/app/src/main/res/values-ar/strings.xml +++ b/app/src/main/res/values-ar/strings.xml @@ -1873,13 +1873,6 @@ جارٍ تنزيل ذاكرة المظلِّلات المؤقتة متوقف مؤقتًا بانتظار البدء - الإشعارات - الشاشة السفلية لـ DroidDeck - اعرض الرسائل والإشعارات الأخرى هنا أثناء اللعب. تبقى على هذه الشاشة فقط ولا تُحفظ. يطلب Android الإذن مرة واحدة: تُفتح صفحة الوصول إلى الإشعارات على هذه الشاشة؛ فعِّل DroidDeck هناك وستعود هذه الصفحة تلقائيًا. - السماح بالوصول إلى الإشعارات - لا توجد إشعارات. - الإشعارات: %1$d - مسح لقطات الشاشة يتطلب FPS تشغيل واجهة الأداء السرعة diff --git a/app/src/main/res/values-b+es+419/strings.xml b/app/src/main/res/values-b+es+419/strings.xml index 29cefed94..d59c5e43c 100644 --- a/app/src/main/res/values-b+es+419/strings.xml +++ b/app/src/main/res/values-b+es+419/strings.xml @@ -1865,13 +1865,6 @@ Descargando caché de shaders En pausa Esperando para empezar - Notificaciones - DroidDeck – pantalla inferior - Muestra aquí los mensajes y otras notificaciones mientras juegas. Solo permanecen en esta pantalla y no se guardan. Android lo pregunta una vez: su página de acceso a notificaciones se abre en esta pantalla; activa DroidDeck ahí y esta página volverá sola. - Permitir acceso a notificaciones - No hay notificaciones. - %1$d notificaciones - Borrar Capturas de pantalla Los FPS requieren el HUD activado Velocidad diff --git a/app/src/main/res/values-bg/strings.xml b/app/src/main/res/values-bg/strings.xml index be31cce01..977de45c9 100644 --- a/app/src/main/res/values-bg/strings.xml +++ b/app/src/main/res/values-bg/strings.xml @@ -1777,13 +1777,6 @@ Изтегляне на кеша на шейдърите На пауза Изчаква стартиране - Известия - Долен екран на DroidDeck - Показвайте тук съобщения и други известия, докато играете. Те остават само на този екран и не се запазват. Android пита веднъж: страницата за достъп до известията се отваря на този екран; включете DroidDeck там и тази страница ще се върне сама. - Разрешаване на достъпа до известията - Няма известия. - Известия: %1$d - Изчисти Екранни снимки За FPS трябва HUD да е включен Скорост diff --git a/app/src/main/res/values-cs/strings.xml b/app/src/main/res/values-cs/strings.xml index 1c5f845fa..cb16b5439 100644 --- a/app/src/main/res/values-cs/strings.xml +++ b/app/src/main/res/values-cs/strings.xml @@ -1825,13 +1825,6 @@ Stahování mezipaměti shaderů Pozastaveno Čeká na spuštění - Oznámení - Spodní obrazovka DroidDeck - Zobrazujte zde zprávy a další oznámení během hraní. Zůstávají jen na této obrazovce a neukládají se. Android se zeptá jednou: na této obrazovce se otevře jeho stránka přístupu k oznámením; zapněte tam DroidDeck a tato stránka se sama vrátí. - Povolit přístup k oznámením - Žádná oznámení. - Oznámení: %1$d - Vymazat Snímky obrazovky FPS vyžaduje zapnutý HUD Rychlost diff --git a/app/src/main/res/values-da/strings.xml b/app/src/main/res/values-da/strings.xml index 366cccbcc..aa31a9c5a 100644 --- a/app/src/main/res/values-da/strings.xml +++ b/app/src/main/res/values-da/strings.xml @@ -1777,13 +1777,6 @@ Downloader shader-cache På pause Venter på at starte - Notifikationer - DroidDecks nederste skærm - Vis beskeder og andre notifikationer her, mens du spiller. De bliver kun på denne skærm og gemmes ikke. Android spørger én gang: siden for notifikationsadgang åbnes på denne skærm; slå DroidDeck til der, så vender denne side selv tilbage. - Tillad notifikationsadgang - Ingen notifikationer. - %1$d notifikationer - Ryd Skærmbilleder FPS kræver, at HUD er slået til Hastighed diff --git a/app/src/main/res/values-de/strings.xml b/app/src/main/res/values-de/strings.xml index 053e9ec66..a7e1fa032 100644 --- a/app/src/main/res/values-de/strings.xml +++ b/app/src/main/res/values-de/strings.xml @@ -1769,13 +1769,6 @@ Shader-Cache wird heruntergeladen Pausiert Wartet auf Start - Benachrichtigungen - DroidDeck – unterer Bildschirm - Zeigt Nachrichten und andere Benachrichtigungen hier an, während du spielst. Sie bleiben nur auf diesem Bildschirm und werden nicht gespeichert. Android fragt einmal: Die Seite für den Benachrichtigungszugriff öffnet sich auf diesem Bildschirm; aktiviere dort DroidDeck, dann kehrt diese Seite von selbst zurück. - Benachrichtigungszugriff erlauben - Keine Benachrichtigungen. - %1$d Benachrichtigungen - Löschen Screenshots FPS benötigt das aktive HUD Geschwindigkeit diff --git a/app/src/main/res/values-el/strings.xml b/app/src/main/res/values-el/strings.xml index 50cac5c80..38aeb85ee 100644 --- a/app/src/main/res/values-el/strings.xml +++ b/app/src/main/res/values-el/strings.xml @@ -1777,13 +1777,6 @@ Λήψη cache shader Σε παύση Αναμονή για έναρξη - Ειδοποιήσεις - DroidDeck – κάτω οθόνη - Εμφάνιση μηνυμάτων και άλλων ειδοποιήσεων εδώ ενώ παίζετε. Παραμένουν μόνο σε αυτή την οθόνη και δεν αποθηκεύονται. Το Android ρωτά μία φορά: η σελίδα πρόσβασης ειδοποιήσεων ανοίγει σε αυτή την οθόνη· ενεργοποιήστε εκεί το DroidDeck και αυτή η σελίδα επιστρέφει μόνη της. - Αποδοχή πρόσβασης ειδοποιήσεων - Δεν υπάρχουν ειδοποιήσεις. - %1$d ειδοποιήσεις - Απαλοιφή Στιγμιότυπα οθόνης Τα FPS απαιτούν ενεργό HUD Ταχύτητα diff --git a/app/src/main/res/values-es/strings.xml b/app/src/main/res/values-es/strings.xml index 76f1b9965..af9618bde 100644 --- a/app/src/main/res/values-es/strings.xml +++ b/app/src/main/res/values-es/strings.xml @@ -1801,13 +1801,6 @@ Descargando caché de shaders En pausa Esperando para empezar - Notificaciones - DroidDeck – pantalla inferior - Muestra aquí los mensajes y otras notificaciones mientras juegas. Solo permanecen en esta pantalla y no se guardan. Android lo pregunta una vez: su página de acceso a notificaciones se abre en esta pantalla; activa DroidDeck ahí y esta página volverá sola. - Permitir acceso a notificaciones - No hay notificaciones. - %1$d notificaciones - Borrar Capturas de pantalla Los FPS requieren el HUD activado Velocidad diff --git a/app/src/main/res/values-fi/strings.xml b/app/src/main/res/values-fi/strings.xml index 7f14a6785..364725062 100644 --- a/app/src/main/res/values-fi/strings.xml +++ b/app/src/main/res/values-fi/strings.xml @@ -1777,13 +1777,6 @@ Ladataan sävytinvälimuistia Keskeytetty Odottaa alkamista - Ilmoitukset - DroidDeckin alanäyttö - Näytä viestit ja muut ilmoitukset täällä pelatessasi. Ne näkyvät vain tällä näytöllä, eikä niitä tallenneta. Android kysyy kerran: sen ilmoitusten käyttöoikeussivu avautuu tälle näytölle; kytke DroidDeck siellä päälle, niin tämä sivu palaa itsestään. - Salli ilmoitusten käyttö - Ei ilmoituksia. - Ilmoituksia: %1$d - Tyhjennä Kuvakaappaukset FPS vaatii HUD:n päälle Nopeus diff --git a/app/src/main/res/values-fr/strings.xml b/app/src/main/res/values-fr/strings.xml index 474c55514..57cf1c900 100644 --- a/app/src/main/res/values-fr/strings.xml +++ b/app/src/main/res/values-fr/strings.xml @@ -1801,13 +1801,6 @@ Téléchargement du cache de shaders En pause En attente de démarrage - Notifications - Écran inférieur DroidDeck - Affichez ici les messages et autres notifications pendant que vous jouez. Ils restent uniquement sur cet écran et ne sont pas enregistrés. Android ne le demande qu\'une fois : sa page d\'accès aux notifications s\'ouvre sur cet écran ; activez-y DroidDeck et cette page reviendra d\'elle-même. - Autoriser l\'accès aux notifications - Aucune notification. - %1$d notifications - Effacer Captures d\'écran Les FPS nécessitent d\'activer le HUD Vitesse diff --git a/app/src/main/res/values-hi/strings.xml b/app/src/main/res/values-hi/strings.xml index 8aa4f18a4..491de8723 100644 --- a/app/src/main/res/values-hi/strings.xml +++ b/app/src/main/res/values-hi/strings.xml @@ -1777,13 +1777,6 @@ शेडर कैश डाउनलोड हो रहा है रुका हुआ शुरू होने का इंतज़ार - नोटिफ़िकेशन - DroidDeck नीचे की स्क्रीन - खेलते समय मैसेज और अन्य नोटिफ़िकेशन यहां दिखाएं। वे सिर्फ़ इसी स्क्रीन पर रहते हैं और सेव नहीं होते। Android एक बार पूछता है: उसका नोटिफ़िकेशन ऐक्सेस पेज इसी स्क्रीन पर खुलता है; वहां DroidDeck चालू करें और यह पेज अपने-आप वापस आ जाएगा। - नोटिफ़िकेशन ऐक्सेस की अनुमति दें - कोई नोटिफ़िकेशन नहीं। - %1$d नोटिफ़िकेशन - साफ़ करें स्क्रीनशॉट FPS के लिए HUD चालू होना चाहिए स्पीड diff --git a/app/src/main/res/values-hu/strings.xml b/app/src/main/res/values-hu/strings.xml index 718dc4cec..e0f3a2e1a 100644 --- a/app/src/main/res/values-hu/strings.xml +++ b/app/src/main/res/values-hu/strings.xml @@ -1777,13 +1777,6 @@ Shader-gyorsítótár letöltése Szüneteltetve Indításra vár - Értesítések - DroidDeck alsó képernyő - Itt jelenítheti meg az üzeneteket és más értesítéseket játék közben. Csak ezen a képernyőn láthatók, és nem kerülnek mentésre. Az Android egyszer kérdez rá: az értesítési hozzáférés oldala ezen a képernyőn nyílik meg; kapcsolja be ott a DroidDecket, és ez az oldal magától visszatér. - Értesítési hozzáférés engedélyezése - Nincsenek értesítések. - %1$d értesítés - Törlés Képernyőképek Az FPS-hez be kell kapcsolni a HUD-ot Sebesség diff --git a/app/src/main/res/values-in/strings.xml b/app/src/main/res/values-in/strings.xml index d5b27181a..65290c5f1 100644 --- a/app/src/main/res/values-in/strings.xml +++ b/app/src/main/res/values-in/strings.xml @@ -1753,13 +1753,6 @@ Mengunduh cache shader Dijeda Menunggu dimulai - Notifikasi - Layar bawah DroidDeck - Tampilkan pesan dan notifikasi lain di sini saat Anda bermain. Notifikasi hanya muncul di layar ini dan tidak disimpan. Android hanya meminta sekali: halaman akses notifikasinya terbuka di layar ini; aktifkan DroidDeck di sana dan halaman ini akan kembali dengan sendirinya. - Izinkan akses notifikasi - Tidak ada notifikasi. - %1$d notifikasi - Hapus Tangkapan layar FPS memerlukan HUD aktif Kecepatan diff --git a/app/src/main/res/values-it/strings.xml b/app/src/main/res/values-it/strings.xml index d1cb231a9..62503c51d 100644 --- a/app/src/main/res/values-it/strings.xml +++ b/app/src/main/res/values-it/strings.xml @@ -1793,13 +1793,6 @@ Download della cache degli shader In pausa In attesa di avvio - Notifiche - Schermo inferiore DroidDeck - Mostra qui messaggi e altre notifiche mentre giochi. Restano solo su questo schermo e non vengono salvate. Android lo chiede una sola volta: la sua pagina di accesso alle notifiche si apre su questo schermo; attiva DroidDeck lì e questa pagina torna da sola. - Consenti accesso alle notifiche - Nessuna notifica. - %1$d notifiche - Cancella Screenshot Gli FPS richiedono l\'HUD attivo Velocità diff --git a/app/src/main/res/values-ja/strings.xml b/app/src/main/res/values-ja/strings.xml index bc6e951db..2c9db1526 100644 --- a/app/src/main/res/values-ja/strings.xml +++ b/app/src/main/res/values-ja/strings.xml @@ -1753,13 +1753,6 @@ シェーダーキャッシュをダウンロード中 一時停止中 開始待ち - 通知 - DroidDeck下部画面 - プレイ中、メッセージなどの通知をここに表示します。通知はこの画面にのみ表示され、保存されません。Androidによる確認は一度だけです。この画面に通知へのアクセスのページが開くので、そこでDroidDeckをオンにすると、このページに自動的に戻ります。 - 通知へのアクセスを許可 - 通知はありません。 - %1$d件の通知 - クリア スクリーンショット FPSの表示にはHUDをオンにする必要があります 速度 diff --git a/app/src/main/res/values-ko/strings.xml b/app/src/main/res/values-ko/strings.xml index 1276d4fee..1efb8cfc9 100644 --- a/app/src/main/res/values-ko/strings.xml +++ b/app/src/main/res/values-ko/strings.xml @@ -1753,13 +1753,6 @@ 셰이더 캐시 다운로드 중 일시 중지됨 시작 대기 중 - 알림 - DroidDeck 하단 화면 - 플레이하는 동안 메시지와 기타 알림을 여기에 표시합니다. 알림은 이 화면에만 표시되며 저장되지 않습니다. Android에서 한 번만 묻습니다. 이 화면에 알림 접근 권한 페이지가 열리면 DroidDeck을 켜세요. 그러면 이 페이지로 자동으로 돌아옵니다. - 알림 접근 허용 - 알림이 없습니다. - 알림 %1$d개 - 지우기 스크린샷 FPS를 보려면 HUD를 켜야 합니다 속도 diff --git a/app/src/main/res/values-ms/strings.xml b/app/src/main/res/values-ms/strings.xml index ca2a29f59..185b46704 100644 --- a/app/src/main/res/values-ms/strings.xml +++ b/app/src/main/res/values-ms/strings.xml @@ -1753,13 +1753,6 @@ Memuat turun cache shader Dijeda Menunggu untuk bermula - Pemberitahuan - Skrin bawah DroidDeck - Paparkan mesej dan pemberitahuan lain di sini semasa anda bermain. Ia hanya kekal pada skrin ini dan tidak disimpan. Android bertanya sekali sahaja: halaman akses pemberitahuannya dibuka pada skrin ini; hidupkan DroidDeck di sana dan halaman ini akan kembali dengan sendirinya. - Benarkan akses pemberitahuan - Tiada pemberitahuan. - %1$d pemberitahuan - Kosongkan Tangkapan skrin FPS memerlukan HUD dihidupkan Kelajuan diff --git a/app/src/main/res/values-nb/strings.xml b/app/src/main/res/values-nb/strings.xml index 7f63bd9fe..a805b2d63 100644 --- a/app/src/main/res/values-nb/strings.xml +++ b/app/src/main/res/values-nb/strings.xml @@ -1777,13 +1777,6 @@ Laster ned shaderbuffer Pauset Venter på å starte - Varslinger - DroidDeck nedre skjerm - Vis meldinger og andre varslinger her mens du spiller. De vises bare på denne skjermen og lagres ikke. Android spør én gang: siden for varseltilgang åpnes på denne skjermen; slå på DroidDeck der, så kommer denne siden tilbake av seg selv. - Tillat varseltilgang - Ingen varslinger. - %1$d varslinger - Fjern Skjermbilder FPS krever at HUD-en er på Fart diff --git a/app/src/main/res/values-nl/strings.xml b/app/src/main/res/values-nl/strings.xml index 2be322aa3..e99884948 100644 --- a/app/src/main/res/values-nl/strings.xml +++ b/app/src/main/res/values-nl/strings.xml @@ -1777,13 +1777,6 @@ Shadercache downloaden Gepauzeerd Wacht om te starten - Meldingen - DroidDeck onderste scherm - Toon berichten en andere meldingen hier terwijl je speelt. Ze blijven alleen op dit scherm en worden niet opgeslagen. Android vraagt dit één keer: de pagina voor meldingstoegang opent op dit scherm; zet DroidDeck daar aan en deze pagina komt vanzelf terug. - Meldingstoegang toestaan - Geen meldingen. - %1$d meldingen - Wissen Screenshots Voor FPS moet de HUD aan staan Snelheid diff --git a/app/src/main/res/values-pl/strings.xml b/app/src/main/res/values-pl/strings.xml index 15ae33c69..29eef5dd3 100644 --- a/app/src/main/res/values-pl/strings.xml +++ b/app/src/main/res/values-pl/strings.xml @@ -1825,13 +1825,6 @@ Pobieranie pamięci podręcznej shaderów Wstrzymano Oczekuje na rozpoczęcie - Powiadomienia - Dolny ekran DroidDeck - Wyświetlaj tu wiadomości i inne powiadomienia podczas gry. Pozostają tylko na tym ekranie i nie są zapisywane. Android zapyta raz: na tym ekranie otworzy się jego strona dostępu do powiadomień; włącz tam DroidDeck, a ta strona wróci sama. - Zezwól na dostęp do powiadomień - Brak powiadomień. - Powiadomienia: %1$d - Wyczyść Zrzuty ekranu FPS wymaga włączonego HUD Prędkość diff --git a/app/src/main/res/values-pt-rPT/strings.xml b/app/src/main/res/values-pt-rPT/strings.xml index 49d0d83fb..562f659d6 100644 --- a/app/src/main/res/values-pt-rPT/strings.xml +++ b/app/src/main/res/values-pt-rPT/strings.xml @@ -1793,13 +1793,6 @@ A transferir a cache de shaders Em pausa A aguardar início - Notificações - Ecrã inferior do DroidDeck - Mostre aqui mensagens e outras notificações enquanto joga. Ficam apenas neste ecrã e não são guardadas. O Android pergunta uma vez: a página de acesso às notificações abre neste ecrã; ative lá o DroidDeck e esta página volta automaticamente. - Permitir acesso às notificações - Sem notificações. - %1$d notificações - Limpar Capturas de ecrã O FPS requer o HUD ativado Velocidade diff --git a/app/src/main/res/values-pt/strings.xml b/app/src/main/res/values-pt/strings.xml index 246f8d91e..b90660bb2 100644 --- a/app/src/main/res/values-pt/strings.xml +++ b/app/src/main/res/values-pt/strings.xml @@ -1793,13 +1793,6 @@ Baixando o cache de shaders Pausado Aguardando para começar - Notificações - Tela inferior do DroidDeck - Mostre aqui mensagens e outras notificações enquanto você joga. Elas ficam só nesta tela e não são salvas. O Android pergunta uma vez: a página de acesso a notificações dele abre nesta tela; ative o DroidDeck lá e esta página volta sozinha. - Permitir acesso a notificações - Nenhuma notificação. - %1$d notificações - Limpar Capturas de tela O FPS precisa do HUD ativado Velocidade diff --git a/app/src/main/res/values-ro/strings.xml b/app/src/main/res/values-ro/strings.xml index 210bc01b2..f39ab16a8 100644 --- a/app/src/main/res/values-ro/strings.xml +++ b/app/src/main/res/values-ro/strings.xml @@ -1801,13 +1801,6 @@ Se descarcă cache-ul de shadere Întrerupt Așteaptă să înceapă - Notificări - Ecranul de jos DroidDeck - Afișează aici mesajele și alte notificări în timp ce te joci. Rămân doar pe acest ecran și nu sunt salvate. Android întreabă o singură dată: pagina sa de acces la notificări se deschide pe acest ecran; activează DroidDeck acolo, iar această pagină revine singură. - Permite accesul la notificări - Nicio notificare. - Notificări: %1$d - Șterge Capturi de ecran FPS necesită HUD-ul activat Viteză diff --git a/app/src/main/res/values-ru/strings.xml b/app/src/main/res/values-ru/strings.xml index da56412f3..009fb80bd 100644 --- a/app/src/main/res/values-ru/strings.xml +++ b/app/src/main/res/values-ru/strings.xml @@ -1888,13 +1888,6 @@ Загрузка кэша шейдеров Приостановлено Ожидает начала - Уведомления - Нижний экран DroidDeck - Показывает сообщения и другие уведомления здесь, пока вы играете. Они отображаются только на этом экране и не сохраняются. Android спросит один раз: на этом экране откроется страница доступа к уведомлениям; включите там DroidDeck, и эта страница вернется сама. - Разрешить доступ к уведомлениям - Нет уведомлений. - Уведомлений: %1$d - Очистить Скриншоты Для FPS нужно включить HUD Скорость diff --git a/app/src/main/res/values-sv/strings.xml b/app/src/main/res/values-sv/strings.xml index a7eaaadac..61d0ef03e 100644 --- a/app/src/main/res/values-sv/strings.xml +++ b/app/src/main/res/values-sv/strings.xml @@ -1777,13 +1777,6 @@ Laddar ned shadercache Pausad Väntar på att starta - Aviseringar - DroidDecks nedre skärm - Visa meddelanden och andra aviseringar här medan du spelar. De visas bara på den här skärmen och sparas inte. Android frågar en gång: sidan för aviseringsåtkomst öppnas på den här skärmen; slå på DroidDeck där så kommer den här sidan tillbaka av sig själv. - Tillåt aviseringsåtkomst - Inga aviseringar. - %1$d aviseringar - Rensa Skärmbilder FPS kräver att HUD:en är på Hastighet diff --git a/app/src/main/res/values-th/strings.xml b/app/src/main/res/values-th/strings.xml index 3fe86755b..232654afe 100644 --- a/app/src/main/res/values-th/strings.xml +++ b/app/src/main/res/values-th/strings.xml @@ -1753,13 +1753,6 @@ กำลังดาวน์โหลดแคชเชเดอร์ หยุดชั่วคราว กำลังรอเริ่ม - การแจ้งเตือน - หน้าจอล่างของ DroidDeck - แสดงข้อความและการแจ้งเตือนอื่นๆ ที่นี่ขณะที่คุณเล่น การแจ้งเตือนจะแสดงบนหน้าจอนี้เท่านั้นและจะไม่ถูกบันทึก Android จะถามเพียงครั้งเดียว: หน้าการเข้าถึงการแจ้งเตือนจะเปิดขึ้นบนหน้าจอนี้ เปิด DroidDeck ที่นั่น แล้วหน้านี้จะกลับมาเอง - อนุญาตการเข้าถึงการแจ้งเตือน - ไม่มีการแจ้งเตือน - การแจ้งเตือน %1$d รายการ - ล้าง ภาพหน้าจอ ต้องเปิด HUD เพื่อแสดง FPS ความเร็ว diff --git a/app/src/main/res/values-tr/strings.xml b/app/src/main/res/values-tr/strings.xml index ea31f6fae..a76a2c434 100644 --- a/app/src/main/res/values-tr/strings.xml +++ b/app/src/main/res/values-tr/strings.xml @@ -1769,13 +1769,6 @@ Gölgelendirici önbelleği indiriliyor Duraklatıldı Başlamayı bekliyor - Bildirimler - DroidDeck alt ekranı - Oynarken mesajları ve diğer bildirimleri burada görün. Yalnızca bu ekranda kalırlar ve kaydedilmezler. Android bunu bir kez sorar: bildirim erişimi sayfası bu ekranda açılır; orada DroidDeck\'i açın, bu sayfa kendiliğinden geri gelir. - Bildirim erişimine izin ver - Bildirim yok. - %1$d bildirim - Temizle Ekran görüntüleri FPS için HUD açık olmalı Hız diff --git a/app/src/main/res/values-uk/strings.xml b/app/src/main/res/values-uk/strings.xml index cc0c3d72e..d7058b222 100644 --- a/app/src/main/res/values-uk/strings.xml +++ b/app/src/main/res/values-uk/strings.xml @@ -1825,13 +1825,6 @@ Завантаження кешу шейдерів Призупинено Очікує початку - Сповіщення - Нижній екран DroidDeck - Показуйте тут повідомлення та інші сповіщення під час гри. Вони лишаються лише на цьому екрані й не зберігаються. Android запитає один раз: сторінка доступу до сповіщень відкриється на цьому екрані; увімкніть там DroidDeck, і ця сторінка повернеться сама. - Дозволити доступ до сповіщень - Немає сповіщень. - Сповіщень: %1$d - Очистити Знімки екрана Для FPS потрібно ввімкнути HUD Швидкість diff --git a/app/src/main/res/values-vi/strings.xml b/app/src/main/res/values-vi/strings.xml index 1efb2844c..9146f9834 100644 --- a/app/src/main/res/values-vi/strings.xml +++ b/app/src/main/res/values-vi/strings.xml @@ -1753,13 +1753,6 @@ Đang tải bộ nhớ đệm shader Đã tạm dừng Đang chờ bắt đầu - Thông báo - Màn hình dưới DroidDeck - Hiển thị tin nhắn và các thông báo khác ở đây khi bạn chơi. Chúng chỉ ở trên màn hình này và không được lưu. Android chỉ hỏi một lần: trang quyền truy cập thông báo sẽ mở trên màn hình này; hãy bật DroidDeck ở đó và trang này sẽ tự quay lại. - Cho phép truy cập thông báo - Không có thông báo. - %1$d thông báo - Xóa Ảnh chụp màn hình Cần bật HUD để xem FPS Tốc độ diff --git a/app/src/main/res/values-zh-rCN/strings.xml b/app/src/main/res/values-zh-rCN/strings.xml index 458a3c83c..337c239ae 100644 --- a/app/src/main/res/values-zh-rCN/strings.xml +++ b/app/src/main/res/values-zh-rCN/strings.xml @@ -1831,13 +1831,6 @@ 正在下载着色器缓存 已暂停 等待开始 - 通知 - DroidDeck 底部屏幕 - 在游戏时于此处显示消息和其他通知。通知只显示在此屏幕上,不会被保存。Android 只会询问一次:其通知使用权页面会在此屏幕上打开,在那里开启 DroidDeck 后,会自动返回此页面。 - 允许通知使用权 - 没有通知。 - %1$d 条通知 - 清除 截图 显示 FPS 需要开启 HUD 速度 diff --git a/app/src/main/res/values-zh-rHK/strings.xml b/app/src/main/res/values-zh-rHK/strings.xml index ebb3580c0..306952037 100644 --- a/app/src/main/res/values-zh-rHK/strings.xml +++ b/app/src/main/res/values-zh-rHK/strings.xml @@ -1753,13 +1753,6 @@ 正在下載著色器快取 已暫停 等待開始 - 通知 - DroidDeck 下方螢幕 - 在遊玩時於此處顯示訊息和其他通知。通知只會顯示在這個螢幕上,不會儲存。Android 只會詢問一次:通知存取權頁面會在這個螢幕上開啟,在那裡開啟 DroidDeck 後,就會自動回到此頁面。 - 允許通知存取權 - 沒有通知。 - %1$d 則通知 - 清除 螢幕截圖 顯示 FPS 需要開啟 HUD 速度 diff --git a/app/src/main/res/values-zh-rTW/strings.xml b/app/src/main/res/values-zh-rTW/strings.xml index 0ebcdccd9..cd7c43967 100644 --- a/app/src/main/res/values-zh-rTW/strings.xml +++ b/app/src/main/res/values-zh-rTW/strings.xml @@ -1753,13 +1753,6 @@ 正在下載著色器快取 已暫停 等待開始 - 通知 - DroidDeck 底部螢幕 - 在遊玩時於此處顯示訊息和其他通知。通知只會顯示在這個螢幕上,不會儲存。Android 只會詢問一次:通知存取權頁面會在這個螢幕上開啟,在那裡開啟 DroidDeck 後,就會自動回到此頁面。 - 允許通知存取權 - 沒有通知。 - %1$d 則通知 - 清除 螢幕擷取畫面 顯示 FPS 需要開啟 HUD 速度 diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index a6abf2eff..b5e086775 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -1965,13 +1965,6 @@ Downloading shader cache Paused Waiting to start - Notifications - DroidDeck bottom screen - Show messages and other notifications here while you play. They stay on this screen only and are not saved. Android asks once: its notification access page opens on this screen; turn DroidDeck on there and this page comes back by itself. - Allow notification access - No notifications. - %1$d notifications - Clear Screenshots FPS needs the HUD on Speed diff --git a/app/src/test/java/com/droiddeck/launcher/session/BottomNotificationsTest.kt b/app/src/test/java/com/droiddeck/launcher/session/BottomNotificationsTest.kt deleted file mode 100644 index 19046dc27..000000000 --- a/app/src/test/java/com/droiddeck/launcher/session/BottomNotificationsTest.kt +++ /dev/null @@ -1,38 +0,0 @@ -package com.droiddeck.launcher.session - -import org.junit.Assert.assertEquals -import org.junit.Assert.assertFalse -import org.junit.Assert.assertTrue -import org.junit.Test - -class BottomNotificationsTest { - private val own = "com.droiddeck.launcher" - - @Test fun messagesFromOtherAppsAreKept() { - assertTrue(BottomNotifications.keep(own, "com.discord", ongoing = false, groupSummary = false, title = "Kurt", text = "ready?")) - } - - @Test fun ownOngoingSummaryAndEmptyNotificationsAreSkipped() { - assertFalse(BottomNotifications.keep(own, own, ongoing = false, groupSummary = false, title = "Session", text = "running")) - assertFalse(BottomNotifications.keep(own, "com.spotify", ongoing = true, groupSummary = false, title = "Song", text = "Artist")) - assertFalse(BottomNotifications.keep(own, "com.discord", ongoing = false, groupSummary = true, title = "2 messages", text = "")) - assertFalse(BottomNotifications.keep(own, "com.discord", ongoing = false, groupSummary = false, title = " ", text = "")) - } - - @Test fun anUpdatedNotificationMovesToTheTopOnce() { - val list = mutableListOf( - BottomNotification("a", "App", "one", "", 1L), - BottomNotification("b", "App", "two", "", 2L), - ) - BottomNotifications.add(list, BottomNotification("b", "App", "two again", "", 3L)) - assertEquals(listOf("b", "a"), list.map { it.key }) - assertEquals("two again", list.first().title) - } - - @Test fun theListKeepsTheNewestThirty() { - val list = mutableListOf() - repeat(35) { BottomNotifications.add(list, BottomNotification("k$it", "App", "t$it", "", it.toLong())) } - assertEquals(30, list.size) - assertEquals("k34", list.first().key) - } -} diff --git a/app/src/test/java/com/droiddeck/launcher/ui/BottomDashboardTest.kt b/app/src/test/java/com/droiddeck/launcher/ui/BottomDashboardTest.kt index 90f39c2c5..459864368 100644 --- a/app/src/test/java/com/droiddeck/launcher/ui/BottomDashboardTest.kt +++ b/app/src/test/java/com/droiddeck/launcher/ui/BottomDashboardTest.kt @@ -16,7 +16,7 @@ class BottomDashboardTest { } @Test fun withNothingPickedTheStatsOpen() { - val ids = listOf("telemetry", "downloads", "notifications", "screenshots", "chat") + val ids = listOf("telemetry", "downloads", "screenshots", "chat") assertEquals("telemetry", pickBottomPage(null, ids)) assertEquals("telemetry", pickBottomPage("gone", ids)) assertEquals("chat", pickBottomPage("chat", ids)) diff --git a/tools/tests/test_memory_pressure.py b/tools/tests/test_memory_pressure.py index 2c852a7af..13f9e2f8d 100644 --- a/tools/tests/test_memory_pressure.py +++ b/tools/tests/test_memory_pressure.py @@ -32,7 +32,7 @@ step() { :; } bl_zram_usage() { _bl_rss=$rss _bl_swap=$client_swap; } bl_zram_send() { echo "send:$1:$3"; rss=$((rss - reclaimed)); _bl_pageout_complete=${pageout_complete:-1}; } ''' + functions + '\nbl_memory_read\n' + body - result = run_bash(harness) + result = run_bash('NOW=0\n' + harness.replace('SECONDS', 'NOW')) self.assertEqual(0, result.returncode, result.stderr) self.assertEqual('', result.stderr) return result.stdout.splitlines() @@ -168,7 +168,7 @@ bl_session_others() { _bl_session=(321 654); } bl_zram_usage() { _bl_rss=$rss _bl_swap=$swap; } bl_zram_send() { echo "send:$1:$3"; rss=$((rss - pushed)); swap=$((swap + pushed)); _bl_pageout_complete=$complete; } ''' + functions + '\nbl_memory_read\n' + body - result = run_bash(harness) + result = run_bash('NOW=0\n' + harness.replace('SECONDS', 'NOW')) self.assertEqual(0, result.returncode, result.stderr) self.assertEqual('', result.stderr) return result.stdout.splitlines() From 75a0230304d8f40478ef7b05095e109d504bcf4d Mon Sep 17 00:00:00 2001 From: MaxsTechReview Date: Mon, 9 Nov 2026 15:02:12 -0500 Subject: [PATCH 16/33] fix(input): Auto relative mouse stays off for direct touch without a mouse (#2305) * fix(input): Auto relative mouse stays off for direct touch without a mouse * fix(input): re-check relative mouse when an input device changes * fix(input): reapply relative mouse after agent readiness * fix(input): only the current session clears the agent-ready hook and readiness resets relative mouse retries --- .../com/droiddeck/launcher/SessionActivity.kt | 20 ++++++++++++++-- .../droiddeck/launcher/agent/AgentGuest.kt | 2 ++ .../launcher/input/TouchGestureConfig.kt | 3 +++ .../launcher/input/GesturePrefsTest.kt | 13 +++++++++++ .../launcher/input/TouchRouterTest.kt | 23 +++++++++++++++++++ 5 files changed, 59 insertions(+), 2 deletions(-) create mode 100644 app/src/test/java/com/droiddeck/launcher/input/TouchRouterTest.kt diff --git a/app/src/main/java/com/droiddeck/launcher/SessionActivity.kt b/app/src/main/java/com/droiddeck/launcher/SessionActivity.kt index e102b1e12..8deb14120 100644 --- a/app/src/main/java/com/droiddeck/launcher/SessionActivity.kt +++ b/app/src/main/java/com/droiddeck/launcher/SessionActivity.kt @@ -286,6 +286,7 @@ class SessionActivity : ComponentActivity(), SurfaceHolder.Callback { private var directHoldBehavior by mutableStateOf(com.droiddeck.launcher.input.TouchGestureConfig.MOUSE_BEHAVIOR_CLICK) private var relativeMouseMode by mutableIntStateOf(0) @Volatile private var appliedRelativeMouseMode: Int? = null + private val agentReadyListener: () -> Unit = { relativeMouseRetries = 0; requestRelativeMouse() } private val appTextFocus get() = wowChat.request != null private val wowChat by lazy { com.droiddeck.launcher.ui.WowChat(this, ::sendWowChat, ::cancelWowChat, ::readGameField) } private var wowChatBottom: SecondScreenPresentation? = null @@ -636,6 +637,7 @@ class SessionActivity : ComponentActivity(), SurfaceHolder.Callback { padBridge?.syncConnectedControllers() refreshControls() updatePointerCapture() + requestRelativeMouse() updateHandCamera() refreshSecondScreenDisplays() } @@ -651,12 +653,14 @@ class SessionActivity : ComponentActivity(), SurfaceHolder.Callback { if (::padMouse.isInitialized) padMouse.reset() refreshControls() updatePointerCapture() + requestRelativeMouse() updateHandCamera() refreshSecondScreenDisplays() } override fun onInputDeviceChanged(deviceId: Int) { joyConVr?.refresh() refreshControls() + requestRelativeMouse() refreshSecondScreenDisplays() } } @@ -665,6 +669,7 @@ class SessionActivity : ComponentActivity(), SurfaceHolder.Callback { override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) + AgentGuest.readyListener = agentReadyListener if (com.droiddeck.launcher.runtime.LinuxRuntimeInstaller.isRemoving() || com.droiddeck.launcher.runtime.LinuxRuntimeInstaller.isMaintaining()) { runtimeRemovalBlocked = true @@ -1960,6 +1965,7 @@ class SessionActivity : ComponentActivity(), SurfaceHolder.Callback { val nextRoutedTouchMode = effectiveTouchMode() if (routedTouchMode != nextRoutedTouchMode) gestureTouchStreamGate.reset() routedTouchMode = nextRoutedTouchMode + requestRelativeMouse() frameGen = FrameGen.mode(this) fexPreset = SessionPrefs.fexPreset(this) suspendPolicy = SessionPrefs.suspendPolicy(this, SessionState.mode) @@ -2981,7 +2987,15 @@ class SessionActivity : ComponentActivity(), SurfaceHolder.Callback { } private fun applyRelativeMouse() { - val mode = desiredRelativeMouseMode + val wanted = desiredRelativeMouseMode + val touchpadRoute = com.droiddeck.launcher.input.TouchRouter.decide(com.droiddeck.launcher.input.TouchRouter.Snapshot( + uiBlocking = false, + mode = routedTouchMode, + scrollLayer = false, + steamSession = SessionState.mode == SessionService.MODE_STEAM, + steamDesktopUi = SessionState.steamUi == "desktop", + )) == com.droiddeck.launcher.input.TouchRouter.Route.TOUCHPAD + val mode = com.droiddeck.launcher.input.TouchGestureConfig.gamescopeRelativeMouse(wanted, mouseAttached(), touchpadRoute) if (appliedRelativeMouseMode == mode) return val request = org.json.JSONObject() .put("kind", "exec") @@ -2997,7 +3011,7 @@ class SessionActivity : ComponentActivity(), SurfaceHolder.Callback { Log.w(TAG, "gamescope did not take relative mouse $mode: ${result.exceptionOrNull() ?: answer}") if (relativeMouseRetries++ < RELATIVE_MOUSE_MAX_RETRIES) uiHandler.postDelayed({ requestRelativeMouse() }, 2_000) } - if (desiredRelativeMouseMode != mode) requestRelativeMouse() + if (desiredRelativeMouseMode != wanted) requestRelativeMouse() } private fun refreshGestureConfig() { @@ -3813,6 +3827,7 @@ class SessionActivity : ComponentActivity(), SurfaceHolder.Callback { if (next == SessionPrefs.TOUCH_OFF) cursorVisible = false gestureTouchStreamGate.reset() routedTouchMode = next + requestRelativeMouse() if (directTouchCursorHidden()) cursorVisible = false } @@ -5319,6 +5334,7 @@ class SessionActivity : ComponentActivity(), SurfaceHolder.Callback { } override fun onDestroy() { + if (AgentGuest.readyListener === agentReadyListener) AgentGuest.readyListener = null com.droiddeck.launcher.input.HomeButtonShortcuts.detach(this) relativeMouseRequests.shutdownNow() backQamInput.cancel() diff --git a/app/src/main/java/com/droiddeck/launcher/agent/AgentGuest.kt b/app/src/main/java/com/droiddeck/launcher/agent/AgentGuest.kt index eaa2bbc0f..e04dda258 100644 --- a/app/src/main/java/com/droiddeck/launcher/agent/AgentGuest.kt +++ b/app/src/main/java/com/droiddeck/launcher/agent/AgentGuest.kt @@ -33,6 +33,7 @@ object AgentGuest { var keyboardDismissals by mutableIntStateOf(0) private set @Volatile private var keyboardRoute = false + @Volatile var readyListener: (() -> Unit)? = null @Volatile private var routeDir: File? = null private const val TAG = "AgentGuest" private const val POLL_MS = 50L @@ -59,6 +60,7 @@ object AgentGuest { when (path) { "hello.json" -> readJson(File(dir, path))?.let { SessionEvents.record("agent.ready", mapOf("pid" to it.optInt("pid"), "focus" to it.optBoolean("focus"))) + readyListener?.invoke() } "focus.json" -> readJson(File(dir, path))?.let(::recordFocus) "keyboard.json" -> readJson(File(dir, path))?.let { recordKeyboard(this, it) } diff --git a/app/src/main/java/com/droiddeck/launcher/input/TouchGestureConfig.kt b/app/src/main/java/com/droiddeck/launcher/input/TouchGestureConfig.kt index d3be47056..035060585 100644 --- a/app/src/main/java/com/droiddeck/launcher/input/TouchGestureConfig.kt +++ b/app/src/main/java/com/droiddeck/launcher/input/TouchGestureConfig.kt @@ -213,6 +213,9 @@ data class TouchGestureConfig( return if (value == MOUSE_BEHAVIOR_CLICK) MOUSE_BEHAVIOR_CLICK else MOUSE_BEHAVIOR_HOLD } + fun gamescopeRelativeMouse(mode: Int, mouseAttached: Boolean, touchpad: Boolean): Int = + if (mode == RELATIVE_MOUSE_AUTO && !mouseAttached && !touchpad) RELATIVE_MOUSE_OFF else mode + private fun normalizeRelativeMouseMode(mode: Int): Int { return if (mode in RELATIVE_MOUSE_AUTO..RELATIVE_MOUSE_OFF) mode else RELATIVE_MOUSE_AUTO } diff --git a/app/src/test/java/com/droiddeck/launcher/input/GesturePrefsTest.kt b/app/src/test/java/com/droiddeck/launcher/input/GesturePrefsTest.kt index 9f0381112..a00594f78 100644 --- a/app/src/test/java/com/droiddeck/launcher/input/GesturePrefsTest.kt +++ b/app/src/test/java/com/droiddeck/launcher/input/GesturePrefsTest.kt @@ -17,6 +17,19 @@ class GesturePrefsTest { assertEquals(TouchGestureConfig.RELATIVE_MOUSE_AUTO, TouchGestureConfig.fromJson("{}").relativeMouseMode) } + @Test fun autoNeverGoesRelativeForDirectTouchWithoutAMouse() { + val auto = TouchGestureConfig.RELATIVE_MOUSE_AUTO + assertEquals(TouchGestureConfig.RELATIVE_MOUSE_OFF, TouchGestureConfig.gamescopeRelativeMouse(auto, mouseAttached = false, touchpad = false)) + assertEquals(auto, TouchGestureConfig.gamescopeRelativeMouse(auto, mouseAttached = true, touchpad = false)) + assertEquals(auto, TouchGestureConfig.gamescopeRelativeMouse(auto, mouseAttached = false, touchpad = true)) + } + + @Test fun aChosenRelativeMouseModeIsSentAsIs() { + for (mode in listOf(TouchGestureConfig.RELATIVE_MOUSE_ON, TouchGestureConfig.RELATIVE_MOUSE_OFF)) + for (mouse in listOf(false, true)) for (pad in listOf(false, true)) + assertEquals(mode, TouchGestureConfig.gamescopeRelativeMouse(mode, mouse, pad)) + } + @Test fun removedOptionsAreIgnoredWhenLoadingExistingSettings() { val old = """{"holdMouseButtonWhileTouchingEnabled":true,"holdMouseButtonWhileTouchingAction":"right_click", "showClickHighlight":true,"showCursorInTouchscreenMode":true,"tapAction":"key_E","longPressDelay":650}""" diff --git a/app/src/test/java/com/droiddeck/launcher/input/TouchRouterTest.kt b/app/src/test/java/com/droiddeck/launcher/input/TouchRouterTest.kt new file mode 100644 index 000000000..38b270e09 --- /dev/null +++ b/app/src/test/java/com/droiddeck/launcher/input/TouchRouterTest.kt @@ -0,0 +1,23 @@ +package com.droiddeck.launcher.input + +import com.droiddeck.launcher.session.SessionPrefs +import org.junit.Assert.assertEquals +import org.junit.Test + +class TouchRouterTest { + @Test fun gesturesAndOffAreNotTouchpadRoutes() { + for (mode in listOf(SessionPrefs.TOUCH_GESTURES, SessionPrefs.TOUCH_OFF)) { + assertEquals(TouchRouter.Route.GESTURES.takeIf { mode == SessionPrefs.TOUCH_GESTURES } ?: TouchRouter.Route.OFF, + TouchRouter.decide(TouchRouter.Snapshot(false, mode, false, false, false))) + assertEquals(TouchRouter.Route.GESTURES.takeIf { mode == SessionPrefs.TOUCH_GESTURES } ?: TouchRouter.Route.OFF, + TouchRouter.decide(TouchRouter.Snapshot(false, mode, false, true, true))) + } + } + + @Test fun autoUsesTouchpadOnlyWhenRouterSelectsTouchpad() { + assertEquals(TouchRouter.Route.TOUCHPAD, + TouchRouter.decide(TouchRouter.Snapshot(false, SessionPrefs.TOUCH_AUTO, false, false, false))) + assertEquals(TouchRouter.Route.DIRECT, + TouchRouter.decide(TouchRouter.Snapshot(false, SessionPrefs.TOUCH_AUTO, false, true, false))) + } +} From c85b84a9648e029cefa4ac898496d1abd6c75a07 Mon Sep 17 00:00:00 2001 From: Kurt Himebauch <136133082+xXJSONDeruloXx@users.noreply.github.com> Date: Mon, 9 Nov 2026 16:41:26 -0500 Subject: [PATCH 17/33] fix(input): fakeinput no longer aborts programs whose allocator calls a hooked function at startup (#2361) * fix(input): fakeinput no longer aborts programs whose allocator calls a hooked function at startup * fix(ctl): screenshot finds display ids that the device name carries inline * fix(input): fakeinput registers its fork handlers before other threads see its config --- app/src/main/cpp/fakeinput_steam.cpp | 43 ++++++++++++-------- tools/droiddeckctl | 7 +++- tools/tests/test_droiddeckctl.py | 9 +++++ tools/tests/test_fakeinput_reentry.py | 58 +++++++++++++++++++++++++++ 4 files changed, 100 insertions(+), 17 deletions(-) create mode 100644 tools/tests/test_fakeinput_reentry.py diff --git a/app/src/main/cpp/fakeinput_steam.cpp b/app/src/main/cpp/fakeinput_steam.cpp index 9558d780c..498461992 100644 --- a/app/src/main/cpp/fakeinput_steam.cpp +++ b/app/src/main/cpp/fakeinput_steam.cpp @@ -30,6 +30,7 @@ #include #include #include +#include #include #include #include @@ -208,23 +209,33 @@ struct FakeConfig { bool uinput_enabled; }; +// The first allocation here can start the host program's own allocator, which may call a hooked +// function (mimalloc probes NUMA nodes with access()) and land back in config(). A guarded static +// would throw a recursive-init error there, so the fields are filled without allocating, and a +// reentry on the initializing thread gets them while other threads wait for the fork handlers. static FakeConfig &config() { - static FakeConfig *cfg = [] { - auto *c = new FakeConfig(); - const char *dir = getenv("FAKE_EVDEV_DIR"); - c->hook_dir = dir ? dir : "/data/data/com.termux/files/home/fake-input"; - c->udev_data_dir = getenv("FAKE_UDEV_DATA_DIR"); - c->vibration_enabled = - getenv("FAKE_EVDEV_VIBRATION") && atoi(getenv("FAKE_EVDEV_VIBRATION")); - const char *identity = getenv("FAKE_EVDEV_IDENTITY"); - c->xbox360_identity = identity && !strcmp(identity, "xbox360"); - c->uinput_enabled = getenv("FAKE_EVDEV_UINPUT") && atoi(getenv("FAKE_EVDEV_UINPUT")); - pthread_atfork(controller_fork_prepare, controller_fork_parent, - controller_fork_child); - Logger::init(); - return c; - }(); - return *cfg; + static FakeConfig cfg; + static std::atomic state{0}; + static __thread bool initializing __attribute__((tls_model("initial-exec"))); + if (state.load(std::memory_order_acquire) == 2 || initializing) return cfg; + int expected = 0; + if (!state.compare_exchange_strong(expected, 1, std::memory_order_acq_rel)) { + while (state.load(std::memory_order_acquire) != 2) sched_yield(); + return cfg; + } + initializing = true; + const char *dir = getenv("FAKE_EVDEV_DIR"); + cfg.hook_dir = dir ? dir : "/data/data/com.termux/files/home/fake-input"; + cfg.udev_data_dir = getenv("FAKE_UDEV_DATA_DIR"); + cfg.vibration_enabled = getenv("FAKE_EVDEV_VIBRATION") && atoi(getenv("FAKE_EVDEV_VIBRATION")); + const char *identity = getenv("FAKE_EVDEV_IDENTITY"); + cfg.xbox360_identity = identity && !strcmp(identity, "xbox360"); + cfg.uinput_enabled = getenv("FAKE_EVDEV_UINPUT") && atoi(getenv("FAKE_EVDEV_UINPUT")); + pthread_atfork(controller_fork_prepare, controller_fork_parent, controller_fork_child); + Logger::init(); + state.store(2, std::memory_order_release); + initializing = false; + return cfg; } static std::recursive_mutex &controller_mutex() { diff --git a/tools/droiddeckctl b/tools/droiddeckctl index 8384d6d8e..8d08288d0 100755 --- a/tools/droiddeckctl +++ b/tools/droiddeckctl @@ -263,10 +263,15 @@ def displays(adb: str, serial: str) -> list[dict]: device = re.search(r"mPrimaryDisplayDevice=(.+)", block) size = re.search(r"real (\d+) x (\d+)", block) name = device.group(1).strip() if device else None + physical = devices.get(name) if name else None + if name and not physical: + # Some ROMs (Retroid Pocket Duo) print the device as "Built-in Screen(local:)" + inline = re.search(r"\(local:(\d+)\)$", name) + physical = inline.group(1) if inline else None out.append({ "display": logical, "name": name, - "physicalId": devices.get(name) if name else None, + "physicalId": physical, "size": [int(size.group(1)), int(size.group(2))] if size else None, }) return out diff --git a/tools/tests/test_droiddeckctl.py b/tools/tests/test_droiddeckctl.py index a626c7868..c0fb5742b 100644 --- a/tools/tests/test_droiddeckctl.py +++ b/tools/tests/test_droiddeckctl.py @@ -328,6 +328,15 @@ class DroiddeckctlTest(FakeAdbTest): self.assertEqual("222", out["displays"][1]["physicalId"]) self.assertEqual([1080, 1240], out["displays"][1]["size"]) + def test_displays_named_with_their_unique_id(self): + text = DUMPSYS_DISPLAY.replace("mPrimaryDisplayDevice=Screen-2", + "mPrimaryDisplayDevice=Screen-2(local:222)") + self.configure({}, dumpsys_display=text) + code, out = self.run_ctl("screenshot", str(self.dir / "shot.png"), "--display", "4") + self.assertEqual(0, code) + shot = [c for c in self.calls() if c["args"][:2] == ["exec-out", "screencap"]][0] + self.assertEqual(["exec-out", "screencap", "-p", "-d", "222"], shot["args"]) + def test_unknown_display_is_an_error(self): self.configure({}, dumpsys_display=DUMPSYS_DISPLAY) code, out = self.run_ctl("screenshot", str(self.dir / "x.png"), "--display", "9") diff --git a/tools/tests/test_fakeinput_reentry.py b/tools/tests/test_fakeinput_reentry.py new file mode 100644 index 000000000..7abd49cae --- /dev/null +++ b/tools/tests/test_fakeinput_reentry.py @@ -0,0 +1,58 @@ +from pathlib import Path +import os +import shutil +import subprocess +import sys +import tempfile +import unittest + +SOURCE = Path(__file__).resolve().parents[2] / "app/src/main/cpp/fakeinput_steam.cpp" + +# Stands in for an allocator like mimalloc that calls access() while it starts up (Claude Code +# probes /sys/devices/system/node/node1 on its first allocation, which comes from fakeinput's +# constructor when it is preloaded). +PROGRAM = r""" +#define _GNU_SOURCE +#include +#include +#include + +extern void *__libc_malloc(size_t); +static int started; + +void *malloc(size_t size) { + if (!started) { + started = 1; + access("/sys/devices/system/node/node1", R_OK); + } + return __libc_malloc(size); +} + +int main(void) { + puts("ok"); + return 0; +} +""" + + +@unittest.skipUnless(sys.platform == "linux" and shutil.which("gcc") and shutil.which("g++"), + "needs Linux, gcc and g++") +class FakeinputReentryTest(unittest.TestCase): + def test_allocator_calling_hooks_during_first_allocation_survives_preload(self): + with tempfile.TemporaryDirectory() as tmp: + path = Path(tmp) + (path / "program.c").write_text(PROGRAM) + subprocess.run(["gcc", "-rdynamic", "-o", str(path / "program"), str(path / "program.c")], check=True) + subprocess.run(["g++", "-shared", "-fPIC", "-O2", "-Wno-attributes", "-pthread", + "-std=c++17", "-static-libstdc++", "-static-libgcc", + "-Wl,--exclude-libs,ALL", "-o", str(path / "libfakeinput.so"), + str(SOURCE), "-ldl"], check=True) + result = subprocess.run([str(path / "program")], + env=dict(os.environ, LD_PRELOAD=str(path / "libfakeinput.so")), + capture_output=True, text=True, timeout=10) + self.assertEqual(result.returncode, 0, result.stderr) + self.assertEqual(result.stdout, "ok\n") + + +if __name__ == "__main__": + unittest.main() From 1b19837fc6e2e652e9506f1b92e0c78ceaaeb7d6 Mon Sep 17 00:00:00 2001 From: Kurt Himebauch <136133082+xXJSONDeruloXx@users.noreply.github.com> Date: Mon, 9 Nov 2026 16:42:38 -0500 Subject: [PATCH 18/33] fix(native): a guest's own seccomp filter gets its SIGSYS, so Firefox tabs stop crashing (#2360) --- tools/proot/PATCHES.md | 10 ++++ tools/proot/bench/guest-trap.c | 49 +++++++++++++++ tools/proot/fastpath/fastpath.c | 48 +++++++++++---- tools/proot/fastpath/svc-patch.h | 9 ++- .../patches/0027-guest-seccomp-sigsys.patch | 59 +++++++++++++++++++ 5 files changed, 162 insertions(+), 13 deletions(-) create mode 100644 tools/proot/bench/guest-trap.c create mode 100644 tools/proot/patches/0027-guest-seccomp-sigsys.patch diff --git a/tools/proot/PATCHES.md b/tools/proot/PATCHES.md index cc120bd3e..c4173a48c 100644 --- a/tools/proot/PATCHES.md +++ b/tools/proot/PATCHES.md @@ -152,6 +152,16 @@ Prototype (inert unless `PROOT_FASTPATH` is set; see `docs/development/proot-per natively reserve 256 GiB above 64 GiB for guest memory: under PRoot the reservation failed and the first guest instruction faulted forever. 48-bit address spaces and 32-bit programs are unchanged in practice. +- `0027-guest-seccomp-sigsys.patch` - PRoot treated every seccomp `SIGSYS` as Android's app policy + refusing a call: it emulated the few it knows and answered the rest with `ENOSYS`, so a filter + the guest installs itself never reached its own handler. Firefox's content sandbox traps + `openat` and opens files through a broker in the parent; every font open came back `ENOSYS`, + and each tab crashed in `gfxFontGroup::GetDefaultFont`. Android's policy traps with + `SECCOMP_RET_DATA` 0, while Firefox's and Chromium's filters tag their traps, so a tagged + `SIGSYS` is now delivered to the guest. A trap from the fast path's trampoline still gets + `ENOSYS`, because its arguments are host paths; the fast path then stops for that process and + repeats the call through a plain `svc`, which the guest's filter traps with the guest path. + `bench/guest-trap.c` installs a tagged `openat` trap and checks its handler answers the call. Old kernel fixes: diff --git a/tools/proot/bench/guest-trap.c b/tools/proot/bench/guest-trap.c new file mode 100644 index 000000000..ae5a30428 --- /dev/null +++ b/tools/proot/bench/guest-trap.c @@ -0,0 +1,49 @@ +// Run inside a session, with and without the fast path: a seccomp filter the guest installs +// itself must reach its own SIGSYS handler, which answers the trapped openat. +#define _GNU_SOURCE +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +#define ANSWER 4242 +static volatile int traps, data; + +static void on_sigsys(int sig, siginfo_t *info, void *context) { + (void)sig; + ucontext_t *uc = context; + traps++; + data = info->si_errno; + uc->uc_mcontext.regs[0] = ANSWER; +} + +int main(void) { + struct sigaction sa; + memset(&sa, 0, sizeof sa); + sa.sa_sigaction = on_sigsys; + sa.sa_flags = SA_SIGINFO; + sigaction(SIGSYS, &sa, NULL); + struct sock_filter code[] = { + BPF_STMT(BPF_LD | BPF_W | BPF_ABS, offsetof(struct seccomp_data, nr)), + BPF_JUMP(BPF_JMP | BPF_JEQ | BPF_K, SYS_openat, 0, 1), + BPF_STMT(BPF_RET | BPF_K, SECCOMP_RET_TRAP | 7), + BPF_STMT(BPF_RET | BPF_K, SECCOMP_RET_ALLOW), + }; + struct sock_fprog filter = {.len = sizeof code / sizeof *code, .filter = code}; + if (prctl(PR_SET_NO_NEW_PRIVS, 1, 0, 0, 0) || prctl(PR_SET_SECCOMP, SECCOMP_MODE_FILTER, &filter, 0, 0)) { + perror("seccomp"); + return 77; + } + int fd = open("/etc/hostname", O_RDONLY); + int err = errno; + printf("open=%d errno=%d traps=%d data=%d\n", fd, fd < 0 ? err : 0, traps, data); + return fd == ANSWER && traps == 1 && data == 7 ? 0 : 1; +} diff --git a/tools/proot/fastpath/fastpath.c b/tools/proot/fastpath/fastpath.c index 5bad6e04e..8d645eddd 100644 --- a/tools/proot/fastpath/fastpath.c +++ b/tools/proot/fastpath/fastpath.c @@ -49,7 +49,30 @@ static size_t rootlen; static long long ttl_ns = 2000000000LL; static unsigned long hits, misses; -static long sc(long nr, long a, long b, long c, long d, long e) { return fp_sys(nr, a, b, c, d, e, 0); } +// A seccomp filter the guest installs itself (Firefox's content sandbox) can trap a trampoline +// syscall that the host allows; proot answers those with ENOSYS. The call is then repeated +// through libc, where the guest's own SIGSYS handler sees the guest path, and the process stops +// using the fast path. +static __thread int fp_refused; +static int optional_syscall(long nr) { + return nr == SYS_statx || nr == SYS_memfd_create || nr == SYS_faccessat2 || nr == SYS_close_range || + nr == SYS_openat2 || nr == SYS_renameat2; +} +static long sc(long nr, long a, long b, long c, long d, long e) { + if (fp_refused) return -ENOSYS; + long r = fp_sys(nr, a, b, c, d, e, 0); + if (r == -ENOSYS && !optional_syscall(nr)) { + __atomic_store_n(&fp_on, 0, __ATOMIC_RELAXED); + fp_refused = 1; + } + return r; +} +static inline int refused(void) { + if (!fp_refused) return 0; + fp_refused = 0; + return 1; +} +static inline int answered(long r) { return !refused() && r != FP_SLOW; } static void fp_init(void); static pthread_once_t init_once = PTHREAD_ONCE_INIT; static inline int fp_ready(void) { @@ -788,7 +811,7 @@ static int ret(long r) { if (!real_##name) real_##name = (type)dlsym(RTLD_NEXT, #name) #define ANSWER(r, dirfd, path, real_call) \ - do { long r_ = (r); if (r_ != FP_SLOW) { hits++; return ret(r_); } missed(__func__, dirfd, path); return real_call; } while (0) + do { long r_ = (r); if (answered(r_)) { hits++; return ret(r_); } missed(__func__, dirfd, path); return real_call; } while (0) static long fp_open(int dirfd, const char *path, int flags, mode_t mode) { @@ -805,7 +828,7 @@ static long fp_open(int dirfd, const char *path, int flags, mode_t mode) { static int do_openat(int dirfd, const char *path, int flags, mode_t mode, int (*real)(int, const char *, int, ...)) { long r = fp_open(dirfd, path, flags, mode); - if (r != FP_SLOW) { hits++; return ret(r); } + if (answered(r)) { hits++; return ret(r); } missed(__func__, dirfd, path); return real(dirfd, path, flags, mode); } @@ -870,8 +893,10 @@ int lstat64(const char *p, struct stat64 *st) { return lstat(p, (struct stat *)s int fstat(int fd, struct stat *st) { REAL(fstat, int (*)(int, struct stat *)); if (!fp_ready()) return real_fstat(fd, st); + long r = sc(SYS_fstat, fd, (long)st, 0, 0, 0); + if (!answered(r)) return real_fstat(fd, st); hits++; - return ret(sc(SYS_fstat, fd, (long)st, 0, 0, 0)); + return ret(r); } int fstat64(int fd, struct stat64 *st) { return fstat(fd, (struct stat *)st); } @@ -989,7 +1014,7 @@ static long fp_access(int dirfd, const char *path, int mode, int flags) { static int access_common(int dirfd, const char *path, int mode, int flags) { REAL(faccessat, int (*)(int, const char *, int, int)); long rs = fp_access(dirfd, path, mode, flags); - if (rs != FP_SLOW) { hits++; return ret(rs); } + if (answered(rs)) { hits++; return ret(rs); } missed(__func__, dirfd, path); if (flags == 0) return (int)syscall(SYS_faccessat, dirfd, path, mode); return real_faccessat(dirfd, path, mode, flags); @@ -1087,7 +1112,7 @@ char *canonicalize_file_name(const char *path) { return realpath(path, NULL); } char *getcwd(char *buf, size_t size) { REAL(getcwd, char *(*)(char *, size_t)); char g[PATH_MAX]; - if (!fp_ready() || own_cwd(g) == FP_SLOW) { missed(__func__, AT_FDCWD, "."); return real_getcwd(buf, size); } + if (!fp_ready() || own_cwd(g) == FP_SLOW || refused()) { missed(__func__, AT_FDCWD, "."); return real_getcwd(buf, size); } hits++; size_t need = strlen(g) + 1; if (buf && size == 0) { errno = EINVAL; return NULL; } @@ -1181,7 +1206,7 @@ static long fp_rename(int olddirfd, const char *old, int newdirfd, const char *n static int rename_common(int olddirfd, const char *old, int newdirfd, const char *new, unsigned flags) { REAL(renameat2, int (*)(int, const char *, int, const char *, unsigned)); long rs = fp_rename(olddirfd, old, newdirfd, new, flags); - if (rs != FP_SLOW) { hits++; return ret(rs); } + if (answered(rs)) { hits++; return ret(rs); } missed(__func__, olddirfd, old); if (!flags) return (int)syscall(SYS_renameat, olddirfd, old, newdirfd, new); return real_renameat2(olddirfd, old, newdirfd, new, flags); @@ -1355,7 +1380,7 @@ static long fp_getxattr(const char *path, const char *name, void *value, size_t static ssize_t getxattr_common(const char *path, const char *name, void *value, size_t size, int follow, ssize_t (*real)(const char *, const char *, void *, size_t)) { long rs = fp_getxattr(path, name, value, size, follow); - if (rs != FP_SLOW) { hits++; return rs < 0 ? ret(rs) : rs; } + if (answered(rs)) { hits++; return rs < 0 ? ret(rs) : rs; } missed(__func__, AT_FDCWD, path); return real(path, name, value, size); } @@ -1378,7 +1403,7 @@ static long fp_listxattr(const char *path, char *list, size_t size, int follow) static ssize_t listxattr_common(const char *path, char *list, size_t size, int follow, ssize_t (*real)(const char *, char *, size_t)) { long rs = fp_listxattr(path, list, size, follow); - if (rs != FP_SLOW) { hits++; return rs < 0 ? ret(rs) : rs; } + if (answered(rs)) { hits++; return rs < 0 ? ret(rs) : rs; } missed(__func__, AT_FDCWD, path); return real(path, list, size); } @@ -1581,6 +1606,7 @@ static int accept_common(int fd, struct sockaddr *addr, socklen_t *len, int flag struct sockaddr_storage got; socklen_t got_len = sizeof got; long r = sc(SYS_accept4, fd, addr ? (long)&got : 0, addr ? (long)&got_len : 0, flags, 0); + if (refused()) return real_accept4(fd, addr, len, flags); if (r >= 0) inherit_long_name(fd, (int)r); if (!addr) return ret(r); if (r >= 0) report_address((int)r, LONG_PEER, &got, got_len, addr, len); @@ -1595,6 +1621,7 @@ static int name_common(long nr, int fd, struct sockaddr *addr, socklen_t *len, i struct sockaddr_storage got; socklen_t got_len = sizeof got; long r = sc(nr, fd, (long)&got, (long)&got_len, 0, 0); + if (refused()) return real(fd, addr, len); if (r == 0) report_address(fd, nr == SYS_getsockname ? LONG_SELF : LONG_PEER, &got, got_len, addr, len); return ret(r); } @@ -1687,7 +1714,7 @@ static int online_cpus(void) { int get_nprocs(void) { REAL(get_nprocs, int (*)(void)); int n = fp_ready() ? online_cpus() : -1; - if (n > 0) { hits++; return n; } + if (n > 0 && !refused()) { hits++; return n; } missed(__func__, AT_FDCWD, "(nprocs)"); return real_get_nprocs(); } @@ -1743,6 +1770,7 @@ int isatty(int fd) { if (!fp_ready()) return real_isatty(fd); hits++; long r = sc(SYS_ioctl, fd, TCGETS, (long)&k, 0, 0); + if (refused()) return real_isatty(fd); if (r < 0) { errno = (int)-r; return 0; } return r == 0; } diff --git a/tools/proot/fastpath/svc-patch.h b/tools/proot/fastpath/svc-patch.h index 6d19da315..09cb01408 100644 --- a/tools/proot/fastpath/svc-patch.h +++ b/tools/proot/fastpath/svc-patch.h @@ -74,11 +74,14 @@ long fp_svc_dispatch(const long *r) { int saved_errno = errno; long result = svc_blocked(r); uintptr_t frame = (uintptr_t)r; - if (result == FP_SLOW && (!svc_busy || frame >= svc_busy)) { + if (result == FP_SLOW && __atomic_load_n(&fp_on, __ATOMIC_RELAXED) && (!svc_busy || frame >= svc_busy)) { uintptr_t previous = svc_busy; svc_busy = frame; - if (svc_stack_room(frame)) { result = svc_path_call(r); svc_busy = 0; } - else svc_busy = previous; + if (svc_stack_room(frame)) { + result = svc_path_call(r); + if (refused()) result = FP_SLOW; + svc_busy = 0; + } else svc_busy = previous; } if (result == FP_SLOW) { __atomic_fetch_add(&svc_slow, 1, __ATOMIC_RELAXED); diff --git a/tools/proot/patches/0027-guest-seccomp-sigsys.patch b/tools/proot/patches/0027-guest-seccomp-sigsys.patch new file mode 100644 index 000000000..73e7c3ce1 --- /dev/null +++ b/tools/proot/patches/0027-guest-seccomp-sigsys.patch @@ -0,0 +1,59 @@ +From 0000000000000000000000000000000000000000 Mon Sep 17 00:00:00 2001 +From: Kurt Himebauch +Date: Mon, 9 Nov 2026 15:30:00 -0500 +Subject: [PATCH] seccomp: hand a guest filter's own SIGSYS back to the guest + +--- +--- a/src/tracee/seccomp.h ++++ b/src/tracee/seccomp.h +@@ -1,3 +1,4 @@ ++#include + #include "tracee/tracee.h" + #include "sys/vfs.h" + +@@ -18,5 +19,5 @@ + + void restart_syscall_after_seccomp(Tracee* tracee); + void set_result_after_seccomp(Tracee *tracee, word_t result); +-int handle_seccomp_event(Tracee* tracee); ++int handle_seccomp_event(Tracee* tracee, const siginfo_t *siginfo); + void fix_and_restart_enosys_syscall(Tracee* tracee); +--- a/src/tracee/seccomp.c ++++ b/src/tracee/seccomp.c +@@ -68,7 +68,7 @@ + * + * Return 0 to swallow signal or SIGSYS to deliver it to process. + */ +-int handle_seccomp_event(Tracee* tracee) ++int handle_seccomp_event(Tracee* tracee, const siginfo_t *siginfo) + { + int ret; + +@@ -100,6 +100,16 @@ + + print_current_regs(tracee, 3, "seccomp SIGSYS"); + ++ /* Android's app policy traps with SECCOMP_RET_DATA 0. A filter the guest ++ * installed itself (Firefox's and Chromium's sandboxes) tags its traps, and ++ * its own SIGSYS handler answers them, usually through a broker process. ++ * A trap from the fast path's trampoline still gets ENOSYS: its arguments ++ * are host paths, and the fast path repeats the call through libc. */ ++ if (siginfo->si_errno != 0 && !from_fastpath_stub(tracee)) { ++ VERBOSE(tracee, 4, "SIGSYS from the guest's own seccomp filter (data %d)", siginfo->si_errno); ++ return SIGSYS; ++ } ++ + return handle_seccomp_event_common(tracee); + } + +--- a/src/tracee/event.c ++++ b/src/tracee/event.c +@@ -676,7 +676,7 @@ + tracee->skip_next_seccomp_signal = false; + signal = 0; + } else { +- signal = handle_seccomp_event(tracee); ++ signal = handle_seccomp_event(tracee, &siginfo); + } + } else { + VERBOSE(tracee, 1, "non-seccomp SIGSYS"); From 836340f3a297af05a1b478e05177e22f2a545e09 Mon Sep 17 00:00:00 2001 From: MaxsTechReview Date: Mon, 9 Nov 2026 17:09:27 -0500 Subject: [PATCH 19/33] fix(session): no endless loading screen after a game exits back to Steam (#2359) * fix(session): avoid stale relaunch loading overlay * fix(session): keep the loading screen while the runtime installs --- .../com/droiddeck/launcher/SessionActivity.kt | 11 +++- .../launcher/session/SessionEvents.kt | 1 + .../session/SessionLoadingDecision.kt | 8 +++ .../launcher/session/SessionService.kt | 7 +-- .../launcher/session/SessionState.kt | 37 +++++++++++++ .../session/SessionLoadingDecisionTest.kt | 53 +++++++++++++++++++ 6 files changed, 112 insertions(+), 5 deletions(-) create mode 100644 app/src/main/java/com/droiddeck/launcher/session/SessionLoadingDecision.kt create mode 100644 app/src/test/java/com/droiddeck/launcher/session/SessionLoadingDecisionTest.kt diff --git a/app/src/main/java/com/droiddeck/launcher/SessionActivity.kt b/app/src/main/java/com/droiddeck/launcher/SessionActivity.kt index 8deb14120..a162ab14b 100644 --- a/app/src/main/java/com/droiddeck/launcher/SessionActivity.kt +++ b/app/src/main/java/com/droiddeck/launcher/SessionActivity.kt @@ -121,6 +121,7 @@ import com.droiddeck.launcher.session.SessionArtifacts import com.droiddeck.launcher.session.SessionLogShare import com.droiddeck.launcher.session.SessionPhase import com.droiddeck.launcher.session.SessionPaths +import com.droiddeck.launcher.session.SessionLoadingDecision import com.droiddeck.launcher.wayland.HdrSupport import com.droiddeck.launcher.session.SessionService import com.droiddeck.launcher.session.SessionState @@ -2435,6 +2436,7 @@ class SessionActivity : ComponentActivity(), SurfaceHolder.Callback { private fun watchSession() { val handler = Handler(Looper.getMainLooper()) var ticks = 0 + val loadingStartedAt = SystemClock.elapsedRealtime() val poll = object : Runnable { override fun run() { if (!watching) return @@ -2447,6 +2449,12 @@ class SessionActivity : ComponentActivity(), SurfaceHolder.Callback { finish() return } + if (loading.visible && !loading.ended && !SessionState.running && !installingRuntime && + SessionLoadingDecision.shouldReturnToLauncher( + loadingStartedAt, SystemClock.elapsedRealtime(), SessionState.running, SessionState.phase)) { + finish() + return + } if (loading.visible && !loading.ended) { if (!installingRuntime) loading.update(this@SessionActivity, SessionState.logFile) if (ticks++ % 2 == 0) loading.tick() @@ -2471,8 +2479,7 @@ class SessionActivity : ComponentActivity(), SurfaceHolder.Callback { val hint = sessionExitHint(status) runOnUiThread { if (isFinishing || isDestroyed) return@runOnUiThread - if (status == 0) SessionState.relaunch?.let { next -> - SessionState.relaunch = null + if (status == 0) SessionState.takeRelaunch()?.let { next -> next.putExtra(EXTRA_RETURN_HOME, intent.getBooleanExtra(EXTRA_RETURN_HOME, false)) next.putExtra(EXTRA_LINK_TASK, intent.getIntExtra(EXTRA_LINK_TASK, -1)) setIntent(next) diff --git a/app/src/main/java/com/droiddeck/launcher/session/SessionEvents.kt b/app/src/main/java/com/droiddeck/launcher/session/SessionEvents.kt index 26b63fd5c..6c0a07645 100644 --- a/app/src/main/java/com/droiddeck/launcher/session/SessionEvents.kt +++ b/app/src/main/java/com/droiddeck/launcher/session/SessionEvents.kt @@ -14,6 +14,7 @@ object SessionEvents { SessionPaths.take() val dir = SessionPaths.beginOrCurrent(context, label ?: SessionPaths.label(mode, null)) synchronized(SessionState) { + SessionState.clearRelaunch() SessionState.phase = SessionPhase.PREPARING SessionState.mode = mode SessionState.sessionId = dir.name diff --git a/app/src/main/java/com/droiddeck/launcher/session/SessionLoadingDecision.kt b/app/src/main/java/com/droiddeck/launcher/session/SessionLoadingDecision.kt new file mode 100644 index 000000000..1c5d034df --- /dev/null +++ b/app/src/main/java/com/droiddeck/launcher/session/SessionLoadingDecision.kt @@ -0,0 +1,8 @@ +package com.droiddeck.launcher.session + +internal object SessionLoadingDecision { + const val TIMEOUT_MS = 10_000L + + fun shouldReturnToLauncher(startedAt: Long, now: Long, running: Boolean, phase: SessionPhase): Boolean = + !running && phase in setOf(SessionPhase.IDLE, SessionPhase.FAILED) && now - startedAt >= TIMEOUT_MS +} diff --git a/app/src/main/java/com/droiddeck/launcher/session/SessionService.kt b/app/src/main/java/com/droiddeck/launcher/session/SessionService.kt index cc67bc800..5fb5e37f2 100644 --- a/app/src/main/java/com/droiddeck/launcher/session/SessionService.kt +++ b/app/src/main/java/com/droiddeck/launcher/session/SessionService.kt @@ -1697,8 +1697,8 @@ class SessionService : Service() { .putExtra(EXTRA_STEAM_UI, ui) .addFlags(Intent.FLAG_ACTIVITY_NEW_TASK) if (url.isNotEmpty()) next.putExtra(EXTRA_STEAM_URL, url) - SessionState.relaunch = next - android.os.Handler(android.os.Looper.getMainLooper()).post { stopSession(0) } + SessionState.queueRelaunch(next) + android.os.Handler(android.os.Looper.getMainLooper()).post { stopSession(0, relaunch = true) } } } watcher.startWatching() @@ -2013,7 +2013,7 @@ class SessionService : Service() { } } - private fun stopSession(status: Int, gen: Int? = null) { + private fun stopSession(status: Int, gen: Int? = null, relaunch: Boolean = false) { if (gen != null && gen != sessionGen) { Log.i(TAG, "ignoring a stop from session $gen; session $sessionGen is current") return @@ -2024,6 +2024,7 @@ class SessionService : Service() { cancelQueuedSteamGame() gameExitWatchToken++ if (!SessionState.running) return + if (relaunch) SessionState.confirmRelaunchStop() SessionEvents.record("guest.exited", mapOf("status" to status)) SessionEvents.transition(SessionPhase.STOPPING, "session.stopping", mapOf("status" to status)) SessionState.running = false diff --git a/app/src/main/java/com/droiddeck/launcher/session/SessionState.kt b/app/src/main/java/com/droiddeck/launcher/session/SessionState.kt index fa6eb38c9..5f17212e5 100644 --- a/app/src/main/java/com/droiddeck/launcher/session/SessionState.kt +++ b/app/src/main/java/com/droiddeck/launcher/session/SessionState.kt @@ -73,7 +73,44 @@ object SessionState { @Volatile var steamClientArch: String? = null var steamVr by mutableStateOf(false) + @Volatile var relaunch: android.content.Intent? = null + @Volatile + private var relaunchSessionId: String? = null + @Volatile + private var relaunchStopRequested = false + + fun queueRelaunch(intent: android.content.Intent) { + synchronized(this) { + relaunch = intent + relaunchSessionId = sessionId + relaunchStopRequested = false + } + } + + fun confirmRelaunchStop() { + synchronized(this) { + relaunchStopRequested = relaunch != null && relaunchSessionId == sessionId + } + } + + fun takeRelaunch(): android.content.Intent? = synchronized(this) { + val next = relaunch + val valid = relaunchStopRequested && relaunchSessionId == sessionId + relaunch = null + relaunchSessionId = null + relaunchStopRequested = false + next.takeIf { valid } + } + + fun clearRelaunch() { + synchronized(this) { + relaunch = null + relaunchSessionId = null + relaunchStopRequested = false + } + } + @JvmStatic var hdr = false @Volatile private var deckPadValue = false diff --git a/app/src/test/java/com/droiddeck/launcher/session/SessionLoadingDecisionTest.kt b/app/src/test/java/com/droiddeck/launcher/session/SessionLoadingDecisionTest.kt new file mode 100644 index 000000000..bdfd2fb5f --- /dev/null +++ b/app/src/test/java/com/droiddeck/launcher/session/SessionLoadingDecisionTest.kt @@ -0,0 +1,53 @@ +package com.droiddeck.launcher.session + +import android.content.Intent +import org.junit.After +import org.junit.Assert.assertEquals +import org.junit.Assert.assertNull +import org.junit.Before +import org.junit.Test + +class SessionLoadingDecisionTest { + private var previousSessionId: String? = null + private var previousRelaunch: Intent? = null + + @Before + fun setUp() { + previousSessionId = SessionState.sessionId + previousRelaunch = SessionState.relaunch + SessionState.clearRelaunch() + } + + @After + fun tearDown() { + SessionState.sessionId = previousSessionId + SessionState.relaunch = previousRelaunch + SessionState.clearRelaunch() + if (previousRelaunch != null) SessionState.queueRelaunch(previousRelaunch!!) + } + + @Test + fun loadingTimesOutOnlyWhenNoSessionStarted() { + assertEquals(false, SessionLoadingDecision.shouldReturnToLauncher(100L, 10_099L, false, SessionPhase.IDLE)) + assertEquals(true, SessionLoadingDecision.shouldReturnToLauncher(100L, 10_100L, false, SessionPhase.IDLE)) + assertEquals(false, SessionLoadingDecision.shouldReturnToLauncher(100L, 10_100L, true, SessionPhase.IDLE)) + assertEquals(false, SessionLoadingDecision.shouldReturnToLauncher(100L, 10_100L, false, SessionPhase.PREPARING)) + } + + @Test + fun relaunchRequiresTheCurrentSessionToHaveRequestedTheStop() { + SessionState.sessionId = "session-a" + val next = Intent().apply { action = "next" } + SessionState.queueRelaunch(next) + assertNull(SessionState.takeRelaunch()) + SessionState.queueRelaunch(next) + SessionState.confirmRelaunchStop() + assertEquals(next, SessionState.takeRelaunch()) + assertNull(SessionState.takeRelaunch()) + + SessionState.queueRelaunch(next) + SessionState.confirmRelaunchStop() + SessionState.sessionId = "session-b" + assertNull(SessionState.takeRelaunch()) + } +} From 6bcd431ca6e05f74c028d4d5aacfbcc3e5ee3267 Mon Sep 17 00:00:00 2001 From: MaxsTechReview Date: Mon, 9 Nov 2026 18:23:24 -0500 Subject: [PATCH 20/33] fix(session): wait for Steam readiness before launch (#2362) * fix(session): wait for Steam readiness before launch * fix(session): keep the launch fallback when Steam readiness or the interstitial is unavailable * fix(session): cap the Steam readiness wait by elapsed time and bound each check * fix(session): keep waiting for Steam readiness through focus changes and never pass a zero timeout --- .../overlay/usr/local/bin/droiddeck-agent | 40 +++++++++++++++++++ .../overlay/usr/local/bin/droiddeck-session | 13 +++++- tools/tests/test_droiddeck_agent.py | 20 ++++++++++ tools/tests/test_steam_cold_launch.py | 38 ++++++++++++------ 4 files changed, 97 insertions(+), 14 deletions(-) diff --git a/tools/linuxfs/overlay/usr/local/bin/droiddeck-agent b/tools/linuxfs/overlay/usr/local/bin/droiddeck-agent index 234be2f00..5ae8031e6 100755 --- a/tools/linuxfs/overlay/usr/local/bin/droiddeck-agent +++ b/tools/linuxfs/overlay/usr/local/bin/droiddeck-agent @@ -927,6 +927,33 @@ def steam_shortcut_state(agent_dir, game_id, timeout=6): time.sleep(min(0.5, max(0, deadline - time.monotonic()))) +def steam_ready(agent_dir): + expression = """(() => { + const store = globalThis.appStore; + return !!store?.m_bIsInitialized && typeof globalThis.SteamClient?.Apps?.ContinueGameAction === 'function'; + })()""" + return cdp_eval(agent_dir, expression, await_promise=False, timeout=3).get("value") is True + + +def steam_clear_pending_interstitial(agent_dir, app_id): + if not isinstance(app_id, str) or not app_id.isascii() or not app_id.isdigit() or int(app_id) <= 0: + raise AgentError("INVALID_APP_ID", "appId must be a positive decimal Steam app id") + expression = """(async () => { + const apps = globalThis.SteamClient?.Apps; + if (typeof apps?.GetActiveGameActions !== 'function' || typeof apps.ContinueGameAction !== 'function') return false; + const active = await apps.GetActiveGameActions() || []; + for (const action of active) { + const appid = String(action?.strAppID ?? action?.gameid ?? action?.nAppID ?? ''); + if (appid === %s && action?.strTaskName === 'ShowInterstitials') { + apps.ContinueGameAction(action.nGameActionID, appid, 'ShowInterstitials'); + return true; + } + } + return false; + })()""" % json.dumps(app_id) + return cdp_eval(agent_dir, expression, timeout=3).get("value") is True + + def game_pids(app_id): wanted = [("SteamAppId=%s" % app_id).encode(), ("SteamGameId=%s" % app_id).encode()] me = os.getpid() @@ -1261,6 +1288,19 @@ def main(argv): return 1 print(state) return {"ready": 0, "missing": 4, "not-shortcut": 3}.get(state, 1) + if argv and argv[0] == "--steam-ready": + try: + return 0 if steam_ready(Path(base) / "agent") else 1 + except (AgentError, OSError, ValueError): + return 1 + if argv and argv[0] == "--steam-clear-pending-interstitial": + if len(argv) != 2: + print("usage: droiddeck-agent --steam-clear-pending-interstitial ", file=sys.stderr) + return 2 + try: + return 0 if steam_clear_pending_interstitial(Path(base) / "agent", argv[1]) else 1 + except (AgentError, OSError, ValueError): + return 1 serve(Path(base) / "agent", focus="--no-focus" not in argv) return 0 diff --git a/tools/linuxfs/overlay/usr/local/bin/droiddeck-session b/tools/linuxfs/overlay/usr/local/bin/droiddeck-session index 907364572..df301aada 100755 --- a/tools/linuxfs/overlay/usr/local/bin/droiddeck-session +++ b/tools/linuxfs/overlay/usr/local/bin/droiddeck-session @@ -1244,7 +1244,14 @@ case $mode in sleep 1 done [ "${#game}" -gt 10 ] && shortcut=1 - [ -n "$shortcut" ] || sleep 4 + _bl_ready_end=$((SECONDS + 30)) + while [ "$waited" -lt 30 ] && [ "$((_bl_ready_end - SECONDS))" -gt 0 ]; do + timeout "$((_bl_ready_end - SECONDS))" /usr/local/bin/droiddeck-agent --steam-ready >/dev/null 2>&1 && break + [ "$((_bl_ready_end - SECONDS))" -gt 1 ] || break + sleep 1 + waited=$((waited + 1)) + done + waited=0 echo "== steam: interface up; launching the requested game $game" _bl_hand_off [ -n "$shortcut" ] || return 0 @@ -1264,7 +1271,9 @@ case $mode in elif _bl_starting; then echo "== steam: the requested game started while waiting; not launching again" elif [ "$(_bl_focused)" = 769 ]; then - echo "== steam: launching the requested game again $game (overview status $status)" + echo "== steam: clearing the pending launch interstitial for $game (overview status $status)" + /usr/local/bin/droiddeck-agent --steam-clear-pending-interstitial "$(((game >> 32) & 0xffffffff))" >/dev/null 2>&1 && return 0 + echo "== steam: launching the requested game again $game" _bl_hand_off else echo "== steam: the game took focus while waiting; not launching again" diff --git a/tools/tests/test_droiddeck_agent.py b/tools/tests/test_droiddeck_agent.py index 02e53f960..a1adca90c 100644 --- a/tools/tests/test_droiddeck_agent.py +++ b/tools/tests/test_droiddeck_agent.py @@ -457,6 +457,26 @@ class AgentTest(unittest.TestCase): self.assertEqual(1, agent.main(["--steam-shortcut-state", game_id])) self.assertEqual(2, agent.main(["--steam-shortcut-state"])) + def test_steam_ready_uses_steams_initialized_store(self): + with patch.object(agent, "cdp_eval", return_value={"value": True}) as evaluate: + self.assertTrue(agent.steam_ready(self.dir)) + self.assertIn("m_bIsInitialized", evaluate.call_args.args[1]) + with patch.object(agent, "cdp_eval", return_value={"value": False}): + self.assertFalse(agent.steam_ready(self.dir)) + + def test_pending_interstitial_is_continued_by_steam(self): + with patch.object(agent, "cdp_eval", return_value={"value": True}) as evaluate: + self.assertTrue(agent.steam_clear_pending_interstitial(self.dir, "3551182975")) + expression = evaluate.call_args.args[1] + self.assertIn("ContinueGameAction", expression) + self.assertIn("ShowInterstitials", expression) + self.assertIn('"3551182975"', expression) + self.assertIn("await apps.GetActiveGameActions()", expression) + with patch.object(agent, "cdp_eval", return_value={"value": False}): + self.assertFalse(agent.steam_clear_pending_interstitial(self.dir, "3551182975")) + with self.assertRaises(agent.AgentError): + agent.steam_clear_pending_interstitial(self.dir, "0") + def test_asleep_command_reports_steams_own_sleep_state(self): with patch.dict(os.environ, {"BL_LAUNCH_DIR": str(self.dir.parent)}): for value, code in (("yes", 0), ("no", 5), ("unknown", 1), (None, 1)): diff --git a/tools/tests/test_steam_cold_launch.py b/tools/tests/test_steam_cold_launch.py index 1695ad0e1..0fe085ff9 100644 --- a/tools/tests/test_steam_cold_launch.py +++ b/tools/tests/test_steam_cold_launch.py @@ -41,6 +41,7 @@ class SteamColdLaunchTest(unittest.TestCase): "#!/bin/sh\n" "printf '%s\n' \"$@\" > \"$AGENT_ARGS\"\n" "n=$(cat \"$AGENT_COUNT\" 2>/dev/null || echo 0); n=$((n + 1)); echo $n > \"$AGENT_COUNT\"\n" + "[ \"$1\" = --steam-ready ] && exit \"${READY_STATUS:-0}\"\n" "exit \"$AGENT_STATUS\"\n") agent.chmod(0o755) sleep = self.bin / "sleep" @@ -55,12 +56,13 @@ class SteamColdLaunchTest(unittest.TestCase): pgrep.chmod(0o755) self.block = LAUNCH_BLOCK.replace("/usr/local/bin/droiddeck-agent", str(agent)) - def run_launch(self, target, focus="769", helper_status=0, running_after=999): + def run_launch(self, target, focus="769", helper_status=0, running_after=999, ready_status=0): env = dict(os.environ, HOME=str(self.root), BL_LAUNCH_DIR=str(self.launch_dir), PATH=str(self.bin) + os.pathsep + os.environ["PATH"], AGENT_ARGS=str(self.agent_args), SLEEP_ARGS=str(self.sleep_args), AGENT_COUNT=str(self.agent_count), AGENT_STATUS=str(helper_status), - FOCUS=focus, FOCUS_COUNT=str(self.focus_count), RUNNING_AFTER=str(running_after)) + FOCUS=focus, FOCUS_COUNT=str(self.focus_count), RUNNING_AFTER=str(running_after), + READY_STATUS=str(ready_status)) script = self.block + "\nprintf 'ARG:%s\n' \"$@\"\nwait\n" url = target if target.startswith("steam://") else "steam://rungameid/" + target result = subprocess.run(["bash", "-c", script, "cold-launch", url], @@ -72,28 +74,40 @@ class SteamColdLaunchTest(unittest.TestCase): return [l for l in result.stdout.splitlines() if "launching the requested game" in l] def test_a_shortcut_that_takes_focus_is_handed_over_once_without_waiting_for_the_overview(self): - result = self.run_launch(SHORTCUT, focus="769 769 4242") + result = self.run_launch(SHORTCUT, focus="769 4242") self.assertEqual(SHORTCUT, (self.launch_dir / "steam-game").read_text().strip()) self.assertEqual(1, len(self.hand_offs(result)), result.stdout) - self.assertFalse(self.agent_args.exists(), result.stdout) - self.assertEqual(["1"], self.sleep_args.read_text().split()) + self.assertEqual(["--steam-ready"], self.agent_args.read_text().splitlines()) + self.assertFalse(self.sleep_args.exists()) def test_a_shortcut_that_never_takes_focus_is_handed_over_again_after_the_overview_wait(self): result = self.run_launch(SHORTCUT, focus="769", helper_status=0) - self.assertEqual(2, len(self.hand_offs(result)), result.stdout) - self.assertIn("launching the requested game again", result.stdout) - self.assertEqual(["--wait-steam-shortcut-overview", SHORTCUT, "45"], self.agent_args.read_text().splitlines()) + self.assertEqual(1, len(self.hand_offs(result)), result.stdout) + self.assertIn("clearing the pending launch interstitial", result.stdout) + self.assertEqual(["--steam-clear-pending-interstitial", "3551182975"], self.agent_args.read_text().splitlines()) self.assertEqual(["1"] * 20, self.sleep_args.read_text().split()) def test_a_timed_out_overview_still_gets_one_more_hand_off(self): result = self.run_launch(SHORTCUT, focus="769", helper_status=1) self.assertEqual(2, len(self.hand_offs(result)), result.stdout) self.assertIn("overview status 1", result.stdout) + self.assertIn("launching the requested game again", result.stdout) + + def test_a_steam_that_never_reports_ready_still_gets_the_launch(self): + result = self.run_launch("413150", ready_status=1) + self.assertEqual("413150", (self.launch_dir / "steam-game").read_text().strip()) + self.assertEqual(1, len(self.hand_offs(result)), result.stdout) + self.assertEqual(["1"] * 30, self.sleep_args.read_text().split()) + + def test_a_focus_change_does_not_end_the_readiness_wait(self): + result = self.run_launch("413150", focus="769 4242", ready_status=1) + self.assertEqual(1, len(self.hand_offs(result)), result.stdout) + self.assertEqual(["1"] * 30, self.sleep_args.read_text().split()) def test_a_game_still_starting_without_a_window_is_not_launched_twice(self): result = self.run_launch(SHORTCUT, running_after=1) self.assertEqual(1, len(self.hand_offs(result)), result.stdout) - self.assertFalse(self.agent_args.exists()) + self.assertIn("--steam-ready", self.agent_args.read_text()) self.assertEqual(["-f", "reaper SteamLaunch AppId=3551182975( |$)"], (self.root / "pgrep-args").read_text().splitlines()) self.assertIn("already starting", result.stdout) @@ -108,15 +122,15 @@ class SteamColdLaunchTest(unittest.TestCase): focus = " ".join(["769"] * 23) + " 4242" result = self.run_launch(SHORTCUT, focus=focus, helper_status=0) self.assertEqual(1, len(self.hand_offs(result)), result.stdout) - self.assertIn("took focus while waiting", result.stdout) + self.assertNotIn("clearing the pending launch interstitial", result.stdout) self.assertNotIn("launching the requested game again", result.stdout) def test_native_app_id_keeps_the_short_delay_and_is_handed_over_once(self): result = self.run_launch("413150") self.assertEqual("413150", (self.launch_dir / "steam-game").read_text().strip()) self.assertEqual(1, len(self.hand_offs(result)), result.stdout) - self.assertEqual("4", self.sleep_args.read_text().strip()) - self.assertFalse(self.agent_args.exists()) + self.assertFalse(self.sleep_args.exists()) + self.assertIn("--steam-ready", self.agent_args.read_text()) def test_a_restart_for_a_game_uses_the_same_hand_off(self): loop = SOURCE[SOURCE.index(' if [ -s "$HOME/.bl-restart-game" ]; then'):SOURCE.index(' if [ -f "$HOME/.bl-restart-once" ]; then')] From ae3e9509f45721f23e8af5897d299644cd2a89c6 Mon Sep 17 00:00:00 2001 From: LunaDL <91587464+EnzoDLofi@users.noreply.github.com> Date: Mon, 9 Nov 2026 21:29:14 -0400 Subject: [PATCH 21/33] Feat: Add new theme color for launcher: Scarlet snow (#2262) --- app/src/main/java/com/droiddeck/launcher/ui/Theme.kt | 8 ++++++++ app/src/main/res/values-ar/strings.xml | 3 +++ app/src/main/res/values-b+es+419/strings.xml | 2 ++ app/src/main/res/values-bg/strings.xml | 3 +++ app/src/main/res/values-cs/strings.xml | 3 +++ app/src/main/res/values-da/strings.xml | 3 +++ app/src/main/res/values-de/strings.xml | 3 +++ app/src/main/res/values-el/strings.xml | 3 +++ app/src/main/res/values-es/strings.xml | 3 +++ app/src/main/res/values-fi/strings.xml | 3 +++ app/src/main/res/values-fr/strings.xml | 3 +++ app/src/main/res/values-hi/strings.xml | 3 +++ app/src/main/res/values-hu/strings.xml | 3 +++ app/src/main/res/values-in/strings.xml | 3 +++ app/src/main/res/values-it/strings.xml | 3 +++ app/src/main/res/values-ja/strings.xml | 3 +++ app/src/main/res/values-ko/strings.xml | 3 +++ app/src/main/res/values-ms/strings.xml | 3 +++ app/src/main/res/values-nb/strings.xml | 3 +++ app/src/main/res/values-nl/strings.xml | 3 +++ app/src/main/res/values-pl/strings.xml | 3 +++ app/src/main/res/values-pt-rPT/strings.xml | 3 +++ app/src/main/res/values-pt/strings.xml | 3 +++ app/src/main/res/values-ro/strings.xml | 3 +++ app/src/main/res/values-ru/strings.xml | 3 +++ app/src/main/res/values-sv/strings.xml | 3 +++ app/src/main/res/values-th/strings.xml | 3 +++ app/src/main/res/values-tr/strings.xml | 3 +++ app/src/main/res/values-uk/strings.xml | 3 +++ app/src/main/res/values-vi/strings.xml | 3 +++ app/src/main/res/values-zh-rCN/strings.xml | 3 +++ app/src/main/res/values-zh-rHK/strings.xml | 3 +++ app/src/main/res/values-zh-rTW/strings.xml | 3 +++ app/src/main/res/values/strings.xml | 3 +++ 34 files changed, 106 insertions(+) diff --git a/app/src/main/java/com/droiddeck/launcher/ui/Theme.kt b/app/src/main/java/com/droiddeck/launcher/ui/Theme.kt index cb6d5ed22..2e2c1049e 100644 --- a/app/src/main/java/com/droiddeck/launcher/ui/Theme.kt +++ b/app/src/main/java/com/droiddeck/launcher/ui/Theme.kt @@ -58,6 +58,7 @@ object Themes { const val RGB_SPEEDS = 10 const val DEFAULT_RGB_SPEED = 3 const val RGB_HUE_STEP = 3f + const val WHITE_RED = "white_red" var rgbSpeed by mutableStateOf(DEFAULT_RGB_SPEED) private set @@ -127,6 +128,13 @@ object Themes { primary = Color(0xFF2E86FF), primary2 = Color(0xFF2684FF), onPrimary = Color(0xFF03111F), signal = Color(0xFF2E86FF), good = Color(0xFF4CD37F), ), + Palette( + WHITE_RED, R.string.theme_white_red, R.string.theme_white_red_detail, + background = Color(0xFFF8F8F8), surface = Color(0xFFFFFFFF), surfaceVariant = Color(0xFFF0F0F0), line = Color(0xFFDDDDDD), line2 = Color(0xFFC8C8C8), + onBackground = Color(0xFF191919), onSurfaceVariant = Color(0xFF555555), + primary = Color(0xFFD62828), primary2 = Color(0xFFB71C1C), onPrimary = Color(0xFFFFFFFF), signal = Color(0xFFD62828), + good = Color(0xFF248548), + ), innerGamer(0f), ThemeColors.palette(DEFAULT_PRIMARY, DEFAULT_SECONDARY, R.string.theme_gradient, R.string.theme_gradient_detail), ) diff --git a/app/src/main/res/values-ar/strings.xml b/app/src/main/res/values-ar/strings.xml index d6a881d86..d95aac020 100644 --- a/app/src/main/res/values-ar/strings.xml +++ b/app/src/main/res/values-ar/strings.xml @@ -3809,4 +3809,7 @@ إعادة تعيين المواضع إظهار التسمية تعذّر استيراد هذا التخطيط + + ثلج قرمزي + diff --git a/app/src/main/res/values-b+es+419/strings.xml b/app/src/main/res/values-b+es+419/strings.xml index d59c5e43c..7e660349a 100644 --- a/app/src/main/res/values-b+es+419/strings.xml +++ b/app/src/main/res/values-b+es+419/strings.xml @@ -3665,4 +3665,6 @@ Restablecer posiciones Mostrar etiqueta No se pudo importar esta disposición + Nieve escarlata + Superficies blancas con acentos rojos. diff --git a/app/src/main/res/values-bg/strings.xml b/app/src/main/res/values-bg/strings.xml index 977de45c9..ffd8da7e9 100644 --- a/app/src/main/res/values-bg/strings.xml +++ b/app/src/main/res/values-bg/strings.xml @@ -3653,4 +3653,7 @@ Нулиране на позициите Показване на етикета Това разположение не можа да бъде импортирано + + Ален сняг + diff --git a/app/src/main/res/values-cs/strings.xml b/app/src/main/res/values-cs/strings.xml index cb16b5439..af28f0a22 100644 --- a/app/src/main/res/values-cs/strings.xml +++ b/app/src/main/res/values-cs/strings.xml @@ -3731,4 +3731,7 @@ Obnovit pozice Zobrazit popisek Toto rozložení nelze importovat + + Šarlatový sníh + diff --git a/app/src/main/res/values-da/strings.xml b/app/src/main/res/values-da/strings.xml index aa31a9c5a..d6605c91d 100644 --- a/app/src/main/res/values-da/strings.xml +++ b/app/src/main/res/values-da/strings.xml @@ -3653,4 +3653,7 @@ Nulstil placeringer Vis etiket Dette layout kunne ikke importeres + + Skarlagensne + diff --git a/app/src/main/res/values-de/strings.xml b/app/src/main/res/values-de/strings.xml index a7e1fa032..bd1c722ef 100644 --- a/app/src/main/res/values-de/strings.xml +++ b/app/src/main/res/values-de/strings.xml @@ -3653,4 +3653,7 @@ Positionen zurücksetzen Beschriftung anzeigen Dieses Layout konnte nicht importiert werden + + Scharlachroter Schnee + diff --git a/app/src/main/res/values-el/strings.xml b/app/src/main/res/values-el/strings.xml index 38aeb85ee..d46aa61bf 100644 --- a/app/src/main/res/values-el/strings.xml +++ b/app/src/main/res/values-el/strings.xml @@ -3653,4 +3653,7 @@ Επαναφορά θέσεων Εμφάνιση ετικέτας Δεν ήταν δυνατή η εισαγωγή αυτής της διάταξης + + Κατακόκκινο χιόνι + diff --git a/app/src/main/res/values-es/strings.xml b/app/src/main/res/values-es/strings.xml index af9618bde..e1fa53600 100644 --- a/app/src/main/res/values-es/strings.xml +++ b/app/src/main/res/values-es/strings.xml @@ -3692,4 +3692,7 @@ Restablecer posiciones Mostrar etiqueta No se ha podido importar esta disposición + + Nieve escarlata + diff --git a/app/src/main/res/values-fi/strings.xml b/app/src/main/res/values-fi/strings.xml index 364725062..1f88c5a89 100644 --- a/app/src/main/res/values-fi/strings.xml +++ b/app/src/main/res/values-fi/strings.xml @@ -3653,4 +3653,7 @@ Palauta sijainnit Näytä nimi Tätä asettelua ei voitu tuoda + + Helakanpunainen lumi + diff --git a/app/src/main/res/values-fr/strings.xml b/app/src/main/res/values-fr/strings.xml index 57cf1c900..9590c0633 100644 --- a/app/src/main/res/values-fr/strings.xml +++ b/app/src/main/res/values-fr/strings.xml @@ -3692,4 +3692,7 @@ Réinitialiser les positions Afficher le libellé Impossible d’importer cette disposition + + Neige écarlate + diff --git a/app/src/main/res/values-hi/strings.xml b/app/src/main/res/values-hi/strings.xml index 491de8723..7a055b6f2 100644 --- a/app/src/main/res/values-hi/strings.xml +++ b/app/src/main/res/values-hi/strings.xml @@ -3653,4 +3653,7 @@ स्थितियाँ रीसेट करें लेबल दिखाएँ यह लेआउट इम्पोर्ट नहीं किया जा सका + + किरमिज़ी बर्फ + diff --git a/app/src/main/res/values-hu/strings.xml b/app/src/main/res/values-hu/strings.xml index e0f3a2e1a..67b038ba0 100644 --- a/app/src/main/res/values-hu/strings.xml +++ b/app/src/main/res/values-hu/strings.xml @@ -3653,4 +3653,7 @@ Pozíciók visszaállítása Címke megjelenítése Ez az elrendezés nem importálható + + Skarlátvörös hó + diff --git a/app/src/main/res/values-in/strings.xml b/app/src/main/res/values-in/strings.xml index 65290c5f1..0a24790cd 100644 --- a/app/src/main/res/values-in/strings.xml +++ b/app/src/main/res/values-in/strings.xml @@ -3623,4 +3623,7 @@ Atur ulang posisi Tampilkan label Tata letak ini tidak dapat diimpor + + Salju Kirmizi + diff --git a/app/src/main/res/values-it/strings.xml b/app/src/main/res/values-it/strings.xml index 62503c51d..2afa9ecf0 100644 --- a/app/src/main/res/values-it/strings.xml +++ b/app/src/main/res/values-it/strings.xml @@ -3692,4 +3692,7 @@ Ripristina posizioni Mostra etichetta Impossibile importare questo layout + + Neve scarlatta + diff --git a/app/src/main/res/values-ja/strings.xml b/app/src/main/res/values-ja/strings.xml index 2c9db1526..8d68c1fee 100644 --- a/app/src/main/res/values-ja/strings.xml +++ b/app/src/main/res/values-ja/strings.xml @@ -3623,4 +3623,7 @@ 位置をリセット ラベルを表示 このレイアウトをインポートできませんでした + + 緋色の雪 + diff --git a/app/src/main/res/values-ko/strings.xml b/app/src/main/res/values-ko/strings.xml index 1efb8cfc9..457e29095 100644 --- a/app/src/main/res/values-ko/strings.xml +++ b/app/src/main/res/values-ko/strings.xml @@ -3623,4 +3623,7 @@ 위치 초기화 라벨 표시 이 레이아웃을 가져올 수 없습니다 + + 진홍빛 눈 + diff --git a/app/src/main/res/values-ms/strings.xml b/app/src/main/res/values-ms/strings.xml index 185b46704..38a0f4a4c 100644 --- a/app/src/main/res/values-ms/strings.xml +++ b/app/src/main/res/values-ms/strings.xml @@ -3623,4 +3623,7 @@ Tetapkan semula kedudukan Tunjukkan label Susun atur ini tidak dapat diimport + + Salji Merah Skarlet + diff --git a/app/src/main/res/values-nb/strings.xml b/app/src/main/res/values-nb/strings.xml index a805b2d63..e7a36fb43 100644 --- a/app/src/main/res/values-nb/strings.xml +++ b/app/src/main/res/values-nb/strings.xml @@ -3653,4 +3653,7 @@ Tilbakestill plasseringer Vis etikett Kunne ikke importere dette oppsettet + + Skarlagenssnø + diff --git a/app/src/main/res/values-nl/strings.xml b/app/src/main/res/values-nl/strings.xml index e99884948..3f2447e8c 100644 --- a/app/src/main/res/values-nl/strings.xml +++ b/app/src/main/res/values-nl/strings.xml @@ -3653,4 +3653,7 @@ Posities herstellen Label tonen Deze indeling kan niet worden geïmporteerd + + Scharlakenrode sneeuw + diff --git a/app/src/main/res/values-pl/strings.xml b/app/src/main/res/values-pl/strings.xml index 29eef5dd3..6591b0473 100644 --- a/app/src/main/res/values-pl/strings.xml +++ b/app/src/main/res/values-pl/strings.xml @@ -3731,4 +3731,7 @@ Resetuj położenie Pokaż etykietę Nie można zaimportować tego układu + + Szkarłatny śnieg + diff --git a/app/src/main/res/values-pt-rPT/strings.xml b/app/src/main/res/values-pt-rPT/strings.xml index 562f659d6..e0fe08437 100644 --- a/app/src/main/res/values-pt-rPT/strings.xml +++ b/app/src/main/res/values-pt-rPT/strings.xml @@ -3692,4 +3692,7 @@ Repor posições Mostrar etiqueta Não foi possível importar este esquema + + Neve Escarlate + diff --git a/app/src/main/res/values-pt/strings.xml b/app/src/main/res/values-pt/strings.xml index b90660bb2..b637c52e2 100644 --- a/app/src/main/res/values-pt/strings.xml +++ b/app/src/main/res/values-pt/strings.xml @@ -3692,4 +3692,7 @@ Redefinir posições Mostrar rótulo Não foi possível importar este layout + + Neve Escarlate + diff --git a/app/src/main/res/values-ro/strings.xml b/app/src/main/res/values-ro/strings.xml index f39ab16a8..80366bf4b 100644 --- a/app/src/main/res/values-ro/strings.xml +++ b/app/src/main/res/values-ro/strings.xml @@ -3692,4 +3692,7 @@ Resetează pozițiile Afișează eticheta Acest aranjament nu a putut fi importat + + Zăpadă stacojie + diff --git a/app/src/main/res/values-ru/strings.xml b/app/src/main/res/values-ru/strings.xml index 009fb80bd..2f10db8d0 100644 --- a/app/src/main/res/values-ru/strings.xml +++ b/app/src/main/res/values-ru/strings.xml @@ -3814,4 +3814,7 @@ Сбросить положение Показывать подпись Не удалось импортировать эту раскладку + + Алый снег + diff --git a/app/src/main/res/values-sv/strings.xml b/app/src/main/res/values-sv/strings.xml index 61d0ef03e..b121e1707 100644 --- a/app/src/main/res/values-sv/strings.xml +++ b/app/src/main/res/values-sv/strings.xml @@ -3653,4 +3653,7 @@ Återställ positioner Visa etikett Det gick inte att importera layouten + + Scharlakansröd snö + diff --git a/app/src/main/res/values-th/strings.xml b/app/src/main/res/values-th/strings.xml index 232654afe..9b87a4105 100644 --- a/app/src/main/res/values-th/strings.xml +++ b/app/src/main/res/values-th/strings.xml @@ -3623,4 +3623,7 @@ รีเซ็ตตำแหน่ง แสดงป้ายกำกับ ไม่สามารถนำเข้าการจัดวางนี้ได้ + + หิมะสีแดงชาด + diff --git a/app/src/main/res/values-tr/strings.xml b/app/src/main/res/values-tr/strings.xml index a76a2c434..86532ab77 100644 --- a/app/src/main/res/values-tr/strings.xml +++ b/app/src/main/res/values-tr/strings.xml @@ -3653,4 +3653,7 @@ Konumları sıfırla Etiketi göster Bu düzen içe aktarılamadı + + Kızıl Kar + diff --git a/app/src/main/res/values-uk/strings.xml b/app/src/main/res/values-uk/strings.xml index d7058b222..772a19fa0 100644 --- a/app/src/main/res/values-uk/strings.xml +++ b/app/src/main/res/values-uk/strings.xml @@ -3731,4 +3731,7 @@ Скинути розташування Показувати підпис Не вдалося імпортувати цю розкладку + + Багряний сніг + diff --git a/app/src/main/res/values-vi/strings.xml b/app/src/main/res/values-vi/strings.xml index 9146f9834..5e55e9bab 100644 --- a/app/src/main/res/values-vi/strings.xml +++ b/app/src/main/res/values-vi/strings.xml @@ -3623,4 +3623,7 @@ Đặt lại vị trí Hiện nhãn Không thể nhập bố cục này + + Tuyết đỏ thẫm + diff --git a/app/src/main/res/values-zh-rCN/strings.xml b/app/src/main/res/values-zh-rCN/strings.xml index 337c239ae..82b07f23c 100644 --- a/app/src/main/res/values-zh-rCN/strings.xml +++ b/app/src/main/res/values-zh-rCN/strings.xml @@ -3627,4 +3627,7 @@ 重置位置 显示标签 无法导入此布局 + + 猩红之雪 + diff --git a/app/src/main/res/values-zh-rHK/strings.xml b/app/src/main/res/values-zh-rHK/strings.xml index 306952037..994470108 100644 --- a/app/src/main/res/values-zh-rHK/strings.xml +++ b/app/src/main/res/values-zh-rHK/strings.xml @@ -3626,4 +3626,7 @@ 重設位置 顯示標籤 無法匯入此佈局 + + 緋紅之雪 + diff --git a/app/src/main/res/values-zh-rTW/strings.xml b/app/src/main/res/values-zh-rTW/strings.xml index cd7c43967..f637a2520 100644 --- a/app/src/main/res/values-zh-rTW/strings.xml +++ b/app/src/main/res/values-zh-rTW/strings.xml @@ -3626,4 +3626,7 @@ 重設位置 顯示標籤 無法匯入此版面配置 + + 緋紅之雪 + diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index b5e086775..985966b15 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -3814,4 +3814,7 @@ Reset positions Show label Could not import this layout + + Scarlet Snow + White surfaces with red accents. From 17936530aa6fa9bd08670fbdf1850d7170776608 Mon Sep 17 00:00:00 2001 From: LunaDL <91587464+EnzoDLofi@users.noreply.github.com> Date: Tue, 10 Nov 2026 00:41:25 -0400 Subject: [PATCH 22/33] feat(display): add left and right picture alignment (#2384) --- app/src/main/cpp/waylandcomp/src/vk_present.c | 12 ++++-- app/src/main/cpp/waylandcomp/src/vk_present.h | 4 +- .../com/droiddeck/launcher/SessionActivity.kt | 42 ++++++++++++++++--- .../launcher/session/SessionPrefs.kt | 4 +- .../launcher/ui/ModeSettingsDialog.kt | 2 + app/src/main/res/values-ar/strings.xml | 2 + app/src/main/res/values-b+es+419/strings.xml | 2 + app/src/main/res/values-bg/strings.xml | 2 + app/src/main/res/values-cs/strings.xml | 2 + app/src/main/res/values-da/strings.xml | 2 + app/src/main/res/values-de/strings.xml | 2 + app/src/main/res/values-el/strings.xml | 2 + app/src/main/res/values-es/strings.xml | 2 + app/src/main/res/values-fi/strings.xml | 2 + app/src/main/res/values-fr/strings.xml | 2 + app/src/main/res/values-hi/strings.xml | 2 + app/src/main/res/values-hu/strings.xml | 2 + app/src/main/res/values-in/strings.xml | 2 + app/src/main/res/values-it/strings.xml | 2 + app/src/main/res/values-ja/strings.xml | 2 + app/src/main/res/values-ko/strings.xml | 2 + app/src/main/res/values-ms/strings.xml | 2 + app/src/main/res/values-nb/strings.xml | 2 + app/src/main/res/values-nl/strings.xml | 2 + app/src/main/res/values-pl/strings.xml | 2 + app/src/main/res/values-pt-rPT/strings.xml | 2 + app/src/main/res/values-pt/strings.xml | 2 + app/src/main/res/values-ro/strings.xml | 2 + app/src/main/res/values-ru/strings.xml | 2 + app/src/main/res/values-sv/strings.xml | 2 + app/src/main/res/values-th/strings.xml | 2 + app/src/main/res/values-tr/strings.xml | 2 + app/src/main/res/values-uk/strings.xml | 2 + app/src/main/res/values-vi/strings.xml | 2 + app/src/main/res/values-zh-rCN/strings.xml | 2 + app/src/main/res/values-zh-rHK/strings.xml | 2 + app/src/main/res/values-zh-rTW/strings.xml | 2 + app/src/main/res/values/strings.xml | 2 + 38 files changed, 117 insertions(+), 13 deletions(-) diff --git a/app/src/main/cpp/waylandcomp/src/vk_present.c b/app/src/main/cpp/waylandcomp/src/vk_present.c index 8d0267cc1..44fea0c76 100644 --- a/app/src/main/cpp/waylandcomp/src/vk_present.c +++ b/app/src/main/cpp/waylandcomp/src/vk_present.c @@ -213,7 +213,7 @@ static struct { } g_map; static pthread_mutex_t g_game_picture_lock = PTHREAD_MUTEX_INITIALIZER; static float g_game_picture_rect[4]; -static int g_game_picture_top; +static int g_game_picture_placed; static int64_t g_swap_retry_at_ns; static int g_swap_fail_logged; @@ -362,7 +362,7 @@ int vkp_completion_unknown(void) { return g_dev_state == -3; } void vk_present_set_scale_mode(int mode, int alignment) { if (mode < VKP_MODE_OFF || mode > VKP_MODE_INTEGER) mode = VKP_MODE_FIT; - if (alignment < VKP_ALIGN_CENTER || alignment > VKP_ALIGN_PICTURE_BOTTOM) alignment = VKP_ALIGN_CENTER; + if (alignment < VKP_ALIGN_CENTER || alignment > VKP_ALIGN_PICTURE_RIGHT) alignment = VKP_ALIGN_CENTER; g_mode = mode; g_align = alignment; } @@ -383,6 +383,8 @@ static const char *align_name(int a) { case VKP_ALIGN_BOTTOM: return "bottom"; case VKP_ALIGN_PICTURE_TOP: return "picture top"; case VKP_ALIGN_PICTURE_BOTTOM: return "picture bottom"; + case VKP_ALIGN_PICTURE_LEFT: return "picture left"; + case VKP_ALIGN_PICTURE_RIGHT: return "picture right"; default: return "center"; } } @@ -420,9 +422,11 @@ static void update_map(int scene_w, int scene_h) { } if (align == VKP_ALIGN_PICTURE_TOP) g_map.off_y = 0; else if (align == VKP_ALIGN_PICTURE_BOTTOM) g_map.off_y = H - scene_h * g_map.ky; + else if (align == VKP_ALIGN_PICTURE_LEFT) g_map.off_x = 0; + else if (align == VKP_ALIGN_PICTURE_RIGHT) g_map.off_x = W - scene_w * g_map.kx; g_map.valid = 1; pthread_mutex_lock(&g_game_picture_lock); - g_game_picture_top = align == VKP_ALIGN_PICTURE_TOP || align == VKP_ALIGN_PICTURE_BOTTOM; + g_game_picture_placed = align == VKP_ALIGN_PICTURE_TOP || align == VKP_ALIGN_PICTURE_BOTTOM || align == VKP_ALIGN_PICTURE_LEFT || align == VKP_ALIGN_PICTURE_RIGHT; g_game_picture_rect[0] = g_map.off_x; g_game_picture_rect[1] = g_map.off_y; g_game_picture_rect[2] = g_map.off_x + scene_w * g_map.kx; @@ -436,7 +440,7 @@ static void update_map(int scene_w, int scene_h) { int vkp_game_picture_rect(float rect[4]) { pthread_mutex_lock(&g_game_picture_lock); - int active = g_game_picture_top; + int active = g_game_picture_placed; if (active) memcpy(rect, g_game_picture_rect, sizeof(g_game_picture_rect)); pthread_mutex_unlock(&g_game_picture_lock); return active; diff --git a/app/src/main/cpp/waylandcomp/src/vk_present.h b/app/src/main/cpp/waylandcomp/src/vk_present.h index 3a76b4c0b..2a363979e 100644 --- a/app/src/main/cpp/waylandcomp/src/vk_present.h +++ b/app/src/main/cpp/waylandcomp/src/vk_present.h @@ -15,7 +15,9 @@ int vkp_quest_layer_presentation(void); enum vkp_scale_mode { VKP_MODE_OFF = 0, VKP_MODE_FIT = 1, VKP_MODE_STRETCH = 2, VKP_MODE_FILL = 3, VKP_MODE_INTEGER = 4 }; -enum vkp_align { VKP_ALIGN_CENTER = 0, VKP_ALIGN_TOP = 1, VKP_ALIGN_BOTTOM = 2, VKP_ALIGN_PICTURE_TOP = 3, VKP_ALIGN_PICTURE_BOTTOM = 4 }; +enum vkp_align { VKP_ALIGN_CENTER = 0, VKP_ALIGN_TOP = 1, VKP_ALIGN_BOTTOM = 2, + VKP_ALIGN_PICTURE_TOP = 3, VKP_ALIGN_PICTURE_BOTTOM = 4, + VKP_ALIGN_PICTURE_LEFT = 5, VKP_ALIGN_PICTURE_RIGHT = 6 }; void vk_present_set_scale_mode(int mode, int alignment); int vkp_game_picture_rect(float rect[4]); int vkp_output_to_scene(double ox, double oy, double *sx, double *sy); diff --git a/app/src/main/java/com/droiddeck/launcher/SessionActivity.kt b/app/src/main/java/com/droiddeck/launcher/SessionActivity.kt index a162ab14b..09ae78546 100644 --- a/app/src/main/java/com/droiddeck/launcher/SessionActivity.kt +++ b/app/src/main/java/com/droiddeck/launcher/SessionActivity.kt @@ -173,6 +173,8 @@ class SessionActivity : ComponentActivity(), SurfaceHolder.Callback { val alignment = if (!active) ALIGN_CENTER else when (picturePosition) { "top" -> ALIGN_PICTURE_TOP "bottom" -> ALIGN_PICTURE_BOTTOM + "left" -> ALIGN_PICTURE_LEFT + "right" -> ALIGN_PICTURE_RIGHT else -> ALIGN_CENTER } WaylandCompositor.nativeSetScaleMode(SCALE_FIT, alignment) @@ -3213,12 +3215,17 @@ class SessionActivity : ComponentActivity(), SurfaceHolder.Callback { cursorVisible = false return } - val rect = drawnRect() ?: return + val rect = if (glassesOutputActive) pointerSurfaceRect() else drawnRect() + val pointerRect = rect ?: return val position = cursorPos - val x = if (position.x >= 0f) position.x.coerceIn(rect.left, rect.right) else rect.centerX() - val y = if (position.y >= 0f) position.y.coerceIn(rect.top, rect.bottom) else rect.centerY() + val x = if (position.x >= 0f) position.x.coerceIn(pointerRect.left, pointerRect.right) else pointerRect.centerX() + val y = if (position.y >= 0f) position.y.coerceIn(pointerRect.top, pointerRect.bottom) else pointerRect.centerY() val out = SessionState.outputSize - movePointer(x + dx * rect.width() / out.first, y + dy * rect.height() / out.second) + val nextX = (x + dx * pointerRect.width() / out.first) + .coerceIn(pointerRect.left, pointerRect.right) + val nextY = (y + dy * pointerRect.height() / out.second) + .coerceIn(pointerRect.top, pointerRect.bottom) + movePointer(nextX, nextY) // Make frame-callback updates visible to drawing before this frame's traversal. androidx.compose.runtime.snapshots.Snapshot.sendApplyNotifications() } @@ -3927,8 +3934,29 @@ class SessionActivity : ComponentActivity(), SurfaceHolder.Callback { val width = outputView().width.takeIf { it > 0 } ?: return null val height = outputView().height.takeIf { it > 0 } ?: return null topPictureMapped = CompositorHost.isStarted && WaylandCompositor.nativeGamePictureRect(gamePictureBounds) - if (topPictureMapped) return android.graphics.RectF( - gamePictureBounds[0], gamePictureBounds[1], gamePictureBounds[2], gamePictureBounds[3]) + if (topPictureMapped) { + val rect = android.graphics.RectF( + gamePictureBounds[0], gamePictureBounds[1], + gamePictureBounds[2], gamePictureBounds[3] + ) + + if (glassesOutputActive) { + val mode = glassesScreen?.display?.mode ?: return null + val outputWidth = mode.physicalWidth.takeIf { it > 0 } ?: return null + val outputHeight = mode.physicalHeight.takeIf { it > 0 } ?: return null + val scaleX = width.toFloat() / outputWidth + val scaleY = height.toFloat() / outputHeight + + return android.graphics.RectF( + rect.left * scaleX, + rect.top * scaleY, + rect.right * scaleX, + rect.bottom * scaleY + ) + } + + return rect + } val out = SessionState.outputSize val scale = minOf(width / out.first.toFloat(), height / out.second.toFloat()) val drawnW = out.first * scale @@ -5493,6 +5521,8 @@ class SessionActivity : ComponentActivity(), SurfaceHolder.Callback { private const val ALIGN_CENTER = 0 private const val ALIGN_PICTURE_TOP = 3 private const val ALIGN_PICTURE_BOTTOM = 4 + private const val ALIGN_PICTURE_LEFT = 5 + private const val ALIGN_PICTURE_RIGHT = 6 private val RUNTIME_LINES = PackageLines( R.string.session_runtime_downloading, R.string.session_runtime_downloading_mb, R.string.session_runtime_checking, R.string.session_runtime_unpacking, diff --git a/app/src/main/java/com/droiddeck/launcher/session/SessionPrefs.kt b/app/src/main/java/com/droiddeck/launcher/session/SessionPrefs.kt index 27cffc335..c8e81550a 100644 --- a/app/src/main/java/com/droiddeck/launcher/session/SessionPrefs.kt +++ b/app/src/main/java/com/droiddeck/launcher/session/SessionPrefs.kt @@ -558,11 +558,11 @@ object SessionPrefs { fun picturePosition(context: Context): String = prefs(context).getString("picturePosition", "center") - ?.takeIf { it in listOf("top", "bottom", "center") } ?: "center" + ?.takeIf { it in listOf("top", "bottom", "left", "right", "center") } ?: "center" fun setPicturePosition(context: Context, position: String) { prefs(context).edit().putString("picturePosition", - position.takeIf { it in listOf("top", "bottom", "center") } ?: "center").apply() + position.takeIf { it in listOf("top", "bottom", "left", "right", "center") } ?: "center").apply() } fun returnAfterGame(context: Context): Boolean = prefs(context).getBoolean("returnAfterGame", true) diff --git a/app/src/main/java/com/droiddeck/launcher/ui/ModeSettingsDialog.kt b/app/src/main/java/com/droiddeck/launcher/ui/ModeSettingsDialog.kt index 8489f5cce..70fd435c9 100644 --- a/app/src/main/java/com/droiddeck/launcher/ui/ModeSettingsDialog.kt +++ b/app/src/main/java/com/droiddeck/launcher/ui/ModeSettingsDialog.kt @@ -224,6 +224,8 @@ fun ModeSheetRows(s: ModeSettings, a: ModeSettingsActions, tab: String, extras: ChoiceRow(host, "picture-position", stringResource(R.string.display_picture_position), null, listOf("top" to stringResource(R.string.display_position_top), "bottom" to stringResource(R.string.display_position_bottom), + "left" to stringResource(R.string.display_position_left), + "right" to stringResource(R.string.display_position_right), "center" to stringResource(R.string.display_position_center)), position, onPick = a.onPicturePosition) } diff --git a/app/src/main/res/values-ar/strings.xml b/app/src/main/res/values-ar/strings.xml index d95aac020..573c669b9 100644 --- a/app/src/main/res/values-ar/strings.xml +++ b/app/src/main/res/values-ar/strings.xml @@ -2976,6 +2976,8 @@ أعلى أسفل الوسط (افتراضي) + يسار + يمين التخزين جارٍ قياس التخزين… يستغرق القياس الأول بضع ثوانٍ. diff --git a/app/src/main/res/values-b+es+419/strings.xml b/app/src/main/res/values-b+es+419/strings.xml index 7e660349a..f38bdcee4 100644 --- a/app/src/main/res/values-b+es+419/strings.xml +++ b/app/src/main/res/values-b+es+419/strings.xml @@ -2828,6 +2828,8 @@ Arriba Abajo Centro (predeterminado) + Izquierda + Derecha Almacenamiento Midiendo el almacenamiento… La primera vez tarda unos segundos. diff --git a/app/src/main/res/values-bg/strings.xml b/app/src/main/res/values-bg/strings.xml index ffd8da7e9..36751e531 100644 --- a/app/src/main/res/values-bg/strings.xml +++ b/app/src/main/res/values-bg/strings.xml @@ -2860,6 +2860,8 @@ Горе Долу В средата (по подразбиране) + Наляво + Надясно Съхранение Измерване на съхранението… Първият път отнема няколко секунди. diff --git a/app/src/main/res/values-cs/strings.xml b/app/src/main/res/values-cs/strings.xml index af28f0a22..010374a13 100644 --- a/app/src/main/res/values-cs/strings.xml +++ b/app/src/main/res/values-cs/strings.xml @@ -2918,6 +2918,8 @@ Nahoře Dole Uprostřed (výchozí) + Vlevo + Vpravo Úložiště Měření úložiště… Poprvé to trvá pár sekund. diff --git a/app/src/main/res/values-da/strings.xml b/app/src/main/res/values-da/strings.xml index d6605c91d..f0c052736 100644 --- a/app/src/main/res/values-da/strings.xml +++ b/app/src/main/res/values-da/strings.xml @@ -2860,6 +2860,8 @@ Øverst Nederst Midten (standard) + Venstre + Højre Lager Måler lager… Første gang tager et par sekunder. diff --git a/app/src/main/res/values-de/strings.xml b/app/src/main/res/values-de/strings.xml index bd1c722ef..ad656b8bb 100644 --- a/app/src/main/res/values-de/strings.xml +++ b/app/src/main/res/values-de/strings.xml @@ -2860,6 +2860,8 @@ Oben Unten Mitte (Standard) + Links + Rechts Speicher Speicher wird erfasst… Die erste Erfassung dauert ein paar Sekunden. diff --git a/app/src/main/res/values-el/strings.xml b/app/src/main/res/values-el/strings.xml index d46aa61bf..0d7d8e1c9 100644 --- a/app/src/main/res/values-el/strings.xml +++ b/app/src/main/res/values-el/strings.xml @@ -2860,6 +2860,8 @@ Πάνω Κάτω Κέντρο (προεπιλογή) + Αριστερά + Δεξιά Αποθήκευση Μέτρηση αποθήκευσης… Την πρώτη φορά παίρνει λίγα δευτερόλεπτα. diff --git a/app/src/main/res/values-es/strings.xml b/app/src/main/res/values-es/strings.xml index e1fa53600..1e7fb4865 100644 --- a/app/src/main/res/values-es/strings.xml +++ b/app/src/main/res/values-es/strings.xml @@ -2889,6 +2889,8 @@ Arriba Abajo Centro (predeterminado) + Izquierda + Derecha Almacenamiento Midiendo el almacenamiento… La primera vez tarda unos segundos. diff --git a/app/src/main/res/values-fi/strings.xml b/app/src/main/res/values-fi/strings.xml index 1f88c5a89..ba86de442 100644 --- a/app/src/main/res/values-fi/strings.xml +++ b/app/src/main/res/values-fi/strings.xml @@ -2860,6 +2860,8 @@ Ylhäällä Alhaalla Keskellä (oletus) + Vasen + Oikea Tallennustila Mitataan tallennustilaa… Ensimmäisellä kerralla kestää muutaman sekunnin. diff --git a/app/src/main/res/values-fr/strings.xml b/app/src/main/res/values-fr/strings.xml index 9590c0633..02c4953bf 100644 --- a/app/src/main/res/values-fr/strings.xml +++ b/app/src/main/res/values-fr/strings.xml @@ -2889,6 +2889,8 @@ Haut Bas Centre (par défaut) + Gauche + Droite Stockage Mesure du stockage… Cela prend quelques secondes la première fois. diff --git a/app/src/main/res/values-hi/strings.xml b/app/src/main/res/values-hi/strings.xml index 7a055b6f2..cdacc2c56 100644 --- a/app/src/main/res/values-hi/strings.xml +++ b/app/src/main/res/values-hi/strings.xml @@ -2860,6 +2860,8 @@ ऊपर नीचे बीच में (डिफ़ॉल्ट) + बाएँ + दाएँ स्टोरेज स्टोरेज मापा जा रहा है… पहली बार कुछ सेकंड लगते हैं। diff --git a/app/src/main/res/values-hu/strings.xml b/app/src/main/res/values-hu/strings.xml index 67b038ba0..3743a2636 100644 --- a/app/src/main/res/values-hu/strings.xml +++ b/app/src/main/res/values-hu/strings.xml @@ -2860,6 +2860,8 @@ Fent Lent Középen (alapértelmezett) + Bal + Jobb Tárhely Tárhely mérése… Első alkalommal pár másodpercig tart. diff --git a/app/src/main/res/values-in/strings.xml b/app/src/main/res/values-in/strings.xml index 0a24790cd..395b19925 100644 --- a/app/src/main/res/values-in/strings.xml +++ b/app/src/main/res/values-in/strings.xml @@ -2831,6 +2831,8 @@ Atas Bawah Tengah (bawaan) + Kiri + Kanan Penyimpanan Mengukur penyimpanan… Butuh beberapa detik pada kali pertama. diff --git a/app/src/main/res/values-it/strings.xml b/app/src/main/res/values-it/strings.xml index 2afa9ecf0..9d7e3392b 100644 --- a/app/src/main/res/values-it/strings.xml +++ b/app/src/main/res/values-it/strings.xml @@ -2889,6 +2889,8 @@ In alto In basso Centro (predefinito) + Sinistra + Destra Archiviazione Misurazione dell\'archiviazione… La prima volta richiede qualche secondo. diff --git a/app/src/main/res/values-ja/strings.xml b/app/src/main/res/values-ja/strings.xml index 8d68c1fee..2be588f1c 100644 --- a/app/src/main/res/values-ja/strings.xml +++ b/app/src/main/res/values-ja/strings.xml @@ -2831,6 +2831,8 @@ 上 下 中央(既定) + 左 + 右 ストレージ ストレージを集計しています… 初回の集計には数秒かかります。 diff --git a/app/src/main/res/values-ko/strings.xml b/app/src/main/res/values-ko/strings.xml index 457e29095..457511bbd 100644 --- a/app/src/main/res/values-ko/strings.xml +++ b/app/src/main/res/values-ko/strings.xml @@ -2831,6 +2831,8 @@ 위 아래 가운데(기본) + 왼쪽 + 오른쪽 저장 공간 저장 공간 계산 중… 처음 계산할 때는 몇 초 걸립니다. diff --git a/app/src/main/res/values-ms/strings.xml b/app/src/main/res/values-ms/strings.xml index 38a0f4a4c..10524febe 100644 --- a/app/src/main/res/values-ms/strings.xml +++ b/app/src/main/res/values-ms/strings.xml @@ -2831,6 +2831,8 @@ Atas Bawah Tengah (lalai) + Kiri + Kanan Storan Mengukur storan… Mengambil beberapa saat pada kali pertama. diff --git a/app/src/main/res/values-nb/strings.xml b/app/src/main/res/values-nb/strings.xml index e7a36fb43..8ac2561ba 100644 --- a/app/src/main/res/values-nb/strings.xml +++ b/app/src/main/res/values-nb/strings.xml @@ -2860,6 +2860,8 @@ Øverst Nederst Midten (standard) + Venstre + Høyre Lagring Måler lagring… Første gang tar noen sekunder. diff --git a/app/src/main/res/values-nl/strings.xml b/app/src/main/res/values-nl/strings.xml index 3f2447e8c..a7bd52c01 100644 --- a/app/src/main/res/values-nl/strings.xml +++ b/app/src/main/res/values-nl/strings.xml @@ -2860,6 +2860,8 @@ Boven Onder Midden (standaard) + Links + Rechts Opslag Opslag meten… De eerste keer duurt een paar seconden. diff --git a/app/src/main/res/values-pl/strings.xml b/app/src/main/res/values-pl/strings.xml index 6591b0473..4d00ba2ee 100644 --- a/app/src/main/res/values-pl/strings.xml +++ b/app/src/main/res/values-pl/strings.xml @@ -2918,6 +2918,8 @@ Góra Dół Środek (domyślnie) + Lewo + Prawo Pamięć Mierzenie pamięci… Pierwszy raz trwa kilka sekund. diff --git a/app/src/main/res/values-pt-rPT/strings.xml b/app/src/main/res/values-pt-rPT/strings.xml index e0fe08437..17ff8cbac 100644 --- a/app/src/main/res/values-pt-rPT/strings.xml +++ b/app/src/main/res/values-pt-rPT/strings.xml @@ -2887,6 +2887,8 @@ Espaço livre desconhecido Posição da imagem Topo + Esquerda + Direita Fundo Centro (predefinido) Armazenamento diff --git a/app/src/main/res/values-pt/strings.xml b/app/src/main/res/values-pt/strings.xml index b637c52e2..73d1a4c4c 100644 --- a/app/src/main/res/values-pt/strings.xml +++ b/app/src/main/res/values-pt/strings.xml @@ -2887,6 +2887,8 @@ Espaço livre desconhecido Posição da imagem Topo + Esquerda + Direita Base Centro (padrão) Armazenamento diff --git a/app/src/main/res/values-ro/strings.xml b/app/src/main/res/values-ro/strings.xml index 80366bf4b..d3b8b9269 100644 --- a/app/src/main/res/values-ro/strings.xml +++ b/app/src/main/res/values-ro/strings.xml @@ -2889,6 +2889,8 @@ Sus Jos Centru (implicit) + Stânga + Dreapta Stocare Se măsoară stocarea… Prima dată durează câteva secunde. diff --git a/app/src/main/res/values-ru/strings.xml b/app/src/main/res/values-ru/strings.xml index 2f10db8d0..89dc1b2d7 100644 --- a/app/src/main/res/values-ru/strings.xml +++ b/app/src/main/res/values-ru/strings.xml @@ -3001,6 +3001,8 @@ Сверху Снизу По центру (по умолчанию) + Слева + Справа Хранилище Измерение хранилища… В первый раз занимает несколько секунд. diff --git a/app/src/main/res/values-sv/strings.xml b/app/src/main/res/values-sv/strings.xml index b121e1707..c8a3088a0 100644 --- a/app/src/main/res/values-sv/strings.xml +++ b/app/src/main/res/values-sv/strings.xml @@ -2860,6 +2860,8 @@ Överst Nederst Mitten (standard) + Vänster + Höger Lagring Mäter lagring… Första gången tar några sekunder. diff --git a/app/src/main/res/values-th/strings.xml b/app/src/main/res/values-th/strings.xml index 9b87a4105..4bbe6cf46 100644 --- a/app/src/main/res/values-th/strings.xml +++ b/app/src/main/res/values-th/strings.xml @@ -2831,6 +2831,8 @@ บน ล่าง กลาง (ค่าเริ่มต้น) + ซ้าย + ขวา พื้นที่จัดเก็บ กำลังวัดพื้นที่… ครั้งแรกใช้เวลาสองสามวินาที diff --git a/app/src/main/res/values-tr/strings.xml b/app/src/main/res/values-tr/strings.xml index 86532ab77..1d53a2d47 100644 --- a/app/src/main/res/values-tr/strings.xml +++ b/app/src/main/res/values-tr/strings.xml @@ -2860,6 +2860,8 @@ Üst Alt Orta (varsayılan) + Sol + Sağ Depolama Depolama ölçülüyor… İlk seferde birkaç saniye sürer. diff --git a/app/src/main/res/values-uk/strings.xml b/app/src/main/res/values-uk/strings.xml index 772a19fa0..79eb4138e 100644 --- a/app/src/main/res/values-uk/strings.xml +++ b/app/src/main/res/values-uk/strings.xml @@ -2918,6 +2918,8 @@ Угорі Унизу По центру (типово) + Ліворуч + Праворуч Сховище Вимірювання сховища… Уперше триває кілька секунд. diff --git a/app/src/main/res/values-vi/strings.xml b/app/src/main/res/values-vi/strings.xml index 5e55e9bab..537168797 100644 --- a/app/src/main/res/values-vi/strings.xml +++ b/app/src/main/res/values-vi/strings.xml @@ -2831,6 +2831,8 @@ Trên Dưới Giữa (mặc định) + Trái + Phải Lưu trữ Đang đo dung lượng… Lần đầu mất vài giây. diff --git a/app/src/main/res/values-zh-rCN/strings.xml b/app/src/main/res/values-zh-rCN/strings.xml index 82b07f23c..8f0ab9c57 100644 --- a/app/src/main/res/values-zh-rCN/strings.xml +++ b/app/src/main/res/values-zh-rCN/strings.xml @@ -2835,6 +2835,8 @@ 置顶 置底 居中(默认) + 左侧 + 右侧 存储 正在统计存储… 首次统计需要几秒钟。 diff --git a/app/src/main/res/values-zh-rHK/strings.xml b/app/src/main/res/values-zh-rHK/strings.xml index 994470108..b8d2bf084 100644 --- a/app/src/main/res/values-zh-rHK/strings.xml +++ b/app/src/main/res/values-zh-rHK/strings.xml @@ -2831,6 +2831,8 @@ 置頂 置底 置中(預設) + 左側 + 右側 儲存空間 正在統計儲存空間… 首次統計需要幾秒鐘。 diff --git a/app/src/main/res/values-zh-rTW/strings.xml b/app/src/main/res/values-zh-rTW/strings.xml index f637a2520..8b883d4a8 100644 --- a/app/src/main/res/values-zh-rTW/strings.xml +++ b/app/src/main/res/values-zh-rTW/strings.xml @@ -2831,6 +2831,8 @@ 置頂 置底 置中(預設) + 左側 + 右側 儲存空間 正在統計儲存空間… 首次統計需要幾秒鐘。 diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 985966b15..6a7a221a0 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -3106,6 +3106,8 @@ Experimental Pro 2 head tracking. Brightness, volume, and display switching are not supported yet. Picture position Top + Left + Right Bottom Center (default) 6DoF virtual screen From 621fd8d43eac7ec89b0ff625cbbe09acdc1f6c7f Mon Sep 17 00:00:00 2001 From: MaxsTechReview Date: Mon, 9 Nov 2026 23:58:24 -0500 Subject: [PATCH 23/33] fix(gog): install owned DLC (#2348) --- .../launcher/stores/gog/GogContent.kt | 46 +- .../stores/gog/GogDownloadManager.java | 393 +----------------- .../launcher/stores/gog/GogMaintenance.kt | 28 +- .../launcher/stores/gog/GogNative.kt | 2 - .../launcher/stores/gog/GogCdnTest.kt | 6 +- .../launcher/stores/gog/GogMaintenanceTest.kt | 54 ++- .../launcher/stores/gog/GogResumeSpaceTest.kt | 43 -- 7 files changed, 120 insertions(+), 452 deletions(-) delete mode 100644 app/src/test/java/com/droiddeck/launcher/stores/gog/GogResumeSpaceTest.kt diff --git a/app/src/main/java/com/droiddeck/launcher/stores/gog/GogContent.kt b/app/src/main/java/com/droiddeck/launcher/stores/gog/GogContent.kt index 3d86fd585..8ab48333e 100644 --- a/app/src/main/java/com/droiddeck/launcher/stores/gog/GogContent.kt +++ b/app/src/main/java/com/droiddeck/launcher/stores/gog/GogContent.kt @@ -16,6 +16,7 @@ internal object GogContent { fun token(context: Context) = GogAuth.validToken(context) ?: error("Sign in to GOG and retry") + @JvmStatic fun builds(id: String, token: String): JSONObject { val result = json("$SYSTEM/products/$id/os/windows/builds?generation=2", token) if (result.optJSONArray("items")?.length() != 0) return result @@ -24,6 +25,36 @@ internal object GogContent { fun product(id: String, token: String): JSONObject = json("https://api.gog.com/products/$id?expand=downloads", token) + @JvmStatic + fun ownedProductIds(token: String): Set? = runCatching { + val body = StoreNet.get("https://embed.gog.com/user/data/games", bearer = token) ?: return@runCatching null + JSONObject(body).optJSONArray("owned")?.strings()?.toSet() ?: emptySet() + }.getOrNull() + + @JvmStatic + fun depotProducts(depot: JSONObject, baseProductId: String): List = + depot.optJSONArray("gameIDs")?.strings() ?: listOf(depot.optString("productId").ifEmpty { baseProductId }) + + @JvmStatic + fun clientCredentials(manifest: JSONObject): Pair? { + val clientId = manifest.optString("clientId") + val clientSecret = manifest.optString("clientSecret") + return clientId.takeIf { it.isNotBlank() }?.let { id -> clientSecret.takeIf { it.isNotBlank() }?.let { id to it } } + } + + @JvmStatic + fun tempExecutable(manifest: JSONObject): String? = manifest.optJSONArray("products")?.optJSONObject(0) + ?.optString("temp_executable")?.takeIf { it.isNotBlank() } + + @JvmStatic + fun installProducts(baseProductId: String, depots: JSONArray, installed: List, token: String, + ownership: (String) -> Set? = { ownedProductIds(it) }): Set { + val listed = depots.objects().flatMap { depotProducts(it, baseProductId) }.distinct() + val owned = if (listed.any { it != baseProductId } || installed.any { it != baseProductId }) ownership(token) else emptySet() + check(installed.all { it == baseProductId || owned?.contains(it) == true }) { "Could not confirm ownership of installed GOG DLC" } + return (listOf(baseProductId) + installed + listed.filter { it == baseProductId || owned?.contains(it) == true }).toSet() + } + fun downloadFilename(url: String): String = java.net.URLDecoder.decode(URL(url).path.substringAfterLast('/').replace("+", "%2B"), "UTF-8") fun resolveDownload(download: GogOfflineDownload, token: String): GogOfflineDownload = download.copy(parts = download.parts.map { part -> @@ -42,6 +73,7 @@ internal object GogContent { } finally { connection.disconnect() } }) + @JvmStatic fun json(url: String, token: String? = null): JSONObject { require(URL(url).protocol == "https") { "GOG metadata requires HTTPS" } val connection = URL(url).openConnection() as HttpURLConnection @@ -61,21 +93,21 @@ internal object GogContent { } fun snapshot(old: GogInstallRecord, build: GogBuild, token: String, - metadata: (String, String?) -> JSONObject = ::json): GogInstallRecord { + ownership: (String) -> Set? = { ownedProductIds(it) }, + metadata: (String, String?) -> JSONObject = ::json, + onManifest: (JSONObject) -> Unit = {}): GogInstallRecord { check(build.link.isNotBlank()) { "GOG build manifest is unavailable" } val manifest = metadata(build.link, token) + onManifest(manifest) val files = JSONArray() val depots = if (build.generation == 1) manifest.optJSONObject("product")?.optJSONArray("depots") ?: manifest.optJSONArray("depot") else manifest.optJSONArray("depots") check(depots != null) { "GOG build has no depot list" } val resolvedLanguage = StoreLanguage.pick(StoreLanguage.codesFromDepots(depots), old.language) val selectedBitness = if (depots.objects().any { it.optJSONArray("osBitness")?.strings()?.contains("64") == true }) "64" else "32" - val owned = if (old.products.any { it != old.id }) StoreNet.get("https://embed.gog.com/user/data/games", bearer = token)?.let { JSONObject(it).optJSONArray("owned")?.strings()?.toSet() } else emptySet() - val products = old.products.filter { it == old.id || owned?.contains(it) == true } - check(old.products.all { it == old.id || owned?.contains(it) == true }) { "Could not confirm ownership of installed GOG DLC" } + val products = installProducts(old.id, depots, old.products, token, ownership) for (depot in depots.objects()) { if (depot.has("redist")) continue - val ids = depot.optJSONArray("gameIDs")?.strings() ?: listOf(depot.optString("productId", old.id)) - val product = ids.firstOrNull { it in products } ?: continue + val product = depotProducts(depot, old.id).firstOrNull { it in products } ?: continue val languages = depot.optJSONArray("languages")?.strings().orEmpty() if (!languageSelected(languages, resolvedLanguage)) continue val bitness = depot.optJSONArray("osBitness")?.strings().orEmpty() @@ -108,7 +140,7 @@ internal object GogContent { } check(files.length() > 0) { "GOG build contains no matching files" } return old.copy(source = "depot", generation = build.generation, build = build.id, version = build.version, - branch = build.branch, language = resolvedLanguage, manifest = sanitize(manifest), files = files, products = products, verified = true) + branch = build.branch, language = resolvedLanguage, manifest = sanitize(manifest), files = files, products = products.toList(), verified = true) } fun languageSelected(list: List, selected: String): Boolean = list.any { it.equals("Neutral", true) } || StoreLanguage.depotMatchesResolved(list.toTypedArray(), selected) diff --git a/app/src/main/java/com/droiddeck/launcher/stores/gog/GogDownloadManager.java b/app/src/main/java/com/droiddeck/launcher/stores/gog/GogDownloadManager.java index 352fa5c1f..a09629bc4 100644 --- a/app/src/main/java/com/droiddeck/launcher/stores/gog/GogDownloadManager.java +++ b/app/src/main/java/com/droiddeck/launcher/stores/gog/GogDownloadManager.java @@ -125,211 +125,22 @@ public final class GogDownloadManager { private static Gen2Outcome runGen2(Context ctx, GogGame game, String token, String buildsJson, File installPath, Callback cb, AtomicBoolean cancelled, String language) { Gen2Outcome out = new Gen2Outcome(); try { - cb.onProgress("Fetching manifest…", 5); - Gen2Manifest gm = resolveGen2Manifest(buildsJson, token, cb); - if (gm == null) { out.error = "gen2 manifest unavailable"; return out; } - JSONObject manifest = gm.manifest; - JSONArray depots = manifest.optJSONArray("depots"); - if (depots == null) { out.error = "no depots in manifest"; return out; } - JSONArray products = manifest.optJSONArray("products"); - String tempExe = null; - if (products != null && products.length() > 0) { - tempExe = products.getJSONObject(0).optString("temp_executable", null); - if (tempExe != null && tempExe.isEmpty()) tempExe = null; - } - String baseProductId = manifest.optString("baseProductId", null); - if ((baseProductId == null || baseProductId.isEmpty()) && products != null && products.length() > 0) - baseProductId = products.getJSONObject(0).optString("productId", null); - if (baseProductId == null || baseProductId.isEmpty()) baseProductId = game.gameId; - JSONArray deps = manifest.optJSONArray("dependencies"); - List dependencies = new ArrayList<>(); - if (deps != null) for (int i = 0; i < deps.length(); i++) { String d = deps.optString(i, ""); if (!d.isEmpty()) dependencies.add(d); } - if (!dependencies.isEmpty()) cb.onLog("gog: build lists dependencies " + deps); - - cb.onProgress("Reading depot manifests…", 10); - List files = new ArrayList<>(); - List depotJsons = new ArrayList<>(); - JSONArray owned = new JSONArray(); - List ownedDepots = new ArrayList<>(); - for (int i = 0; i < depots.length(); i++) { - JSONObject depot = depots.getJSONObject(i); - String depotPid = depot.optString("productId", ""); - if (!depotPid.isEmpty() && !depotPid.equals(baseProductId)) continue; - owned.put(depot); - ownedDepots.add(depot); - } - String resolved = StoreLanguage.pick(StoreLanguage.codesFromDepots(owned), language); - cb.onLog("gog: language " + resolved); - for (int i = 0; i < ownedDepots.size(); i++) { - if (cancelled.get()) return out; - JSONObject depot = ownedDepots.get(i); - if (!depotSelected(depot, resolved)) continue; - String manifestHash = depot.optString("manifest"); - if (manifestHash == null || manifestHash.isEmpty()) continue; - byte[] dmRaw = fetchBytes("https://gog-cdn-fastly.gog.com/content-system/v2/meta/" + buildCdnPath(manifestHash), null); - if (dmRaw == null) { cb.onLog("gog: depot " + i + " meta fetch failed"); continue; } - String dmStr = decompressBytes(dmRaw); - if (dmStr == null) { cb.onLog("gog: depot " + i + " decompress failed"); continue; } - parseDepotManifest(dmStr, files); - depotJsons.add(dmStr); - } - if (files.isEmpty()) { out.error = "no depot files collected"; return out; } - - cb.onProgress("Fetching CDN link…", 15); - String secureLinkUrl = "https://content-system.gog.com/products/" + baseProductId + "/secure_link?_version=2&generation=2&path=/"; - String cdnBase = parseCdnUrl(httpGet(secureLinkUrl, token)); - if (cdnBase == null) { out.error = "secure link refused"; return out; } - - installPath.mkdirs(); - long plannedBytes = 0; - for (DepotFile df : files) plannedBytes += df.totalSize; - long remainingBytes = remainingInstallBytes(files, installPath); - if (remainingBytes > 0) { - long usable = com.droiddeck.launcher.stores.StoreInstallRoot.availableBytes(installPath); - if (com.droiddeck.launcher.stores.StoreInstallRoot.insufficientSpace(remainingBytes, usable)) { - out.hardStop = true; - out.error = "Not enough free space: need " + formatBytes(remainingBytes) + ", only " + formatBytes(usable) + " free"; - return out; - } - } - Collections.sort(files, (a, b) -> Long.compare(b.totalSize, a.totalSize)); - - final int total = files.size(); - final long planned = plannedBytes; - final AtomicInteger doneCount = new AtomicInteger(0); - final AtomicLong totalBytes = new AtomicLong(0); - final AtomicLong lastSpeedMs = new AtomicLong(System.currentTimeMillis()); - final AtomicLong lastSpeedB = new AtomicLong(0); - final AtomicLong speedBps = new AtomicLong(0); - final AtomicBoolean anyFailed = new AtomicBoolean(false); - final AtomicReference cdnBaseRef = new AtomicReference<>(cdnBase); - final AtomicInteger cdnRefreshCount = new AtomicInteger(0); - final int MAX_CDN_REFRESH = 5; - cb.onSizes(planned, planned); - cb.onBytes(0, planned, 0); - cb.onLog("gog: " + total + " files, " + formatBytes(planned) + ", base=" + baseProductId); - - int threads = downloadThreads(); - if (GogNative.isAvailable()) { - runNativeEngine(ctx, GogNative.KIND_GEN2_CHUNKS, "Verified…", depotJsons, installPath, cdnBaseRef, secureLinkUrl, token, - cdnRefreshCount, MAX_CDN_REFRESH, threads, threads, true, "gog base=" + baseProductId, - cancelled, anyFailed, doneCount, total, planned, totalBytes, lastSpeedMs, lastSpeedB, speedBps, cb); - } else { - cb.onLog("gog: engine=built-in (" + threads + " threads)"); - ExecutorService pool = Executors.newFixedThreadPool(threads, com.droiddeck.launcher.stores.download.DownloadQueue.workerFactory("gog-dl")); - List> futures = new ArrayList<>(); - for (DepotFile df : files) { - futures.add(pool.submit((Callable) () -> { - if (cancelled.get() || anyFailed.get()) return null; - File outFile = new File(installPath, df.relativePath); - outFile.getParentFile().mkdirs(); - if (fileVerified(outFile, df.totalSize, df.md5)) { - int done = doneCount.incrementAndGet(); - long tb = totalBytes.addAndGet(df.totalSize); - cb.onProgress("Verified…", 15 + (int) ((done / (float) total) * 80)); - cb.onBytes(tb, planned, speedBps.get()); - return null; - } - boolean ok = assembleDepotFile(df, outFile, cdnBaseRef, secureLinkUrl, token, cdnRefreshCount, MAX_CDN_REFRESH, cancelled, cb); - if (cancelled.get()) return null; - if (!ok) { cb.onLog("FAIL file=" + df.relativePath); anyFailed.set(true); return null; } - int done = doneCount.incrementAndGet(); - long tb = totalBytes.addAndGet(df.totalSize); - sampleSpeed(tb, lastSpeedMs, lastSpeedB, speedBps); - cb.onProgress("Downloading: " + baseName(df.relativePath) + speedSuffix(speedBps.get()), 15 + (int) ((done / (float) total) * 80)); - cb.onBytes(tb, planned, speedBps.get()); - return null; - })); - } - pool.shutdown(); - try { - for (Future f : futures) f.get(); - } catch (Exception e) { - pool.shutdownNow(); - out.error = "download pool error: " + e.getClass().getSimpleName(); - return out; - } - } - if (cancelled.get()) return out; - if (anyFailed.get()) { out.error = "one or more chunks failed to download"; return out; } - - cb.onProgress("Finishing…", 96); - try { - GogMaintenance.captureDepot(game.gameId, installPath, gm.buildId, buildsJson, manifest, new JSONArray(depotJsons), resolved); - } catch (Exception e) { - cb.onLog("gog: maintenance inventory not recorded: " + e.getMessage()); - } - writeFile(new File(installPath, MARKER), ("{\"gameId\":\"" + game.gameId + "\",\"buildId\":\"" + gm.buildId + "\"}").getBytes("UTF-8")); - deleteCounted(new File(installPath, ".gog_chunks"), cb); - String clientId = manifest.optString("clientId", null); - if (clientId != null && !clientId.isEmpty()) GogPrefs.get(ctx).edit().putString("client_id_" + game.gameId, clientId).apply(); - String clientSecret = manifest.optString("clientSecret", null); - if (clientSecret != null && !clientSecret.isEmpty()) GogPrefs.get(ctx).edit().putString("client_secret_" + game.gameId, clientSecret).apply(); - GameTask task = pickTask(installPath, game.gameId, tempExe, game.title); - out.result = new Result(task == null ? "" : task.path, gm.buildId, planned); - if (task != null) out.result.workDir = task.workDir; - out.result.dependencies = dependencies; - out.result.dependenciesRead = true; - out.result.language = resolved; - return out; + out.result = GogMaintenance.installContent(ctx, game, installPath, new JSONObject(buildsJson), language, cb, cancelled); + if (out.result == null && !cancelled.get()) out.error = "no usable public Windows build"; } catch (Exception e) { out.error = "exception: " + e.getClass().getSimpleName() + (e.getMessage() != null ? ": " + e.getMessage() : ""); return out; } - } - - private static final class Gen2Manifest { - final JSONObject manifest; final String buildId; - Gen2Manifest(JSONObject manifest, String buildId) { this.manifest = manifest; this.buildId = buildId; } - } - - private static Gen2Manifest resolveGen2Manifest(String buildsJson, String token, Callback cb) { - try { - JSONArray items = new JSONObject(buildsJson).optJSONArray("items"); - if (items == null || items.length() == 0) { cb.onLog("gog: no gen2 builds"); return null; } - String buildId = null, manifestUrl = null; - for (int i = 0; i < items.length(); i++) { - JSONObject item = items.getJSONObject(i); - if ("windows".equals(item.optString("os")) && (item.isNull("branch") || item.optString("branch").isEmpty())) { - buildId = item.optString("build_id"); - manifestUrl = item.optString("link"); - if (manifestUrl == null || manifestUrl.isEmpty()) manifestUrl = item.optString("meta_url"); - break; - } - } - if (buildId == null || manifestUrl == null || manifestUrl.isEmpty()) { cb.onLog("gog: no windows build"); return null; } - byte[] raw = fetchBytes(manifestUrl, token); - if (raw == null) { cb.onLog("gog: manifest fetch failed"); return null; } - String str = decompressBytes(raw); - if (str == null) { cb.onLog("gog: manifest decompress failed"); return null; } - return new Gen2Manifest(new JSONObject(str), buildId); - } catch (Exception e) { - cb.onLog("gog: manifest " + e.getClass().getSimpleName()); - return null; - } - } - - private static boolean depotSelected(JSONObject depot, String resolved) { - JSONArray languages = depot.optJSONArray("languages"); - if (languages == null || languages.length() == 0) return true; - String[] listed = new String[languages.length()]; - for (int i = 0; i < languages.length(); i++) listed[i] = languages.optString(i, ""); - return StoreLanguage.depotMatchesResolved(listed, resolved); + return out; } public static List listLanguages(Context ctx, String gameId) { try { String token = GogAuth.validToken(ctx); - String buildsUrl = "https://content-system.gog.com/products/" + gameId + "/os/windows/builds?generation=2"; - String buildsJson = httpGet(buildsUrl, null); - if (buildsJson == null) buildsJson = httpGet(buildsUrl, token); - if (buildsJson == null) return Collections.emptyList(); - Callback silent = new Callback() { - @Override public void onProgress(String message, int pct) {} - }; - Gen2Manifest gm = resolveGen2Manifest(buildsJson, token, silent); - if (gm == null) return Collections.emptyList(); - return StoreLanguage.codesFromDepots(gm.manifest.optJSONArray("depots")); + JSONObject builds = GogContent.builds(gameId, token); + GogBuild build = GogBuild.Companion.select(builds, "", ""); + if (build == null || build.getGeneration() != 2) return Collections.emptyList(); + return StoreLanguage.codesFromDepots(GogContent.json(build.getLink(), token).optJSONArray("depots")); } catch (Exception e) { return Collections.emptyList(); } @@ -398,7 +209,7 @@ public final class GogDownloadManager { int threads = downloadThreads(); if (GogNative.isAvailable()) { runNativeEngine(ctx, GogNative.KIND_GEN1_RANGES, "Resuming…", Collections.singletonList(manifestStr), installPath, - new AtomicReference<>(""), null, null, new AtomicInteger(0), 0, threads, threads, false, "gog gen1=" + game.gameId, + threads, threads, false, "gog gen1=" + game.gameId, cancelled, anyFailed, done, total, planned, totalBytes, lastSpeedMs, lastSpeedB, speedBps, cb); } else { ExecutorService pool = Executors.newFixedThreadPool(threads, com.droiddeck.launcher.stores.download.DownloadQueue.workerFactory("gog-dl")); @@ -659,8 +470,7 @@ public final class GogDownloadManager { private static void runNativeEngine(Context ctx, int kind, String verifiedMsg, List depotJsons, File installPath, - AtomicReference cdnBaseRef, String secureLinkUrl, String token, - AtomicInteger cdnRefreshCount, int maxCdnRefresh, int maxWorkers, int processWorkers, + int maxWorkers, int processWorkers, boolean sortLargestFirst, String label, AtomicBoolean cancelled, AtomicBoolean anyFailed, AtomicInteger doneCount, int total, long planned, AtomicLong totalBytes, AtomicLong lastSpeedMs, AtomicLong lastSpeedB, AtomicLong speedBps, Callback cb) { @@ -673,10 +483,8 @@ public final class GogDownloadManager { int run = 0; while (!cancelled.get()) { run++; - final String base = cdnBaseRef.get(); final CountDownLatch latch = new CountDownLatch(1); final AtomicBoolean okRef = new AtomicBoolean(false); - final AtomicBoolean linkExpiryRef = new AtomicBoolean(false); final AtomicReference errRef = new AtomicReference<>(""); final long runStart = totalBytes.get(); final AtomicLong verifiedInRun = new AtomicLong(0); @@ -702,12 +510,10 @@ public final class GogDownloadManager { @Override public void onLog(String line) { cb.onLog(line); } @Override public void onMaintenance(long networkBytes, long reusedBytes, long appliedBytes) {} @Override public void onComplete(boolean success, boolean wasCancelled, boolean linkExpiry, String error, long bytesWritten, int filesDone) { - okRef.set(success); linkExpiryRef.set(linkExpiry); errRef.set(error == null ? "" : error); latch.countDown(); + okRef.set(success); errRef.set(error == null ? "" : error); latch.countDown(); } }; - String[] bases = (base == null || base.isEmpty()) ? new String[0] : base.split("\n"); - if (run == 1) cb.onLog("gog: " + bases.length + " CDN host(s) offered"); - long handle = GogNative.start(kind, manifests, bases, installPath.getAbsolutePath(), donePaths.toArray(new String[0]), "", + long handle = GogNative.start(kind, manifests, new String[0], installPath.getAbsolutePath(), donePaths.toArray(new String[0]), "", workers, process, sortLargestFirst, label + " run=" + run, listener); if (handle == 0L) { cb.onLog("gog: native start failed"); anyFailed.set(true); return; } boolean interrupted = false; @@ -721,85 +527,13 @@ public final class GogDownloadManager { } if (interrupted) { Thread.currentThread().interrupt(); anyFailed.set(true); return; } if (cancelled.get() || okRef.get()) return; - String err = errRef.get(); - if (linkExpiryRef.get() && tryRefreshCdn(cdnBaseRef, base, secureLinkUrl, token, cdnRefreshCount, maxCdnRefresh, cb)) { - cb.onLog("gog: secure link expired (" + err + "); refreshed, running again for the remaining files"); - continue; - } - cb.onLog("gog: engine failed: " + err); + cb.onLog("gog: engine failed: " + errRef.get()); anyFailed.set(true); return; } } - private static void parseDepotManifest(String json, List out) { - try { - JSONObject depotObj = new JSONObject(json).optJSONObject("depot"); - if (depotObj == null) return; - JSONArray depot = depotObj.optJSONArray("items"); - if (depot == null) return; - for (int i = 0; i < depot.length(); i++) { - JSONObject entry = depot.getJSONObject(i); - String path = entry.optString("path", "").replace("\\", "/"); - if (path.startsWith("/")) path = path.substring(1); - JSONArray chunks = entry.optJSONArray("chunks"); - if (path.isEmpty() || chunks == null || chunks.length() == 0) continue; - DepotFile df = new DepotFile(path); - String fileMd5 = entry.optString("md5", null); - if (fileMd5 != null && !fileMd5.isEmpty()) df.md5 = fileMd5; - long fileTotal = 0; - for (int c = 0; c < chunks.length(); c++) { - JSONObject chunk = chunks.getJSONObject(c); - String compressedMd5 = chunk.optString("compressedMd5"), decMd5 = chunk.optString("md5"); - String hash = (compressedMd5 != null && !compressedMd5.isEmpty()) ? compressedMd5 : decMd5; - if (hash == null || hash.isEmpty()) continue; - long decSize = chunk.optLong("size", 0); - df.chunks.add(new DepotFile.ChunkRef(hash, compressedMd5, decMd5, chunk.optLong("compressedSize", 0), decSize)); - fileTotal += decSize; - } - df.totalSize = fileTotal; - if (!df.chunks.isEmpty()) out.add(df); - } - } catch (Exception e) { - Log.w(TAG, "depot manifest parse: " + e.getClass().getSimpleName()); - } - } - - static String buildCdnPath(String hash) { return hash.substring(0, 2) + "/" + hash.substring(2, 4) + "/" + hash; } - - private static boolean assembleDepotFile(DepotFile df, File outFile, AtomicReference cdnBaseRef, String secureLinkUrl, String token, - AtomicInteger cdnRefreshCount, int maxCdnRefresh, AtomicBoolean cancelled, Callback cb) { - File tmpFile = new File(outFile.getAbsolutePath() + ".bhtmp"); - File parent = outFile.getParentFile(); - if (parent != null) parent.mkdirs(); - tmpFile.delete(); - boolean ok = true; - try (FileOutputStream fos = new FileOutputStream(tmpFile)) { - for (DepotFile.ChunkRef chunk : df.chunks) { - if (cancelled.get()) { ok = false; break; } - byte[] inflated = fetchChunkVerified(cdnBaseRef, buildCdnPath(chunk.hash), chunk, secureLinkUrl, token, cdnRefreshCount, maxCdnRefresh, cancelled, cb); - if (inflated == null) { ok = false; break; } - fos.write(inflated); - } - } catch (Exception e) { - ok = false; - } - if (!ok || cancelled.get()) { tmpFile.delete(); return false; } - if (df.totalSize > 0 && tmpFile.length() != df.totalSize) { cb.onLog("size mismatch file=" + df.relativePath); tmpFile.delete(); return false; } - if (df.md5 != null && !df.md5.isEmpty()) { - String actual = md5HexFile(tmpFile); - if (actual == null || !actual.equalsIgnoreCase(df.md5)) { cb.onLog("md5 mismatch file=" + df.relativePath); tmpFile.delete(); return false; } - } - if (outFile.exists()) outFile.delete(); - return tmpFile.renameTo(outFile); - } - - private static String parseCdnUrl(String json) { - java.util.List all = parseCdnUrls(json); - return all.isEmpty() ? null : String.join("\n", all); - } - static java.util.List parseCdnUrls(String json) { java.util.List out = new ArrayList<>(); if (json == null) return out; @@ -824,18 +558,6 @@ public final class GogDownloadManager { return out; } - private static final AtomicInteger CDN_TURN = new AtomicInteger(); - - static String pickCdn(String set) { - String[] all = set.split("\n"); - return all[Math.floorMod(CDN_TURN.getAndIncrement(), all.length)]; - } - - static String firstCdn(String set) { - int nl = set.indexOf('\n'); - return nl < 0 ? set : set.substring(0, nl); - } - private static boolean downloadRange(String url, long offset, long size, File out) { try { HttpURLConnection conn = (HttpURLConnection) new URL(url).openConnection(); @@ -951,15 +673,6 @@ public final class GogDownloadManager { } } - private static String buildChunkUrl(String base, String chunkPath) { - int q = base.indexOf('?'); - return q >= 0 ? base.substring(0, q) + "/" + chunkPath + base.substring(q) : base + "/" + chunkPath; - } - - private static String md5Hex(byte[] data) { - try { return toHex(java.security.MessageDigest.getInstance("MD5").digest(data)); } catch (Exception e) { return null; } - } - private static String md5HexFile(File f) { try (FileInputStream fis = new FileInputStream(f)) { java.security.MessageDigest md = java.security.MessageDigest.getInstance("MD5"); @@ -988,58 +701,6 @@ public final class GogDownloadManager { return true; } - private static void sleepBackoff(int attempt) { - try { Thread.sleep(Math.min(1000L << Math.max(0, attempt - 1), 8000L)); } catch (InterruptedException ie) { Thread.currentThread().interrupt(); } - } - - private static boolean tryRefreshCdn(AtomicReference cdnBaseRef, String staleBase, String secureLinkUrl, String token, - AtomicInteger refreshCount, int cap, Callback cb) { - if (secureLinkUrl == null) return false; - synchronized (cdnBaseRef) { - String current = cdnBaseRef.get(); - if (staleBase == null || !staleBase.equals(current)) return true; - if (refreshCount.get() >= cap) { cb.onLog("gog: CDN refresh cap reached (" + cap + ")"); return false; } - String neu = parseCdnUrl(httpGet(secureLinkUrl, token)); - if (neu == null || neu.isEmpty()) { cb.onLog("gog: CDN refresh failed"); return false; } - cdnBaseRef.set(neu); - cb.onLog("gog: secure link refreshed #" + refreshCount.incrementAndGet()); - return true; - } - } - - private static byte[] fetchChunkVerified(AtomicReference cdnBaseRef, String chunkPath, DepotFile.ChunkRef chunk, String secureLinkUrl, String token, - AtomicInteger cdnRefreshCount, int maxCdnRefresh, AtomicBoolean cancelled, Callback cb) { - int hardFail = 0, guard = 0; - while (guard++ < 8) { - if (cancelled.get()) return null; - String base = cdnBaseRef.get(); - HttpResult res = fetchBytesEx(buildChunkUrl(pickCdn(base), chunkPath), null); - if (res.body == null) { - int code = res.status; - if ((code == 401 || code == 403 || code == 404 || code == 500) && tryRefreshCdn(cdnBaseRef, base, secureLinkUrl, token, cdnRefreshCount, maxCdnRefresh, cb)) continue; - cb.onLog("chunk http fail code=" + code); - if (++hardFail >= 3) return null; - sleepBackoff(hardFail); - continue; - } - byte[] raw = res.body; - if (chunk.compressedSize > 0 && raw.length != chunk.compressedSize) { if (++hardFail >= 3) return null; sleepBackoff(hardFail); continue; } - if (chunk.compressedMd5 != null && !chunk.compressedMd5.isEmpty()) { - String actual = md5Hex(raw); - if (actual == null || !actual.equalsIgnoreCase(chunk.compressedMd5)) { if (++hardFail >= 3) return null; sleepBackoff(hardFail); continue; } - } - byte[] inflated = inflateZlib(raw); - if (inflated == null) inflated = raw; - if (chunk.size > 0 && inflated.length != chunk.size) { if (++hardFail >= 3) return null; sleepBackoff(hardFail); continue; } - if (chunk.md5 != null && !chunk.md5.isEmpty()) { - String actual = md5Hex(inflated); - if (actual == null || !actual.equalsIgnoreCase(chunk.md5)) { if (++hardFail >= 3) return null; sleepBackoff(hardFail); continue; } - } - return inflated; - } - return null; - } - private static void sampleSpeed(long totalBytes, AtomicLong lastSpeedMs, AtomicLong lastSpeedB, AtomicLong speedBps) { long nowMs = System.currentTimeMillis(), prevMs = lastSpeedMs.get(); if (nowMs - prevMs >= 500 && lastSpeedMs.compareAndSet(prevMs, nowMs)) { @@ -1289,30 +950,6 @@ public final class GogDownloadManager { for (File f : files) if (f.isDirectory()) collectExeRecursive(f, out); } - static long remainingInstallBytes(List files, File installPath) { - long remaining = 0; - for (DepotFile file : files) { - File output = new File(installPath, file.relativePath); - if (!fileVerified(output, file.totalSize, file.md5)) remaining += file.totalSize; - } - return remaining; - } - - static final class DepotFile { - final String relativePath; - final List chunks = new ArrayList<>(); - String md5; - long totalSize; - DepotFile(String relativePath) { this.relativePath = relativePath; } - static final class ChunkRef { - final String hash, compressedMd5, md5; - final long compressedSize, size; - ChunkRef(String hash, String compressedMd5, String md5, long compressedSize, long size) { - this.hash = hash; this.compressedMd5 = compressedMd5; this.md5 = md5; this.compressedSize = compressedSize; this.size = size; - } - } - } - private static final class Gen1File { final String path, url; final long offset, size; Gen1File(String path, String url, long offset, long size) { this.path = path; this.url = url; this.offset = offset; this.size = size; } @@ -1382,7 +1019,11 @@ public final class GogDownloadManager { String resolved = StoreLanguage.pick(StoreLanguage.codesFromDepots(depots), StoreLanguage.preferredCode()); for (int i = 0; i < depots.length(); i++) { JSONObject depot = depots.getJSONObject(i); - if (depotSelected(depot, resolved)) total += depot.optLong("size", 0); + JSONArray languages = depot.optJSONArray("languages"); + String[] listed = new String[languages == null ? 0 : languages.length()]; + for (int j = 0; j < listed.length; j++) listed[j] = languages.optString(j, ""); + if (listed.length == 0 || StoreLanguage.depotMatchesResolved(listed, resolved)) + total += depot.optLong("size", 0); } return total > 0 ? total : -1; } catch (Exception e) { diff --git a/app/src/main/java/com/droiddeck/launcher/stores/gog/GogMaintenance.kt b/app/src/main/java/com/droiddeck/launcher/stores/gog/GogMaintenance.kt index 84cb27802..e024d7d95 100644 --- a/app/src/main/java/com/droiddeck/launcher/stores/gog/GogMaintenance.kt +++ b/app/src/main/java/com/droiddeck/launcher/stores/gog/GogMaintenance.kt @@ -377,16 +377,24 @@ internal object GogMaintenance { val build = GogBuild.select(builds) ?: return null folder.mkdirs() val old = GogInstallRecord(game.gameId, "depot", language = language) - val target = GogContent.snapshot(old, build, GogContent.token(context)) + var credentials: Pair? = null + val target = GogContent.snapshot(old, build, GogContent.token(context), onManifest = { manifest -> + credentials = GogContent.clientCredentials(manifest) + }) GogFiles.validate(target.files, folder) val bytes = target.files.objects().sumOf { it.optJSONArray("chunks")?.objects()?.sumOf { c -> c.optLong("compressedSize") }?.takeIf { n -> n > 0 } ?: it.optLong("size") } val disk = target.files.objects().sumOf { it.optLong("size") } - check(!StoreInstallRoot.insufficientSpace(disk, StoreInstallRoot.availableBytes(folder))) { "Not enough free space for GOG installation" } + val remaining = GogEstimate.calculate(folder, old, target, JSONArray(), reuseChunks = GogNative.isAvailable()).required + check(!StoreInstallRoot.insufficientSpace(remaining, StoreInstallRoot.availableBytes(folder))) { "Not enough free space for GOG installation" } callback.onSizes(bytes, disk) transfer(context, Proposal(folder, old, target, GogOperation.UPDATE, bytes, disk), null, cancelled, callback) if (cancelled.get()) return null target.write(folder) - val task = GogDownloadManager.pickTask(folder, game.gameId, null, game.title) + credentials?.let { (clientId, clientSecret) -> + GogPrefs.get(context).edit().putString("client_id_${game.gameId}", clientId) + .putString("client_secret_${game.gameId}", clientSecret).apply() + } + val task = GogDownloadManager.pickTask(folder, game.gameId, GogContent.tempExecutable(target.manifest), game.title) val result = GogDownloadManager.Result(task?.path ?: "", build.id, disk) result.workDir = task?.workDir result.language = target.language @@ -395,20 +403,6 @@ internal object GogMaintenance { return result } - @JvmStatic - fun captureDepot(id: String, folder: File, build: String, buildsJson: String, manifest: JSONObject, depots: JSONArray, language: String) { - val selected = GogBuild.select(JSONObject(buildsJson), pinned = build) - val files = JSONArray() - for (depot in depots.strings()) for (item in JSONObject(depot).getJSONObject("depot").getJSONArray("items").objects()) { - val chunks = item.optJSONArray("chunks") ?: continue - files.put(JSONObject().put("path", item.getString("path").replace('\\', '/')).put("size", chunks.objects().sumOf { it.optLong("size") }) - .put("md5", item.optString("md5")).put("chunks", chunks).put("product", id)) - } - GogFiles.validate(files, folder) - GogInstallRecord(id, "depot", 2, build, selected?.version.orEmpty(), selected?.branch.orEmpty(), language, - manifest = GogContent.sanitize(manifest), files = files).write(folder) - } - @JvmStatic fun captureOffline(id: String, folder: File, downloads: JSONObject) { val selected = GogOfflineDownload.parse(downloads, "installers").firstOrNull() ?: return diff --git a/app/src/main/java/com/droiddeck/launcher/stores/gog/GogNative.kt b/app/src/main/java/com/droiddeck/launcher/stores/gog/GogNative.kt index 80024ab6a..0714117eb 100644 --- a/app/src/main/java/com/droiddeck/launcher/stores/gog/GogNative.kt +++ b/app/src/main/java/com/droiddeck/launcher/stores/gog/GogNative.kt @@ -6,8 +6,6 @@ import com.droiddeck.launcher.stores.StoresNative object GogNative { private const val TAG = "GogNative" - const val KIND_GEN2_CHUNKS = 0 - const val KIND_GEN1_RANGES = 1 interface Listener { diff --git a/app/src/test/java/com/droiddeck/launcher/stores/gog/GogCdnTest.kt b/app/src/test/java/com/droiddeck/launcher/stores/gog/GogCdnTest.kt index 324338e17..424e6a576 100644 --- a/app/src/test/java/com/droiddeck/launcher/stores/gog/GogCdnTest.kt +++ b/app/src/test/java/com/droiddeck/launcher/stores/gog/GogCdnTest.kt @@ -15,16 +15,12 @@ class GogCdnTest { {"endpoint_name":"lumen","url_format":"https://cdn.gog.com/{path}","parameters":{}} ]}""" - @Test fun allHostsAreParsedInOrderAndTakenInTurn() { + @Test fun allHostsAreParsedInOrder() { val all = GogDownloadManager.parseCdnUrls(answer) assertEquals(3, all.size) assertEquals("https://gog-cdn-fastly.gog.com/token=nva=1~token=t", all[0]) assertEquals("https://gog-cdn-akamai.gog.com/s", all[1]) assertEquals("https://cdn.gog.com", all[2]) - val set = all.joinToString("\n") - assertEquals(all[0], GogDownloadManager.firstCdn(set)) - val picks = (0 until 6).map { GogDownloadManager.pickCdn(set) }.toSet() - assertEquals(all.toSet(), picks) assertEquals(0, GogDownloadManager.parseCdnUrls("not json").size) } } diff --git a/app/src/test/java/com/droiddeck/launcher/stores/gog/GogMaintenanceTest.kt b/app/src/test/java/com/droiddeck/launcher/stores/gog/GogMaintenanceTest.kt index 1d755a6db..06c88bfd1 100644 --- a/app/src/test/java/com/droiddeck/launcher/stores/gog/GogMaintenanceTest.kt +++ b/app/src/test/java/com/droiddeck/launcher/stores/gog/GogMaintenanceTest.kt @@ -26,6 +26,45 @@ import java.util.concurrent.atomic.AtomicBoolean class GogMaintenanceTest { @get:Rule val temporary = TemporaryFolder() + @Test fun ownedDlcDepotIsSelected() { + val allowed = GogContent.installProducts("1", JSONArray("[{\"gameIDs\":[\"2\"]}]"), emptyList(), "unused") { setOf("2") } + assertEquals(setOf("1", "2"), allowed) + } + + @Test fun unownedDlcDepotIsSkipped() { + val allowed = GogContent.installProducts("1", JSONArray("[{\"productId\":\"2\"}]"), emptyList(), "unused") { emptySet() } + assertEquals(setOf("1"), allowed) + } + + @Test fun ownershipFetchFailureFallsBackToBaseOnly() { + val allowed = GogContent.installProducts("1", JSONArray("[{\"productId\":\"2\"}]"), emptyList(), "unused") { null } + assertEquals(setOf("1"), allowed) + } + + @Test fun installManifestCredentialsAndTempExecutableAreExtractedBeforeSanitizing() { + val manifest = JSONObject("""{"clientId":"client","clientSecret":"secret","products":[{"temp_executable":"bin/game.exe"}]}""") + assertEquals("client" to "secret", GogContent.clientCredentials(manifest)) + assertEquals("bin/game.exe", GogContent.tempExecutable(GogContent.sanitize(manifest))) + assertFalse(GogContent.sanitize(manifest).toString().contains("secret")) + } + + @Test fun emptyProductIdDepotUsesBaseAndDlcFilesKeepTheirProduct() { + val build = GogBuild("1", "", 2, "", "https://example.invalid/build") + val snapshot = GogContent.snapshot(GogInstallRecord("1", "depot"), build, "unused", ownership = { setOf("2") }, metadata = { url, _ -> + when (url) { + build.link -> JSONObject("{\"depots\":[{\"gameIDs\":[\"1\"],\"manifest\":\"aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa\"},{\"gameIDs\":[\"2\"],\"manifest\":\"bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb\"},{\"productId\":\"\",\"manifest\":\"cccccccccccccccccccccccccccccccc\"}]}") + "https://gog-cdn-fastly.gog.com/content-system/v2/meta/aa/aa/aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa" -> JSONObject("{\"depot\":{\"items\":[{\"path\":\"base.bin\",\"chunks\":[{\"size\":1}]}]}}") + "https://gog-cdn-fastly.gog.com/content-system/v2/meta/bb/bb/bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb" -> JSONObject("{\"depot\":{\"items\":[{\"path\":\"dlc.bin\",\"chunks\":[{\"size\":1}]}]}}") + "https://gog-cdn-fastly.gog.com/content-system/v2/meta/cc/cc/cccccccccccccccccccccccccccccccc" -> JSONObject("{\"depot\":{\"items\":[{\"path\":\"empty.bin\",\"chunks\":[{\"size\":1}]}]}}") + else -> error("Unexpected metadata: $url") + } + }) + assertEquals(setOf("1", "2"), snapshot.products.toSet()) + assertEquals("1", snapshot.files.getJSONObject(0).getString("product")) + assertEquals("2", snapshot.files.getJSONObject(1).getString("product")) + assertEquals("1", snapshot.files.getJSONObject(2).getString("product")) + } + @Test fun selectsPublicBranchAndTreatsBuildIdsAsOpaque() { val builds = JSONObject("""{"items":[ {"os":"windows","branch":"beta","build_id":"99","generation":2,"link":"https://example.invalid/beta"}, @@ -213,7 +252,7 @@ class GogMaintenanceTest { fun fixture(name: String) = JSONObject(javaClass.classLoader!!.getResource("gog/$name")!!.readText()) val build = GogBuild("3301", "", 1, "", "https://example.invalid/repository.json") val requested = ArrayList() - val snapshot = GogContent.snapshot(GogInstallRecord("1207658940", "depot"), build, "unused") { url, _ -> + val snapshot = GogContent.snapshot(GogInstallRecord("1207658940", "depot"), build, "unused", metadata = { url, _ -> requested.add(url) when { url == build.link -> fixture("rayman-gen1-repository.json") @@ -221,7 +260,7 @@ class GogMaintenanceTest { url.endsWith("ec88140c-2589-41a0-bbf3-8fc1f215fe5a.json") -> fixture("rayman-gen1-english-excerpt.json") else -> error("Unexpected metadata: $url") } - } + }) assertEquals("3301", snapshot.build) assertEquals(1, snapshot.generation) assertEquals(4, snapshot.files.length()) @@ -269,6 +308,17 @@ class GogMaintenanceTest { assertThrows(IllegalArgumentException::class.java) { GogFiles.inside(folder, "link/game.exe") } } + @Test fun resumedInstallSpaceCountsOnlyFilesStillMissing() { + val folder = temporary.newFolder() + val done = File(folder, "done.bin").apply { writeText("verified") } + val files = JSONArray() + .put(JSONObject().put("path", done.name).put("size", done.length()).put("md5", GogFiles.hash(done))) + .put(JSONObject().put("path", "missing.bin").put("size", 12L).put("md5", "0".repeat(32))) + val missingOnly = GogInstallRecord("1", "depot", files = JSONArray().put(files.getJSONObject(1))) + fun required(target: GogInstallRecord) = GogEstimate.calculate(folder, GogInstallRecord("1", "depot"), target, JSONArray(), reuseChunks = false).required + assertEquals(required(missingOnly), required(GogInstallRecord("1", "depot", files = files))) + } + @Test fun sameSizeCorruptionFailsVerificationAndLanguageVariantsRemainDistinct() { val folder = temporary.newFolder() val file = File(folder, "game.bin").apply { writeText("original") } diff --git a/app/src/test/java/com/droiddeck/launcher/stores/gog/GogResumeSpaceTest.kt b/app/src/test/java/com/droiddeck/launcher/stores/gog/GogResumeSpaceTest.kt deleted file mode 100644 index 4934b5898..000000000 --- a/app/src/test/java/com/droiddeck/launcher/stores/gog/GogResumeSpaceTest.kt +++ /dev/null @@ -1,43 +0,0 @@ -package com.droiddeck.launcher.stores.gog - -import java.io.File -import java.security.MessageDigest -import org.junit.Assert.assertEquals -import org.junit.Assert.assertFalse -import org.junit.Assert.assertTrue -import org.junit.Test -import org.junit.runner.RunWith -import org.robolectric.RobolectricTestRunner -import org.robolectric.RuntimeEnvironment -import org.robolectric.annotation.Config -import org.robolectric.shadows.ShadowStatFs -import com.droiddeck.launcher.stores.StoreInstallRoot - -@RunWith(RobolectricTestRunner::class) -@Config(sdk = [33]) -class GogResumeSpaceTest { - @Test fun remainingWorkRejectsMeasuredZeroButNotUnknownSpace() { - val root = File(RuntimeEnvironment.getApplication().filesDir, "gog-zero").apply { mkdirs() } - ShadowStatFs.registerStats(root.path, 1024, 0, 0) - val files = listOf(GogDownloadManager.DepotFile("missing").apply { totalSize = 4 }) - val needed = GogDownloadManager.remainingInstallBytes(files, root) - assertEquals(4L, needed) - assertTrue(StoreInstallRoot.insufficientSpace(needed, StoreInstallRoot.availableBytes(root))) - assertFalse(StoreInstallRoot.insufficientSpace(needed, StoreInstallRoot.SPACE_UNKNOWN)) - } - - @Test fun remainingSpaceExcludesVerifiedFilesButIncludesCorruptAndPartialFiles() { - val root = File(RuntimeEnvironment.getApplication().filesDir, "gog-resume").apply { mkdirs() } - val data = byteArrayOf(1, 2, 3, 4) - val hash = MessageDigest.getInstance("MD5").digest(data).joinToString("") { "%02x".format(it) } - val files = listOf("good", "corrupt", "partial", "missing").map { - GogDownloadManager.DepotFile(it).apply { totalSize = 4; md5 = hash } - } - File(root, "good").writeBytes(data) - File(root, "corrupt").writeBytes(ByteArray(4)) - File(root, "partial").writeBytes(byteArrayOf(1)) - assertEquals(12L, GogDownloadManager.remainingInstallBytes(files, root)) - for (file in files) File(root, file.relativePath).writeBytes(data) - assertEquals(0L, GogDownloadManager.remainingInstallBytes(files, root)) - } -} From 1ec2b696b75bfa401c61cb72850c3464fbf57d31 Mon Sep 17 00:00:00 2001 From: MaxsTechReview Date: Tue, 10 Nov 2026 02:55:13 -0500 Subject: [PATCH 24/33] fix(flatpak): stop inheriting host Zink overrides (#2391) * fix(flatpak): stop inheriting host zink overrides * Check every cleared driver override in the Flatpak launcher test --- .../usr/local/bin/droiddeck-flatpak-run | 1 + tools/tests/test_game_gpu_launchers.py | 20 ++++++++++++++----- 2 files changed, 16 insertions(+), 5 deletions(-) diff --git a/tools/linuxfs/overlay/usr/local/bin/droiddeck-flatpak-run b/tools/linuxfs/overlay/usr/local/bin/droiddeck-flatpak-run index 6fdd22f3d..b8210c355 100644 --- a/tools/linuxfs/overlay/usr/local/bin/droiddeck-flatpak-run +++ b/tools/linuxfs/overlay/usr/local/bin/droiddeck-flatpak-run @@ -1,6 +1,7 @@ #!/bin/bash set -u export FLATPAK_BWRAP=/usr/local/bin/droiddeck-bwrap +unset MESA_LOADER_DRIVER_OVERRIDE GALLIUM_DRIVER LIBGL_KOPPER_DRI2 VK_ICD_FILENAMES VK_DRIVER_FILES case ":${XDG_DATA_DIRS:-}:" in *"/flatpak/exports/share:"*) ;; *) export XDG_DATA_DIRS=$HOME/.local/share/flatpak/exports/share:${XDG_DATA_DIRS:-/usr/local/share:/usr/share} ;; diff --git a/tools/tests/test_game_gpu_launchers.py b/tools/tests/test_game_gpu_launchers.py index 71f39c22c..09b3aef85 100644 --- a/tools/tests/test_game_gpu_launchers.py +++ b/tools/tests/test_game_gpu_launchers.py @@ -8,8 +8,12 @@ import unittest from pathlib import Path BIN = Path(__file__).resolve().parents[1] / "linuxfs/overlay/usr/local/bin" -KEYS = ("TU_DEBUG", "DROIDDECK_GAME_TU_DEBUG") +KEYS = ("TU_DEBUG", "DROIDDECK_GAME_TU_DEBUG", "MESA_LOADER_DRIVER_OVERRIDE", "GALLIUM_DRIVER", + "LIBGL_KOPPER_DRI2", "VK_ICD_FILENAMES", "VK_DRIVER_FILES") SESSION = {"TU_DEBUG": "sysmem", "DROIDDECK_GAME_TU_DEBUG": ""} +GPU_SESSION = {"MESA_LOADER_DRIVER_OVERRIDE": "zink", "GALLIUM_DRIVER": "zink", + "LIBGL_KOPPER_DRI2": "true", "VK_ICD_FILENAMES": "/runtime/turnip.json", + "VK_DRIVER_FILES": "/runtime/turnip.json"} class GameGpuLaunchersTest(unittest.TestCase): @@ -50,11 +54,16 @@ class GameGpuLaunchersTest(unittest.TestCase): return self.run_env(["bash", str(BIN / "droiddeck-flatpak-run"), "--command=app", "org.example.App"], session) def test_the_wrapper_gives_a_program_the_game_setting(self): - self.assertEqual({"TU_DEBUG": "unset", "DROIDDECK_GAME_TU_DEBUG": "unset"}, self.wrapper()) + self.assertEqual({key: "unset" for key in KEYS}, self.wrapper()) self.assertEqual("gmem", self.wrapper(session={"TU_DEBUG": "sysmem", "DROIDDECK_GAME_TU_DEBUG": "gmem"})["TU_DEBUG"]) + def test_non_flatpak_programs_keep_the_session_gpu_path(self): + values = self.wrapper(session=GPU_SESSION) + self.assertEqual(GPU_SESSION, {key: values[key] for key in GPU_SESSION}) + def test_the_wrapper_keeps_the_session_setting_for_chromium(self): - self.assertEqual({"TU_DEBUG": "sysmem", "DROIDDECK_GAME_TU_DEBUG": "unset"}, self.wrapper("--chromium")) + values = self.wrapper("--chromium") + self.assertEqual(("sysmem", "unset"), (values["TU_DEBUG"], values["DROIDDECK_GAME_TU_DEBUG"])) self.assertEqual("unset", self.wrapper("--for", str(self.app / "files"))["TU_DEBUG"]) self.chromium() self.assertEqual("sysmem", self.wrapper("--for", str(self.app / "files"))["TU_DEBUG"]) @@ -71,12 +80,13 @@ class GameGpuLaunchersTest(unittest.TestCase): timeout=30).returncode) def test_a_flatpak_app_gets_the_game_setting(self): - self.assertEqual({"TU_DEBUG": "unset", "DROIDDECK_GAME_TU_DEBUG": "unset"}, self.flatpak()) + self.assertEqual({key: "unset" for key in KEYS}, self.flatpak(session=dict(SESSION, **GPU_SESSION))) self.assertEqual("nolrz", self.flatpak({"TU_DEBUG": "sysmem", "DROIDDECK_GAME_TU_DEBUG": "nolrz"})["TU_DEBUG"]) def test_a_chromium_flatpak_keeps_the_session_setting(self): self.chromium() - self.assertEqual({"TU_DEBUG": "sysmem", "DROIDDECK_GAME_TU_DEBUG": "unset"}, self.flatpak()) + values = self.flatpak() + self.assertEqual(("sysmem", "unset"), (values["TU_DEBUG"], values["DROIDDECK_GAME_TU_DEBUG"])) def test_a_flatpak_without_the_game_setting_is_unchanged(self): self.assertEqual("sysmem", self.flatpak({"TU_DEBUG": "sysmem"})["TU_DEBUG"]) From 13c8edb3d58ad8d10ed111982e07d07fba3f2f53 Mon Sep 17 00:00:00 2001 From: MaxsTechReview Date: Tue, 10 Nov 2026 06:24:03 -0500 Subject: [PATCH 25/33] fix(game-env): repair foreign-architecture system DLLs in ARM64 prefixes (#2318) Co-authored-by: Kurt Himebauch <136133082+xXJSONDeruloXx@users.noreply.github.com> --- .../launcher/session/SessionFiles.kt | 1 + .../overlay/usr/local/bin/droiddeck-game-env | 122 ++++++++-- .../usr/local/bin/droiddeck-wincomponents | 45 +++- .../overlay/usr/local/bin/droiddeck-wine-pe | 94 ++++++++ tools/tests/test_game_environment.py | 209 ++++++++++++++++++ tools/tests/test_wincomponents.py | 132 ++++++++++- 6 files changed, 579 insertions(+), 24 deletions(-) create mode 100755 tools/linuxfs/overlay/usr/local/bin/droiddeck-wine-pe diff --git a/app/src/main/java/com/droiddeck/launcher/session/SessionFiles.kt b/app/src/main/java/com/droiddeck/launcher/session/SessionFiles.kt index 3cefd44c0..f3b8478a2 100644 --- a/app/src/main/java/com/droiddeck/launcher/session/SessionFiles.kt +++ b/app/src/main/java/com/droiddeck/launcher/session/SessionFiles.kt @@ -43,6 +43,7 @@ object SessionFiles { "usr/local/bin/droiddeck-game-gpu" to "usr/local/bin/droiddeck-game-gpu", "usr/local/bin/droiddeck-diagnostic-inventory" to "usr/local/bin/droiddeck-diagnostic-inventory", "usr/local/bin/droiddeck-wincomponents" to "usr/local/bin/droiddeck-wincomponents", + "usr/local/bin/droiddeck-wine-pe" to "usr/local/bin/droiddeck-wine-pe", "usr/local/bin/droiddeck-msi-install" to "usr/local/bin/droiddeck-msi-install", "usr/local/bin/droiddeck-esync" to "usr/local/bin/droiddeck-esync", "usr/local/bin/droiddeck-steam-compat" to "usr/local/bin/droiddeck-steam-compat", diff --git a/tools/linuxfs/overlay/usr/local/bin/droiddeck-game-env b/tools/linuxfs/overlay/usr/local/bin/droiddeck-game-env index a2e130b5a..5ed1f7f11 100755 --- a/tools/linuxfs/overlay/usr/local/bin/droiddeck-game-env +++ b/tools/linuxfs/overlay/usr/local/bin/droiddeck-game-env @@ -333,12 +333,12 @@ def engine_fixes(exe, args): return env, extra, notes -def win_components(compat, app_id): +def win_components(compat, app_id, proton=None, env=None): if not compat: return "" try: module = runpy.run_path(str(Path(__file__).resolve().parent / "droiddeck-wincomponents")) - names, notes = module["apply"](compat.rstrip("/"), app_id) + names, notes = module["apply"](compat.rstrip("/"), app_id, proton=proton, env=env or os.environ) for note in notes: print("droiddeck-game-env: " + note, file=sys.stderr) return module["overrides_value"](names) @@ -859,6 +859,60 @@ WINE_DLL_DIRS = ("/lib/wine/i386-windows", "/lib/wine/x86_64-windows", "/lib/win "/lib/wine/fakedlls", "/lib64/wine", "/lib64/wine/fakedlls", "/lib64/wine/x86_64-windows") +FOREIGN_SUFFIX = ".foreign-arch" +PE = runpy.run_path(str(Path(__file__).resolve().parent / "droiddeck-wine-pe")) +pe_machine = PE["pe_machine"] + + +def repair_foreign_system_dlls(prefix, proton, env=None): + dist = Path(proton).parent / "files/lib/wine" + windows = Path(prefix) / "pfx/drive_c/windows" + machines_by_folder = PE["proton_pe_machines"](proton, env or {}) + if machines_by_folder is None: + return [] + repaired = [] + for folder, (libdir, machines) in machines_by_folder.items(): + builtins = dist / libdir + try: + builtin_names = {name.lower(): name for name in os.listdir(builtins)} + except OSError: + continue + try: + names = sorted(os.listdir(windows / folder)) + except OSError: + continue + for name in names: + path = windows / folder / name + builtin_name = builtin_names.get(name.lower()) + source = builtins / builtin_name if builtin_name else builtins / name + if path.is_symlink() or not name.lower().endswith(".dll") or not source.is_file() or not path.is_file(): + continue + machine = pe_machine(path) + if machine is None or PE["machine_compatible"](path, machines, machine): + continue + link = windows / folder / (name + ".bl-new") + backup = windows / folder / (name + FOREIGN_SUFFIX) + serial = 1 + while os.path.lexists(backup): + backup = windows / folder / (name + FOREIGN_SUFFIX + ".%d" % serial) + serial += 1 + try: + os.symlink(os.path.relpath(source, path.parent), link) + os.replace(path, backup) + try: + os.replace(link, path) + except OSError: + if not os.path.lexists(path) and os.path.lexists(backup): + os.replace(backup, path) + raise + except OSError: + if link.is_symlink(): + link.unlink() + continue + repaired.append("%s/%s (machine 0x%x)" % (folder, name, machine)) + return repaired + + def is_wine_dll_link(path): try: return os.path.islink(path) and os.path.dirname(os.readlink(path)).endswith(WINE_DLL_DIRS) @@ -1156,6 +1210,42 @@ def main(): env = game_gpu(os.environ.copy()) mic_enabled = env.get("BL_MIC_ENABLED") debug_dir = env.get("BL_DEBUG_DIR", "") + prefix_path = env.get("STEAM_COMPAT_DATA_PATH", "").rstrip("/") + app_id = game_id(env) + components = "" + waiting = bool(app_id and len(command) > 1 and command[1] == "waitforexitandrun") + config = {} + launch_config = None + launch_config_valid = True + effective_env = env + if waiting: + path = Path.home() / ".config/droiddeck/game-environment.json" + try: + with path.open(encoding="utf-8") as source: + config = json.load(source) + launch_config = config + except FileNotFoundError: + launch_config = {"version": 1} + except (OSError, ValueError, TypeError, AttributeError): + launch_config_valid = False + if launch_config_valid: + try: + effective_env = apply_config(env, launch_config, app_id) + except (OSError, ValueError, TypeError, AttributeError): + launch_config_valid = False + if not launch_config_valid: + print("droiddeck-game-env: invalid configuration; using inherited environment", file=sys.stderr) + if waiting: + components = win_components(prefix_path, app_id, command[0], effective_env) + if prefix_path and command: + try: + fixed = repair_foreign_system_dlls(prefix_path, command[0], effective_env) + except OSError as error: + fixed = [] + print("droiddeck-game-env: prefix system DLL check skipped (%s)" % error, file=sys.stderr) + if fixed: + print("droiddeck-game-env: replaced foreign-architecture system DLLs with this Proton's own: %s" % ", ".join(fixed), + file=sys.stderr) scripts = mark_install_scripts(command, env) if scripts and install_scripts_done(scripts, env): print("droiddeck-game-env: install scripts already run in this prefix; evaluator skipped", file=sys.stderr) @@ -1164,8 +1254,6 @@ def main(): digest = scripts_digest(scripts) if scripts else None except OSError: digest = None - app_id = game_id(env) - waiting = bool(app_id and len(command) > 1 and command[1] == "waitforexitandrun") diagnostic_app_id = app_id if waiting and os.environ.get("DROIDDECK_ENHANCED_DIAGNOSTICS") == "1" else None dirt_prefix = None if app_id: @@ -1183,7 +1271,6 @@ def main(): for note in notes: print("droiddeck-game-env: " + note, file=sys.stderr) command += extra - components = win_components(env.get("STEAM_COMPAT_DATA_PATH", ""), app_id) if components: own = fixes.get("WINEDLLOVERRIDES") fixes["WINEDLLOVERRIDES"] = components + ";" + own if own else components @@ -1191,22 +1278,21 @@ def main(): if dirt_prefix: own = fixes.get("WINEDLLOVERRIDES") fixes["WINEDLLOVERRIDES"] = own + ";ws2_32=n,b" if own else "ws2_32=n,b" - path = Path.home() / ".config/droiddeck/game-environment.json" - config = {} - try: - with path.open(encoding="utf-8") as source: - config = json.load(source) - if waiting: + if waiting: + if launch_config_valid: if "vblank_mode" in config.get("shared", {}): fixes.pop("vblank_mode", None) - env = apply_config(env, config, app_id, fixes) - else: + env = apply_config(env, launch_config, app_id, fixes) + else: + path = Path.home() / ".config/droiddeck/game-environment.json" + try: + with path.open(encoding="utf-8") as source: + config = json.load(source) env = apply_config(env, sync_config(config, app_id), app_id) - except FileNotFoundError: - if waiting: - env = apply_config(env, {"version": 1}, app_id, fixes) - except (OSError, ValueError, TypeError, AttributeError): - print("droiddeck-game-env: invalid configuration; using inherited environment", file=sys.stderr) + except FileNotFoundError: + pass + except (OSError, ValueError, TypeError, AttributeError): + print("droiddeck-game-env: invalid configuration; using inherited environment", file=sys.stderr) if waiting: before_debug = env env = apply_game_debug_logs(env, config, debug_dir) diff --git a/tools/linuxfs/overlay/usr/local/bin/droiddeck-wincomponents b/tools/linuxfs/overlay/usr/local/bin/droiddeck-wincomponents index 393f97102..dde2d6e6b 100755 --- a/tools/linuxfs/overlay/usr/local/bin/droiddeck-wincomponents +++ b/tools/linuxfs/overlay/usr/local/bin/droiddeck-wincomponents @@ -2,11 +2,17 @@ import json import os import re +import runpy import shutil import sys import time from pathlib import Path +PE = runpy.run_path(str(Path(__file__).resolve().parent / "droiddeck-wine-pe")) +PE_MACHINES = PE["PE_MACHINES"] +pe_machine = PE["pe_machine"] +machine_compatible = PE["machine_compatible"] + STORE = Path("/opt/droiddeck/wincomponents") SELECTION = Path.home() / ".config/droiddeck/wincomponents.json" PENDING = Path.home() / ".config/droiddeck/wincomponents-pending.json" @@ -473,6 +479,15 @@ def save_state(compat, state): os.replace(temp, compat / STATE) +def foreign_component(rel, path, machines): + parts = Path(rel).parts + folder = parts[2] if len(parts) > 2 and parts[:2] == ("drive_c", "windows") else None + if not machines or folder not in PE_MACHINES or not rel.lower().endswith(".dll"): + return False + machine = pe_machine(path) + return machine is not None and not machine_compatible(path, machines[folder][1], machine) + + def pending_for(app_id, pending=PENDING): try: with open(pending, encoding="utf-8") as source: @@ -514,7 +529,8 @@ def wait_for_pending(app_id, pending=PENDING, store=STORE, timeout=PENDING_WAIT, return "wincomp: %s waited %dms for %s" % (app_id, waited, ",".join(asked)) -def apply(compat_path, app_id, selection=SELECTION, store=STORE, pending=PENDING, wait=wait_for_pending): +def apply(compat_path, app_id, selection=SELECTION, store=STORE, pending=PENDING, wait=wait_for_pending, + proton=None, env=None): compat = Path(compat_path) pfx = compat / "pfx" waited = wait(app_id, pending, store) if wait else None @@ -527,6 +543,9 @@ def apply(compat_path, app_id, selection=SELECTION, store=STORE, pending=PENDING installed = [] values = [] held = set() + skipped = set() + placed_stems = set() + machines = PE["proton_pe_machines"](proton, env) if proton else None saved_overrides = state.get("overrides") if isinstance(state.get("overrides"), dict) else {} saved_requires = state.get("requires") if isinstance(state.get("requires"), dict) else {} recorded = [c for c in state.get("components", []) if isinstance(c, str) and valid_id(c)] @@ -566,9 +585,17 @@ def apply(compat_path, app_id, selection=SELECTION, store=STORE, pending=PENDING kept_requires[cid] = list(requires) values += [(cid, value) for value in registry] for rel, src in files: - if place(src, pfx / rel, None if rel in state["files"] else compat / BACKUP / rel): + foreign = foreign_component(rel, src, machines) + if foreign: + skipped.add(Path(rel).stem.lower()) + changed = False + else: + changed = place(src, pfx / rel, None if rel in state["files"] else compat / BACKUP / rel) + placed_stems.add(Path(rel).stem.lower()) + if changed: copied += 1 - keep[rel] = cid + if not foreign: + keep[rel] = cid removed = restored = 0 for rel, cid in state["files"].items(): if rel in keep: @@ -579,6 +606,10 @@ def apply(compat_path, app_id, selection=SELECTION, store=STORE, pending=PENDING path = existing(pfx / rel) saved = compat / BACKUP / rel if saved.is_file(): + if foreign_component(rel, saved, machines): + if path is not None and not path.is_symlink(): + path.unlink() + continue target = path or (pfx / rel) os.replace(saved, target) restored += 1 @@ -590,6 +621,14 @@ def apply(compat_path, app_id, selection=SELECTION, store=STORE, pending=PENDING (compat / "config_info").unlink() except FileNotFoundError: pass + def override_name(entry): + name = entry.partition("=")[0].strip().lower() + return name[:-4] if name.endswith(".dll") else name + + dropped = skipped - placed_stems + overrides = [entry for entry in overrides if override_name(entry) not in dropped] + for cid in kept_overrides: + kept_overrides[cid] = [entry for entry in kept_overrides[cid] if override_name(entry) not in dropped] compat.mkdir(parents=True, exist_ok=True) new_state = {"files": keep, "components": installed + sorted(held), "overrides": kept_overrides, "requires": kept_requires, "registry": state.get("registry", {})} diff --git a/tools/linuxfs/overlay/usr/local/bin/droiddeck-wine-pe b/tools/linuxfs/overlay/usr/local/bin/droiddeck-wine-pe new file mode 100755 index 000000000..7034bf301 --- /dev/null +++ b/tools/linuxfs/overlay/usr/local/bin/droiddeck-wine-pe @@ -0,0 +1,94 @@ +#!/usr/bin/python3 +from pathlib import Path + + +PE_MACHINES = { + "system32": ("aarch64-windows", frozenset((0xAA64, 0xA641))), + "syswow64": ("i386-windows", frozenset((0x14C,))), +} + + +def pe_machine(path): + try: + with open(path, "rb") as image: + head = image.read(64) + if head[:2] != b"MZ" or len(head) < 64: + return None + image.seek(int.from_bytes(head[60:64], "little")) + header = image.read(6) + except OSError: + return None + if header[:4] != b"PE\0\0" or len(header) < 6: + return None + return int.from_bytes(header[4:6], "little") + + +def pe_has_arm64ec_metadata(path): + try: + with open(path, "rb") as image: + image.seek(60) + raw = image.read(4) + if len(raw) != 4: + return False + pe_offset = int.from_bytes(raw, "little") + image.seek(pe_offset) + if image.read(4) != b"PE\0\0": + return False + coff = image.read(20) + if len(coff) != 20: + return False + section_count = int.from_bytes(coff[2:4], "little") + if section_count > 96: + return False + optional_size = int.from_bytes(coff[16:18], "little") + optional_offset = pe_offset + 24 + image.seek(optional_offset) + optional = image.read(optional_size) + if len(optional) != optional_size or int.from_bytes(optional[:2], "little") != 0x20B: + return False + directory_count = int.from_bytes(optional[108:112], "little") + directory_offset = 112 + 10 * 8 + if directory_count <= 10 or directory_offset + 8 > len(optional): + return False + load_config_rva = int.from_bytes(optional[directory_offset:directory_offset + 4], "little") + load_config_size = int.from_bytes(optional[directory_offset + 4:directory_offset + 8], "little") + if not load_config_rva or load_config_size < 0xD0: + return False + section_offset = optional_offset + optional_size + for index in range(section_count): + image.seek(section_offset + index * 40) + section = image.read(40) + if len(section) != 40: + return False + virtual_address = int.from_bytes(section[12:16], "little") + raw_size = int.from_bytes(section[16:20], "little") + raw_offset = int.from_bytes(section[20:24], "little") + relative = load_config_rva - virtual_address + if 0 <= relative <= raw_size - 0xD0: + image.seek(raw_offset + relative) + load_config = image.read(0xD0) + if len(load_config) != 0xD0 or int.from_bytes(load_config[:4], "little") < 0xD0: + return False + return int.from_bytes(load_config[0xC8:0xD0], "little") != 0 + except (OSError, OverflowError, ValueError): + return False + return False + + +def machine_compatible(path, machines, machine=None): + machine = pe_machine(path) if machine is None else machine + if machine == 0x8664 and pe_has_arm64ec_metadata(path): + return 0xA641 in machines + return machine in machines + + +def proton_pe_machines(proton, env=None): + dist = Path(proton).parent / "files/lib/wine" + if (dist / "aarch64-unix").is_dir() and (env or {}).get("PROTON_USE_ARM64", "1") == "1": + return PE_MACHINES + if (dist / "x86_64-unix").is_dir(): + return { + "system32": ("x86_64-windows", frozenset((0x8664,))), + "syswow64": PE_MACHINES["syswow64"], + } + return None diff --git a/tools/tests/test_game_environment.py b/tools/tests/test_game_environment.py index 48d08a0b5..ce902817a 100644 --- a/tools/tests/test_game_environment.py +++ b/tools/tests/test_game_environment.py @@ -708,6 +708,54 @@ class ReadConfigMappingTest(unittest.TestCase): class GameEnvironmentTest(unittest.TestCase): + def test_component_filter_and_prefix_repair_use_per_game_proton_architecture(self): + with tempfile.TemporaryDirectory() as tmp: + home = Path(tmp) + config = home / ".config/droiddeck/game-environment.json" + config.parent.mkdir(parents=True) + config.write_text(json.dumps({"version": 1, "shared": {"PROTON_USE_ARM64": "1"}, + "games": {"42": {"PROTON_USE_ARM64": "0"}}})) + seen_components, seen_repair, seen_start = [], [], [] + globals = MODULE["main"].__globals__ + stubs = { + "game_gpu": lambda env: env, + "mark_install_scripts": lambda command, env: [], + "install_scripts_done": lambda scripts, env: False, + "scripts_digest": lambda scripts: None, + "win_components": lambda *args: seen_components.append(args[3]) or "", + "repair_foreign_system_dlls": lambda *args: seen_repair.append(args[2]) or [], + "gl_vsync": lambda env: False, + "game_images": lambda env: None, + "skip_red_launcher": lambda command: command, + "ubisoft_overlay_off": lambda env: None, + "project_zomboid_jvm": lambda command: None, + "engine_fixes": lambda *args: ({}, [], []), + "dirt_ws2_wanted": lambda *args: None, + "apply_game_debug_logs": lambda env, config, debug_dir: env, + "offline_without_anticheat": lambda command, env: command, + "game_logs": lambda command, env, app_id: env, + "directaudio_settings": lambda env: env, + "vram_cap": lambda env: env, + "render_path_defaults": lambda *args: None, + "bridge_feature_level": lambda env: None, + "force_ssbs": lambda command, env: env, + "game_cores": lambda env: None, + "cpu_topology": lambda env, command: None, + "ego_core_plan": lambda *args: None, + "runpy": mock.Mock(run_path=lambda *args: {"launch": lambda command, env: None}), + "load_sync": lambda: {}, + "select_sync": lambda command, env, sync: (command, env), + "start": lambda command, env, *args: seen_start.append(env) or 7, + } + environment = {"HOME": tmp, "STEAM_COMPAT_DATA_PATH": "/compatdata/42", "PROTON_USE_ARM64": "1"} + command = ["/stock/proton", "waitforexitandrun", "/game/game.exe"] + with patch.dict(os.environ, environment, clear=True), patch.object(sys, "argv", ["game-env", *command]), \ + patch.dict(globals, stubs): + self.assertEqual(MODULE["main"](), 7) + self.assertEqual(["0"], [env["PROTON_USE_ARM64"] for env in seen_components]) + self.assertEqual(["0"], [env["PROTON_USE_ARM64"] for env in seen_repair]) + self.assertEqual(["0"], [env["PROTON_USE_ARM64"] for env in seen_start]) + def test_optional_xbox_loader_failure_keeps_stock_launch(self): from unittest.mock import patch for error in (FileNotFoundError('missing'), RuntimeError('module initialization failed')): @@ -2585,6 +2633,167 @@ class UbisoftCompositionTest(unittest.TestCase): self.assertEqual(list(prefix.glob(".ubisoft-*.reg")), []) + +def pe_image(machine): + head = bytearray(64) + head[:2] = b"MZ" + head[60:64] = (64).to_bytes(4, "little") + return bytes(head) + b"PE\0\0" + machine.to_bytes(2, "little") + b"\0" * 32 + + +def arm64ec_pe_image(): + image = bytearray(0x400) + image[:2] = b"MZ" + image[60:64] = (64).to_bytes(4, "little") + image[64:68] = b"PE\0\0" + coff = 68 + image[coff:coff + 2] = (0x8664).to_bytes(2, "little") + image[coff + 2:coff + 4] = (1).to_bytes(2, "little") + image[coff + 16:coff + 18] = (240).to_bytes(2, "little") + optional = coff + 20 + image[optional:optional + 2] = (0x20B).to_bytes(2, "little") + image[optional + 108:optional + 112] = (16).to_bytes(4, "little") + directory = optional + 112 + 10 * 8 + image[directory:directory + 4] = (0x1000).to_bytes(4, "little") + image[directory + 4:directory + 8] = (0xD0).to_bytes(4, "little") + section = optional + 240 + image[section + 8:section + 12] = (0x200).to_bytes(4, "little") + image[section + 12:section + 16] = (0x1000).to_bytes(4, "little") + image[section + 16:section + 20] = (0x200).to_bytes(4, "little") + image[section + 20:section + 24] = (0x200).to_bytes(4, "little") + image[0x200:0x204] = (0xD0).to_bytes(4, "little") + image[0x2C8:0x2D0] = (0x10000000).to_bytes(8, "little") + return bytes(image) + + +class ForeignSystemDllTest(unittest.TestCase): + def prefix(self, tmp, arm=True): + proton = Path(tmp) / "Proton" + dist = proton / "files/lib/wine" + if arm: + (dist / "aarch64-unix").mkdir(parents=True) + else: + (dist / "x86_64-unix").mkdir(parents=True) + for libdir, machine in (("aarch64-windows", 0xAA64), ("i386-windows", 0x14C)): + (dist / libdir).mkdir(parents=True) + for name in ("ucrtbase.dll", "rpcrt4.dll"): + (dist / libdir / name).write_bytes(pe_image(machine)) + windows = Path(tmp) / "compat/pfx/drive_c/windows" + for folder in ("system32", "syswow64"): + (windows / folder).mkdir(parents=True) + return proton / "proton", Path(tmp) / "compat", windows + + def run_repair(self, compat, proton): + return MODULE["repair_foreign_system_dlls"](str(compat), str(proton)) + + def test_x64_dll_in_arm64_system32_is_replaced_by_the_builtin(self): + with tempfile.TemporaryDirectory() as tmp: + proton, compat, windows = self.prefix(tmp) + (windows / "system32/UCRTBASE.DLL").write_bytes(pe_image(0x8664)) + (windows / "system32/rpcrt4.dll").write_bytes(pe_image(0xAA64)) + fixed = self.run_repair(compat, proton) + self.assertEqual(["system32/UCRTBASE.DLL (machine 0x8664)"], fixed) + self.assertTrue((windows / "system32/UCRTBASE.DLL").is_symlink()) + self.assertEqual(0xAA64, MODULE["pe_machine"](windows / "system32/UCRTBASE.DLL")) + self.assertEqual(0x8664, MODULE["pe_machine"](windows / "system32/UCRTBASE.DLL.foreign-arch")) + self.assertFalse((windows / "system32/rpcrt4.dll").is_symlink()) + self.assertEqual([], self.run_repair(compat, proton)) + + def test_64_bit_dll_in_syswow64_is_replaced(self): + with tempfile.TemporaryDirectory() as tmp: + proton, compat, windows = self.prefix(tmp) + (windows / "syswow64/ucrtbase.dll").write_bytes(pe_image(0xAA64)) + self.assertEqual(["syswow64/ucrtbase.dll (machine 0xaa64)"], self.run_repair(compat, proton)) + self.assertEqual(0x14C, MODULE["pe_machine"](windows / "syswow64/ucrtbase.dll")) + + def test_matching_ec_symlinked_unknown_and_non_pe_files_are_left_alone(self): + with tempfile.TemporaryDirectory() as tmp: + proton, compat, windows = self.prefix(tmp) + system32 = windows / "system32" + (system32 / "ucrtbase.dll").write_bytes(pe_image(0xA641)) + (system32 / "userlib.dll").write_bytes(pe_image(0x8664)) + (system32 / "rpcrt4.dll").write_bytes(b"not a pe file") + self.assertEqual([], self.run_repair(compat, proton)) + self.assertFalse((system32 / "ucrtbase.dll").is_symlink()) + self.assertEqual(0x8664, MODULE["pe_machine"](system32 / "userlib.dll")) + + def test_final_arm64ec_image_is_not_repaired_as_a_foreign_x64_dll(self): + with tempfile.TemporaryDirectory() as tmp: + proton, compat, windows = self.prefix(tmp) + dll = windows / "system32/ucrtbase.dll" + dll.write_bytes(arm64ec_pe_image()) + self.assertEqual(0x8664, MODULE["pe_machine"](dll)) + self.assertTrue(MODULE["PE"]["machine_compatible"](dll, frozenset((0xAA64, 0xA641)))) + self.assertFalse(MODULE["PE"]["machine_compatible"](dll, frozenset((0x8664,)))) + self.assertEqual([], self.run_repair(compat, proton)) + self.assertFalse(dll.is_symlink()) + + def test_component_owned_and_native_first_foreign_dlls_are_still_replaced(self): + with tempfile.TemporaryDirectory() as tmp: + proton, compat, windows = self.prefix(tmp) + (windows / "system32/ucrtbase.dll").write_bytes(pe_image(0x8664)) + (windows / "system32/rpcrt4.dll").write_bytes(pe_image(0x8664)) + state = {"files": {"drive_c/windows/system32/ucrtbase.dll": {}}} + (compat / ".droiddeck-wincomponents.json").write_text(json.dumps(state)) + reg = 'WINE REGISTRY Version 2\n\n[Software\\\\Wine\\\\DllOverrides] 1\n"RPCRT4.DLL"="native,builtin"\n' + (compat / "pfx/user.reg").write_text(reg) + env = {"WINEDLLOVERRIDES": "ucrtbase=n,b"} + self.assertEqual(["system32/rpcrt4.dll (machine 0x8664)", "system32/ucrtbase.dll (machine 0x8664)"], + sorted(MODULE["repair_foreign_system_dlls"](str(compat), str(proton), env))) + self.assertEqual(0xAA64, MODULE["pe_machine"](windows / "system32/ucrtbase.dll")) + self.assertEqual(0xAA64, MODULE["pe_machine"](windows / "system32/rpcrt4.dll")) + + def test_newest_foreign_copy_is_the_only_backup_and_failed_final_replace_restores_it(self): + with tempfile.TemporaryDirectory() as tmp: + proton, compat, windows = self.prefix(tmp) + dll = windows / "system32/ucrtbase.dll" + dll.write_bytes(pe_image(0x8664)) + backup = windows / "system32/ucrtbase.dll.foreign-arch" + backup.write_bytes(pe_image(0x1234)) + self.assertEqual(["system32/ucrtbase.dll (machine 0x8664)"], self.run_repair(compat, proton)) + self.assertEqual(0x1234, MODULE["pe_machine"](backup)) + self.assertEqual(0x8664, MODULE["pe_machine"](backup.with_name(backup.name + ".1"))) + self.assertEqual(0xAA64, MODULE["pe_machine"](dll)) + with tempfile.TemporaryDirectory() as tmp: + proton, compat, windows = self.prefix(tmp) + dll = windows / "system32/ucrtbase.dll" + dll.write_bytes(pe_image(0x8664)) + backup = windows / "system32/ucrtbase.dll.foreign-arch" + backup.write_bytes(pe_image(0x1234)) + real = os.replace + def flaky_with_backup(src, dst): + if str(src).endswith(".bl-new"): + raise OSError("boom") + return real(src, dst) + with mock.patch("os.replace", side_effect=flaky_with_backup): + self.assertEqual([], self.run_repair(compat, proton)) + self.assertEqual(0x8664, MODULE["pe_machine"](dll)) + self.assertFalse((windows / "system32/ucrtbase.dll.foreign-arch.1").exists()) + with tempfile.TemporaryDirectory() as tmp: + proton, compat, windows = self.prefix(tmp) + dll = windows / "system32/ucrtbase.dll" + dll.write_bytes(pe_image(0x8664)) + real = os.replace + def flaky(src, dst): + if str(src).endswith(".bl-new"): + raise OSError("boom") + return real(src, dst) + with mock.patch("os.replace", side_effect=flaky): + self.assertEqual([], self.run_repair(compat, proton)) + self.assertEqual(0x8664, MODULE["pe_machine"](dll)) + self.assertFalse(dll.is_symlink()) + self.assertFalse((windows / "system32/ucrtbase.dll.foreign-arch").exists()) + self.assertFalse((windows / "system32/ucrtbase.dll.bl-new").is_symlink()) + + def test_non_arm64_proton_and_missing_prefix_are_untouched(self): + with tempfile.TemporaryDirectory() as tmp: + proton, compat, windows = self.prefix(tmp, arm=False) + (windows / "system32/ucrtbase.dll").write_bytes(pe_image(0x8664)) + self.assertEqual([], self.run_repair(compat, proton)) + self.assertEqual(0x8664, MODULE["pe_machine"](windows / "system32/ucrtbase.dll")) + self.assertEqual([], self.run_repair(Path(tmp) / "missing", proton)) + + if __name__ == "__main__": unittest.main() diff --git a/tools/tests/test_wincomponents.py b/tools/tests/test_wincomponents.py index 51c555b9b..e6ad95e2d 100644 --- a/tools/tests/test_wincomponents.py +++ b/tools/tests/test_wincomponents.py @@ -35,8 +35,134 @@ class WinComponentsTest(unittest.TestCase): def pick(self, *ids): self.selection.write_text(json.dumps({"version": 1, "games": {"2571976725": list(ids)}})) - def apply(self): - return MODULE["apply"](self.compat, "2571976725", self.selection, self.store) + def apply(self, proton=None, env=None): + return MODULE["apply"](self.compat, "2571976725", self.selection, self.store, proton=proton, env=env) + + def proton(self, arm=True): + root = Path(self.tmp.name) / ("arm-proton" if arm else "x64-proton") + (root / "files/lib/wine" / ("aarch64-unix" if arm else "x86_64-unix")).mkdir(parents=True) + return root / "proton" + + def pe_image(self, machine): + head = bytearray(64) + head[:2] = b"MZ" + head[60:64] = (64).to_bytes(4, "little") + return bytes(head) + b"PE\0\0" + machine.to_bytes(2, "little") + b"\0" * 32 + + def arm64ec_pe_image(self): + image = bytearray(0x400) + image[:2] = b"MZ" + image[60:64] = (64).to_bytes(4, "little") + image[64:68] = b"PE\0\0" + coff = 68 + image[coff:coff + 2] = (0x8664).to_bytes(2, "little") + image[coff + 2:coff + 4] = (1).to_bytes(2, "little") + image[coff + 16:coff + 18] = (240).to_bytes(2, "little") + optional = coff + 20 + image[optional:optional + 2] = (0x20B).to_bytes(2, "little") + image[optional + 108:optional + 112] = (16).to_bytes(4, "little") + directory = optional + 112 + 10 * 8 + image[directory:directory + 4] = (0x1000).to_bytes(4, "little") + image[directory + 4:directory + 8] = (0xD0).to_bytes(4, "little") + section = optional + 240 + image[section + 8:section + 12] = (0x200).to_bytes(4, "little") + image[section + 12:section + 16] = (0x1000).to_bytes(4, "little") + image[section + 16:section + 20] = (0x200).to_bytes(4, "little") + image[section + 20:section + 24] = (0x200).to_bytes(4, "little") + image[0x200:0x204] = (0xD0).to_bytes(4, "little") + image[0x2C8:0x2D0] = (0x10000000).to_bytes(8, "little") + return bytes(image) + + def test_foreign_component_dll_is_skipped_and_override_dropped(self): + proton = self.proton() + self.component("vc", ["ucrtbase"], {"system32/ucrtbase.dll": self.pe_image(0x8664)}) + self.pick("vc") + names, _ = self.apply(proton) + self.assertEqual(names, []) + self.assertFalse((self.pfx / "system32/ucrtbase.dll").exists()) + names, notes = self.apply(proton) + self.assertEqual(names, []) + self.assertEqual(notes, []) + + def test_component_final_arm64ec_dll_is_placed_and_keeps_its_override(self): + proton = self.proton() + self.component("vc", ["ucrtbase"], {"system32/ucrtbase.dll": self.arm64ec_pe_image()}) + self.pick("vc") + names, _ = self.apply(proton) + self.assertEqual(names, ["ucrtbase"]) + self.assertEqual(MODULE["pe_machine"](self.pfx / "system32/ucrtbase.dll"), 0x8664) + + def test_compatible_copy_keeps_override_when_other_architecture_copy_is_skipped(self): + proton = self.proton() + self.component("vc", ["ucrtbase"], { + "system32/ucrtbase.dll": self.pe_image(0x8664), + "syswow64/ucrtbase.dll": self.pe_image(0x14C), + }) + self.pick("vc") + names, _ = self.apply(proton) + self.assertEqual(names, ["ucrtbase"]) + self.assertFalse((self.pfx / "system32/ucrtbase.dll").exists()) + self.assertEqual(MODULE["pe_machine"](self.pfx / "syswow64/ucrtbase.dll"), 0x14C) + + def test_existing_foreign_component_dll_is_removed_for_repair(self): + proton = self.proton() + self.component("vc", ["ucrtbase"], {"system32/ucrtbase.dll": self.pe_image(0x8664)}) + self.pick("vc") + self.pfx.joinpath("system32").mkdir(parents=True) + self.pfx.joinpath("system32/ucrtbase.dll").write_bytes(self.pe_image(0x8664)) + self.compat.joinpath(".droiddeck-wincomponents.json").write_text(json.dumps({ + "files": {"drive_c/windows/system32/ucrtbase.dll": "vc"}, "components": ["vc"]})) + self.apply(proton) + self.assertFalse((self.pfx / "system32/ucrtbase.dll").exists()) + self.assertEqual([], GAME_ENV["repair_foreign_system_dlls"](str(self.compat), str(proton))) + self.assertEqual([], GAME_ENV["repair_foreign_system_dlls"](str(self.compat), str(proton))) + + def test_foreign_component_restore_does_not_override_repair_with_native_override(self): + proton = self.proton() + builtin = Path(self.tmp.name) / "arm-proton/files/lib/wine/aarch64-windows/ucrtbase.dll" + builtin.parent.mkdir(parents=True, exist_ok=True) + builtin.write_bytes(self.pe_image(0xAA64)) + system32 = self.pfx / "system32" + system32.mkdir(parents=True) + dll = system32 / "ucrtbase.dll" + dll.write_bytes(self.pe_image(0x8664)) + backup = self.compat / ".droiddeck-wincomponents-backup/drive_c/windows/system32/ucrtbase.dll" + backup.parent.mkdir(parents=True) + backup.write_bytes(self.pe_image(0x8664)) + self.compat.joinpath(".droiddeck-wincomponents.json").write_text(json.dumps({ + "files": {"drive_c/windows/system32/ucrtbase.dll": "vc"}, "components": ["vc"]})) + self.compat.joinpath("pfx/user.reg").parent.mkdir(parents=True, exist_ok=True) + self.compat.joinpath("pfx/user.reg").write_text( + 'WINE REGISTRY Version 2\n\n[Software\\\\Wine\\\\DllOverrides] 1\n"ucrtbase"="native,builtin"\n') + fixed = GAME_ENV["repair_foreign_system_dlls"](str(self.compat), str(proton)) + self.assertEqual(["system32/ucrtbase.dll (machine 0x8664)"], fixed) + self.apply(proton) + self.assertTrue(dll.is_symlink()) + self.assertEqual(0xAA64, MODULE["pe_machine"](dll)) + + def test_matching_component_dll_is_placed(self): + proton = self.proton() + self.component("vc", ["ucrtbase"], {"system32/ucrtbase.dll": self.pe_image(0xAA64)}) + self.pick("vc") + names, _ = self.apply(proton) + self.assertEqual(names, ["ucrtbase"]) + self.assertEqual(MODULE["pe_machine"](self.pfx / "system32/ucrtbase.dll"), 0xAA64) + + def test_x64_proton_places_x64_component_dll(self): + proton = self.proton(arm=False) + self.component("vc", ["ucrtbase"], {"system32/ucrtbase.dll": self.pe_image(0x8664)}) + self.pick("vc") + names, _ = self.apply(proton) + self.assertEqual(names, ["ucrtbase"]) + self.assertEqual(MODULE["pe_machine"](self.pfx / "system32/ucrtbase.dll"), 0x8664) + + def test_arm_proton_with_x64_selection_places_x64_component_dll(self): + proton = self.proton() + self.component("vc", ["ucrtbase"], {"system32/ucrtbase.dll": self.pe_image(0x8664)}) + self.pick("vc") + names, _ = self.apply(proton, {"PROTON_USE_ARM64": "0"}) + self.assertEqual(names, ["ucrtbase"]) + self.assertEqual(MODULE["pe_machine"](self.pfx / "system32/ucrtbase.dll"), 0x8664) def test_places_files_before_the_prefix_exists_and_returns_overrides(self): self.component("openal", ["openal32"], {"syswow64/OpenAL32.dll": b"x86", "system32/OpenAL32.dll": b"x64"}) @@ -235,7 +361,7 @@ class WinComponentsTest(unittest.TestCase): self.pick("openal") globals_ = GAME_ENV["win_components"].__globals__ real = runpy.run_path(str(BIN / "droiddeck-wincomponents")) - real["apply"].__defaults__ = (self.selection, self.store, self.selection.parent / "no-pending.json", None) + real["apply"].__defaults__ = (self.selection, self.store, self.selection.parent / "no-pending.json", None, None, None) with mock.patch.dict(globals_, {"runpy": mock.Mock(run_path=lambda _path: real)}): self.assertEqual(GAME_ENV["win_components"](str(self.compat) + "/", "2571976725"), "openal32=n,b") self.assertEqual(GAME_ENV["win_components"]("", "2571976725"), "") From 634cb390c9ee6df97578359634bb9facf8bcaf43 Mon Sep 17 00:00:00 2001 From: MaxsTechReview Date: Tue, 10 Nov 2026 07:09:56 -0500 Subject: [PATCH 26/33] fix(proton-run): register the wrapper for an installed Valve Proton depot (#2392) * fix(proton-run): find Proton tools in Steam libraries * fix(proton-run): register the wrapper for an installed Valve Proton depot Valve's ARM64 Proton depots are not compatibility tools, and running them directly bypasses the DroidDeck wrapper. Register the wrapper from the depot when a store app is launched before Steam has started, instead of scanning steamapps/common. * test(proton): install complete tool fixtures in the auto-update tests * fix(proton-run): skip incomplete Proton depots and keep listing tools when the wrapper cannot be written * fix(proton-run): pick a runnable depot and rebuild a wrapper whose launcher is missing * test(proton-run): remove redundant depot fixture write * fix(proton-run): rebuild a wrapper whose manifest is missing * test(proton-run): cover runnable depot selection --- .../launcher/session/ProtonExtras.kt | 10 ++- .../droiddeck/launcher/ProtonMenuAutoTest.kt | 4 +- .../launcher/session/ProtonExtrasTest.kt | 13 ++++ .../usr/local/bin/droiddeck-proton-run | 13 +++- .../overlay/usr/local/bin/steam-compatibility | 5 +- tools/tests/test_desktop_open.py | 77 +++++++++++++++++++ 6 files changed, 117 insertions(+), 5 deletions(-) diff --git a/app/src/main/java/com/droiddeck/launcher/session/ProtonExtras.kt b/app/src/main/java/com/droiddeck/launcher/session/ProtonExtras.kt index 962291d2c..d17d40df4 100644 --- a/app/src/main/java/com/droiddeck/launcher/session/ProtonExtras.kt +++ b/app/src/main/java/com/droiddeck/launcher/session/ProtonExtras.kt @@ -48,11 +48,19 @@ object ProtonExtras { internal fun installedIn(dir: File, tool: Tool): List = dir.listFiles() - ?.filter { it.isDirectory && it.name.startsWith(tool.prefix) && File(it, "toolmanifest.vdf").isFile } + ?.filter { it.isDirectory && it.name.startsWith(tool.prefix) && usable(it) } ?.map { it.name } ?.sortedWith { a, b -> compareVersions(a, b) } ?: emptyList() + private fun usable(dir: File): Boolean { + val manifest = runCatching { File(dir, "toolmanifest.vdf").readText() }.getOrNull() ?: return false + val command = Regex("\\\"commandline\\\"\\s+\\\"/?([^\\\" ]+)").find(manifest)?.groupValues?.getOrNull(1) ?: return false + val compat = runCatching { File(dir, "compatibilitytool.vdf").readText() }.getOrNull() ?: return false + return Regex("\\\"from_oslist\\\"\\s+\\\"[^\\\"]*windows", RegexOption.IGNORE_CASE).containsMatchIn(compat) && + File(dir, command).isFile && File(dir, command).canExecute() + } + private val ARCH = Regex("(?i)aarch64|arm64|x86_64|x86-64|amd64") private val NUMBER = Regex("\\d+") diff --git a/app/src/test/java/com/droiddeck/launcher/ProtonMenuAutoTest.kt b/app/src/test/java/com/droiddeck/launcher/ProtonMenuAutoTest.kt index 7aff48691..14dbdb833 100644 --- a/app/src/test/java/com/droiddeck/launcher/ProtonMenuAutoTest.kt +++ b/app/src/test/java/com/droiddeck/launcher/ProtonMenuAutoTest.kt @@ -83,7 +83,9 @@ class ProtonMenuAutoTest { private fun install(name: String): File = File(root, "root/.local/share/Steam/compatibilitytools.d/$name").apply { mkdirs() - File(this, "toolmanifest.vdf").writeText("manifest") + File(this, "toolmanifest.vdf").writeText("\"manifest\" { \"commandline\" \"/proton %verb%\" }") + File(this, "compatibilitytool.vdf").writeText("\"from_oslist\" \"windows\"") + File(this, "proton").apply { writeText(""); setExecutable(true) } } private fun waitUntil(done: () -> Boolean) { diff --git a/app/src/test/java/com/droiddeck/launcher/session/ProtonExtrasTest.kt b/app/src/test/java/com/droiddeck/launcher/session/ProtonExtrasTest.kt index 411268ef6..43571a3ff 100644 --- a/app/src/test/java/com/droiddeck/launcher/session/ProtonExtrasTest.kt +++ b/app/src/test/java/com/droiddeck/launcher/session/ProtonExtrasTest.kt @@ -44,6 +44,8 @@ class ProtonExtrasTest { private fun installTool(parent: File, name: String) = File(parent, name).apply { mkdirs() File(this, "toolmanifest.vdf").writeText("\"manifest\" { \"commandline\" \"/proton %verb%\" }") + File(this, "compatibilitytool.vdf").writeText("\"from_oslist\" \"windows\"") + File(this, "proton").apply { writeText(""); setExecutable(true) } } @Test @@ -120,6 +122,17 @@ class ProtonExtrasTest { assertEquals(emptyList(), ProtonExtras.installedIn(File(tools, "missing"), tool("ge"))) } + @Test + fun interruptedBuildsAreNotReportedAsInstalled() { + val tools = temp.newFolder("compatibilitytools.d") + installTool(tools, "GE-Proton11-10") + File(tools, "GE-Proton11-11").apply { + mkdirs() + File(this, "toolmanifest.vdf").writeText("\"manifest\" { \"commandline\" \"/proton %verb%\" }") + } + assertEquals(listOf("GE-Proton11-10"), ProtonExtras.installedIn(tools, tool("ge"))) + } + @Test fun anUpdateIsANewerReleaseThanTheNewestInstalledBuild() { assertEquals("DroidDeck-Proton-11-7.6", ProtonExtras.updateFor(listOf("DroidDeck-Proton 11-7.5"), "DroidDeck-Proton-11-7.6")) diff --git a/tools/linuxfs/overlay/usr/local/bin/droiddeck-proton-run b/tools/linuxfs/overlay/usr/local/bin/droiddeck-proton-run index 3d2b6ec6f..164dbd028 100644 --- a/tools/linuxfs/overlay/usr/local/bin/droiddeck-proton-run +++ b/tools/linuxfs/overlay/usr/local/bin/droiddeck-proton-run @@ -70,8 +70,19 @@ def mapping(root): def tools(root): + c = compat() + wrapper = os.path.join(root, "compatibilitytools.d", c["TOOL"]) + complete = os.access(os.path.join(wrapper, c["LAUNCHER"]), os.X_OK) and os.path.isfile(os.path.join(wrapper, "toolmanifest.vdf")) + if c["TOOL"].lower() not in c["local_tools"](root) or not complete: + depot = c["find_source"](root, runnable=True) + if depot is not None: + try: + c["build_tool"](wrapper, depot) + except OSError: + pass found = {} - for base in [os.path.join(root, "compatibilitytools.d")] + list(SYSTEM_TOOLS): + bases = [os.path.join(root, "compatibilitytools.d")] + list(SYSTEM_TOOLS) + for base in bases: try: entries = sorted(os.listdir(base)) except OSError: diff --git a/tools/linuxfs/overlay/usr/local/bin/steam-compatibility b/tools/linuxfs/overlay/usr/local/bin/steam-compatibility index 5695bd0b1..4a2cbcb15 100755 --- a/tools/linuxfs/overlay/usr/local/bin/steam-compatibility +++ b/tools/linuxfs/overlay/usr/local/bin/steam-compatibility @@ -1599,10 +1599,11 @@ def depot_build(depot): return int(first[0]) if first and first[0].isdigit() else 0 -def find_source(steam_root, sources=SOURCES): +def find_source(steam_root, sources=SOURCES, runnable=False): for name in sources: found = [os.path.join(root, "steamapps", "common", name) for root in libraries(steam_root) - if os.path.isdir(os.path.join(root, "steamapps", "common", name, "files", "bin-arm64"))] + if os.path.isdir(os.path.join(root, "steamapps", "common", name, "files", "bin-arm64")) + and (not runnable or os.access(os.path.join(root, "steamapps", "common", name, "proton"), os.X_OK))] if found: return max(found, key=depot_build) return None diff --git a/tools/tests/test_desktop_open.py b/tools/tests/test_desktop_open.py index a23d85544..c32baf71a 100644 --- a/tools/tests/test_desktop_open.py +++ b/tools/tests/test_desktop_open.py @@ -482,6 +482,83 @@ class ProtonRunTest(Temp): self.assertEqual(found["droiddeck-proton-arm64"], (str(self.root / "compatibilitytools.d/droiddeck-proton-arm64"), "droiddeck-proton")) + def test_a_valve_depot_is_registered_through_the_droiddeck_wrapper(self): + name = "Proton Experimental (ARM64)" + folder = "steamapps/common/%s" % name + shutil.rmtree(self.root / "compatibilitytools.d/droiddeck-proton-arm64") + self.write("Steam/steamapps/appmanifest_4427310.acf", '"AppState" { "appid" "4427310" }') + self.write("Steam/%s/toolmanifest.vdf" % folder, '"manifest"\n{\n "commandline" "/proton %verb%"\n}\n') + self.write("Steam/%s/proton" % folder, b"#!/bin/sh\n", 0o755) + self.write("Steam/%s/files/bin-arm64/proton" % folder, b"#!/bin/sh\n", 0o755) + found = RUN["tools"](str(self.root)) + wrapper = self.root / "compatibilitytools.d/droiddeck-proton-arm64" + self.assertEqual(found["droiddeck-proton-arm64"], (str(wrapper), "droiddeck-proton")) + self.assertNotIn(name, found) + + def test_an_incomplete_valve_depot_is_not_registered(self): + name = "Proton Experimental (ARM64)" + folder = "steamapps/common/%s" % name + shutil.rmtree(self.root / "compatibilitytools.d/droiddeck-proton-arm64") + self.write("Steam/steamapps/appmanifest_4427310.acf", '"AppState" { "appid" "4427310" }') + self.write("Steam/%s/toolmanifest.vdf" % folder, '"manifest"\n{\n "commandline" "/proton %verb%"\n}\n') + self.write("Steam/%s/files/bin-arm64/proton" % folder, b"#!/bin/sh\n", 0o755) + found = RUN["tools"](str(self.root)) + self.assertNotIn("droiddeck-proton-arm64", found) + self.assertFalse((self.root / "compatibilitytools.d/droiddeck-proton-arm64").exists()) + + def test_a_failed_wrapper_build_does_not_hide_existing_tools(self): + shutil.rmtree(self.root / "compatibilitytools.d/droiddeck-proton-arm64") + self.write("Steam/steamapps/appmanifest_4427310.acf", '"AppState" { "appid" "4427310" }') + folder = "steamapps/common/Proton Experimental (ARM64)" + self.write("Steam/%s/proton" % folder, b"#!/bin/sh\n", 0o755) + self.write("Steam/%s/files/bin-arm64/proton" % folder, b"#!/bin/sh\n", 0o755) + build = mock.Mock(side_effect=OSError("read-only")) + with mock.patch.dict(RUN["compat"](), {"build_tool": build}): + found = RUN["tools"](str(self.root)) + build.assert_called_once() + self.assertIn("droiddeck-proton-11-arm64", found) + + def test_a_runnable_depot_is_chosen_over_a_newer_incomplete_one(self): + shutil.rmtree(self.root / "compatibilitytools.d/droiddeck-proton-arm64") + self.write("Steam/steamapps/appmanifest_4427310.acf", '"AppState" { "appid" "4427310" }') + good = "Steam/steamapps/common/Proton Experimental (ARM64)" + self.write("%s/proton" % good, b"#!/bin/sh\n", 0o755) + self.write("%s/files/bin-arm64/proton" % good, b"#!/bin/sh\n", 0o755) + broken = "Library/steamapps/common/Proton Experimental (ARM64)" + self.write("Library/steamapps/libraryfolders.vdf", b"") + self.write("%s/version" % broken, b"2\n") + self.write("%s/files/bin-arm64/proton" % broken, b"#!/bin/sh\n", 0o755) + self.write("Steam/steamapps/libraryfolders.vdf", '"path" "%s"' % (self.tmp / "Library")) + compat = RUN["compat"]() + source = compat["find_source"] + self.assertEqual(source(str(self.root), runnable=True), str(self.tmp / good)) + + def test_a_registered_wrapper_without_its_launcher_is_rebuilt(self): + wrapper = self.root / "compatibilitytools.d/droiddeck-proton-arm64" + self.write("Steam/steamapps/appmanifest_4427310.acf", '"AppState" { "appid" "4427310" }') + folder = "Steam/steamapps/common/Proton Experimental (ARM64)" + self.write("%s/proton" % folder, b"#!/bin/sh\n", 0o755) + self.write("%s/files/bin-arm64/proton" % folder, b"#!/bin/sh\n", 0o755) + launcher = wrapper / RUN["compat"]()["LAUNCHER"] + if launcher.exists(): + launcher.unlink() + found = RUN["tools"](str(self.root)) + self.assertTrue(os.access(launcher, os.X_OK)) + self.assertIn("droiddeck-proton-arm64", found) + + def test_a_registered_wrapper_without_its_manifest_is_rebuilt(self): + wrapper = self.root / "compatibilitytools.d/droiddeck-proton-arm64" + self.write("Steam/steamapps/appmanifest_4427310.acf", '"AppState" { "appid" "4427310" }') + folder = "Steam/steamapps/common/Proton Experimental (ARM64)" + self.write("%s/proton" % folder, b"#!/bin/sh\n", 0o755) + self.write("%s/files/bin-arm64/proton" % folder, b"#!/bin/sh\n", 0o755) + manifest = wrapper / "toolmanifest.vdf" + if manifest.exists(): + manifest.unlink() + found = RUN["tools"](str(self.root)) + self.assertTrue(manifest.is_file()) + self.assertIn("droiddeck-proton-arm64", found) + def test_steams_choice_for_the_title_then_its_default(self): available = RUN["tools"](str(self.root)) current = RUN["mapping"](str(self.root)) From aa1984bb9460fc6c38a127046ecee85019db9e79 Mon Sep 17 00:00:00 2001 From: Kurt Himebauch <136133082+xXJSONDeruloXx@users.noreply.github.com> Date: Tue, 10 Nov 2026 07:21:03 -0500 Subject: [PATCH 27/33] docs: DroidDeck 0.7.1 release notes (#2399) * docs: DroidDeck 0.7.1 release notes * docs: add #2392 to 0.7.1 notes --- docs/releases/0.7.1.md | 55 ++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 55 insertions(+) create mode 100644 docs/releases/0.7.1.md diff --git a/docs/releases/0.7.1.md b/docs/releases/0.7.1.md new file mode 100644 index 000000000..5633edbb8 --- /dev/null +++ b/docs/releases/0.7.1.md @@ -0,0 +1,55 @@ +# DroidDeck 0.7.1 + +

+ Download DroidDeck 0.7.1 + Join the DroidDeck Discord +

+ +**DroidDeck is 100% open source. No ads. No telemetry. No proprietary components. No source withheld.** + +0.7.1 is a fix release for 0.7.0. Since 0.7.0, **4 people** sent **33 pull requests**, and **2 of them are first-time contributors**. It fixes Steam launches that hung at "Processing install script", the endless loading screen after quitting a game and colour on PowerVR, plus GOG DLC and a new launcher theme. + +### Features + +- Added the **Scarlet snow** launcher theme, DroidDeck’s first light theme. (@EnzoDLofi, [#2262](https://github.com/Droid-Deck/DroidDeck/pull/2262)) +- Added **left and right picture alignment** next to top and bottom. The controller pointer and direct touch follow the moved picture, also with glasses. (@EnzoDLofi, [#2384](https://github.com/Droid-Deck/DroidDeck/pull/2384)) +- **GOG installs the DLC you own**, and the GOG search reaches GOG’s whole catalog. (@maxjivi05, [#2348](https://github.com/Droid-Deck/DroidDeck/pull/2348), [#2296](https://github.com/Droid-Deck/DroidDeck/pull/2296)) + +### Fixed + +- Fixed **Steam games stuck at “Processing install script”** or a download that never finishes, and the black screen with “Abort game” after quitting, which started in 0.7.0. (@xXJSONDeruloXx, [#2303](https://github.com/Droid-Deck/DroidDeck/pull/2303)) +- Fixed **a game launched from DroidDeck stalling at Steam’s launch screen**: DroidDeck now waits for Steam to be ready. (@maxjivi05, [#2362](https://github.com/Droid-Deck/DroidDeck/pull/2362)) +- Fixed **an endless loading screen** after a game exits back to Steam. (@maxjivi05, [#2359](https://github.com/Droid-Deck/DroidDeck/pull/2359)) +- The bottom-screen Dashboard’s **Notifications page is removed**, so DroidDeck no longer asks to read your notifications. (@xXJSONDeruloXx, [#2342](https://github.com/Droid-Deck/DroidDeck/pull/2342)) +- Fixed **grayscale Steam and games on PowerVR** (Pixel 10 Pro and Pro XL) since the Ubuntu 26.04 runtime. (@maxjivi05, [#2287](https://github.com/Droid-Deck/DroidDeck/pull/2287)) +- Fixed **games that need EasyAntiCheat** never starting: Linux-only tools such as the Proton EasyAntiCheat Runtime keep their native platform. (@maxjivi05, [#2313](https://github.com/Droid-Deck/DroidDeck/pull/2313)) +- Fixed **a Proton picked in Steam**, such as Proton Experimental, being switched back to DroidDeck-Proton, and **store apps launched before Steam opens** reporting that no Proton is installed. (@maxjivi05, [#2284](https://github.com/Droid-Deck/DroidDeck/pull/2284), [#2392](https://github.com/Droid-Deck/DroidDeck/pull/2392)) +- Fixed **games failing with `c000007b`** after an installer put an x64 system DLL into an ARM64 prefix. (@maxjivi05, [#2318](https://github.com/Droid-Deck/DroidDeck/pull/2318)) +- Fixed **emulators and other AppImage, Flatpak and Snap programs** rendering through system memory on Adreno. They get the game GPU setting, and Flatpak apps open their window instead of playing audio only. (@maxjivi05, [#2288](https://github.com/Droid-Deck/DroidDeck/pull/2288), [#2295](https://github.com/Droid-Deck/DroidDeck/pull/2295), [#2391](https://github.com/Droid-Deck/DroidDeck/pull/2391)) +- Fixed **Linux emulators that run guest code natively** hanging on launch. (@maxjivi05, [#2279](https://github.com/Droid-Deck/DroidDeck/pull/2279)) +- Fixed **Firefox tabs crashing** and the **desktop browser hanging** in the Desktop session. (@xXJSONDeruloXx, @maxjivi05, [#2360](https://github.com/Droid-Deck/DroidDeck/pull/2360), [#2304](https://github.com/Droid-Deck/DroidDeck/pull/2304)) +- Fixed **taps on pop-up dialogs** and windows away from the screen’s top-left corner doing nothing. (@maxjivi05, [#2280](https://github.com/Droid-Deck/DroidDeck/pull/2280), [#2285](https://github.com/Droid-Deck/DroidDeck/pull/2285)) +- Fixed **direct touch with Relative mouse: Auto** turning taps into small mouse movements when no mouse is connected. (@maxjivi05, [#2305](https://github.com/Droid-Deck/DroidDeck/pull/2305)) +- Fixed **the Controls below the game tabs** being cut off, which left the panel stuck on the trackpads. (@maxjivi05, [#2302](https://github.com/Droid-Deck/DroidDeck/pull/2302)) +- Fixed **GOG**: owned games missing from the library since 0.6.0 are back, and install scripts’ support files reach the prefix, so modded editions start as installed. (@maxjivi05, [#2296](https://github.com/Droid-Deck/DroidDeck/pull/2296), [#2294](https://github.com/Droid-Deck/DroidDeck/pull/2294)) +- Fixed **stutter on devices with 13 GB of RAM or less**: game memory the game reads straight back is no longer compressed again every 30 s. (@maxjivi05, [#2312](https://github.com/Droid-Deck/DroidDeck/pull/2312)) +- Fixed **the frame graph jumping around** on the bottom-screen Dashboard’s In-game stats. (@xXJSONDeruloXx, [#2345](https://github.com/Droid-Deck/DroidDeck/pull/2345)) +- Fixed smaller things: programs whose allocator calls a hooked function at startup no longer abort with the input preload, `droiddeckctl` works on shells without the `content` command such as the Retroid Pocket Duo’s, a full disk no longer crashes the app on an agent call, and ending a session without the graphics runtime pack no longer logs an error. (@xXJSONDeruloXx, @maxjivi05, [#2361](https://github.com/Droid-Deck/DroidDeck/pull/2361), [#2321](https://github.com/Droid-Deck/DroidDeck/pull/2321), [#2325](https://github.com/Droid-Deck/DroidDeck/pull/2325), [#2316](https://github.com/Droid-Deck/DroidDeck/pull/2316)) + +### Community + +These are everyone whose code is in 0.7.1: + +| Contributor | Pull requests | +|---|---| +| @maxjivi05 | 22: Steam launch readiness, PowerVR colour, GOG DLC and library, AppImage and Flatpak GPU setting, dialog taps, EasyAntiCheat and more | +| @xXJSONDeruloXx | 8: Steam session bus, notification listener removal, Firefox in the desktop, `droiddeckctl` and more | +| @EnzoDLofi | 2: Scarlet snow theme, left and right picture alignment | +| @GiovanniCarbinatti | 1: README GPU support | + +### New contributors + +- @EnzoDLofi made their first contribution in [#2262](https://github.com/Droid-Deck/DroidDeck/pull/2262). +- @GiovanniCarbinatti made their first contribution in [#2311](https://github.com/Droid-Deck/DroidDeck/pull/2311). + +[Full changelog](https://github.com/Droid-Deck/DroidDeck/compare/0.7.0...0.7.1) From cdff94e72c45da50af43bbceff28effaf0179caf Mon Sep 17 00:00:00 2001 From: Kurt Himebauch <136133082+xXJSONDeruloXx@users.noreply.github.com> Date: Tue, 10 Nov 2026 07:21:27 -0500 Subject: [PATCH 28/33] chore: bump version to 0.7.1 (19) (#2400) --- app/build.gradle | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/app/build.gradle b/app/build.gradle index f86084df2..c87e70938 100644 --- a/app/build.gradle +++ b/app/build.gradle @@ -131,8 +131,8 @@ android { applicationId "com.droiddeck.launcher" minSdkVersion 26 targetSdkVersion 28 - versionCode 18 - versionName "0.7.0" + versionCode 19 + versionName "0.7.1" buildConfigField "String", "BUILD_LABEL", "\"${escapedBuildLabel}\"" buildConfigField "String", "SOURCE_COMMIT", "\"${sourceCommit}\"" buildConfigField "int", "PR_NUMBER", "${prNumber}" From 98ab71a8144f7cfae1748d1b381b98688cd8f301 Mon Sep 17 00:00:00 2001 From: Kurt Himebauch <136133082+xXJSONDeruloXx@users.noreply.github.com> Date: Tue, 10 Nov 2026 07:38:07 -0500 Subject: [PATCH 29/33] docs: point README downloads at 0.7.1 (#2401) --- README.md | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/README.md b/README.md index 5b329368c..f7ec639de 100644 --- a/README.md +++ b/README.md @@ -10,7 +10,7 @@ DroidDeck brings the SteamOS experience to Android: Steam Gaming Mode, full Linu

100% open source. No ads. No telemetry. No proprietary components. No source withheld.

- Download DroidDeck 0.7.0 + Download DroidDeck 0.7.1 Join the DroidDeck Discord

@@ -38,7 +38,7 @@ DroidDeck brings the SteamOS experience to Android: Steam Gaming Mode, full Linu 1. Android 9 or newer on Adreno 730 or newer, or any 8xx. Adreno 6xx, Mali, Xclipse and PowerVR are experimental. Adreno 710 is currently unsupported. No root needed. 2. About 3 GB free for the runtime, plus 1.1 GB for the desktop and emulators. -3. Install the [APK](https://github.com/Droid-Deck/DroidDeck/releases/download/0.7.0/DroidDeck-0.7.0.apk), install the Linux runtime, press **Play** and sign in to Steam. +3. Install the [APK](https://github.com/Droid-Deck/DroidDeck/releases/download/0.7.1/DroidDeck-0.7.1.apk), install the Linux runtime, press **Play** and sign in to Steam. 4. On Android 14 and newer, turn on **Disable child process restrictions** in Developer options. On Android 12 and 13, Steam's first launch shows a **Fix it for me** button that walks you through it. Smart glasses setup is in the [support matrix](docs/smart-glasses-support.md). Release notes are in [docs/releases](docs/releases). From 40242fa0825cbb1a85d2ad0b58fe75b8c9217b5e Mon Sep 17 00:00:00 2001 From: Kurt Himebauch <136133082+xXJSONDeruloXx@users.noreply.github.com> Date: Tue, 10 Nov 2026 11:15:07 -0500 Subject: [PATCH 30/33] ci: scan signed main and release APKs on VirusTotal (#2408) * ci: scan signed main and release APKs on VirusTotal * ci: upload to VirusTotal only for unknown hashes and escape annotations --- .github/workflows/build.yml | 10 ++++ .github/workflows/release.yml | 9 ++++ .github/workflows/scan.yml | 45 ++++++++++++++++ tools/release/virustotal-scan.sh | 89 ++++++++++++++++++++++++++++++++ 4 files changed, 153 insertions(+) create mode 100644 .github/workflows/scan.yml create mode 100755 tools/release/virustotal-scan.sh diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index 42ce804dd..0d225b45c 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -883,6 +883,16 @@ jobs: variants: ${{ needs.build.outputs.variants }} secrets: inherit + scan: + needs: sign + if: github.event_name != 'pull_request' && github.ref == 'refs/heads/main' + permissions: + contents: read + uses: ./.github/workflows/scan.yml + with: + artifact: droiddeck-signed + secrets: inherit + packs: needs: [build, checks] if: github.event_name != 'pull_request' && github.ref == 'refs/heads/main' diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index a15159268..eabc77a35 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -63,6 +63,15 @@ jobs: artifact: droiddeck-release secrets: inherit + scan: + needs: sign + permissions: + contents: read + uses: ./.github/workflows/scan.yml + with: + artifact: droiddeck-release + secrets: inherit + packs: needs: find permissions: diff --git a/.github/workflows/scan.yml b/.github/workflows/scan.yml new file mode 100644 index 000000000..408983955 --- /dev/null +++ b/.github/workflows/scan.yml @@ -0,0 +1,45 @@ +name: Scan APKs + +on: + workflow_call: + inputs: + artifact: + description: Prefix of the signed artifacts to scan, as passed to sign.yml + type: string + required: true + fail_on_detection: + type: boolean + default: false + +permissions: + contents: read + +jobs: + virustotal: + name: VirusTotal + runs-on: ubuntu-24.04 + timeout-minutes: 90 + steps: + - uses: actions/checkout@v4 + with: + persist-credentials: false + sparse-checkout: tools/release + + - uses: actions/download-artifact@v4 + with: + pattern: ${{ inputs.artifact }}-* + merge-multiple: true + path: scan + + - name: Scan + continue-on-error: ${{ !inputs.fail_on_detection }} + env: + VT_API_KEY: ${{ secrets.VIRUSTOTAL_API_KEY }} + FAIL_ON_DETECTION: ${{ inputs.fail_on_detection && '1' || '0' }} + run: | + set -euo pipefail + if [ -z "$VT_API_KEY" ]; then + echo "::warning title=VirusTotal::no VIRUSTOTAL_API_KEY secret; skipping the scan" + exit 0 + fi + bash tools/release/virustotal-scan.sh scan/*.apk diff --git a/tools/release/virustotal-scan.sh b/tools/release/virustotal-scan.sh new file mode 100755 index 000000000..8138a428a --- /dev/null +++ b/tools/release/virustotal-scan.sh @@ -0,0 +1,89 @@ +#!/usr/bin/env bash +# Usage: virustotal-scan.sh ... +# Scans each file on VirusTotal and writes the verdicts to $GITHUB_STEP_SUMMARY. +# Exits 1 when an engine flags a file and FAIL_ON_DETECTION=1. +set -euo pipefail + +API=${VT_API:-https://www.virustotal.com/api/v3} +: "${VT_API_KEY:?VT_API_KEY is not set}" +POLL_SECONDS=${POLL_SECONDS:-30} +TIMEOUT_SECONDS=${TIMEOUT_SECONDS:-1200} +SUMMARY=${GITHUB_STEP_SUMMARY:-/dev/stdout} + +vt() { + curl -sS --fail-with-body --retry 5 --retry-delay 20 --retry-all-errors \ + -H "x-apikey: $VT_API_KEY" "$@" +} + +# Workflow commands end at a newline and decode %XX sequences. +warn() { + local message=$1 + message=${message//'%'/'%25'} + message=${message//$'\r'/'%0D'} + message=${message//$'\n'/'%0A'} + printf '::warning title=VirusTotal::%s\n' "$message" +} + +# The free API allows 4 requests a minute; poll slowly. +wait_for_analysis() { + local id=$1 waited=0 status + while :; do + status=$(vt "$API/analyses/$id" | jq -r .data.attributes.status) + [ "$status" = completed ] && return 0 + [ "$waited" -ge "$TIMEOUT_SECONDS" ] && return 1 + sleep "$POLL_SECONDS" + waited=$((waited + POLL_SECONDS)) + done +} + +flagged_total=0 +{ + echo "### VirusTotal" + echo + echo "| File | Flagged | Engines | Detections |" + echo "| --- | --- | --- | --- |" +} >> "$SUMMARY" + +for file in "$@"; do + name=$(basename "$file") + sha=$(sha256sum "$file" | cut -d' ' -f1) + link="https://www.virustotal.com/gui/file/$sha" + + known=$(curl -sS --retry 5 --retry-delay 20 -o /dev/null -w '%{http_code}' \ + -H "x-apikey: $VT_API_KEY" "$API/files/$sha") + if [ "$known" != 200 ] && [ "$known" != 404 ]; then + echo "::error title=VirusTotal::hash lookup for $name returned HTTP $known" + exit 1 + fi + if [ "$known" = 404 ]; then + # Files over 32 MB need a one-time upload URL. + url=$(vt "$API/files/upload_url" | jq -r .data) + id=$(vt -X POST -F "file=@$file" "$url" | jq -r .data.id) + echo "uploaded $name ($sha), analysis $id" + if ! wait_for_analysis "$id"; then + warn "$name was still being analysed after ${TIMEOUT_SECONDS}s: $link" + echo "| [$name]($link) | pending | | |" >> "$SUMMARY" + continue + fi + fi + + report=$(vt "$API/files/$sha") + malicious=$(jq -r '.data.attributes.last_analysis_stats.malicious // 0' <<<"$report") + suspicious=$(jq -r '.data.attributes.last_analysis_stats.suspicious // 0' <<<"$report") + engines=$(jq -r '[(.data.attributes.last_analysis_stats // {})[]] | add // 0' <<<"$report") + detections=$(jq -r '[(.data.attributes.last_analysis_results // {})[] + | select(.category == "malicious" or .category == "suspicious") + | "\(.engine_name): \(.result)"] | join("
")' <<<"$report") + flagged=$((malicious + suspicious)) + flagged_total=$((flagged_total + flagged)) + echo "$name: $flagged/$engines flagged $link" + echo "| [$name]($link) | $flagged | $engines | ${detections:-none} |" >> "$SUMMARY" + if [ "$flagged" -gt 0 ]; then + warn "$name flagged by $flagged/$engines engines: ${detections//
/, } $link" + fi +done + +if [ "$flagged_total" -gt 0 ] && [ "${FAIL_ON_DETECTION:-0}" = 1 ]; then + echo "::error::VirusTotal flagged the release" + exit 1 +fi From 9bf638a3c4131ee569eac305c4f4ec28198cd0e2 Mon Sep 17 00:00:00 2001 From: MaxsTechReview Date: Tue, 10 Nov 2026 11:55:38 -0500 Subject: [PATCH 31/33] fix(input): system Back only turns into a right-click with an external mouse (#2323) * fix(input): injected Back is a right-click only while an external mouse is attached * input: order imports * fix(input): reject built-in devices for injected Back * fix(input): log matched mouse for injected Back * fix(input): ignore mice that name themselves virtual for injected Back --- .../com/droiddeck/launcher/SessionActivity.kt | 26 ++++++++++- .../launcher/input/MouseBackInput.kt | 18 +++++++- .../launcher/input/MouseBackInputTest.kt | 44 +++++++++++++++++++ 3 files changed, 85 insertions(+), 3 deletions(-) diff --git a/app/src/main/java/com/droiddeck/launcher/SessionActivity.kt b/app/src/main/java/com/droiddeck/launcher/SessionActivity.kt index 09ae78546..5f39d03c6 100644 --- a/app/src/main/java/com/droiddeck/launcher/SessionActivity.kt +++ b/app/src/main/java/com/droiddeck/launcher/SessionActivity.kt @@ -391,8 +391,9 @@ class SessionActivity : ComponentActivity(), SurfaceHolder.Callback { ) private val mouseBackInput = com.droiddeck.launcher.input.MouseBackInput( sendRight = { down -> heldButtons.set(32, PointerGestures.BTN_RIGHT, down) }, - mouseAttached = ::mouseAttached, + mouseAttached = ::externalMouseAttached, ) + private var lastExternalMouse: InputDevice? = null private var drawerDirectionKey = KeyEvent.KEYCODE_UNKNOWN private var drawerDirectionDownTime = 0L private var drawerDirectionDeviceId = -1 @@ -2683,7 +2684,22 @@ class SessionActivity : ComponentActivity(), SurfaceHolder.Callback { } val mouseToGuest = !pipUi && !appTextFocus && !drawerOpen && !pcKeyboardOpen && !achievementsOpen && !gestureSettingsOpen && !inputSettingsOpen && !SessionState.suspended && !(::loading.isInitialized && (loading.visible || loading.ended)) - if (mouseBackInput.onKeyEvent(event, mouseToGuest)) return true + if (event.keyCode == KeyEvent.KEYCODE_BACK && event.action == KeyEvent.ACTION_DOWN && event.repeatCount == 0 && + com.droiddeck.launcher.input.MouseBackInput.isInjectedBack(event)) lastExternalMouse = null + val mouseBackHandled = mouseBackInput.onKeyEvent(event, mouseToGuest) + if (event.keyCode == KeyEvent.KEYCODE_BACK && event.action == KeyEvent.ACTION_DOWN && event.repeatCount == 0 && + com.droiddeck.launcher.input.MouseBackInput.isInjectedBack(event) && mouseToGuest) { + val device = lastExternalMouse + if (mouseBackHandled && device != null) { + Log.i(TAG, String.format(java.util.Locale.ROOT, + "injected Back right-click mouse=\"%s\" (%04x:%04x, id %d, sources 0x%x, external=%s, virtual=%s)", + device.name, device.vendorId, device.productId, device.id, device.sources, + Build.VERSION.SDK_INT < Build.VERSION_CODES.Q || device.isExternal, device.isVirtual)) + } else if (!mouseBackHandled) { + Log.i(TAG, "injected Back no external mouse found") + } + } + if (mouseBackHandled) return true val backToQam = !pipUi && !appTextFocus && !drawerOpen && !pcKeyboardOpen && !achievementsOpen && !inputSettingsOpen && !gestureSettingsOpen && SessionState.mode == SessionService.MODE_STEAM && SessionState.running && SessionState.firstFrameSeen && @@ -4163,6 +4179,12 @@ class SessionActivity : ComponentActivity(), SurfaceHolder.Callback { isHardwareMouse(d) && d?.supportsSource(InputDevice.SOURCE_TOUCHPAD) != true } + private fun externalMouseAttached(): Boolean { + lastExternalMouse = InputDevice.getDeviceIds().map { InputDevice.getDevice(it) } + .firstOrNull { device -> device != null && com.droiddeck.launcher.input.MouseBackInput.isExternalMouse(device) } + return lastExternalMouse != null + } + private fun relativeMouseAttached(): Boolean = !isInMultiWindowMode && !glassesOutputActive && InputDevice.getDeviceIds().any { id -> val d = InputDevice.getDevice(id) isHardwareMouse(d) && d?.supportsSource(InputDevice.SOURCE_TOUCHPAD) != true diff --git a/app/src/main/java/com/droiddeck/launcher/input/MouseBackInput.kt b/app/src/main/java/com/droiddeck/launcher/input/MouseBackInput.kt index 261c88a74..9f8240640 100644 --- a/app/src/main/java/com/droiddeck/launcher/input/MouseBackInput.kt +++ b/app/src/main/java/com/droiddeck/launcher/input/MouseBackInput.kt @@ -1,5 +1,6 @@ package com.droiddeck.launcher.input +import android.os.Build import android.view.InputDevice import android.view.KeyCharacterMap import android.view.KeyEvent @@ -7,7 +8,7 @@ import android.view.KeyEvent /** * Some firmware turns a mouse's right button into Back. Most report it from the mouse itself; REDMAGIC's * cast mode injects it from no device and without the virtual-hard-key flag that the navigation bar and - * back gesture set, so that form only counts while a mouse is connected. + * back gesture set, so that form only counts while an external mouse is connected. */ class MouseBackInput(private val sendRight: (Boolean) -> Unit, private val mouseAttached: () -> Boolean) { private val held = mutableSetOf() @@ -45,6 +46,21 @@ class MouseBackInput(private val sendRight: (Boolean) -> Unit, private val mouse } companion object { + fun isExternalMouse(device: InputDevice?): Boolean { + if (device == null || device.isVirtual || PadBridge.isSystemVirtualInputName(device.name) || + declaresVirtual(device.name) || + PadBridge.isFromController(device) || + (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q && !device.isExternal)) return false + return (device.supportsSource(InputDevice.SOURCE_MOUSE) || + device.supportsSource(InputDevice.SOURCE_MOUSE_RELATIVE)) && + !device.supportsSource(InputDevice.SOURCE_TOUCHPAD) && + !device.supportsSource(InputDevice.SOURCE_TOUCHSCREEN) + } + + private val VIRTUAL_NAME = Regex("\\bvirtual\\b", RegexOption.IGNORE_CASE) + + fun declaresVirtual(name: String?): Boolean = name != null && VIRTUAL_NAME.containsMatchIn(name) + fun isFromMouse(event: KeyEvent): Boolean = event.isFromSource(InputDevice.SOURCE_MOUSE) || event.isFromSource(InputDevice.SOURCE_MOUSE_RELATIVE) diff --git a/app/src/test/java/com/droiddeck/launcher/input/MouseBackInputTest.kt b/app/src/test/java/com/droiddeck/launcher/input/MouseBackInputTest.kt index 625f93e7c..ba7e666c6 100644 --- a/app/src/test/java/com/droiddeck/launcher/input/MouseBackInputTest.kt +++ b/app/src/test/java/com/droiddeck/launcher/input/MouseBackInputTest.kt @@ -8,6 +8,7 @@ import org.junit.Test import org.junit.runner.RunWith import org.robolectric.RobolectricTestRunner import org.robolectric.annotation.Config +import org.robolectric.shadows.InputDeviceBuilder @RunWith(RobolectricTestRunner::class) @Config(sdk = [34], manifest = Config.NONE) @@ -151,4 +152,47 @@ class MouseBackInputTest { assertFalse(input.onKeyEvent(key(KeyEvent.ACTION_UP), toGuest = true)) assertTrue(presses.isEmpty()) } + + private fun device(name: String, sources: Int, external: Boolean = true, virtual: Boolean = false) = + InputDeviceBuilder.newBuilder().setId(if (virtual) -1 else 11).setName(name).setDescriptor(name) + .setExternal(external).setSources(sources).build() + + @Test fun anExternalMouseCounts() { + assertTrue(MouseBackInput.isExternalMouse(device("USB mouse", InputDevice.SOURCE_MOUSE))) + assertTrue(MouseBackInput.isExternalMouse(device("Bluetooth mouse", InputDevice.SOURCE_MOUSE_RELATIVE))) + } + + @Test fun theLoggedControllerDeviceDoesNotCountAsAnExternalMouse() { + val odinController = device("Odin Controller", 0x1000511) + assertTrue(PadBridge.isFromController(odinController)) + assertFalse(MouseBackInput.isExternalMouse(odinController)) + } + + @Test fun virtualTouchpadTouchscreenUinputAndNonMouseDevicesDoNotCount() { + assertFalse(MouseBackInput.isExternalMouse(device("virtual mouse", InputDevice.SOURCE_MOUSE, virtual = true))) + assertFalse(MouseBackInput.isExternalMouse(device("touchpad", InputDevice.SOURCE_MOUSE or InputDevice.SOURCE_TOUCHPAD))) + assertFalse(MouseBackInput.isExternalMouse(device("touchscreen", InputDevice.SOURCE_MOUSE or InputDevice.SOURCE_TOUCHSCREEN))) + assertFalse(MouseBackInput.isExternalMouse(device("uinput-mouse", InputDevice.SOURCE_MOUSE))) + assertFalse(MouseBackInput.isExternalMouse(device("gamepad", InputDevice.SOURCE_GAMEPAD))) + assertFalse(MouseBackInput.isExternalMouse(null)) + } + + @Test fun aMouseThatNamesItselfVirtualDoesNotCount() { + assertFalse(MouseBackInput.isExternalMouse(device("ODIN Station Virtual Mouse", 0x20004))) + assertFalse(MouseBackInput.isExternalMouse(device("VIRTUAL pointer", InputDevice.SOURCE_MOUSE))) + assertTrue(MouseBackInput.isExternalMouse(device("Virtuality Pro Mouse", InputDevice.SOURCE_MOUSE))) + assertTrue(MouseBackInput.isExternalMouse(device("Logitech MX Master 3", InputDevice.SOURCE_MOUSE))) + } + + @Test fun injectedBackIsLeftAloneWithoutAnExternalMouse() { + val odinController = device("Odin Controller", 0x1000511) + val controllerOnly = MouseBackInput(presses::add) { MouseBackInput.isExternalMouse(odinController) } + assertFalse(controllerOnly.onKeyEvent(injected(KeyEvent.ACTION_DOWN), toGuest = true)) + assertTrue(presses.isEmpty()) + val external = MouseBackInput(presses::add) { + MouseBackInput.isExternalMouse(device("USB mouse", InputDevice.SOURCE_MOUSE)) + } + assertTrue(external.onKeyEvent(injected(KeyEvent.ACTION_DOWN), toGuest = true)) + assertEquals(listOf(true), presses) + } } From d8ed02f2cf38ecfa6aebed976281be37bd36f1fc Mon Sep 17 00:00:00 2001 From: Kurt Himebauch <136133082+xXJSONDeruloXx@users.noreply.github.com> Date: Tue, 10 Nov 2026 14:56:29 -0500 Subject: [PATCH 32/33] feat: EA store with sign-in, library, native downloads, updates and launch without the EA app (#2259) * docs: research and plan for EA and Ubisoft stores * feat(ea): EA store with sign-in, library, native downloads, updates and launch without the EA app * perf(ea): fetch large archive records as parallel ranges * fix(ea): register game display names * fix(ea): license legacy EA Core games and harden the LSX endpoint * fix(ea): run legacy Origin SDK games with a stand-in Origin process * fix(ea): trust only the prepared game on LSX and stop stale library syncs * fix(ea): close LSX sessions on stop and drop clients that never hand-shake * fix(ea): require every hashed field before licensing or caching a probe * fix(ea): trust one LSX session per launch * fix(ea): serialize the library cache write with sign-out and rerun a skipped sync * fix(ea): hand off library sync requests under one lock * fix(ea): clear the sign-in before invalidating the library * fix(ea): serialize sign-out with token refresh and with the library cache * fix(ea): LSX trusts only verified peers, new launch closes old sessions, exit stops its own game * fix(ea): LSX hides profile and presence from unverified peers * fix(ea): LSX handshake has a fixed deadline and untrusted sessions yield their slot * fix(ea): LSX never registers a client after its listener was stopped * fix(ea): LSX evicts each untrusted session at most once * fix(ea): LSX admission and eviction share one lock and keep the cap * fix(ea): LSX trust promotion excludes eviction * fix(ea): LSX launch generation guards restarts and drops results after shutdown * fix(ea): LSX publishes launch state under the admission lock and tags accepts by generation * fix(ea): LSX restarts rebind its listener and promotion checks the launch generation * fix(ea): record why EA refuses a game licence in the store log * chore(ea): log owned offers and the full licence refusal * chore(ea): log the account's entitlements when no game licence is granted * chore(ea): log legacy entitlements when no game licence is granted * chore(ea): log only the refused game's own entitlements * fix(ea): skip trial launchers flagged 1 and license games whose LSX peer Android cannot name --- .gitattributes | 2 + README.md | 1 + .../main/java/com/droiddeck/launcher/App.kt | 2 + .../droiddeck/launcher/stores/CloudSaves.kt | 6 +- .../droiddeck/launcher/stores/LoginFlows.kt | 2 + .../com/droiddeck/launcher/stores/Store.kt | 3 +- .../droiddeck/launcher/stores/StoreLaunch.kt | 13 +- .../launcher/stores/StoreLaunchRequests.kt | 7 + .../droiddeck/launcher/stores/StoreSizes.kt | 2 + .../com/droiddeck/launcher/stores/ea/EaApi.kt | 224 +++++ .../droiddeck/launcher/stores/ea/EaAuth.kt | 230 +++++ .../droiddeck/launcher/stores/ea/EaBackend.kt | 131 +++ .../droiddeck/launcher/stores/ea/EaCrypto.kt | 83 ++ .../droiddeck/launcher/stores/ea/EaHttp.kt | 47 + .../launcher/stores/ea/EaInstaller.kt | 140 +++ .../droiddeck/launcher/stores/ea/EaLaunch.kt | 138 +++ .../droiddeck/launcher/stores/ea/EaLibrary.kt | 293 ++++++ .../com/droiddeck/launcher/stores/ea/EaLsx.kt | 446 +++++++++ .../launcher/stores/ea/EaManifest.kt | 134 +++ .../droiddeck/launcher/stores/ea/EaNative.kt | 57 ++ .../com/droiddeck/launcher/stores/ea/EaOoa.kt | 196 ++++ .../droiddeck/launcher/ui/EaUpdateButton.kt | 17 + .../com/droiddeck/launcher/ui/GameDetail.kt | 3 +- .../com/droiddeck/launcher/ui/LibraryPage.kt | 2 +- .../com/droiddeck/launcher/ui/StoreChips.kt | 2 + .../com/droiddeck/launcher/ui/StoresPage.kt | 1 + .../main/resources/ea/graphql/GameImages.gql | 69 ++ .../resources/ea/graphql/GetUserPlayer.gql | 49 + .../resources/ea/graphql/availableBuilds.gql | 9 + .../main/resources/ea/graphql/downloadUrl.gql | 7 + .../ea/graphql/getLegacyCatalogDefs.gql | 66 ++ .../ea/graphql/getLegacyEntitlements.gql | 33 + .../ea/graphql/getPreloadedOwnedGames.gql | 69 ++ .../resources/ea/graphql/sdkEntitlements.gql | 27 + app/src/main/rust/stores/src/ea/ffi.rs | 207 +++++ app/src/main/rust/stores/src/ea/install.rs | 850 ++++++++++++++++++ .../main/rust/stores/src/ea/install/tests.rs | 258 ++++++ app/src/main/rust/stores/src/ea/mod.rs | 4 + app/src/main/rust/stores/src/ea/net.rs | 98 ++ app/src/main/rust/stores/src/ea/zip.rs | 288 ++++++ app/src/main/rust/stores/src/lib.rs | 1 + .../droiddeck/launcher/stores/ea/EaTest.kt | 323 +++++++ docs/development/ea-store.md | 31 + docs/development/ea-ubisoft-stores.md | 245 +++++ .../usr/local/bin/droiddeck-store-launch | 346 +++++++ tools/tests/test_store_launch_ea.py | 129 +++ 46 files changed, 5284 insertions(+), 7 deletions(-) create mode 100644 app/src/main/java/com/droiddeck/launcher/stores/ea/EaApi.kt create mode 100644 app/src/main/java/com/droiddeck/launcher/stores/ea/EaAuth.kt create mode 100644 app/src/main/java/com/droiddeck/launcher/stores/ea/EaBackend.kt create mode 100644 app/src/main/java/com/droiddeck/launcher/stores/ea/EaCrypto.kt create mode 100644 app/src/main/java/com/droiddeck/launcher/stores/ea/EaHttp.kt create mode 100644 app/src/main/java/com/droiddeck/launcher/stores/ea/EaInstaller.kt create mode 100644 app/src/main/java/com/droiddeck/launcher/stores/ea/EaLaunch.kt create mode 100644 app/src/main/java/com/droiddeck/launcher/stores/ea/EaLibrary.kt create mode 100644 app/src/main/java/com/droiddeck/launcher/stores/ea/EaLsx.kt create mode 100644 app/src/main/java/com/droiddeck/launcher/stores/ea/EaManifest.kt create mode 100644 app/src/main/java/com/droiddeck/launcher/stores/ea/EaNative.kt create mode 100644 app/src/main/java/com/droiddeck/launcher/stores/ea/EaOoa.kt create mode 100644 app/src/main/java/com/droiddeck/launcher/ui/EaUpdateButton.kt create mode 100644 app/src/main/resources/ea/graphql/GameImages.gql create mode 100644 app/src/main/resources/ea/graphql/GetUserPlayer.gql create mode 100644 app/src/main/resources/ea/graphql/availableBuilds.gql create mode 100644 app/src/main/resources/ea/graphql/downloadUrl.gql create mode 100644 app/src/main/resources/ea/graphql/getLegacyCatalogDefs.gql create mode 100644 app/src/main/resources/ea/graphql/getLegacyEntitlements.gql create mode 100644 app/src/main/resources/ea/graphql/getPreloadedOwnedGames.gql create mode 100644 app/src/main/resources/ea/graphql/sdkEntitlements.gql create mode 100644 app/src/main/rust/stores/src/ea/ffi.rs create mode 100644 app/src/main/rust/stores/src/ea/install.rs create mode 100644 app/src/main/rust/stores/src/ea/install/tests.rs create mode 100644 app/src/main/rust/stores/src/ea/mod.rs create mode 100644 app/src/main/rust/stores/src/ea/net.rs create mode 100644 app/src/main/rust/stores/src/ea/zip.rs create mode 100644 app/src/test/java/com/droiddeck/launcher/stores/ea/EaTest.kt create mode 100644 docs/development/ea-store.md create mode 100644 docs/development/ea-ubisoft-stores.md create mode 100644 tools/tests/test_store_launch_ea.py diff --git a/.gitattributes b/.gitattributes index 940dbb5f1..019aa1e8f 100644 --- a/.gitattributes +++ b/.gitattributes @@ -10,3 +10,5 @@ tools/mangoapp/mangoapp text eol=lf app/src/main/assets/desktop-removal/*.json text eol=lf app/src/main/assets/desktop-removal/*.txt text eol=lf *.pdf binary + +app/src/main/resources/ea/graphql/*.gql -text diff --git a/README.md b/README.md index f7ec639de..73c18762b 100644 --- a/README.md +++ b/README.md @@ -93,6 +93,7 @@ DroidDeck is [GPL-3.0](LICENSE). Steam and Proton belong to Valve Corporation. V - Epic Games and GOG build on [Phobos](https://github.com/phobos665)'s adaptation of [Legendary](https://github.com/legendary-gl/legendary) and [gogdl](https://github.com/Heroic-Games-Launcher/heroic-gogdl). GOG install script registry entries are read the way [Heroic Games Launcher](https://github.com/Heroic-Games-Launcher/HeroicGamesLauncher) (GPL-3.0) reads them. - Epic's official Linux launcher preview runs through FEX. That setup is outside Epic's listed Linux support. - Battle.net is by [@kayakyakr](https://github.com/kayakyakr). It runs Blizzard's own client in Proton (downloaded from Blizzard, not included) and shows key art from the [Battle.net Shop](https://shop.battle.net). Without Steam, its games see the pad as an Xbox 360 controller via `PROTON_SPOOF_STEAMINPUT_VIDPID` in [Proton](https://github.com/ValveSoftware/wine)'s `dlls/winebus.sys`. +- The EA store follows the EA app's protocols as documented by [ArmchairDevelopers](https://github.com/ArmchairDevelopers)' [Maxima](https://github.com/ArmchairDevelopers/Maxima) (GPL-3.0): sign-in, the GraphQL queries (included verbatim, since EA matches them by hash), build archives, online activation licences and the LSX game endpoint. The code is DroidDeck's own. - Battle.net installs drive Blizzard's Agent the way [@barncastle](https://github.com/barncastle)'s [Battle.Net-Installer](https://github.com/barncastle/Battle.Net-Installer) does, with requests from [wowdev.wiki](https://wowdev.wiki/Agent). Read without taking code: [@mubaraknumann](https://github.com/mubaraknumann)'s [unifideck](https://github.com/mubaraknumann/unifideck), [@djmittens](https://github.com/djmittens)'s [neo-mittens](https://github.com/djmittens/neo-mittens) `bnetswitch`, [@BCD1210](https://github.com/BCD1210)'s [soju](https://github.com/BCD1210/soju) and [Lutris](https://github.com/lutris/lutris)'s `product.db` reader. - World of Warcraft addon imports replace an addon's folder from a temporary copy, as [WowUp](https://github.com/WowUp/WowUp) does, and read flavour folders the way [WoWDeck](https://github.com/sigboe/wowdeck) does. No code from either is used. - The World of Warcraft addon Download window searches [WoWInterface](https://www.wowinterface.com) through its MMOUI API. [CurseForge](https://www.curseforge.com/wow) opens in the user's browser, with no API and no key. diff --git a/app/src/main/java/com/droiddeck/launcher/App.kt b/app/src/main/java/com/droiddeck/launcher/App.kt index 5ef39f6b6..7235bf95c 100644 --- a/app/src/main/java/com/droiddeck/launcher/App.kt +++ b/app/src/main/java/com/droiddeck/launcher/App.kt @@ -61,6 +61,8 @@ class App : Application() { com.droiddeck.launcher.stores.StoresState.register(com.droiddeck.launcher.stores.xbox.XboxBackend) com.droiddeck.launcher.sync.Syncthing.init(this) com.droiddeck.launcher.stores.StoresState.register(com.droiddeck.launcher.stores.battlenet.BattleNetBackend) + com.droiddeck.launcher.stores.ea.EaBackend.app = this + com.droiddeck.launcher.stores.StoresState.register(com.droiddeck.launcher.stores.ea.EaBackend) } private fun prefetchAddonPacks() { diff --git a/app/src/main/java/com/droiddeck/launcher/stores/CloudSaves.kt b/app/src/main/java/com/droiddeck/launcher/stores/CloudSaves.kt index 48244c489..389caee48 100644 --- a/app/src/main/java/com/droiddeck/launcher/stores/CloudSaves.kt +++ b/app/src/main/java/com/droiddeck/launcher/stores/CloudSaves.kt @@ -87,7 +87,7 @@ object CloudSaves { val item = sidecar.extra["catalogItemId"].orEmpty() if (ns.isEmpty() || item.isEmpty()) "" else EpicApiClient.getCustomAttribute(token, ns, item, "CloudSaveFolder") } - Store.AMAZON, Store.ITCH, Store.GAMEJOLT, Store.XBOX, Store.BATTLENET -> "" + Store.AMAZON, Store.ITCH, Store.GAMEJOLT, Store.XBOX, Store.BATTLENET, Store.EA -> "" } ?: return null p.edit() .putString("cloud_template_$id", fetched.ifEmpty { TEMPLATE_NONE }) @@ -148,7 +148,7 @@ object CloudSaves { val dir = when (store) { Store.GOG -> CloudSavePaths.gog(template, pfx, folder) Store.EPIC -> CloudSavePaths.epic(template, pfx, folder, EpicCredentialStore.load(context)?.accountId.orEmpty(), id) - Store.AMAZON, Store.ITCH, Store.GAMEJOLT, Store.XBOX, Store.BATTLENET -> null + Store.AMAZON, Store.ITCH, Store.GAMEJOLT, Store.XBOX, Store.BATTLENET, Store.EA -> null } return dir to (if (dir == null) "unresolved" else "ok") } @@ -173,7 +173,7 @@ object CloudSaves { val account = EpicCredentialStore.load(context)?.accountId.orEmpty() if (token == null || account.isEmpty()) null else EpicCloud(account, id, token) } - Store.AMAZON, Store.ITCH, Store.GAMEJOLT, Store.XBOX, Store.BATTLENET -> null + Store.AMAZON, Store.ITCH, Store.GAMEJOLT, Store.XBOX, Store.BATTLENET, Store.EA -> null } private fun gogScopedToken(context: Context, id: String, clientId: String): String? { diff --git a/app/src/main/java/com/droiddeck/launcher/stores/LoginFlows.kt b/app/src/main/java/com/droiddeck/launcher/stores/LoginFlows.kt index 4e53c5ea0..640635788 100644 --- a/app/src/main/java/com/droiddeck/launcher/stores/LoginFlows.kt +++ b/app/src/main/java/com/droiddeck/launcher/stores/LoginFlows.kt @@ -13,11 +13,13 @@ internal object LoginFlows { Store.EPIC -> epicFlow() Store.AMAZON -> amazonFlow() Store.GAMEJOLT -> gameJoltFlow(browserUserAgent) + Store.EA -> eaFlow(browserUserAgent) Store.ITCH, Store.XBOX, Store.BATTLENET -> null } @Volatile var epicFlow: () -> StoreLoginActivity.Flow? = { null } @Volatile var amazonFlow: () -> StoreLoginActivity.Flow? = { null } + @Volatile var eaFlow: (String) -> StoreLoginActivity.Flow? = { null } @Volatile var gameJoltFlow: (String) -> StoreLoginActivity.Flow? = { null } private class GogFlow(private val state: String, override val userAgent: String) : StoreLoginActivity.Flow { diff --git a/app/src/main/java/com/droiddeck/launcher/stores/Store.kt b/app/src/main/java/com/droiddeck/launcher/stores/Store.kt index b7d0fbce1..8813675b2 100644 --- a/app/src/main/java/com/droiddeck/launcher/stores/Store.kt +++ b/app/src/main/java/com/droiddeck/launcher/stores/Store.kt @@ -7,7 +7,8 @@ enum class Store(val id: String, val label: String, val shortLabel: String) { XBOX("xbox", "Xbox", "Xbox"), ITCH("itch", "Itch.io", "Itch.io"), GAMEJOLT("gamejolt", "Game Jolt", "Game Jolt"), - BATTLENET("battlenet", "Battle.net", "Battle.net"); + BATTLENET("battlenet", "Battle.net", "Battle.net"), + EA("ea", "EA", "EA"); val folder: String get() = if (this == ITCH) "itch" else shortLabel diff --git a/app/src/main/java/com/droiddeck/launcher/stores/StoreLaunch.kt b/app/src/main/java/com/droiddeck/launcher/stores/StoreLaunch.kt index 378d7fa74..f1a527a39 100644 --- a/app/src/main/java/com/droiddeck/launcher/stores/StoreLaunch.kt +++ b/app/src/main/java/com/droiddeck/launcher/stores/StoreLaunch.kt @@ -11,9 +11,12 @@ object StoreLaunch { const val LAUNCHER = ".droiddeck-launch.bat" const val EPIC_CODE = ".droiddeck-epic-code" const val EPIC_ARGS = ".droiddeck-epic-args" + const val EA_ENV = ".droiddeck-ea-env" + /** A sleeping stand-in that the launch hook installs; older EA SDKs require a running Origin process. */ + const val EA_ORIGIN = "C:\\Program Files (x86)\\Origin\\Origin.exe" fun writeLauncher(folder: File, sidecar: StoreGameSidecar): StoreGameSidecar { - val needs = sidecar.args.isNotEmpty() || sidecar.env.isNotEmpty() || sidecar.store == Store.EPIC + val needs = sidecar.args.isNotEmpty() || sidecar.env.isNotEmpty() || sidecar.store == Store.EPIC || sidecar.store == Store.EA val file = File(folder, LAUNCHER) if (!needs) { file.delete(); return sidecar.copy(launcher = null) } file.writeText(launcherText(sidecar)) @@ -49,6 +52,14 @@ object StoreLaunch { lines += "del \"%~dp0$EPIC_ARGS\"" lines += "if exist \"%~dp0$EPIC_CODE\" del \"%~dp0$EPIC_CODE\"" lines += "set \"$CLIENT_VAR=\" & \"%~dp0$exe\" %$CLIENT_VAR%" + } else if (sidecar.store == Store.EA) { + lines += "if not exist \"%~dp0$EA_ENV\" goto run" + lines += "for /f \"usebackq tokens=1,* delims==\" %%a in (\"%~dp0$EA_ENV\") do set \"%%a=%%b\"" + lines += "del \"%~dp0$EA_ENV\"" + lines += ":run" + lines += "if exist \"$EA_ORIGIN\" start \"\" \"$EA_ORIGIN\"" + lines += run + lines += "taskkill /f /im Origin.exe >nul 2>&1" } else { lines += run } diff --git a/app/src/main/java/com/droiddeck/launcher/stores/StoreLaunchRequests.kt b/app/src/main/java/com/droiddeck/launcher/stores/StoreLaunchRequests.kt index b4b9f7669..dbfa4a80d 100644 --- a/app/src/main/java/com/droiddeck/launcher/stores/StoreLaunchRequests.kt +++ b/app/src/main/java/com/droiddeck/launcher/stores/StoreLaunchRequests.kt @@ -38,6 +38,7 @@ object StoreLaunchRequests { fun stop() { com.droiddeck.launcher.stores.xbox.XboxRuntime.stop() + com.droiddeck.launcher.stores.ea.EaLsx.stop() synchronized(lock) { observer?.stopWatching() observer = null @@ -58,6 +59,12 @@ object StoreLaunchRequests { "xbox-renew" -> JSONObject().put("ok", com.droiddeck.launcher.stores.xbox.XboxRuntime.renew(app, request.optString("id"), request.optString("lease"))) "xbox-prepare" -> runCatching { com.droiddeck.launcher.stores.xbox.XboxRuntime.prepare(app, request.getString("id")) } .getOrElse { JSONObject().put("ok", false).put("reason", "Xbox account service is unavailable. Sign in again and retry.") } + "ea-prepare" -> runCatching { lastLaunched = Store.EA to request.optString("id"); com.droiddeck.launcher.stores.ea.EaLaunch.prepare(app, request) } + .getOrElse { JSONObject().put("ok", false).put("reason", "error-" + it.javaClass.simpleName) } + "ea-exited" -> { + com.droiddeck.launcher.stores.ea.EaLsx.stop(request.optString("id")) + JSONObject().put("ok", true) + } "gog-achievements" -> runCatching { com.droiddeck.launcher.stores.gog.GogComet.prepare(app, request.optString("id")) } .getOrElse { JSONObject().put("ok", false).put("ready", false).put("reason", "service-unavailable") } "epic-code" -> { diff --git a/app/src/main/java/com/droiddeck/launcher/stores/StoreSizes.kt b/app/src/main/java/com/droiddeck/launcher/stores/StoreSizes.kt index 545c981e5..65d72c6d9 100644 --- a/app/src/main/java/com/droiddeck/launcher/stores/StoreSizes.kt +++ b/app/src/main/java/com/droiddeck/launcher/stores/StoreSizes.kt @@ -55,6 +55,7 @@ object StoreSizes { Store.EPIC -> EpicPrefs.get(app).edit().putLong("size_${item.id}", it).apply() Store.AMAZON -> AmazonPrefs.get(app).edit().putLong("size_${item.id}", it).apply() Store.ITCH, Store.GAMEJOLT -> {} + Store.EA -> com.droiddeck.launcher.stores.ea.EaPrefs.get(app).edit().putLong("size_${item.id}", it).apply() Store.GOG, Store.XBOX, Store.BATTLENET -> {} } } @@ -111,6 +112,7 @@ object StoreSizes { val bytes = AmazonApiClient.getBytes(AmazonApiClient.appendPath(spec.downloadUrl, "manifest.proto"), token) ?: return 0L AmazonManifest.parse(bytes).totalInstallSize } + Store.EA -> com.droiddeck.launcher.stores.ea.EaSizes.size(context, item) Store.GOG, Store.XBOX, Store.BATTLENET -> 0L Store.ITCH -> com.droiddeck.launcher.stores.itch.ItchLibrary.size(context, item) Store.GAMEJOLT -> com.droiddeck.launcher.stores.gamejolt.GameJoltBuilds.size(context, item) diff --git a/app/src/main/java/com/droiddeck/launcher/stores/ea/EaApi.kt b/app/src/main/java/com/droiddeck/launcher/stores/ea/EaApi.kt new file mode 100644 index 000000000..185e23f42 --- /dev/null +++ b/app/src/main/java/com/droiddeck/launcher/stores/ea/EaApi.kt @@ -0,0 +1,224 @@ +package com.droiddeck.launcher.stores.ea + +import android.net.Uri +import org.json.JSONArray +import org.json.JSONObject + +class EaApiException(val status: Int, message: String) : Exception(message) + +object EaApi { + private const val GRAPHQL = "https://service-aggregation-layer.juno.ea.com/graphql" + private const val LOCALE = "DEFAULT" + + class Player(val userId: String, val personaId: String, val displayName: String, val eaId: String) + + class OwnedProduct( + val offerId: String, + val productId: String, + val name: String, + val slug: String, + val baseSlug: String, + val gameType: String, + val downloadable: Boolean, + val trial: Boolean, + val ownership: List, + ) + + class Offer( + val offerId: String, + val contentId: String, + val displayName: String, + val displayType: String, + val downloadable: Boolean, + val executePathOverride: String, + val installCheckOverride: String, + val executeParameters: String, + val multiplayerId: String, + val masterTitleId: String, + val gameVersion: String, + val cloudSaves: String, + val locales: List, + ) + + class Images(val tall: String?, val wide: String?, val logo: String?) + + class Build(val buildId: String, val gameVersion: String, val liveDate: String) + + private val queries = HashMap() + + internal fun query(name: String): String = synchronized(queries) { + queries.getOrPut(name) { + val stream = EaApi::class.java.getResourceAsStream("/ea/graphql/$name.gql") ?: throw IllegalStateException("missing query $name") + stream.use { it.readBytes().toString(Charsets.UTF_8) } + } + } + + private fun persisted(name: String): String = + JSONObject().put("persistedQuery", JSONObject().put("version", 1).put("sha256Hash", EaCrypto.hex(EaCrypto.sha256(query(name).toByteArray())))).toString() + + internal fun call(token: String, name: String, variables: JSONObject): JSONObject { + val headers = mapOf("Authorization" to "Bearer $token", "Accept" to "application/json") + val get = Uri.parse(GRAPHQL).buildUpon() + .appendQueryParameter("extensions", persisted(name)) + .appendQueryParameter("operationName", name) + .appendQueryParameter("variables", variables.toString()) + .build().toString() + var reply = EaHttp.request(get, headers = headers) + var json = runCatching { JSONObject(reply.text) }.getOrNull() + if (!reply.ok || json == null || json.has("errors")) { + val body = JSONObject() + .put("extensions", JSONObject(persisted(name))) + .put("variables", variables) + .put("operationName", name) + .put("query", query(name)) + reply = EaHttp.request(GRAPHQL, "POST", headers + ("Content-Type" to "application/json"), body.toString().toByteArray()) + json = runCatching { JSONObject(reply.text) }.getOrNull() + } + if (reply.status == 401) throw EaApiException(401, "EA refused the session") + if (json == null) throw EaApiException(reply.status, "EA gave no answer to $name (${reply.status})") + json.optJSONArray("errors")?.optJSONObject(0)?.let { throw EaApiException(reply.status, "$name: ${it.optString("message")}") } + return json.optJSONObject("data") ?: throw EaApiException(reply.status, "$name returned no data") + } + + fun player(token: String): Player { + val me = call(token, "GetUserPlayer", JSONObject()).optJSONObject("me") ?: throw EaApiException(0, "no player") + val p = me.optJSONObject("player") + return Player( + userId = me.optString("pd"), + personaId = p?.optString("psd").orEmpty(), + displayName = p?.optString("displayName").orEmpty(), + eaId = p?.optString("uniqueName").orEmpty().ifEmpty { p?.optString("displayName").orEmpty() }, + ) + } + + internal fun parseOwned(page: JSONObject): Pair, String?> { + val owned = page.optJSONObject("me")?.optJSONObject("ownedGameProducts") ?: return emptyList() to null + val items = owned.optJSONArray("items") ?: JSONArray() + val list = (0 until items.length()).mapNotNull { items.optJSONObject(it) }.mapNotNull { o -> + val product = o.optJSONObject("product") ?: return@mapNotNull null + val base = product.optJSONObject("baseItem") + val user = product.optJSONObject("gameProductUser") + val methods = user?.optJSONArray("ownershipMethods") + OwnedProduct( + offerId = o.optString("originOfferId").ifEmpty { return@mapNotNull null }, + productId = product.optString("id"), + name = (base?.optString("title")?.takeIf { it.isNotBlank() } ?: product.optString("name")).replace("\n", " ").trim(), + slug = product.optString("gameSlug"), + baseSlug = base?.optString("baseGameSlug").orEmpty().takeIf { it != "null" }.orEmpty(), + gameType = base?.optString("gameType").orEmpty(), + downloadable = product.optBoolean("downloadable", true), + trial = product.optJSONObject("trialDetails")?.optString("trialType").orEmpty().let { it.isNotEmpty() && it != "null" }, + ownership = (0 until (methods?.length() ?: 0)).map { methods!!.optString(it) }, + ) + } + val next = owned.optString("next").takeIf { it.isNotBlank() && it != "null" } + return list to next + } + + fun owned(token: String): List { + val out = ArrayList() + var next: String? = "0" + var guard = 0 + while (next != null && guard++ < 50) { + val variables = JSONObject() + .put("isMac", false) + .put("limit", 100) + .put("next", next) + .put("type", JSONArray().put("DIGITAL_FULL_GAME").put("PACKAGED_FULL_GAME")) + .put("storefronts", JSONArray().put("EA")) + .put("platforms", JSONArray().put("PC")) + val (items, after) = parseOwned(call(token, "getPreloadedOwnedGames", variables)) + out += items + next = after?.takeIf { it != next && items.isNotEmpty() } + } + return out + } + + internal fun parseOffers(data: JSONObject): List { + val arr = data.optJSONArray("legacyOffers") ?: return emptyList() + return (0 until arr.length()).mapNotNull { arr.optJSONObject(it) }.mapNotNull { o -> + val downloads = o.optJSONArray("downloads") + val version = (0 until (downloads?.length() ?: 0)).mapNotNull { downloads!!.optJSONObject(it) } + .firstOrNull { it.optString("downloadType").equals("LIVE", true) }?.optString("gameVersion") + ?: downloads?.optJSONObject(0)?.optString("gameVersion") + val locales = o.optJSONArray("softwareLocales") + fun s(k: String) = o.optString(k).takeIf { it != "null" }.orEmpty() + Offer( + offerId = s("offerId").ifEmpty { return@mapNotNull null }, + contentId = s("contentId"), + displayName = s("displayName"), + displayType = s("displayType"), + downloadable = o.optBoolean("isDownloadable", true), + executePathOverride = s("executePathOverride"), + installCheckOverride = s("installCheckOverride"), + executeParameters = s("executeParameters"), + multiplayerId = s("multiplayerId"), + masterTitleId = s("primaryMasterTitleId"), + gameVersion = version.orEmpty().takeIf { it != "null" }.orEmpty(), + cloudSaves = s("cloudSaveConfigurationOverride"), + locales = (0 until (locales?.length() ?: 0)).map { locales!!.optString(it) }, + ) + } + } + + fun offers(token: String, ids: List): List = ids.distinct().chunked(50).flatMap { chunk -> + parseOffers(call(token, "getLegacyCatalogDefs", JSONObject().put("offerIds", JSONArray(chunk)))) + } + + private fun path(o: JSONObject?, vararg keys: String): String? { + var cur = o ?: return null + for (k in keys.dropLast(1)) cur = cur.optJSONObject(k) ?: return null + return cur.optString(keys.last()).takeIf { it.startsWith("http") } + } + + internal fun parseImages(data: JSONObject): Images { + val game = data.optJSONObject("game") + val key = game?.optJSONObject("keyArt") + val pack = game?.optJSONObject("packArt") + return Images( + tall = path(pack, "aspect9x16Image", "path") ?: path(key, "aspect9x16Image", "path"), + wide = path(key, "aspect16x9Image", "path") ?: path(key, "largestImage", "path") ?: path(pack, "aspect2x1Image", "path"), + logo = path(game, "primaryLogo", "largestImage", "path") ?: path(game, "primaryLogo", "path"), + ) + } + + fun images(token: String, slug: String): Images = + parseImages(call(token, "GameImages", JSONObject().put("gameSlug", slug).put("locale", LOCALE))) + + fun downloadUrl(token: String, offerId: String, buildId: String? = null): String { + val data = call(token, "downloadUrl", JSONObject().put("offerId", offerId).put("buildId", buildId.orEmpty())) + return data.optJSONObject("downloadUrl")?.optString("url").orEmpty().ifEmpty { throw EaApiException(0, "EA gave no download link") } + } + + fun builds(token: String, offerId: String): List { + val arr = call(token, "availableBuilds", JSONObject().put("offerId", offerId)).optJSONArray("availableBuilds") ?: return emptyList() + return (0 until arr.length()).mapNotNull { arr.optJSONObject(it) }.map { + Build(it.optString("buildId"), it.optString("gameVersion").takeIf { v -> v != "null" }.orEmpty(), it.optString("buildLiveDate")) + } + } + + fun legacyEntitlements(token: String): List { + val out = ArrayList() + var next: String? = "0" + var guard = 0 + while (next != null && guard++ < 50) { + val variables = JSONObject().put("limit", 100).put("next", next).put("storeFronts", JSONArray().put("EA")) + val page = call(token, "getLegacyEntitlements", variables).optJSONObject("me")?.optJSONObject("legacyEntitlements") ?: break + val items = page.optJSONArray("items") ?: JSONArray() + for (i in 0 until items.length()) items.optJSONObject(i)?.let(out::add) + next = page.optString("next").takeIf { it.isNotBlank() && it != "null" && it != next && items.length() > 0 } + } + return out + } + + fun entitlements(token: String, group: String): JSONArray { + val variables = JSONObject() + .put("productIds", JSONArray()) + .put("includeChildGroups", false) + .put("groupNames", JSONArray().put(group)) + .put("entitlementTag", "") + .put("pageSize", 100) + .put("pageNumber", 1) + return call(token, "sdkEntitlements", variables).optJSONObject("me")?.optJSONObject("sdkEntitlements")?.optJSONArray("entitlements") ?: JSONArray() + } +} diff --git a/app/src/main/java/com/droiddeck/launcher/stores/ea/EaAuth.kt b/app/src/main/java/com/droiddeck/launcher/stores/ea/EaAuth.kt new file mode 100644 index 000000000..67fb8e655 --- /dev/null +++ b/app/src/main/java/com/droiddeck/launcher/stores/ea/EaAuth.kt @@ -0,0 +1,230 @@ +package com.droiddeck.launcher.stores.ea + +import android.content.Context +import android.net.Uri +import android.util.Log +import com.droiddeck.launcher.stores.Store +import com.droiddeck.launcher.stores.StoreAccounts +import org.json.JSONObject +import java.security.SecureRandom +import java.text.SimpleDateFormat +import java.util.Date +import java.util.Locale +import java.util.TimeZone + +object EaAuth { + private const val TAG = "EaAuth" + const val CLIENT_ID = "JUNO_PC_CLIENT" + private const val CLIENT_SECRET = "4mRLtYMb6vq9qglomWEaT4ChxsXWcyqbQpuBNfMPOYOiDmYYQmjuaBsF2Zp0RyVeWkfqhE9TuGgAw7te" + const val AUTH_URL = "https://accounts.ea.com/connect/auth" + private const val TOKEN_URL = "https://accounts.ea.com/connect/token" + const val REDIRECT = "qrc:///html/login_successful.html" + private const val REFRESH_MARGIN_MS = 5 * 60 * 1000L + private val refreshLock = Any() + private val random = SecureRandom() + + private val PC_SIGN_KEYS = mapOf( + "v1" to "ISa3dpGOc8wW7Adn4auACSQmaccrOyR2".toByteArray(), + "v2" to "nt5FfJbdPzNcl2pkC3zgjO43Knvscxft".toByteArray(), + ) + + val OPAQUE_SCOPES = listOf( + "basic.commerce.cartv2", "service.atom", "dp.client.default", "signin", "social_recommendation_user", + "basic.optin.write", "basic.commerce.cartv2.write", "basic.billing", "external.social_information_ups_admin", + ) + + class Account( + val accessToken: String, + val refreshToken: String, + val expiresAt: Long, + val userId: String, + val personaId: String, + val displayName: String, + val eaId: String, + ) + + class Pending(val url: String, val verifier: String) + + internal fun fromJson(o: JSONObject): Account? { + val access = o.optString("access_token").ifEmpty { return null } + return Account( + accessToken = access, + refreshToken = o.optString("refresh_token"), + expiresAt = o.optLong("expires_at"), + userId = o.optString("user_id"), + personaId = o.optString("persona_id"), + displayName = o.optString("display_name"), + eaId = o.optString("ea_id"), + ) + } + + fun load(context: Context): Account? = StoreAccounts.read(context.applicationContext, Store.EA)?.let(::fromJson) + + private fun deviceId(context: Context): String { + val prefs = EaPrefs.get(context) + prefs.getString("device", null)?.let { return it } + val made = EaCrypto.hex(ByteArray(16).also(random::nextBytes)) + prefs.edit().putString("device", made).apply() + return made + } + + internal fun pcSign(device: String, now: Date = Date(), version: String = if (random.nextBoolean()) "v1" else "v2"): String { + val stamp = SimpleDateFormat("yyyy-MM-dd HH:mm:ss:SSS", Locale.US).apply { timeZone = TimeZone.getTimeZone("UTC") }.format(now) + val mid = EaCrypto.fnv1a(("DroidDeck" + device.uppercase() + "DroidDeck").toByteArray()).toString() + val json = "{\"av\":\"v1\",\"bsn\":\"None\",\"gid\":0,\"hsn\":\"None\",\"mid\":\"$mid\",\"msn\":\"${device.uppercase()}\",\"sv\":\"$version\",\"ts\":\"$stamp\"}" + val payload = EaCrypto.base64Url(json.toByteArray()) + val signature = EaCrypto.base64Url(EaCrypto.hmacSha256(PC_SIGN_KEYS.getValue(version), payload.toByteArray())) + return "$payload.$signature" + } + + internal fun authUrl( + device: String, + responseType: String, + challenge: String? = null, + accessToken: String? = null, + clientId: String = CLIENT_ID, + scopes: List = emptyList(), + tokenFormat: String? = null, + expiresIn: Int? = null, + ): String { + val b = Uri.parse(AUTH_URL).buildUpon() + .appendQueryParameter("client_id", clientId) + .appendQueryParameter("sbiod_enabled", "false") + .appendQueryParameter("response_type", responseType) + .appendQueryParameter("locale", "en_US") + .appendQueryParameter("pc_sign", pcSign(device)) + .appendQueryParameter("nonce", random.nextInt().toString()) + if (scopes.isNotEmpty()) b.appendQueryParameter("scope", scopes.joinToString(" ")) + if (challenge != null) { + b.appendQueryParameter("code_challenge_method", "S256") + b.appendQueryParameter("code_challenge", challenge) + } + if (accessToken != null) b.appendQueryParameter("access_token", accessToken) + if (tokenFormat != null) b.appendQueryParameter("token_format", tokenFormat) + if (expiresIn != null) b.appendQueryParameter("expires_in", expiresIn.toString()) + return b.build().toString() + } + + fun begin(context: Context): Pending { + val verifier = EaCrypto.base64Url(ByteArray(32).also(random::nextBytes)) + val challenge = EaCrypto.base64Url(EaCrypto.sha256(verifier.toByteArray())) + return Pending(authUrl(deviceId(context), "code", challenge = challenge), verifier) + } + + /** Pulls one parameter out of the redirect EA sends to its desktop client's local page. */ + internal fun redirectValue(location: String, name: String): String? { + val normal = location.replace("qrc:///html", "http://127.0.0.1").replace("qrc:/html", "http://127.0.0.1") + val uri = Uri.parse(normal) + uri.getQueryParameter(name)?.let { return it } + val fragment = uri.encodedFragment ?: return null + return Uri.parse("http://x/?$fragment").getQueryParameter(name) + } + + private fun tokenCall(values: List>): JSONObject { + val reply = EaHttp.request( + TOKEN_URL, "POST", + mapOf("Content-Type" to "application/x-www-form-urlencoded"), + EaHttp.form(values + listOf("client_id" to CLIENT_ID, "client_secret" to CLIENT_SECRET)).toByteArray(), + follow = false, + ) + val json = runCatching { JSONObject(reply.text) }.getOrNull() + if (!reply.ok || json == null || json.optString("access_token").isEmpty()) { + throw IllegalStateException("EA token request failed (${reply.status}): ${json?.optString("error_description")?.ifEmpty { json.optString("error") } ?: ""}") + } + return json + } + + fun exchange(context: Context, code: String, verifier: String) { + val token = tokenCall( + listOf( + "grant_type" to "authorization_code", "code" to code, "code_verifier" to verifier, + "redirect_uri" to REDIRECT, "token_format" to "JWS", + ), + ) + val access = token.getString("access_token") + val player = runCatching { EaApi.player(access) }.onFailure { Log.w(TAG, "player: ${it.message}") }.getOrNull() + val record = JSONObject() + .put("access_token", access) + .put("refresh_token", token.optString("refresh_token")) + .put("expires_at", System.currentTimeMillis() + token.optLong("expires_in", 3600) * 1000) + .put("user_id", player?.userId.orEmpty()) + .put("persona_id", player?.personaId.orEmpty()) + .put("display_name", player?.displayName.orEmpty()) + .put("ea_id", player?.eaId.orEmpty()) + StoreAccounts.write(context.applicationContext, Store.EA, record) + } + + private fun fresh(a: Account) = a.expiresAt - System.currentTimeMillis() > REFRESH_MARGIN_MS || a.refreshToken.isEmpty() + + private fun refreshLocked(context: Context, a: Account): String? { + if (a.refreshToken.isEmpty()) return null + val token = try { + tokenCall(listOf("grant_type" to "refresh_token", "refresh_token" to a.refreshToken)) + } catch (e: Exception) { + Log.w(TAG, "refresh: ${e.message}") + return null + } + val access = token.getString("access_token") + val player = if (load(context)?.userId.isNullOrEmpty()) runCatching { EaApi.player(access) }.getOrNull() else null + return access.takeIf { storeRefreshed(context, access, token.optLong("expires_in", 3600), token.optString("refresh_token"), player) } + } + + /** Writes a refreshed token into the saved sign-in, unless sign-out removed it meanwhile. */ + internal fun storeRefreshed(context: Context, access: String, expiresIn: Long, refresh: String, player: EaApi.Player? = null): Boolean = + synchronized(refreshLock) { + val app = context.applicationContext + val record = StoreAccounts.read(app, Store.EA) ?: return false + record.put("access_token", access).put("expires_at", System.currentTimeMillis() + expiresIn * 1000) + refresh.takeIf { it.isNotEmpty() }?.let { record.put("refresh_token", it) } + player?.let { record.put("user_id", it.userId).put("persona_id", it.personaId).put("display_name", it.displayName).put("ea_id", it.eaId) } + StoreAccounts.write(app, Store.EA, record) + true + } + + fun accessToken(context: Context): String? { + val current = load(context) ?: return null + if (fresh(current)) return current.accessToken + synchronized(refreshLock) { + val again = load(context) ?: return null + if (fresh(again)) return again.accessToken + return refreshLocked(context, again) ?: again.accessToken.takeIf { again.expiresAt > System.currentTimeMillis() } + } + } + + fun refreshNow(context: Context, stale: String?): String? = synchronized(refreshLock) { + val current = load(context) ?: return null + if (stale != null && current.accessToken != stale) return current.accessToken + refreshLocked(context, current) + } + + /** Asks EA to mint a code or token for another client from the signed-in session, as the EA app does for games. */ + fun mint( + context: Context, + clientId: String, + responseType: String, + scopes: List = emptyList(), + tokenFormat: String? = null, + expiresIn: Int? = null, + ): String? { + val access = accessToken(context) ?: return null + val url = authUrl(deviceId(context), responseType, accessToken = access, clientId = clientId, scopes = scopes, tokenFormat = tokenFormat, expiresIn = expiresIn) + val reply = runCatching { EaHttp.request(url, follow = false) }.getOrElse { + Log.w(TAG, "mint $clientId: ${it.javaClass.simpleName}") + return null + } + val location = reply.header("Location") ?: run { + Log.w(TAG, "mint $clientId: no redirect (${reply.status})") + return null + } + if (location.startsWith("https://signin.ea.com")) { + Log.w(TAG, "mint $clientId: the session needs a new sign-in") + return null + } + return redirectValue(location, if (responseType == "token") "access_token" else responseType) + } + + /** Waits for any refresh in progress, so it cannot write the sign-in back afterwards. */ + fun signOut(context: Context) { + synchronized(refreshLock) { StoreAccounts.clear(context.applicationContext, Store.EA) } + } +} diff --git a/app/src/main/java/com/droiddeck/launcher/stores/ea/EaBackend.kt b/app/src/main/java/com/droiddeck/launcher/stores/ea/EaBackend.kt new file mode 100644 index 000000000..091243ae5 --- /dev/null +++ b/app/src/main/java/com/droiddeck/launcher/stores/ea/EaBackend.kt @@ -0,0 +1,131 @@ +package com.droiddeck.launcher.stores.ea + +import android.content.Context +import android.content.Intent +import android.util.Log +import android.webkit.WebView +import androidx.compose.runtime.mutableStateMapOf +import com.droiddeck.launcher.R +import com.droiddeck.launcher.stores.CatalogItem +import com.droiddeck.launcher.stores.InstalledStoreGame +import com.droiddeck.launcher.stores.LoginFlows +import com.droiddeck.launcher.stores.Store +import com.droiddeck.launcher.stores.StoreBackend +import com.droiddeck.launcher.stores.StoreInstallRoot +import com.droiddeck.launcher.stores.StoreLoginActivity +import com.droiddeck.launcher.stores.StoresState +import com.droiddeck.launcher.stores.WebCookies +import com.droiddeck.launcher.stores.download.DownloadEntry +import com.droiddeck.launcher.stores.download.DownloadQueue + +object EaBackend : StoreBackend { + override val store: Store = Store.EA + + @Volatile internal var app: Context? = null + + init { + LoginFlows.eaFlow = { ua -> app?.let { EaLogin(EaAuth.begin(it), ua) } } + } + + override fun signIn(context: Context) { + app = context.applicationContext + context.startActivity( + Intent(context, StoreLoginActivity::class.java) + .putExtra(StoreLoginActivity.EXTRA_STORE, store.id) + .addFlags(Intent.FLAG_ACTIVITY_NEW_TASK), + ) + } + + override fun signOut(context: Context) { + val app = context.applicationContext + EaAuth.signOut(app) + EaLibrary.reset(app) + EaUpdates.available.clear() + runCatching { WebCookies.expire(EaLogin.SIGN_IN_URLS, ".ea.com", listOf("/")) {} } + } + + override fun syncLibrary(context: Context, force: Boolean) { + val app = context.applicationContext + this.app = app + Thread({ EaLibrary.sync(app, force) }, "ea-sync").start() + } + + override fun loadShelves(context: Context, force: Boolean) {} + + override fun install(context: Context, item: CatalogItem, root: java.io.File) { + val app = context.applicationContext + if (!EaNative.ready()) { + StoresState.logLine(EaNative.ENGINE_MISSING) + StoresState.post { StoresState.problems[store] = EaNative.ENGINE_MISSING } + return + } + val folder = StoreInstallRoot.folderFor(app, store, item.id, item.title, root) + val entry = DownloadEntry(store, item.id, item.title, cover = item.tallImageUrl ?: item.imageUrl, bytesTotal = item.sizeBytes, location = StoreInstallRoot.labelFor(app, folder)) + DownloadQueue.enqueue(app, entry) { EaInstaller(app, item, folder) } + } + + override fun uninstall(context: Context, game: InstalledStoreGame) {} +} + +internal class EaLogin(private val pending: EaAuth.Pending, override val userAgent: String) : StoreLoginActivity.Flow { + override val startUrl: String get() = pending.url + + override fun isRedirect(url: String): Boolean = url.startsWith("qrc:", ignoreCase = true) + + override fun forgetSignIn(done: () -> Unit) { + runCatching { WebCookies.expire(SIGN_IN_URLS, ".ea.com", listOf("/")) { done() } }.onFailure { done() } + } + + override fun finish(activity: StoreLoginActivity, view: WebView, url: String): String? { + val code = EaAuth.redirectValue(url, "code") ?: return activity.getString(R.string.stores_login_error_verification) + return try { + EaAuth.exchange(activity, code, pending.verifier) + null + } catch (e: Exception) { + Log.w("EaLogin", "sign-in failed: ${e.message}") + activity.getString(R.string.stores_login_error_generic) + } + } + + companion object { + val SIGN_IN_URLS = listOf("https://accounts.ea.com", "https://signin.ea.com", "https://www.ea.com") + } +} + +object EaUpdates { + val available = mutableStateMapOf() + private val checked = java.util.concurrent.ConcurrentHashMap() + private const val RECHECK_MS = 30 * 60 * 1000L + + fun check(context: Context, game: InstalledStoreGame, force: Boolean = false) { + val app = context.applicationContext + val id = game.sidecar.id + val now = System.currentTimeMillis() + if (!force && now - (checked[id] ?: 0L) < RECHECK_MS) return + checked[id] = now + Thread({ + val token = EaAuth.accessToken(app) + val offer = token?.let { runCatching { EaApi.offers(it, listOf(id)) }.getOrNull() }?.firstOrNull() + if (offer == null) { + checked.remove(id) + return@Thread + } + val live = offer.gameVersion + val installed = game.sidecar.installVersion + StoresState.post { + if (live.isNotEmpty() && installed.isNotEmpty() && live != installed) available[game.key] = live else available.remove(game.key) + } + }, "ea-update-check").start() + } + + fun start(context: Context, game: InstalledStoreGame) { + val app = context.applicationContext + val root = game.folder.parentFile?.parentFile ?: return + val known = EaLibrary.game(app, game.sidecar.id) + val item = known?.let { EaLibrary.toCatalogItem(app, it) } ?: CatalogItem( + Store.EA, game.sidecar.id, game.sidecar.title, game.sidecar.hero, tallImageUrl = game.sidecar.cover, owned = true, extra = game.sidecar.extra, + ) + available.remove(game.key) + StoresState.install(app, item, root) + } +} diff --git a/app/src/main/java/com/droiddeck/launcher/stores/ea/EaCrypto.kt b/app/src/main/java/com/droiddeck/launcher/stores/ea/EaCrypto.kt new file mode 100644 index 000000000..d36a54108 --- /dev/null +++ b/app/src/main/java/com/droiddeck/launcher/stores/ea/EaCrypto.kt @@ -0,0 +1,83 @@ +package com.droiddeck.launcher.stores.ea + +import java.security.MessageDigest +import java.time.LocalDate +import java.time.ZoneOffset +import javax.crypto.Cipher +import javax.crypto.Mac +import javax.crypto.spec.IvParameterSpec +import javax.crypto.spec.SecretKeySpec + +internal object EaCrypto { + val LSX_DEFAULT_KEY = ByteArray(16) { it.toByte() } + + private val OOA_KEY = intArrayOf(65, 50, 114, 45, 208, 130, 239, 176, 220, 100, 87, 197, 118, 104, 202, 9) + .map { it.toByte() }.toByteArray() + + private const val PRIME_10K = 104729L + private const val PRIME_20K = 224737L + private const val PRIME_30K = 350377L + + fun ecbEncrypt(data: ByteArray, key: ByteArray): ByteArray = + Cipher.getInstance("AES/ECB/PKCS5Padding").run { init(Cipher.ENCRYPT_MODE, SecretKeySpec(key, "AES")); doFinal(data) } + + fun ecbDecrypt(data: ByteArray, key: ByteArray): ByteArray = + Cipher.getInstance("AES/ECB/PKCS5Padding").run { init(Cipher.DECRYPT_MODE, SecretKeySpec(key, "AES")); doFinal(data) } + + fun licenseEncrypt(data: ByteArray): ByteArray = + Cipher.getInstance("AES/CBC/PKCS5Padding").run { init(Cipher.ENCRYPT_MODE, SecretKeySpec(OOA_KEY, "AES"), IvParameterSpec(ByteArray(16))); doFinal(data) } + + fun licenseDecrypt(data: ByteArray): ByteArray = + Cipher.getInstance("AES/CBC/PKCS5Padding").run { init(Cipher.DECRYPT_MODE, SecretKeySpec(OOA_KEY, "AES"), IvParameterSpec(ByteArray(16))); doFinal(data) } + + fun hex(bytes: ByteArray): String = bytes.joinToString("") { "%02x".format(it.toInt() and 0xFF) } + + fun unhex(text: String): ByteArray { + val digits = text.lowercase().filter { it in '0'..'9' || it in 'a'..'f' } + if (digits.length % 2 != 0) return ByteArray(0) + return ByteArray(digits.length / 2) { i -> digits.substring(i * 2, i * 2 + 2).toInt(16).toByte() } + } + + /** The session key both sides derive after the LSX challenge, from a seed the game picks. */ + fun lsxKey(seed: Int): ByteArray { + if (seed == 0) return LSX_DEFAULT_KEY.copyOf() + var state = 7L + fun next(): Int { + state = (state * 214013L + 2531011L) and 0xFFFFFFFFL + return ((state ushr 16) and 0xFFFF).toInt() + } + val start = (next().toLong() + seed) and 0xFFFFFFFFL + state = start + return ByteArray(16) { next().toByte() } + } + + fun challengeAnswer(key: String): String = hex(ecbEncrypt(key.toByteArray(Charsets.US_ASCII), LSX_DEFAULT_KEY)) + + fun challengeSeed(version: String, answer: String): Int? = when (version) { + "2" -> 0 + "3" -> (answer[0].code shl 8) or answer[1].code + else -> null + } + + /** EA's launch handshake code, which changes every UTC day. */ + fun rtpCode(day: LocalDate = LocalDate.now(ZoneOffset.UTC)): Long { + val time = ((PRIME_10K * day.year) xor (day.monthValue * PRIME_20K) xor (day.dayOfMonth * PRIME_30K)) and 0xFFFFFFFFL + return (time xor ((time shl 16) and 0xFFFFFFFFL) xor (time ushr 16)) and 0xFFFFFFFFL + } + + fun sha256(data: ByteArray): ByteArray = MessageDigest.getInstance("SHA-256").digest(data) + + fun sha1(data: ByteArray): ByteArray = MessageDigest.getInstance("SHA-1").digest(data) + + fun hmacSha256(key: ByteArray, data: ByteArray): ByteArray = + Mac.getInstance("HmacSHA256").run { init(SecretKeySpec(key, "HmacSHA256")); doFinal(data) } + + fun fnv1a(data: ByteArray): ULong { + var hash = 0xcbf29ce484222325UL + for (b in data) hash = (hash xor (b.toUByte().toULong())) * 0x100000001b3UL + return hash + } + + fun base64Url(data: ByteArray): String = + java.util.Base64.getUrlEncoder().withoutPadding().encodeToString(data) +} diff --git a/app/src/main/java/com/droiddeck/launcher/stores/ea/EaHttp.kt b/app/src/main/java/com/droiddeck/launcher/stores/ea/EaHttp.kt new file mode 100644 index 000000000..193c4d741 --- /dev/null +++ b/app/src/main/java/com/droiddeck/launcher/stores/ea/EaHttp.kt @@ -0,0 +1,47 @@ +package com.droiddeck.launcher.stores.ea + +import java.net.HttpURLConnection +import java.net.URL +import java.net.URLEncoder + +internal class EaReply(val status: Int, val body: ByteArray, val headers: Map>) { + val ok: Boolean get() = status in 200..299 + val text: String get() = body.toString(Charsets.UTF_8) + fun header(name: String): String? = headers.entries.firstOrNull { it.key.equals(name, ignoreCase = true) }?.value?.firstOrNull() +} + +internal object EaHttp { + const val USER_AGENT = "Mozilla/5.0 EA Desktop/13.735.2.6250" + private const val TIMEOUT = 30_000 + + fun form(values: List>): String = + values.joinToString("&") { (k, v) -> URLEncoder.encode(k, "UTF-8") + "=" + URLEncoder.encode(v, "UTF-8") } + + fun request( + url: String, + method: String = "GET", + headers: Map = emptyMap(), + body: ByteArray? = null, + follow: Boolean = true, + ): EaReply { + val c = URL(url).openConnection() as HttpURLConnection + try { + c.requestMethod = method + c.connectTimeout = TIMEOUT + c.readTimeout = TIMEOUT + c.instanceFollowRedirects = follow + c.setRequestProperty("User-Agent", USER_AGENT) + for ((k, v) in headers) c.setRequestProperty(k, v) + if (body != null) { + c.doOutput = true + c.outputStream.use { it.write(body) } + } + val status = c.responseCode + val stream = if (status >= 400) c.errorStream else c.inputStream + val bytes = stream?.use { it.readBytes() } ?: ByteArray(0) + return EaReply(status, bytes, c.headerFields.filterKeys { it != null }) + } finally { + c.disconnect() + } + } +} diff --git a/app/src/main/java/com/droiddeck/launcher/stores/ea/EaInstaller.kt b/app/src/main/java/com/droiddeck/launcher/stores/ea/EaInstaller.kt new file mode 100644 index 000000000..22d973592 --- /dev/null +++ b/app/src/main/java/com/droiddeck/launcher/stores/ea/EaInstaller.kt @@ -0,0 +1,140 @@ +package com.droiddeck.launcher.stores.ea + +import android.content.Context +import com.droiddeck.launcher.R +import com.droiddeck.launcher.stores.CatalogItem +import com.droiddeck.launcher.stores.Store +import com.droiddeck.launcher.stores.StoreExe +import com.droiddeck.launcher.stores.StoreGameSidecar +import com.droiddeck.launcher.stores.StoreInstalls +import com.droiddeck.launcher.stores.StoreLanguage +import com.droiddeck.launcher.stores.amazon.AmazonNative +import com.droiddeck.launcher.stores.download.DownloadQueue +import com.droiddeck.launcher.stores.download.DownloadStage +import com.droiddeck.launcher.stores.download.StoreDownloadTier +import org.json.JSONArray +import org.json.JSONObject +import java.io.File +import java.util.concurrent.atomic.AtomicBoolean +import java.util.concurrent.atomic.AtomicLong + +internal class EaInstaller( + private val app: Context, + private val item: CatalogItem, + private val folder: File, +) : DownloadQueue.DownloadJob { + private val cancelled = AtomicBoolean(false) + + override fun run(handle: DownloadQueue.JobHandle): String? { + handle.stage(DownloadStage.MANIFEST, app.getString(R.string.stores_sync_sign_in)) + var token = EaAuth.accessToken(app) ?: throw IllegalStateException("Not signed in to EA") + val url = try { + EaApi.downloadUrl(token, item.id) + } catch (e: EaApiException) { + if (e.status != 401) throw IllegalStateException(e.message) + token = EaAuth.refreshNow(app, token) ?: throw IllegalStateException("The EA sign-in has expired") + EaApi.downloadUrl(token, item.id) + } + val game = EaLibrary.game(app, item.id) + val version = runCatching { EaApi.offers(token, listOf(item.id)).firstOrNull()?.gameVersion }.getOrNull().orEmpty() + .ifEmpty { game?.version.orEmpty() } + val cover = item.tallImageUrl ?: item.imageUrl + StoreInstalls.begin(folder, Store.EA, item.id, item.title, cover, item.imageUrl) + if (cancelled.get()) return null + + val tier = StoreDownloadTier.current(app) + handle.log("ea: \"${item.title}\" tier=${tier.id} window=${tier.networkWindow}") + val request = JSONObject() + .put("url", url) + .put("gameDir", folder.absolutePath) + .put("maxWorkers", tier.networkWindow.coerceIn(1, 64)) + .put("processWorkers", tier.processWorkers) + .put("caBundle", "") + val shown = AtomicLong(-1) + val downloading = AtomicBoolean(false) + val listener = object : AmazonNative.InstallListener { + override fun onStatus(text: String) { handle.stage(DownloadStage.MANIFEST, text) } + override fun onCheck(doneBytes: Long, totalBytes: Long, files: Int, filesTotal: Int) { + handle.stage(DownloadStage.MANIFEST, "Checking files…") + handle.stageProgress(doneBytes, totalBytes, files, filesTotal, bytes = true) + } + override fun onSizes(pendingBytes: Long, totalBytes: Long) { handle.diskSize(totalBytes) } + override fun onProgress(done: Long, total: Long, files: Int, filesTotal: Int) { + if (downloading.compareAndSet(false, true)) handle.stage(DownloadStage.DOWNLOAD) + while (true) { + val before = shown.get() + if (done < before) return + if (shown.compareAndSet(before, done)) break + } + handle.progress(done, total, "Downloading ($files/$filesTotal files)") + } + override fun onLog(line: String) { handle.log("ea: $line") } + override fun beforeReplace(): Boolean = try { + StoreInstalls.markUpdating(folder) + true + } catch (e: Exception) { + handle.log("ea: the install record could not be switched to updating (${e.javaClass.simpleName})") + false + } + override fun onComplete(success: Boolean, cancelled: Boolean, error: String, result: String) = Unit + } + val outcome = EaNative.install(request, listener, cancelled) + if (outcome.cancelled || cancelled.get() && !outcome.success) return null + if (!outcome.success) throw IllegalStateException(outcome.error.ifEmpty { "The EA install failed" }) + val result = outcome.result ?: JSONObject() + result.optLong("totalBytes").takeIf { it > 0 }?.let { EaPrefs.get(app).edit().putLong("size_${item.id}", it).apply() } + + handle.stage(DownloadStage.INSTALL, app.getString(R.string.stores_registering)) + val sidecar = sidecarFor(app, item, folder, game, version, cover) { handle.log("ea: $it") } + StoreInstalls.complete(app, folder, sidecar, onStep = { done, steps -> handle.stageProgress(done.toLong(), steps.toLong()) }) + return folder.path + } + + override fun cancel(deleteFiles: Boolean) { + cancelled.set(true) + if (deleteFiles) StoreInstalls.discard(app, folder, emptyList()) + } + + companion object { + fun locale(context: Context, available: List): String { + val wanted = StoreLanguage.localeFor(null, context) + val exact = "${wanted.language}_${wanted.country}" + return available.firstOrNull { it.equals(exact, true) } + ?: available.firstOrNull { it.substringBefore('_').equals(wanted.language, true) } + ?: available.firstOrNull { it.equals("en_US", true) } + ?: "en_US" + } + + internal fun sidecarFor(app: Context, item: CatalogItem, folder: File, game: EaGame?, version: String, cover: String?, log: (String) -> Unit): StoreGameSidecar { + val manifest = EaManifest.read(folder) + if (manifest == null) log("no installerdata.xml in ${folder.name}") + val launcher = manifest?.launcher() + val candidates = listOfNotNull(launcher?.path, game?.executePath?.takeIf { it.isNotEmpty() }) + var exe = candidates.firstNotNullOfOrNull { EaPaths.relativeOnDisk(folder, EaPaths.relative(it)) }.orEmpty() + if (exe.isEmpty()) exe = StoreExe.pick(folder, null, item.title) + if (exe.isEmpty()) { log("no exe found; pick one in Steam settings › Added games"); exe = "game.exe" } else log("exe=$exe") + val args = EaPaths.splitArgs(listOf(game?.parameters.orEmpty(), launcher?.parameters.orEmpty()).filter { it.isNotBlank() }.joinToString(" ")) + val keys = JSONArray() + listOfNotNull(game?.installCheck, game?.executePath, launcher?.path).mapNotNull { EaPaths.registered(it) } + .distinctBy { it.key.lowercase() + "|" + it.value.lowercase() } + .forEach { keys.put(JSONArray().put(it.key).put(it.value)) } + val extra = linkedMapOf( + "contentId" to (game?.contentId ?: item.extra["contentId"].orEmpty()), + "slug" to (game?.slug ?: item.extra["slug"].orEmpty()), + "multiplayerId" to game?.multiplayerId.orEmpty(), + "masterTitleId" to game?.masterTitleId.orEmpty(), + "locale" to locale(app, game?.locales.orEmpty()), + "registry" to keys.toString(), + "touchup" to manifest?.touchupPath.orEmpty().replace('\\', '/').trimStart('/'), + "touchupArgs" to manifest?.touchupParameters.orEmpty(), + "contentIds" to manifest?.contentIds.orEmpty().joinToString(","), + "displayName" to manifest?.title(locale(app, game?.locales.orEmpty())).orEmpty(), + ).filterValues { it.isNotEmpty() } + return StoreGameSidecar( + Store.EA, item.id, item.title, exe = exe, args = args, + installVersion = version.ifEmpty { manifest?.version.orEmpty() }, installedAt = System.currentTimeMillis(), + cover = cover, hero = item.imageUrl, extra = extra, cloud = false, logo = item.logoUrl, + ) + } + } +} diff --git a/app/src/main/java/com/droiddeck/launcher/stores/ea/EaLaunch.kt b/app/src/main/java/com/droiddeck/launcher/stores/ea/EaLaunch.kt new file mode 100644 index 000000000..a9b623a2d --- /dev/null +++ b/app/src/main/java/com/droiddeck/launcher/stores/ea/EaLaunch.kt @@ -0,0 +1,138 @@ +package com.droiddeck.launcher.stores.ea + +import android.content.Context +import android.util.Base64 +import android.util.Log +import com.droiddeck.launcher.stores.Store +import com.droiddeck.launcher.stores.StoreGameSidecar +import com.droiddeck.launcher.stores.StoreInstallRoot +import org.json.JSONArray +import org.json.JSONObject +import java.io.File +import java.util.UUID + +object EaLaunch { + private const val TAG = "EaLaunch" + const val ENV_FILE = ".droiddeck-ea-env" + + private fun installed(context: Context, id: String): Pair? = + StoreInstallRoot.gameFolders(context).firstNotNullOfOrNull { f -> + StoreGameSidecar.read(f)?.takeIf { it.store == Store.EA && it.id == id && it.isInstalled }?.let { f to it } + } + + internal fun environment( + offerId: String, + contentId: String, + locale: String, + generic: String, + access: String, + opaque: String, + account: EaAuth.Account, + session: String = UUID.randomUUID().toString(), + ): LinkedHashMap = linkedMapOf( + "EAAuthCode" to "unavailable", + "EAEgsProxyIpcPort" to "0", + "EAEntitlementSource" to "EA", + "EAExternalSource" to "EA", + "EAFreeTrialGame" to "false", + "EAGameLocale" to locale, + "EAGenericAuthToken" to generic, + "EALaunchCode" to "unavailable", + "EALaunchOwner" to "EA", + "EALaunchEAID" to account.eaId.ifEmpty { account.displayName }, + "EALaunchEnv" to "production", + "EALaunchOfflineMode" to "false", + "EALsxPort" to EaLsx.PORT.toString(), + "EARtPLaunchCode" to EaCrypto.rtpCode().toString(), + "EASecureLaunchTokenTemp" to account.userId, + "EASteamProxyIpcPort" to "0", + "OriginSessionKey" to session, + "ContentId" to contentId, + "EAOnErrorExitRetCode" to "1", + "EAConnectionId" to offerId, + "EALicenseToken" to offerId, + "EALaunchUserAuthToken" to opaque, + "EAAccessTokenJWS" to access, + ) + + /** Adds what installs made before the manifest's content ids and title were recorded are missing. */ + internal fun backfill(folder: File, sidecar: StoreGameSidecar): StoreGameSidecar { + if (sidecar.extra.containsKey("contentIds") || sidecar.extra.containsKey("displayName")) return sidecar + val manifest = EaManifest.read(folder) ?: return sidecar + val locale = sidecar.extra["locale"].orEmpty().ifEmpty { "en_US" } + val added = mapOf( + "contentIds" to manifest.contentIds.joinToString(","), + "displayName" to manifest.title(locale).orEmpty(), + ).filterValues { it.isNotEmpty() } + if (added.isEmpty()) return sidecar + val updated = sidecar.copy(extra = sidecar.extra + added) + runCatching { updated.write(folder) }.onFailure { Log.w(TAG, "sidecar: ${it.javaClass.simpleName}") } + return updated + } + + /** Answers the guest's request right before an EA game starts: session, licence and the LSX endpoint. */ + fun prepare(context: Context, request: JSONObject): JSONObject { + val app = context.applicationContext + val id = request.optString("id") + val reply = JSONObject().put("ok", false) + val (folder, recorded) = installed(app, id) ?: return reply.put("reason", "not-installed") + val sidecar = backfill(folder, recorded) + runCatching { com.droiddeck.launcher.stores.StoreLaunch.refreshLauncher(folder, sidecar) } + File(folder, ENV_FILE).delete() + val account = EaAuth.load(app) ?: return reply.put("reason", "signed-out") + val access = EaAuth.accessToken(app) ?: return reply.put("reason", "sign-in-expired") + val contentId = sidecar.extra["contentId"].orEmpty() + val locale = sidecar.extra["locale"].orEmpty().ifEmpty { "en_US" } + val core = EaOoa.coreDir(folder, sidecar.exe) + val hashVersion = core?.let(EaOoa::activationVersion) ?: 1 + val hardware = request.optJSONObject("hardware")?.let(EaOoa.Hardware::fromJson)?.takeIf { it.complete(hashVersion) } + val legacy = EaOoa.legacyCore(core) + + val lifetime = if (legacy) 14400 else 550 + val opaque = EaAuth.mint(app, EaAuth.CLIENT_ID, "token", EaAuth.OPAQUE_SCOPES, "OPAQUE", lifetime).orEmpty() + val known = (sidecar.extra["contentIds"].orEmpty().split(',') + sidecar.extra["multiplayerId"].orEmpty()).map { it.trim() }.filter { it.isNotEmpty() }.toSet() + val lsx = EaLsx.start(app, EaLsx.Game(id, contentId, sidecar.title, sidecar.installVersion, locale, hardware, hashVersion, opaque, + System.currentTimeMillis() + (lifetime - 30) * 1000L, known)) + val env = environment(id, contentId, locale, if (legacy && opaque.isNotEmpty()) opaque else access, access, opaque, EaAuth.load(app) ?: account) + val tmp = File(folder, "$ENV_FILE.tmp") + tmp.writeText(env.entries.joinToString("\r\n", postfix = "\r\n") { "${it.key}=${it.value}" }) + tmp.renameTo(File(folder, ENV_FILE)) + + val licenses = JSONArray() + val notes = ArrayList() + if (core != null) { + val ids = (listOf(contentId) + sidecar.extra["contentIds"].orEmpty().split(',')).map { it.trim() }.filter { it.isNotEmpty() }.distinct() + if (hardware == null) notes += "no-hardware" else { + val hash = EaOoa.machineHash(hardware, hashVersion) + for (content in ids) { + notes += try { + for ((path, data) in EaOoa.files(core, EaOoa.request(access, content, hash))) { + licenses.put(JSONObject().put("path", path).put("data", Base64.encodeToString(data, Base64.NO_WRAP))) + } + "$content:ok" + } catch (e: Exception) { + Log.w(TAG, "licence $content for $id: ${e.message}") + com.droiddeck.launcher.stores.StoresState.logLine("ea licence $content for $id v$hashVersion legacy=$legacy: ${e.message}") + "$content:refused" + } + } + } + } + if (core != null && notes.isNotEmpty() && notes.none { it.endsWith(":ok") }) runCatching { + val rows = EaApi.legacyEntitlements(access).filter { it.optString("productId") == id } + val types = rows.joinToString(",") { it.optString("entitlementType") + "/" + it.optString("entitlementTag") + "/" + it.optString("status") } + com.droiddeck.launcher.stores.StoresState.logLine("ea $id has no licence; entitlements: ${types.ifEmpty { "none" }}") + } + val licenseNote = if (core == null) "none-needed" else "v$hashVersion " + notes.joinToString(",") + Log.i(TAG, "prepared $id lsx=$lsx legacy=$legacy opaque=${opaque.isNotEmpty()} licence=$licenseNote") + return reply.put("ok", true).put("lsx", lsx).put("license", licenseNote).put("licenses", licenses) + } +} + +object EaSizes { + fun size(context: Context, item: com.droiddeck.launcher.stores.CatalogItem): Long { + val token = EaAuth.accessToken(context) ?: return 0L + val url = runCatching { EaApi.downloadUrl(token, item.id) }.getOrNull() ?: return 0L + return EaNative.call("zip.size", JSONObject().put("url", url))?.optLong("totalBytes") ?: 0L + } +} diff --git a/app/src/main/java/com/droiddeck/launcher/stores/ea/EaLibrary.kt b/app/src/main/java/com/droiddeck/launcher/stores/ea/EaLibrary.kt new file mode 100644 index 000000000..2a90b2550 --- /dev/null +++ b/app/src/main/java/com/droiddeck/launcher/stores/ea/EaLibrary.kt @@ -0,0 +1,293 @@ +package com.droiddeck.launcher.stores.ea + +import android.content.Context +import android.content.SharedPreferences +import com.droiddeck.launcher.R +import com.droiddeck.launcher.stores.CatalogItem +import com.droiddeck.launcher.stores.Store +import com.droiddeck.launcher.stores.StoreNet +import com.droiddeck.launcher.stores.StoreShelves +import com.droiddeck.launcher.stores.StoresState +import com.droiddeck.launcher.stores.mergeItems +import org.json.JSONArray +import org.json.JSONObject +import java.util.concurrent.Executors +import java.util.concurrent.TimeUnit +import java.util.concurrent.atomic.AtomicBoolean + +object EaPrefs { + const val FILE = "stores.ea" + fun get(context: Context): SharedPreferences = context.applicationContext.getSharedPreferences(FILE, Context.MODE_PRIVATE) +} + +data class EaGame( + val offerId: String, + val contentId: String, + val title: String, + val slug: String, + val tall: String = "", + val wide: String = "", + val logo: String = "", + val version: String = "", + val executePath: String = "", + val installCheck: String = "", + val parameters: String = "", + val multiplayerId: String = "", + val masterTitleId: String = "", + val cloudSaves: String = "", + val locales: List = emptyList(), +) { + fun toJson(): JSONObject = JSONObject() + .put("offerId", offerId).put("contentId", contentId).put("title", title).put("slug", slug) + .put("tall", tall).put("wide", wide).put("logo", logo).put("version", version) + .put("executePath", executePath).put("installCheck", installCheck).put("parameters", parameters) + .put("multiplayerId", multiplayerId).put("masterTitleId", masterTitleId).put("cloudSaves", cloudSaves) + .put("locales", JSONArray(locales)) + + companion object { + fun fromJson(o: JSONObject): EaGame? { + val locales = o.optJSONArray("locales") + return EaGame( + offerId = o.optString("offerId").ifEmpty { return null }, + contentId = o.optString("contentId"), + title = o.optString("title"), + slug = o.optString("slug"), + tall = o.optString("tall"), + wide = o.optString("wide"), + logo = o.optString("logo"), + version = o.optString("version"), + executePath = o.optString("executePath"), + installCheck = o.optString("installCheck"), + parameters = o.optString("parameters"), + multiplayerId = o.optString("multiplayerId"), + masterTitleId = o.optString("masterTitleId"), + cloudSaves = o.optString("cloudSaves"), + locales = (0 until (locales?.length() ?: 0)).map { locales!!.optString(it) }, + ) + } + } +} + +object EaLibrary { + private const val KEY_CACHE = "library_cache" + private const val KEY_SYNCED_AT = "library_synced_at" + private const val THROTTLE_MS = 6 * 60 * 60 * 1000L + private const val ART_TTL_MS = 7 * 24 * 60 * 60 * 1000L + private const val ART_THREADS = 4 + const val STORE_URL = "https://www.ea.com/ea-app/library" + private val gate = SyncGate() + private val generation = java.util.concurrent.atomic.AtomicInteger() + + /** Drops the cache and makes any sync already running drop its results, in one step. */ + fun reset(context: Context) { + synchronized(cacheLock) { + generation.incrementAndGet() + EaPrefs.get(context).edit().remove(KEY_CACHE).remove(KEY_SYNCED_AT).commit() + } + } + + internal fun snapshot(app: Context): Pair> = synchronized(cacheLock) { generation.get() to cached(app) } + + internal fun storeCache(app: Context, games: List, gen: Int): Boolean = synchronized(cacheLock) { + gen == generation.get() && + EaPrefs.get(app).edit().putString(KEY_CACHE, JSONArray(games.map { it.toJson() }).toString()).putLong(KEY_SYNCED_AT, System.currentTimeMillis()).commit() + } + + private val cacheLock = Any() + private val store = Store.EA + + fun cached(context: Context): List { + val text = EaPrefs.get(context).getString(KEY_CACHE, null) ?: return emptyList() + val arr = runCatching { JSONArray(text) }.getOrNull() ?: return emptyList() + return (0 until arr.length()).mapNotNull { arr.optJSONObject(it)?.let(EaGame::fromJson) } + } + + fun game(context: Context, offerId: String): EaGame? = cached(context).firstOrNull { it.offerId == offerId } + + + fun toCatalogItem(context: Context, g: EaGame): CatalogItem { + val prefs = EaPrefs.get(context) + val tall = g.tall.ifEmpty { prefs.getString("poster_${g.offerId}", null).orEmpty() } + return CatalogItem( + store = store, + id = g.offerId, + title = g.title.ifBlank { g.slug }, + imageUrl = g.wide.ifEmpty { tall }.ifEmpty { null }, + tallImageUrl = tall.ifEmpty { g.wide }.ifEmpty { null }, + publisher = "Electronic Arts", + owned = true, + sizeBytes = prefs.getLong("size_${g.offerId}", 0L), + storeUrl = STORE_URL, + extra = mapOf("contentId" to g.contentId, "slug" to g.slug, "version" to g.version), + logoUrl = g.logo.ifEmpty { null }, + ) + } + + /** One entry per game: owned editions of the same base game collapse onto the base offer. */ + internal fun pick(owned: List, offers: Map): List> { + val usable = owned.filter { o -> + val offer = offers[o.offerId] ?: return@filter false + !o.trial && o.downloadable && offer.downloadable && + (offer.displayType.isEmpty() || offer.displayType.equals("FullGame", true)) && + (o.gameType.isEmpty() || o.gameType.equals("BASE_GAME", true)) + } + return usable.groupBy { it.baseSlug.ifEmpty { it.slug }.ifEmpty { it.offerId } }.values.map { group -> + val chosen = group.firstOrNull { it.baseSlug.isEmpty() } ?: group.first() + chosen to offers.getValue(chosen.offerId) + }.sortedBy { it.first.name.lowercase() } + } + + private fun art(app: Context, token: String, slugs: Collection, gen: Int): Map { + val prefs = EaPrefs.get(app) + val now = System.currentTimeMillis() + val out = java.util.concurrent.ConcurrentHashMap() + val pool = Executors.newFixedThreadPool(ART_THREADS) + try { + for (slug in slugs.filter { it.isNotEmpty() }) { + if (gen != generation.get()) break + val cached = prefs.getString("art_$slug", null)?.let { runCatching { JSONObject(it) }.getOrNull() } + if (cached != null && now - cached.optLong("at") < ART_TTL_MS) { + out[slug] = EaApi.Images(cached.optString("tall").ifEmpty { null }, cached.optString("wide").ifEmpty { null }, cached.optString("logo").ifEmpty { null }) + continue + } + pool.execute { + if (gen != generation.get()) return@execute + val images = runCatching { EaApi.images(token, slug) }.getOrNull() ?: return@execute + out[slug] = images + prefs.edit().putString( + "art_$slug", + JSONObject().put("at", now).put("tall", images.tall.orEmpty()).put("wide", images.wide.orEmpty()).put("logo", images.logo.orEmpty()).toString(), + ).apply() + } + } + } finally { + pool.shutdown() + pool.awaitTermination(2, TimeUnit.MINUTES) + } + return out + } + + private fun posters(app: Context, games: List, gen: Int) { + val prefs = EaPrefs.get(app) + val todo = games.filter { it.tall.isEmpty() && !prefs.contains("poster_${it.offerId}") }.take(40) + if (todo.isEmpty()) return + val pool = Executors.newFixedThreadPool(ART_THREADS) + try { + for (g in todo) pool.execute { + if (gen != generation.get()) return@execute + prefs.edit().putString("poster_${g.offerId}", runCatching { StoreNet.sgdbPoster(g.title) }.getOrDefault("")).apply() + } + } finally { + pool.shutdown() + pool.awaitTermination(5, TimeUnit.MINUTES) + } + } + + private fun publish(app: Context, games: List, gen: Int = generation.get()) { + if (gen != generation.get()) return + val items = games.map { toCatalogItem(app, it) } + StoresState.post { + if (gen != generation.get()) return@post + StoresState.library[store] = mergeItems(StoresState.library[store], items) + val installed = StoresState.installed.filter { it.sidecar.store == store }.map { it.sidecar.id }.toSet() + StoresState.shelves[store] = StoreShelves(trending = items.filterNot { it.id in installed }, art = StoresState.shelves[store]?.art.orEmpty()) + } + } + + private inline fun withToken(app: Context, token: String, call: (String) -> T): T = try { + call(token) + } catch (e: EaApiException) { + if (e.status != 401) throw e + call(EaAuth.refreshNow(app, token) ?: throw e) + } + + fun sync(context: Context, force: Boolean) { + if (!gate.enter()) return + var forced = force + do { + runCatching { syncOnce(context.applicationContext, forced) } + forced = true + } while (gate.leave()) + } + + private fun syncOnce(app: Context, force: Boolean) { + val (gen, cache) = snapshot(app) + val prefs = EaPrefs.get(app) + val quiet = cache.isNotEmpty() && !force + fun status(id: Int) { if (!quiet) StoresState.post { StoresState.status[store] = app.getString(id) } } + fun problem(text: String?) { + StoresState.post { + StoresState.status.remove(store) + if (text == null) StoresState.problems.remove(store) else StoresState.problems[store] = text + } + } + try { + if (cache.isNotEmpty()) publish(app, cache, gen) + if (!force && cache.isNotEmpty() && System.currentTimeMillis() - prefs.getLong(KEY_SYNCED_AT, 0L) in 0 until THROTTLE_MS) { + return problem(null) + } + status(R.string.stores_sync_sign_in) + val token = EaAuth.accessToken(app) ?: return problem(StoresState.notSignedInLine(store)) + status(R.string.stores_sync_game_list) + val owned = withToken(app, token) { EaApi.owned(it) } + val offers = withToken(app, token) { EaApi.offers(it, owned.map { o -> o.offerId }) }.associateBy { it.offerId } + val picked = pick(owned, offers) + StoresState.logLine("ea: ${owned.size} owned products, ${picked.size} games") + for ((o, offer) in picked) StoresState.logLine( + "ea: owned ${o.offerId} content=${offer.contentId} master=${offer.masterTitleId} mp=${offer.multiplayerId} via=${o.ownership.joinToString("+")} type=${o.gameType}") + if (picked.isEmpty()) return problem(if (cache.isEmpty()) app.getString(R.string.stores_sync_no_games, store.label) else null) + if (gen != generation.get()) return + status(R.string.stores_sync_cover_art) + val images = art(app, token, picked.map { it.first.slug }, gen) + val games = picked.map { (o, offer) -> + val img = images[o.slug] + EaGame( + offerId = o.offerId, contentId = offer.contentId, title = o.name.ifBlank { offer.displayName }, slug = o.slug, + tall = img?.tall.orEmpty(), wide = img?.wide.orEmpty(), logo = img?.logo.orEmpty(), version = offer.gameVersion, + executePath = offer.executePathOverride, installCheck = offer.installCheckOverride, parameters = offer.executeParameters, + multiplayerId = offer.multiplayerId, masterTitleId = offer.masterTitleId, cloudSaves = offer.cloudSaves, locales = offer.locales, + ) + } + if (!storeCache(app, games, gen)) return + posters(app, games, gen) + if (gen != generation.get()) return + publish(app, games, gen) + problem(null) + } catch (e: EaApiException) { + StoresState.logLine("ea: library sync failed: ${e.message}") + if (e.status == 401) StoresState.markSignInExpired(store) + problem(if (cache.isEmpty()) e.message else null) + } catch (e: Exception) { + StoresState.logLine("ea: library sync failed: ${e.javaClass.simpleName} ${e.message.orEmpty()}") + problem(if (cache.isEmpty()) e.message ?: app.getString(R.string.stores_sync_failed) else null) + } + } +} + +/** One sync at a time; a request made meanwhile makes the running one go round again rather than being lost. */ +internal class SyncGate { + private var busy = false + private var again = false + + /** True when the caller should run; false when a running sync will pick the request up. */ + @Synchronized + fun enter(): Boolean { + if (busy) { + again = true + return false + } + busy = true + return true + } + + /** True when another round was requested while running; the caller stays the runner. */ + @Synchronized + fun leave(): Boolean { + if (again) { + again = false + return true + } + busy = false + return false + } +} diff --git a/app/src/main/java/com/droiddeck/launcher/stores/ea/EaLsx.kt b/app/src/main/java/com/droiddeck/launcher/stores/ea/EaLsx.kt new file mode 100644 index 000000000..f9e6b286a --- /dev/null +++ b/app/src/main/java/com/droiddeck/launcher/stores/ea/EaLsx.kt @@ -0,0 +1,446 @@ +package com.droiddeck.launcher.stores.ea + +import android.content.Context +import android.util.Log +import org.xmlpull.v1.XmlPullParser +import org.xmlpull.v1.XmlPullParserFactory +import java.io.ByteArrayOutputStream +import java.io.OutputStream +import java.io.StringReader +import java.net.InetAddress +import java.net.ServerSocket +import java.net.Socket +import java.time.LocalDateTime +import java.time.ZoneOffset +import java.time.format.DateTimeFormatter + +/** + * The local SDK endpoint EA games talk to in place of the EA app (LSX: XML over TCP, AES after a + * challenge). It answers for the one game DroidDeck last prepared. + */ +object EaLsx { + private const val TAG = "EaLsx" + const val PORT = 3216 + private const val SENDER = "EALS" + private const val CHALLENGE_KEY = "cacf897a20b6d612ad0c05e011df52bb" + private const val CHALLENGE_VERSION = "10,5,30,15625" + private const val LANGUAGES = "ar_SA,de_DE,en_US,es_ES,es_MX,fr_FR,it_IT,ja_JP,ko_KR,pl_PL,pt_BR,ru_RU,zh_CN,zh_TW" + + private val SERVICES = listOf( + "EbisuSDK" to "SDK", "EbisuSDK" to "PROFILE", "XMPP" to "PRESENCE", "XMPP" to "FRIENDS", "Commerce" to "COMMERCE", + "EbisuSDK" to "RECENTPLAYER", "EbisuSDK" to "IGO", "EbisuSDK" to "MISC", "EALS" to "LOGIN", "EbisuSDK" to "UTILITY", + "XMPP" to "XMPP", "XMPP" to "CHAT", "EbisuSDK" to "IGO_EVENT", "EALS" to "EALS_EVENTS", "EbisuSDK" to "LOGIN_EVENT", + "XMPP" to "INVITE_EVENT", "EbisuSDK" to "PROFILE_EVENT", "XMPP" to "PRESENCE_EVENT", "XMPP" to "FRIENDS_EVENT", + "Commerce" to "COMMERCE_EVENT", "XMPP" to "CHAT_EVENT", "EbisuSDK" to "DOWNLOAD_EVENT", "EbisuSDK" to "PERMISSION", + "EbisuSDK" to "RESOURCES", "EbisuSDK" to "BLOCKED_USERS", "EbisuSDK" to "BLOCKED_USER_EVENT", "EbisuSDK" to "GET_USERID", + "EbisuSDK" to "ONLINE_STATUS_EVENT", "EbisuSDK" to "ACHIEVEMENT", "EbisuSDK" to "ACHIEVEMENT_EVENT", + "EbisuSDK" to "BROADCAST_EVENT", "PI" to "PROGRESSIVE_INSTALLATION", "PI" to "PROGRESSIVE_INSTALLATION_EVENT", + "EbisuSDK" to "CONTENT", + ) + + class Game( + val offerId: String, + val contentId: String, + val title: String, + val version: String, + val locale: String, + val hardware: EaOoa.Hardware?, + val hashVersion: Int, + val token: String = "", + val tokenExpiresAt: Long = 0L, + val contentIds: Set = emptySet(), + ) { + val validToken: String? get() = token.takeIf { it.isNotEmpty() && System.currentTimeMillis() < tokenExpiresAt } + } + + /** The parts of a request a reply depends on. */ + class Message(val recipient: String, val id: String, val name: String, val attrs: Map, val children: List>) + + interface Account { + val userId: String + val personaId: String + val persona: String + fun authCode(clientId: String, scope: String?): String? + fun license(contentId: String, requestTicket: String, ticketEngine: String): String? + fun entitlements(group: String): List> + fun token(): String? + } + + @Volatile var game: Game? = null + @Volatile private var account: Account? = null + /** The one session of the current launch that may act for the account. */ + /** Bumped whenever the launch changes; a connection accepted under an older one is not admitted. */ + @Volatile internal var generation = 0 + private val holder = java.util.concurrent.atomic.AtomicReference(null) + private var server: ServerSocket? = null + private val lock = Any() + private const val MAX_CLIENTS = 8 + private val WHITESPACE = Regex("[ \t]+") + + fun start(context: Context, game: Game): Boolean { + synchronized(lock) { + closeSessions() + this.game = game + account = AppAccount(context.applicationContext) + runCatching { server?.close() } + return try { + val socket = ServerSocket().apply { + reuseAddress = true + bind(java.net.InetSocketAddress(InetAddress.getByName("127.0.0.1"), PORT), 8) + } + server = socket + Thread({ accept(socket) }, "ea-lsx").apply { isDaemon = true }.start() + Log.i(TAG, "listening on $PORT") + true + } catch (e: Exception) { + Log.w(TAG, "could not listen on $PORT: ${e.javaClass.simpleName}") + false + } + } + } + + /** Ends the endpoint; with [offerId], only if it still serves that game, so a late exit cannot stop a newer launch. */ + fun stop(offerId: String? = null) { + synchronized(lock) { + if (offerId != null && game?.offerId != offerId) return + runCatching { server?.close() } + server = null + game = null + account = null + closeSessions() + } + } + + private fun closeSessions() { + generation++ + holder.set(null) + synchronized(sessions) { sessions.forEach { it.trusted = false } } + for (socket in open) runCatching { socket.close() } + open.clear() + } + + private val open = java.util.Collections.newSetFromMap(java.util.concurrent.ConcurrentHashMap()) + private const val HANDSHAKE_TIMEOUT_MS = 15_000 + private const val UNTRUSTED_IDLE_MS = 10 * 60 * 1000 + + private fun accept(socket: ServerSocket) { + while (!socket.isClosed) { + val client = runCatching { socket.accept() }.getOrNull() ?: continue + val (owner, unreadable) = ownerUid(client) + val verified = owner?.let { it == android.os.Process.myUid() } + if (verified == false) { + Log.w(TAG, "refused a connection from another app") + runCatching { client.close() } + continue + } + if (unreadable != null) note("peer cannot be identified: $unreadable") + val session = runCatching { Session(client, client.getOutputStream(), verified == true, unreadable != null) }.getOrNull() + val live = session != null && synchronized(lock) { server === socket && admit(session).also { if (it) open += client } } + if (!live) { + runCatching { client.close() } + continue + } + Thread({ try { serve(client, session!!) } finally { synchronized(sessions) { sessions -= session!! }; open -= client } }, "ea-lsx-client").apply { isDaemon = true }.start() + } + } + + /** + * The uid owning the connecting socket, or null where SELinux hides /proc/net. The guest runs + * under this app's uid, so any other uid is another app; an unknown owner never gets credentials. + */ + /** The connecting socket's uid, or why Android hid socket ownership from this app (some builds deny /proc/net). */ + private fun ownerUid(client: Socket): Pair { + val port = client.port + var denied: String? = null + var read = false + for (path in listOf("/proc/net/tcp6", "/proc/net/tcp")) { + val lines = runCatching { java.io.File(path).readLines() }.onFailure { denied = "$path ${it.javaClass.simpleName}" }.getOrNull() ?: continue + read = true + owner(lines, port)?.let { return it to null } + } + return null to if (read) null else denied ?: "no socket table" + } + + internal fun owner(lines: List, port: Int): Int? = lines.drop(1).firstNotNullOfOrNull { line -> + val f = line.trim().split(WHITESPACE) + val local = f.getOrNull(1)?.substringAfterLast(':')?.toIntOrNull(16) + val remote = f.getOrNull(2)?.substringAfterLast(':')?.toIntOrNull(16) + if (local == port && remote == PORT) f.getOrNull(7)?.toIntOrNull() else null + } + + /** + * Adds [session] unless the cap is reached by sessions that cannot be replaced; otherwise the + * oldest session that never claimed the prepared game (then the oldest untrusted one) makes room. + * Counting and eviction share one lock, so the live total never passes the cap. + */ + internal fun admit(session: Session): Boolean = synchronized(sessions) { + if (sessions.count { !it.evicted } >= MAX_CLIENTS) { + val candidates = sessions.filter { !it.trusted && !it.evicted } + val victim = candidates.firstOrNull { !it.claimed } ?: candidates.firstOrNull() ?: return false + victim.evicted = true + runCatching { victim.socket.close() } + } + sessions += session + true + } + + internal val sessions = LinkedHashSet() + + /** Trust and eviction exclude each other: an evicted session never becomes trusted, a trusted one is never evicted. */ + internal fun promote(session: Session, matches: Boolean) { + synchronized(sessions) { session.trusted = matches && !session.evicted && session.born == generation && holder.compareAndSet(null, session) } + } + + internal class Session(val socket: Socket, val output: OutputStream, val verified: Boolean, val unverifiable: Boolean = false) { + val born = generation + val connectedAt = System.currentTimeMillis() + @Volatile var evicted = false + @Volatile var claimed = false + var key: ByteArray? = null + @Volatile var trusted = false + var pending: ByteArray? = null + + /** A peer Android names as another app never gets account data; one Android cannot name is judged by its claim alone. */ + val mayTrust get() = (verified || unverifiable) && claimed + + @Synchronized + fun send(xml: String) { + val key = this.key + val text = if (key == null) xml else EaCrypto.hex(EaCrypto.ecbEncrypt(xml.toByteArray(), key)) + output.write(text.toByteArray()) + output.write(0) + output.flush() + } + } + + private fun note(line: String) = com.droiddeck.launcher.stores.StoresState.logLine("ea lsx: $line") + + private fun serve(client: Socket, session: Session) { + client.tcpNoDelay = true + client.soTimeout = HANDSHAKE_TIMEOUT_MS + val seen = LinkedHashSet() + var why = "closed by the game" + note("game connected") + try { + session.send("") + val input = client.getInputStream() + val pending = ByteArrayOutputStream() + val chunk = ByteArray(16 * 1024) + while (true) { + if (session.key == null) { + val left = session.connectedAt + HANDSHAKE_TIMEOUT_MS - System.currentTimeMillis() + if (left <= 0) throw java.net.SocketTimeoutException() + client.soTimeout = left.toInt() + } + val n = input.read(chunk) + if (n < 0) break + pending.write(chunk, 0, n) + for (frame in frames(pending, session.key)) { + for (one in Regex(".*?", RegexOption.DOT_MATCHES_ALL).findAll(frame)) { + val message = parse(one.value) ?: continue + if (seen.add(message.name)) note("request ${message.name}") + val reply = runCatching { answer(session, message) } + .onFailure { note("${message.name} failed: ${it.javaClass.simpleName}") }.getOrNull() + if (reply != null) session.send("$reply") + session.pending?.let { session.key = it; session.pending = null } + if (session.key != null) { + val idle = if (session.trusted) 0 else UNTRUSTED_IDLE_MS + if (client.soTimeout != idle) client.soTimeout = idle + } + } + } + if (pending.size() > 4 * 1024 * 1024) { why = "message too large"; break } + } + } catch (e: java.net.SocketTimeoutException) { + why = "no trusted handshake in time" + } catch (e: Exception) { + why = e.javaClass.simpleName + } finally { + holder.compareAndSet(session, null) + note("game disconnected ($why)") + runCatching { client.close() } + } + } + + /** + * Complete messages in what has arrived so far. Messages normally end in a NUL; one that does + * not counts once it holds a whole `` element, plain or decrypted. + */ + private fun frames(pending: ByteArrayOutputStream, key: ByteArray?): List { + val bytes = pending.toByteArray() + val out = ArrayList() + var start = 0 + for (i in bytes.indices) { + if (bytes[i].toInt() != 0) continue + if (i > start) decode(String(bytes, start, i - start, Charsets.UTF_8), key)?.let(out::add) + start = i + 1 + } + var rest = String(bytes, start, bytes.size - start, Charsets.UTF_8) + if (rest.isNotBlank()) decode(rest, key)?.takeIf { it.contains("") }?.let { out += it; rest = "" } + pending.reset() + pending.write(rest.toByteArray(Charsets.UTF_8)) + return out + } + + private fun decode(frame: String, key: ByteArray?): String? { + val text = frame.trim() + if (text.isEmpty()) return null + if (text.startsWith("<")) return text + val k = key ?: return null + return runCatching { String(EaCrypto.ecbDecrypt(EaCrypto.unhex(text), k)) }.getOrNull() + } + + internal fun parse(xml: String): Message? = runCatching { + val parser = XmlPullParserFactory.newInstance().newPullParser() + parser.setInput(StringReader(xml)) + var recipient = "" + var id = "" + var name = "" + val attrs = HashMap() + val children = ArrayList>() + var depth = 0 + var child = "" + while (parser.eventType != XmlPullParser.END_DOCUMENT) { + when (parser.eventType) { + XmlPullParser.START_TAG -> { + depth++ + when (depth) { + 2 -> if (parser.name == "Request") { + recipient = parser.getAttributeValue(null, "recipient").orEmpty() + id = parser.getAttributeValue(null, "id").orEmpty() + } else return@runCatching null + 3 -> { + name = parser.name + for (i in 0 until parser.attributeCount) attrs[parser.getAttributeName(i)] = parser.getAttributeValue(i) + } + 4 -> child = parser.name + } + } + XmlPullParser.TEXT -> if (depth == 4) children += child to parser.text.trim() + XmlPullParser.END_TAG -> depth-- + } + parser.next() + } + if (name.isEmpty()) null else Message(recipient, id, name, attrs, children) + }.getOrNull() + + private fun esc(s: String) = s.replace("&", "&").replace("<", "<").replace(">", ">").replace("\"", """) + + private fun tag(name: String, vararg attrs: Pair, body: String? = null): String { + val a = attrs.filter { it.second != null }.joinToString("") { " ${it.first}=\"${esc(it.second.toString())}\"" } + return if (body == null) "<$name$a/>" else "<$name$a>$body" + } + + private fun now() = LocalDateTime.now(ZoneOffset.UTC).format(DateTimeFormatter.ofPattern("yyyy-MM-dd'T'HH:mm:ss")) + + private fun answer(session: Session, m: Message): String? { + val g = game + val who = account + val person = who?.takeIf { session.trusted } + val locale = g?.locale ?: "en_US" + return when (m.name) { + "ChallengeResponse" -> { + val answer = EaCrypto.challengeAnswer(m.attrs["key"].orEmpty()) + val version = m.attrs["version"].orEmpty() + val seed = EaCrypto.challengeSeed(version, answer) ?: 0 + val content = m.children.firstOrNull { it.first == "ContentId" }?.second.orEmpty() + session.claimed = g != null && content.isNotEmpty() && (content == g.contentId || content == g.offerId || content in g.contentIds) + val matches = session.mayTrust + promote(session, matches) + note("challenge version '$version' content '$content' ${when { session.trusted -> "matches the prepared game"; matches -> "matches, but another session holds this launch"; !session.verified && !session.unverifiable -> "comes from an unverified peer"; else -> "does not match the prepared game" }}") + session.pending = EaCrypto.lsxKey(seed) + Log.i(TAG, "game connected: ${m.children.firstOrNull { it.first == "Title" }?.second.orEmpty()}") + tag("ChallengeAccepted", "response" to answer) + } + "GetConfig" -> tag("GetConfigResponse", body = SERVICES.joinToString("") { tag("Service", "Name" to it.first, "Facility" to it.second) }) + "GetProfile" -> tag( + "GetProfileResponse", + "Persona" to person?.persona.orEmpty(), "SubscriberLevel" to 0, "CommerceCurrency" to "USD", "IsTrialSubscriber" to false, + "Country" to "US", "UserId" to person?.userId.orEmpty(), "GeoCountry" to "US", "AvatarId" to "", "IsSubscriber" to false, + "IsSteamSubscriber" to false, "PersonaId" to person?.personaId.orEmpty(), "IsUnderAge" to false, "UserIndex" to 0, + ) + "GetSetting" -> tag("GetSettingResponse", "Setting" to when (m.attrs["SettingId"]) { + "ENVIRONMENT" -> "production" + "LANGUAGE" -> locale + else -> "false" + }) + "GetGameInfo" -> tag("GetGameInfoResponse", "GameInfo" to when (m.attrs["GameInfoId"]) { + "FREETRIAL" -> "false" + "LANGUAGES" -> LANGUAGES + "INSTALLED_LANGUAGE" -> locale + "INSTALLED_VERSION", "AVAILABLE_VERSION" -> g?.version.orEmpty() + else -> "" + }) + "GetAllGameInfo" -> tag( + "GetAllGameInfoResponse", + "FullGamePurchased" to true, "FullGameReleased" to true, "InstalledVersion" to (g?.version ?: "0"), "MaxGroupSize" to 16, + "Languages" to LANGUAGES, "Expiration" to "0000-00-00T00:00:00", "UpToDate" to true, "HasExpiration" to false, + "EntitlementSource" to "EA", "AvailableVersion" to (g?.version ?: "0"), "DisplayName" to g?.title.orEmpty(), + "FreeTrial" to false, "InstalledLanguage" to locale, "FullGameReleaseDate" to "2000-01-01T00:00:00", "SystemTime" to now(), + ) + "GetInternetConnectedState" -> tag("InternetConnectedState", "connected" to 1) + "IsProgressiveInstallationAvailable" -> tag("IsProgressiveInstallationAvailableResponse", "ItemId" to m.attrs["ItemId"].orEmpty(), "Available" to false) + "AreChunksInstalled" -> tag( + "AreChunksInstalledResponse", "ItemId" to m.attrs["ItemId"].orEmpty(), "Installed" to true, + body = m.children.joinToString("") { tag(it.first, body = esc(it.second)) }, + ) + "GetAuthCode" -> tag("AuthCode", "value" to (who?.takeIf { session.trusted }?.authCode(m.attrs["ClientId"].orEmpty(), m.attrs["Scope"])?.takeIf { session.trusted } ?: "invalid")) + "GetAuthToken" -> tag("AuthToken", "value" to if (!session.trusted) "" else (g?.validToken ?: who?.token().orEmpty())) + "RequestLicense" -> { + val token = who?.takeIf { session.trusted }?.license(g?.contentId.orEmpty(), m.attrs["RequestTicket"].orEmpty(), m.attrs["TicketEngine"].orEmpty())?.takeIf { session.trusted }.orEmpty() + tag("RequestLicenseResponse", "License" to token) + } + "QueryEntitlements" -> { + val group = m.attrs["Group"].orEmpty() + val rows = who?.takeIf { session.trusted }?.entitlements(group).orEmpty() + tag("QueryEntitlementsResponse", body = rows.joinToString("") { e -> + tag( + "Entitlement", + "LastModifiedDate" to "0000-00-00T00:00:00", "EntitlementId" to e["id"], "UseCount" to (e["useCount"] ?: "0"), + "Version" to (e["version"] ?: "0"), "ItemId" to e["productId"], "ResourceId" to "", "GrantDate" to e["grantDate"], + "Group" to group, "EntitlementTag" to e["entitlementTag"], "Type" to e["entitlementType"], + "Expiration" to (e["terminationDate"]?.takeIf { it.isNotEmpty() && it != "null" } ?: "0000-00-00T00:00:00"), "Source" to "", + ) + }) + } + "GetPresence" -> tag("GetPresenceResponse", "UserId" to person?.userId.orEmpty(), "Presence" to "INGAME") + "QueryPresence" -> tag("QueryPresenceResponse", body = "") + "QueryFriends" -> tag("QueryFriendsResponse", body = "") + "GetBlockList" -> tag("GetBlockListResponse", "Return" to "Success", body = "") + "QueryOffers" -> tag("QueryOffersResponse", body = "") + "QueryImage" -> tag("QueryImageResponse", "Result" to 0, body = "") + "GetVoipStatus" -> tag("GetVoipStatusResponse", "Available" to false, "Active" to false) + "ShowIGOWindow", "SetDownloaderUtilization" -> null + else -> tag("ErrorSuccess", "Code" to 0, "Description" to "") + } + } + + private class AppAccount(private val app: Context) : Account { + private val saved get() = EaAuth.load(app) + override val userId: String get() = saved?.userId.orEmpty() + override val personaId: String get() = saved?.personaId.orEmpty() + override val persona: String get() = saved?.eaId?.ifEmpty { null } ?: saved?.displayName.orEmpty() + + override fun authCode(clientId: String, scope: String?): String? = + EaAuth.mint(app, clientId, "code", scopes = scope?.split(' ')?.filter { it.isNotBlank() }.orEmpty()) + + override fun license(contentId: String, requestTicket: String, ticketEngine: String): String? { + val g = game ?: return null + val hw = g.hardware ?: return null + val token = EaAuth.accessToken(app) ?: return null + return runCatching { + EaOoa.request(token, contentId, EaOoa.machineHash(hw, g.hashVersion), requestTicket.ifEmpty { null }, ticketEngine.ifEmpty { null }).gameToken + }.onFailure { Log.w(TAG, "licence: ${it.message}") }.getOrNull() + } + + override fun token(): String? = EaAuth.mint(app, EaAuth.CLIENT_ID, "token", EaAuth.OPAQUE_SCOPES, "OPAQUE", 14400) + + override fun entitlements(group: String): List> { + val token = EaAuth.accessToken(app) ?: return emptyList() + val arr = runCatching { EaApi.entitlements(token, group) }.getOrNull() ?: return emptyList() + return (0 until arr.length()).mapNotNull { arr.optJSONObject(it) }.map { o -> + o.keys().asSequence().associateWith { k -> o.optString(k) } + } + } + } +} diff --git a/app/src/main/java/com/droiddeck/launcher/stores/ea/EaManifest.kt b/app/src/main/java/com/droiddeck/launcher/stores/ea/EaManifest.kt new file mode 100644 index 000000000..8c98311c0 --- /dev/null +++ b/app/src/main/java/com/droiddeck/launcher/stores/ea/EaManifest.kt @@ -0,0 +1,134 @@ +package com.droiddeck.launcher.stores.ea + +import org.xmlpull.v1.XmlPullParser +import org.xmlpull.v1.XmlPullParserFactory +import java.io.File +import java.io.StringReader + +/** What `__Installer/installerdata.xml` in an EA build says about running and finishing the install. */ +class EaManifest( + val version: String, + val launchers: List, + val touchupPath: String, + val touchupParameters: String, + val contentIds: List = emptyList(), + val titles: Map = emptyMap(), +) { + fun title(locale: String): String? = titles[locale] ?: titles["en_US"] ?: titles.values.firstOrNull() + + class Launcher(val path: String, val parameters: String, val trial: Boolean) + + fun launcher(): Launcher? = launchers.firstOrNull { !it.trial } ?: launchers.firstOrNull() + + companion object { + const val RELATIVE = "__Installer/installerdata.xml" + + fun decode(bytes: ByteArray): String { + val utf16 = bytes.size >= 2 && ((bytes[0] == 0xFF.toByte() && bytes[1] == 0xFE.toByte()) || (bytes[0] == 0xFE.toByte() && bytes[1] == 0xFF.toByte())) + val text = if (utf16) String(bytes, Charsets.UTF_16) else String(bytes, Charsets.UTF_8) + return text.trimStart('') + } + + fun read(folder: File): EaManifest? { + val file = File(folder, RELATIVE).takeIf { it.isFile } ?: EaPaths.find(folder, RELATIVE) ?: return null + return runCatching { parse(decode(file.readBytes())) }.getOrNull() + } + + fun parse(xml: String): EaManifest { + val parser = XmlPullParserFactory.newInstance().newPullParser() + parser.setInput(StringReader(xml.replace(Regex("^<\\?xml[^>]*\\?>"), ""))) + val stack = ArrayList() + var version = "" + val launchers = ArrayList() + var touchupPath = "" + var touchupParams = "" + var path = "" + var params = "" + var trial = false + val contentIds = ArrayList() + val titles = LinkedHashMap() + var locale = "" + while (parser.eventType != XmlPullParser.END_DOCUMENT) { + when (parser.eventType) { + XmlPullParser.START_TAG -> { + val name = parser.name + stack += name + if (name.equals("gameVersion", true)) { + version = parser.getAttributeValue(null, "version") ?: version + } + if (name.equals("launcher", true)) { path = ""; params = ""; trial = false } + if (name.equals("localeInfo", true)) locale = parser.getAttributeValue(null, "locale").orEmpty() + } + XmlPullParser.TEXT -> { + val text = parser.text.trim() + val tag = stack.lastOrNull().orEmpty() + val parent = stack.getOrNull(stack.size - 2).orEmpty() + if (text.isNotEmpty()) when { + parent.equals("launcher", true) && tag.equals("filePath", true) -> path = text + parent.equals("launcher", true) && tag.equals("parameters", true) -> params = text + parent.equals("launcher", true) && tag.equals("trial", true) -> trial = text.equals("true", true) || text == "1" + (parent.equals("touchup", true) || parent.equals("executable", true)) && tag.equals("filePath", true) -> touchupPath = text + (parent.equals("touchup", true) || parent.equals("executable", true)) && tag.equals("parameters", true) -> touchupParams = text + parent.equals("contentIDs", true) && tag.equals("contentID", true) -> contentIds += text + parent.equals("localeInfo", true) && tag.equals("title", true) && locale.isNotEmpty() -> titles[locale] = text + } + } + XmlPullParser.END_TAG -> { + if (parser.name.equals("launcher", true) && path.isNotEmpty()) launchers += Launcher(path, params, trial) + stack.removeLastOrNull() + } + } + parser.next() + } + return EaManifest(version, launchers, touchupPath, touchupParams, contentIds, titles) + } + } +} + +object EaPaths { + private val KEY = Regex("^\\[([^\\]]+)\\](.*)$") + + /** `[HKEY_LOCAL_MACHINE\SOFTWARE\Publisher\Game\Install Dir]bin\game.exe` splits into the registry key, value and relative path. */ + class Registered(val key: String, val value: String, val rest: String) + + fun registered(path: String): Registered? { + val m = KEY.find(path.trim()) ?: return null + val full = m.groupValues[1] + val cut = full.lastIndexOf('\\').takeIf { it > 0 } ?: return null + return Registered(full.substring(0, cut), full.substring(cut + 1), m.groupValues[2]) + } + + fun relative(path: String): String { + val rest = registered(path)?.rest ?: path.replace("{installLocation}", "", ignoreCase = true) + return rest.replace('\\', '/').trimStart('/').split('/').filter { it.isNotEmpty() && it != "." }.joinToString("/") + } + + /** The real file for a path the manifest may spell in a different case. */ + fun find(folder: File, relative: String): File? { + var cur = folder + for (part in relative.replace('\\', '/').split('/').filter { it.isNotEmpty() }) { + val names = cur.listFiles() ?: return null + cur = names.firstOrNull { it.name == part } ?: names.firstOrNull { it.name.equals(part, true) } ?: return null + } + return cur.takeIf { it.exists() && it != folder } + } + + fun relativeOnDisk(folder: File, relative: String): String? = + find(folder, relative)?.relativeTo(folder)?.path?.replace(File.separatorChar, '/') + + fun splitArgs(text: String): List { + val out = ArrayList() + val cur = StringBuilder() + var quoted = false + var any = false + for (c in text) { + when { + c == '"' -> { quoted = !quoted; any = true } + c.isWhitespace() && !quoted -> { if (any || cur.isNotEmpty()) out += cur.toString(); cur.clear(); any = false } + else -> cur.append(c) + } + } + if (any || cur.isNotEmpty()) out += cur.toString() + return out + } +} diff --git a/app/src/main/java/com/droiddeck/launcher/stores/ea/EaNative.kt b/app/src/main/java/com/droiddeck/launcher/stores/ea/EaNative.kt new file mode 100644 index 000000000..b8a4b6005 --- /dev/null +++ b/app/src/main/java/com/droiddeck/launcher/stores/ea/EaNative.kt @@ -0,0 +1,57 @@ +package com.droiddeck.launcher.stores.ea + +import android.util.Log +import com.droiddeck.launcher.stores.StoresNative +import com.droiddeck.launcher.stores.amazon.AmazonNative +import org.json.JSONObject +import java.util.concurrent.CountDownLatch +import java.util.concurrent.TimeUnit +import java.util.concurrent.atomic.AtomicBoolean + +object EaNative { + private const val TAG = "EaNative" + const val ENGINE_MISSING = "EA: the store engine is not available" + + fun ready(): Boolean = StoresNative.ensureLoaded() + + fun call(op: String, request: JSONObject): JSONObject? { + if (!ready()) return null + val text = try { nativeCall(op, request.toString()) } catch (t: Throwable) { + Log.w(TAG, "$op: ${t.javaClass.simpleName}") + null + } ?: return null + return runCatching { JSONObject(text) }.getOrNull() + } + + fun install(request: JSONObject, events: AmazonNative.InstallListener, cancelled: AtomicBoolean): AmazonNative.InstallOutcome { + if (!ready()) return AmazonNative.InstallOutcome(false, false, ENGINE_MISSING, null) + val done = CountDownLatch(1) + val holder = arrayOfNulls(1) + val bridge = object : AmazonNative.InstallListener by events { + override fun onComplete(success: Boolean, cancelled: Boolean, error: String, result: String) { + holder[0] = AmazonNative.InstallOutcome(success, cancelled, error, result.takeIf { it.isNotEmpty() }?.let { runCatching { JSONObject(it) }.getOrNull() }) + done.countDown() + } + } + val handle = try { nativeInstall(request.toString(), bridge) } catch (t: Throwable) { + return AmazonNative.InstallOutcome(false, false, "The EA install could not start: ${t.javaClass.simpleName}", null) + } + if (handle == 0L) return holder[0] ?: AmazonNative.InstallOutcome(false, false, "The EA install could not start", null) + try { + var forwarded = false + while (true) { + val finished = try { done.await(250, TimeUnit.MILLISECONDS) } catch (e: InterruptedException) { cancelled.set(true); false } + if (finished) break + if (!forwarded && cancelled.get()) { nativeCancel(handle); forwarded = true } + } + } finally { + nativeRelease(handle) + } + return holder[0] ?: AmazonNative.InstallOutcome(false, false, "The EA install ended without a result", null) + } + + @JvmStatic private external fun nativeCall(op: String, request: String): String? + @JvmStatic private external fun nativeInstall(request: String, listener: AmazonNative.InstallListener): Long + @JvmStatic private external fun nativeCancel(handle: Long) + @JvmStatic private external fun nativeRelease(handle: Long) +} diff --git a/app/src/main/java/com/droiddeck/launcher/stores/ea/EaOoa.kt b/app/src/main/java/com/droiddeck/launcher/stores/ea/EaOoa.kt new file mode 100644 index 000000000..9590ce4a0 --- /dev/null +++ b/app/src/main/java/com/droiddeck/launcher/stores/ea/EaOoa.kt @@ -0,0 +1,196 @@ +package com.droiddeck.launcher.stores.ea + +import org.json.JSONObject +import org.xmlpull.v1.XmlPullParser +import org.xmlpull.v1.XmlPullParserFactory +import java.io.File +import java.io.StringReader +import java.net.URLEncoder +import java.security.SecureRandom + +/** Origin Online Activation: the per-machine licence an EA game's activation layer checks before it runs. */ +object EaOoa { + private const val LICENSES = "https://proxy.novafusion.ea.com/licenses" + const val LICENSE_DIR = "ProgramData/Electronic Arts/EA Services/License" + private const val SIGNATURE_SLOT = 65 + + /** + * What the game sees of the machine inside its Wine prefix: firmware and OS values from Wine's + * WMI, CPU values from CPUID executed as x86 code, since an emulated game sees the emulator's + * CPUID rather than the host CPU WMI describes. + */ + class Hardware( + val boardManufacturer: String, + val boardSerial: String, + val biosManufacturer: String, + val biosSerial: String, + val osInstallDate: String, + val osSerial: String, + val volumeSerial: String, + val gpuPnpId: String, + val cpuVendor: String, + val cpuEdx: String, + val cpuEax: String, + val cpuEcx: String, + val brand: String, + val hostname: String, + ) { + /** Whether the probe reported everything the hash of this activation version is built from. */ + fun complete(version: Int): Boolean { + val needed = arrayListOf(boardManufacturer, biosManufacturer) + when { + version <= 1 -> needed += listOf(hostname, osInstallDate, osSerial) + version == 2 -> needed += listOf(osInstallDate, osSerial, volumeSerial, cpuVendor, cpuEdx, cpuEcx) + version == 3 -> needed += listOf(volumeSerial, cpuVendor, cpuEdx, cpuEcx) + else -> needed += listOf(volumeSerial, cpuVendor, cpuEdx, cpuEax) + } + return needed.all { it.isNotEmpty() } + } + + companion object { + fun fromJson(o: JSONObject): Hardware { + fun s(k: String) = o.optString(k).trim() + return Hardware( + boardManufacturer = s("boardManufacturer"), + boardSerial = s("boardSerial").ifEmpty { "None" }, + biosManufacturer = s("biosManufacturer"), + biosSerial = s("biosSerial").ifEmpty { "None" }, + osInstallDate = s("osInstallDate"), + osSerial = s("osSerial"), + volumeSerial = s("volumeSerial").replace("-", "").lowercase().let { if (it.isEmpty()) it else it.padStart(8, '0') }, + gpuPnpId = s("gpuPnpId").ifEmpty { "None" }, + cpuVendor = s("cpuVendor"), + cpuEdx = s("cpuEdx").uppercase(), + cpuEax = s("cpuEax").uppercase(), + cpuEcx = s("cpuEcx").lowercase(), + brand = o.optString("cpuBrand"), + hostname = s("hostname"), + ) + } + } + } + + fun machineHash(hw: Hardware, version: Int): String { + val edxEax = hw.cpuEdx + hw.cpuEax + val edx = hw.cpuEdx.lowercase() + val shortGpu = hw.gpuPnpId.substringBeforeLast('\\', hw.gpuPnpId) + val parts = arrayListOf(hw.boardManufacturer, hw.boardSerial) + when { + version <= 1 -> parts += listOf(hw.hostname, hw.biosManufacturer, hw.biosSerial, hw.osInstallDate, hw.osSerial) + version == 2 -> parts += listOf(hw.biosManufacturer, hw.biosSerial, hw.osInstallDate, hw.osSerial, hw.volumeSerial, shortGpu, hw.cpuVendor, edx, hw.cpuEcx) + version == 3 -> parts += listOf(hw.biosManufacturer, hw.biosSerial, hw.volumeSerial, shortGpu, hw.cpuVendor, edx, hw.cpuEcx) + else -> parts += listOf(hw.biosManufacturer, hw.biosSerial, hw.volumeSerial, hw.gpuPnpId, hw.cpuVendor, edxEax) + } + val text = StringBuilder(parts.joinToString(";")).append(';') + if (version >= 2) text.append(hw.brand.take(47).padEnd(47, '\u0000')).append(';') + val digest = EaCrypto.sha1(text.toString().toByteArray(Charsets.ISO_8859_1)) + return if (version < 4) digest.joinToString("") { Integer.toHexString(it.toInt() and 0xFF) } else EaCrypto.hex(digest) + } + + /** Version of the game's activation layer, which decides how the machine hash is built. */ + fun activationVersion(core: File): Int { + val dll = listOf("activation.dll", "awc.dll").firstNotNullOfOrNull { EaPaths.find(core, it) } ?: return 1 + val (major, minor, patch) = productVersion(dll.readBytes()) ?: return 1 + return when { + major >= 5 -> 4 + major == 4 && (minor > 7 || (minor == 7 && patch >= 6)) -> 3 + major == 4 && minor == 7 -> 2 + major == 4 -> 1 + else -> 0 + } + } + + /** The activation layer's folder: `Core` beside the game's exe, where EA ships it, or at the game root. */ + fun coreDir(game: File, exe: String): File? { + val exeDir = exe.replace('\\', '/').substringBeforeLast('/', "") + val candidates = listOfNotNull(exeDir.takeIf { it.isNotEmpty() }?.let { "$it/Core" }, "Core") + return candidates.firstNotNullOfOrNull { EaPaths.find(game, it)?.takeIf { f -> f.isDirectory } } + } + + /** Games built on the 2012-era EA Core client, which want an opaque token rather than a JWS one. */ + fun legacyCore(core: File?): Boolean = core != null && EaPaths.find(core, "EACore.dll") != null + + private fun encodedSignature(core: File): Boolean = EaPaths.find(core, "activation.exe") != null + + internal fun productVersion(pe: ByteArray): Triple? { + val magic = byteArrayOf(0xBD.toByte(), 0x04, 0xEF.toByte(), 0xFE.toByte()) + var i = 0 + while (i + 24 <= pe.size) { + if (pe[i] == magic[0] && pe[i + 1] == magic[1] && pe[i + 2] == magic[2] && pe[i + 3] == magic[3]) { + fun u32(at: Int) = (pe[at].toLong() and 0xFF) or ((pe[at + 1].toLong() and 0xFF) shl 8) or ((pe[at + 2].toLong() and 0xFF) shl 16) or ((pe[at + 3].toLong() and 0xFF) shl 24) + val ms = u32(i + 16) + val ls = u32(i + 20) + return Triple((ms ushr 16).toInt(), (ms and 0xFFFF).toInt(), (ls ushr 16).toInt()) + } + i++ + } + return null + } + + class License(val fields: LinkedHashMap, val namespace: String, val signature: String) { + val gameToken: String? get() = fields["GameToken"]?.takeIf { it.isNotEmpty() } + val contentId: String get() = fields["ContentId"].orEmpty() + } + + internal fun parseLicense(xml: String, signature: String): License { + val parser = XmlPullParserFactory.newInstance().apply { isNamespaceAware = false }.newPullParser() + parser.setInput(StringReader(xml.replace(Regex("^<\\?xml[^>]*\\?>"), "").trim())) + val fields = LinkedHashMap() + var ns = "" + var depth = 0 + var tag = "" + while (parser.eventType != XmlPullParser.END_DOCUMENT) { + when (parser.eventType) { + XmlPullParser.START_TAG -> { + depth++ + if (depth == 1) ns = parser.getAttributeValue(null, "xmlns").orEmpty() else tag = parser.name + if (depth == 2) fields[tag] = "" + } + XmlPullParser.TEXT -> if (depth == 2) fields[tag] = fields[tag].orEmpty() + parser.text + XmlPullParser.END_TAG -> depth-- + } + parser.next() + } + return License(fields, ns, signature) + } + + private fun esc(s: String) = s.replace("&", "&").replace("<", "<").replace(">", ">").replace("\"", """) + + fun licenseXml(l: License): String { + val order = listOf("CipherKey", "MachineHash", "ContentId", "UserId", "GameToken", "GrantTime", "StartTime", "Nonce") + val body = StringBuilder("") + body.append("") + for (k in order) { + val v = l.fields[k] ?: continue + if (k == "GameToken" && v.isEmpty()) continue + body.append('<').append(k).append('>').append(esc(v)).append("') + } + return body.append("").toString() + } + + fun licenseBlob(l: License, encoded: Boolean): ByteArray { + val signature = if (encoded) l.signature.toByteArray() else java.util.Base64.getDecoder().decode(l.signature) + val head = ByteArray(SIGNATURE_SLOT) + System.arraycopy(signature, 0, head, 0, signature.size.coerceAtMost(SIGNATURE_SLOT)) + return head + EaCrypto.licenseEncrypt(licenseXml(l).toByteArray()) + } + + fun request(token: String, contentId: String, machineHash: String, requestToken: String? = null, requestType: String? = null): License { + val nonce = EaCrypto.hex(ByteArray(4).also(SecureRandom()::nextBytes)) + val params = mutableListOf("contentId" to contentId, "machineHash" to machineHash, "nonce" to nonce, "ea_eadmtoken" to token) + if (requestToken != null) params += "requestToken" to requestToken + if (requestType != null) params += "requestType" to requestType + val url = LICENSES + "?" + params.joinToString("&") { (k, v) -> k + "=" + URLEncoder.encode(v, "UTF-8") } + val reply = EaHttp.request(url, headers = mapOf("X-Requester-Id" to "Origin Online Activation", "User-Agent" to "EACTransaction")) + if (!reply.ok) throw IllegalStateException("EA refused the licence (${reply.status}): ${reply.text.replace(Regex("(eadmToken value=\")[^\"]*"), "$1\"").take(600)}") + val signature = reply.header("x-signature") ?: throw IllegalStateException("EA's licence had no signature") + return parseLicense(String(EaCrypto.licenseDecrypt(reply.body), Charsets.UTF_8), signature) + } + + /** Licence files for a game about to start, named as the activation layer looks for them. */ + fun files(core: File, license: License): List> { + val blob = licenseBlob(license, encodedSignature(core)) + val id = license.contentId + return listOf("$LICENSE_DIR/$id.dlf" to blob, "$LICENSE_DIR/${id}_cached.dlf" to blob) + } +} diff --git a/app/src/main/java/com/droiddeck/launcher/ui/EaUpdateButton.kt b/app/src/main/java/com/droiddeck/launcher/ui/EaUpdateButton.kt new file mode 100644 index 000000000..9fe2a4115 --- /dev/null +++ b/app/src/main/java/com/droiddeck/launcher/ui/EaUpdateButton.kt @@ -0,0 +1,17 @@ +package com.droiddeck.launcher.ui + +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.res.stringResource +import com.droiddeck.launcher.R +import com.droiddeck.launcher.stores.InstalledStoreGame +import com.droiddeck.launcher.stores.ea.EaUpdates + +@Composable +internal fun EaUpdateButton(installed: InstalledStoreGame, enabled: Boolean) { + val ctx = LocalContext.current + LaunchedEffect(installed.key, installed.sidecar.installVersion) { EaUpdates.check(ctx, installed) } + if (EaUpdates.available[installed.key] == null) return + SecondaryButton(stringResource(R.string.common_update), enabled = enabled) { EaUpdates.start(ctx, installed) } +} diff --git a/app/src/main/java/com/droiddeck/launcher/ui/GameDetail.kt b/app/src/main/java/com/droiddeck/launcher/ui/GameDetail.kt index 808c94485..f42a85de5 100644 --- a/app/src/main/java/com/droiddeck/launcher/ui/GameDetail.kt +++ b/app/src/main/java/com/droiddeck/launcher/ui/GameDetail.kt @@ -430,6 +430,7 @@ internal fun GameActions( openUrl(ctx, item?.storeUrl.orEmpty().ifBlank { GameJoltApi.pageUrl("game", installed.sidecar.id) }) } } + if (store == Store.EA && installed != null) EaUpdateButton(installed, download == null && StoresState.uninstalling[installed.key] == null) if (store == Store.GOG && installed != null) GogMaintenanceActions(installed) if (store == Store.XBOX && id != null) XboxInstalledStream(id, game?.name ?: item?.title.orEmpty(), item) if (store == Store.XBOX && installed != null && id != null) { @@ -915,7 +916,7 @@ private fun AddonRow(name: String, target: WowAddons.Target, a: FrontEndActions) @Composable private fun CloudRows(g: Library.SteamGame) { - val store = Store.byId(g.source)?.takeIf { it != Store.AMAZON } ?: return + val store = Store.byId(g.source)?.takeIf { it != Store.AMAZON && it != Store.EA } ?: return val id = g.storeId ?: return val context = LocalContext.current.applicationContext var busy by remember(g.gameId) { mutableStateOf(null) } diff --git a/app/src/main/java/com/droiddeck/launcher/ui/LibraryPage.kt b/app/src/main/java/com/droiddeck/launcher/ui/LibraryPage.kt index dfba4cf31..73dad7aa7 100644 --- a/app/src/main/java/com/droiddeck/launcher/ui/LibraryPage.kt +++ b/app/src/main/java/com/droiddeck/launcher/ui/LibraryPage.kt @@ -742,7 +742,7 @@ internal fun LibraryMarker(e: LibraryEntry, download: DownloadEntry?, showDownlo } } -private fun libraryUpdateAvailable(e: LibraryEntry): Boolean = e.storeKey?.let { it in ItchUpdates.available || it in GameJoltUpdates.available } == true || +private fun libraryUpdateAvailable(e: LibraryEntry): Boolean = e.storeKey?.let { it in ItchUpdates.available || it in GameJoltUpdates.available || it in com.droiddeck.launcher.stores.ea.EaUpdates.available } == true || e.source == Store.GOG.id && (e.item?.id ?: e.game?.storeId)?.let { GogMaintenance.checks[it]?.state } == GogMaintenance.State.AVAILABLE @Composable diff --git a/app/src/main/java/com/droiddeck/launcher/ui/StoreChips.kt b/app/src/main/java/com/droiddeck/launcher/ui/StoreChips.kt index 1d2aef9d6..3eede9da1 100644 --- a/app/src/main/java/com/droiddeck/launcher/ui/StoreChips.kt +++ b/app/src/main/java/com/droiddeck/launcher/ui/StoreChips.kt @@ -33,6 +33,7 @@ internal fun sourceColours(source: String): SourceColours = when (source) { Store.ITCH.id -> SourceColours(Color(0xFF3A1A1E), Color(0xFFFFB3B3), Color(0xFFFF5C5C)) Store.GAMEJOLT.id -> SourceColours(Color(0xFF232E00), Color(0xFFE3FF66), Color(0xFFCCFF00)) Store.BATTLENET.id -> SourceColours(Color(0xFF0A2440), Color(0xFF9DD1FF), Color(0xFF148EFF)) + Store.EA.id -> SourceColours(Color(0xFF3A0E12), Color(0xFFFF9A9A), Color(0xFFFF4747)) Library.SOURCE_STEAM -> SourceColours(Color(0xFF16293D), Color(0xFF7CC4FF), Color(0xFF1A9FFF)) FLATHUB -> SourceColours(Color(0xFF0B2E2B), Color(0xFF9FE3DD), FlathubTeal) else -> SourceColours(Color(0xFF1A1D22), Color(0xFF9AA3AF), Color(0xFF9AA3AF)) @@ -48,6 +49,7 @@ internal fun sourceLabel(source: String): String = when (source) { Store.ITCH.id -> Store.ITCH.label Store.GAMEJOLT.id -> Store.GAMEJOLT.label Store.BATTLENET.id -> Store.BATTLENET.label + Store.EA.id -> Store.EA.label else -> stringResource(R.string.source_added) } diff --git a/app/src/main/java/com/droiddeck/launcher/ui/StoresPage.kt b/app/src/main/java/com/droiddeck/launcher/ui/StoresPage.kt index c5f31f5d3..88e473285 100644 --- a/app/src/main/java/com/droiddeck/launcher/ui/StoresPage.kt +++ b/app/src/main/java/com/droiddeck/launcher/ui/StoresPage.kt @@ -1157,6 +1157,7 @@ internal fun storePalette(base: Palette, store: Store?): Palette { Store.ITCH -> Color(0xFFFF5C5C) to Color(0xFFD93B3B) Store.GAMEJOLT -> Color(0xFFCCFF00) to Color(0xFFA3CC00) Store.BATTLENET -> Color(0xFF148EFF) to Color(0xFF0B6BD3) + Store.EA -> Color(0xFFFF4747) to Color(0xFFE03030) null -> return base } val on = if (accent.luminance() > 0.18f) Color(0xFF0B0B0D) else Color.White diff --git a/app/src/main/resources/ea/graphql/GameImages.gql b/app/src/main/resources/ea/graphql/GameImages.gql new file mode 100644 index 000000000..92d8a50b8 --- /dev/null +++ b/app/src/main/resources/ea/graphql/GameImages.gql @@ -0,0 +1,69 @@ +query GameImages($gameSlug: String!, $locale: Locale!) { + game(slug: $gameSlug, locale: $locale) { + id + slug + title + keyArt { + ...imageRenditionData + __typename + } + packArt { + ...PackArt + __typename + } + ...PrimaryLogo + __typename + } +} + +fragment imageRenditionData on ImageRendition { + path + aspect9x16Image { + path + __typename + } + aspect16x9Image { + path + __typename + } + aspect1x1Image { + path + __typename + } + largestImage { + path + __typename + } + rawImages { + path + __typename + } + __typename +} + +fragment PrimaryLogo on GameV2 { + primaryLogo(uiClientContexts: ["juno-web-game-tile"]) { + path + title + largestImage { + path + width + height + __typename + } + __typename + } + __typename +} + +fragment PackArt on ImageRendition { + aspect2x1Image { + path + __typename + } + aspect9x16Image { + path + __typename + } + __typename +} diff --git a/app/src/main/resources/ea/graphql/GetUserPlayer.gql b/app/src/main/resources/ea/graphql/GetUserPlayer.gql new file mode 100644 index 000000000..4094c264a --- /dev/null +++ b/app/src/main/resources/ea/graphql/GetUserPlayer.gql @@ -0,0 +1,49 @@ +query GetUserPlayer { + me { + id: pd + pd + player { + ...Player + __typename + } + __typename + } +} + +fragment Player on Player { + id: pd + pd + psd + displayName + uniqueName + nickname + avatar { + ...Avatar + __typename + } + relationship + __typename +} + +fragment Avatar on AvatarList { + large { + ...image + __typename + } + medium { + ...image + __typename + } + small { + ...image + __typename + } + __typename +} + +fragment image on Image { + height + width + path + __typename +} diff --git a/app/src/main/resources/ea/graphql/availableBuilds.gql b/app/src/main/resources/ea/graphql/availableBuilds.gql new file mode 100644 index 000000000..b410f0a7f --- /dev/null +++ b/app/src/main/resources/ea/graphql/availableBuilds.gql @@ -0,0 +1,9 @@ +query availableBuilds($offerId: String!) { + availableBuilds(offerId: $offerId) { + buildId + downloadType + gameVersion + buildLiveDate + __typename + } +} diff --git a/app/src/main/resources/ea/graphql/downloadUrl.gql b/app/src/main/resources/ea/graphql/downloadUrl.gql new file mode 100644 index 000000000..0110c64f9 --- /dev/null +++ b/app/src/main/resources/ea/graphql/downloadUrl.gql @@ -0,0 +1,7 @@ +query downloadUrl($offerId: String!, $buildId: String) { + downloadUrl(offerId: $offerId, buildId: $buildId) { + url + syncUrl + __typename + } +} diff --git a/app/src/main/resources/ea/graphql/getLegacyCatalogDefs.gql b/app/src/main/resources/ea/graphql/getLegacyCatalogDefs.gql new file mode 100644 index 000000000..6d3da0d9f --- /dev/null +++ b/app/src/main/resources/ea/graphql/getLegacyCatalogDefs.gql @@ -0,0 +1,66 @@ +query getLegacyCatalogDefs($offerIds: [String!]!, $locale: Locale) { + legacyOffers(offerIds: $offerIds, locale: $locale) { + offerId: id + contentId + basePlatform + primaryMasterTitleId + mdmProjectNumber + achievementSetOverride + gameLauncherURL + gameLauncherURLClientID + stagingKeyPath + mdmTitleIds + multiplayerId + executePathOverride + installationDirectory + installCheckOverride + monitorPlay + displayName + displayType + igoBrowserDefaultUrl + executeParameters + softwareLocales + dipManifestRelativePath + metadataInstallLocation + distributionSubType + downloads { + igoApiEnabled + downloadType + version + executeElevated + buildReleaseVersion + buildLiveDate + buildMetaData + gameVersion + treatUpdatesAsMandatory + enableDifferentialUpdate + __typename + } + locale + greyMarketControls + isDownloadable + isPreviewDownload + downloadStartDate + releaseDate + useEndDate + subscriptionUnlockDate + subscriptionUseEndDate + softwarePlatform + softwareId + downloadPackageType + installerPath + processorArchitecture + macBundleID + gameEditionTypeFacetKeyRankDesc + appliedCountryCode + cloudSaveConfigurationOverride + firstParties{ + partner + partnerId + partnerIdType + __typename + } + suppressedOfferIds + __typename + } +} diff --git a/app/src/main/resources/ea/graphql/getLegacyEntitlements.gql b/app/src/main/resources/ea/graphql/getLegacyEntitlements.gql new file mode 100644 index 000000000..d4a6de101 --- /dev/null +++ b/app/src/main/resources/ea/graphql/getLegacyEntitlements.gql @@ -0,0 +1,33 @@ +query getLegacyEntitlements($limit: Int, $next: String, $storeFronts: [UserLegacyEntitlementStorefront!]!) { + me { + legacyEntitlements( + paging: {limit: $limit, next: $next}, + storefronts: $storeFronts, + status: [ACTIVE, DISABLED] + ) { + next + items { + cdKey + entitlementTag + entitlementType + externalType + grantDate + groupName + id + isSubscription + isSubscriptionExpired + lastModifiedDate + originPermissions + productId + status + statusReasonCode + terminationDate + trialTimeRemainingSeconds + useCount + version + __typename + } + __typename + } + } +} \ No newline at end of file diff --git a/app/src/main/resources/ea/graphql/getPreloadedOwnedGames.gql b/app/src/main/resources/ea/graphql/getPreloadedOwnedGames.gql new file mode 100644 index 000000000..b3a5a488e --- /dev/null +++ b/app/src/main/resources/ea/graphql/getPreloadedOwnedGames.gql @@ -0,0 +1,69 @@ +query getPreloadedOwnedGames($next: String, $locale: Locale, $limit: Int, $type: [GameProductType!]!, $entitlementEnabled: Boolean, $isMac: Boolean = false, $storefronts: [UserGameProductStorefront!], $ownershipMethods: [OwnershipMethod!], $processorArchitectures: [ProcessorArchitecture!], $platforms: [GamePlatform!]!) { + me { + id: pd + ownedGameProducts(storefronts: $storefronts, locale: $locale, paging: {limit: $limit, next: $next}, productFound: true, orderBy: {field: NAME, direction: ASC}, ownershipMethod: $ownershipMethods, processorArchitectures: $processorArchitectures, type: $type, downloadableOnly: false, entitlementEnabled: $entitlementEnabled, platforms: $platforms) { + next + totalCount + items { + ...userGameProductFragment + __typename + } + __typename + } + __typename + } +} + +fragment userGameProductFragment on UserGameProduct { + id: originOfferId + originOfferId + status + product { + ...OwnedGameProductFragment + __typename + } + __typename +} + +fragment OwnedGameProductFragment on GameProduct { + id + name + downloadable + gameSlug + trialDetails { + trialType + __typename + } + baseItem(availabilities: [VISIBLE]) { + title + id + baseGameSlug + gameType + __typename + } + gamePlatformDetails @include(if: $isMac) { + gamePlatform + __typename + } + processorArchitectureDetails @include(if: $isMac) { + processorArchitecture + platform + __typename + } + gameProductUser(storefronts: $storefronts) { + ownershipMethods + initialEntitlementDate + entitlementId + gameProductUserTrial { + trialTimeRemainingSeconds + __typename + } + status + __typename + } + purchaseStatus { + repurchasable + __typename + } + __typename +} diff --git a/app/src/main/resources/ea/graphql/sdkEntitlements.gql b/app/src/main/resources/ea/graphql/sdkEntitlements.gql new file mode 100644 index 000000000..df6613663 --- /dev/null +++ b/app/src/main/resources/ea/graphql/sdkEntitlements.gql @@ -0,0 +1,27 @@ +query sdkEntitlements($productIds: [String!]!, $includeChildGroups: Boolean!, $groupNames: [String!]!, $entitlementTag: String!, $pageSize: Int!, $pageNumber: Int!) { + me { + sdkEntitlements( + productIds: $productIds, + includeChildGroups: $includeChildGroups, + groupNames: $groupNames, + entitlementTag: $entitlementTag, + paging: { + pageSize: $pageSize, pageNumber: $pageNumber + } + ) { + entitlements { + entitlementTag + entitlementType + grantDate + groupName + id + productId + terminationDate + useCount + version + __typename + } + __typename + } + } +} diff --git a/app/src/main/rust/stores/src/ea/ffi.rs b/app/src/main/rust/stores/src/ea/ffi.rs new file mode 100644 index 000000000..7656c6b33 --- /dev/null +++ b/app/src/main/rust/stores/src/ea/ffi.rs @@ -0,0 +1,207 @@ +use std::panic::{catch_unwind, AssertUnwindSafe}; +use std::sync::atomic::{AtomicBool, Ordering}; +use std::sync::Arc; + +use jni::objects::{GlobalRef, JClass, JObject, JString, JValue}; +use jni::sys::{jlong, jstring, JNI_FALSE, JNI_TRUE}; +use jni::{JNIEnv, JavaVM}; +use serde_json::json; + +use super::install; +use crate::amazon::install::{Events, Stop}; + +fn read_string(env: &mut JNIEnv, value: &JString) -> String { + if value.is_null() { + return String::new(); + } + env.get_string(value).map(|s| s.to_string_lossy().into_owned()).unwrap_or_default() +} + +fn new_string(env: &mut JNIEnv, text: &str) -> jstring { + env.new_string(text).map(|s| s.into_raw()).unwrap_or(std::ptr::null_mut()) +} + +fn call(op: &str, request: &str) -> String { + let parsed: serde_json::Value = serde_json::from_str(request).unwrap_or(serde_json::Value::Null); + let url = parsed.get("url").and_then(|v| v.as_str()).unwrap_or(""); + let answer = match op { + "zip.size" => install::size(url).unwrap_or_else(|e| json!({ "error": e })), + _ => json!({ "error": format!("unknown operation {op}") }), + }; + answer.to_string() +} + +#[no_mangle] +pub extern "system" fn Java_com_droiddeck_launcher_stores_ea_EaNative_nativeCall( + mut env: JNIEnv, + _class: JClass, + op: JString, + request: JString, +) -> jstring { + let op = read_string(&mut env, &op); + let request = read_string(&mut env, &request); + match catch_unwind(AssertUnwindSafe(|| call(&op, &request))) { + Ok(text) => new_string(&mut env, &text), + Err(_) => std::ptr::null_mut(), + } +} + +struct Listener { + vm: JavaVM, + target: GlobalRef, +} + +impl Listener { + fn with_env(&self, fallback: R, f: impl FnOnce(&mut JNIEnv) -> R) -> R { + let Ok(mut env) = self.vm.attach_current_thread_as_daemon() else { + return fallback; + }; + let out = f(&mut env); + if env.exception_check().unwrap_or(false) { + let _ = env.exception_clear(); + } + out + } + + fn text(&self, method: &str, text: &str) { + self.with_env((), |env| { + let Ok(s) = env.new_string(text) else { return }; + let s = JObject::from(s); + let _ = env.call_method(self.target.as_obj(), method, "(Ljava/lang/String;)V", &[JValue::Object(&s)]); + let _ = env.delete_local_ref(s); + }); + } + + fn counts(&self, method: &str, a: u64, b: u64, c: u32, d: u32) { + self.with_env((), |env| { + let _ = env.call_method( + self.target.as_obj(), + method, + "(JJII)V", + &[ + JValue::Long(a.min(i64::MAX as u64) as i64), + JValue::Long(b.min(i64::MAX as u64) as i64), + JValue::Int(c.min(i32::MAX as u32) as i32), + JValue::Int(d.min(i32::MAX as u32) as i32), + ], + ); + }); + } + + fn complete(&self, success: bool, cancelled: bool, error: &str, result: &str) { + self.with_env((), |env| { + let (Ok(e), Ok(r)) = (env.new_string(error), env.new_string(result)) else { return }; + let (e, r) = (JObject::from(e), JObject::from(r)); + let _ = env.call_method( + self.target.as_obj(), + "onComplete", + "(ZZLjava/lang/String;Ljava/lang/String;)V", + &[ + JValue::Bool(if success { JNI_TRUE } else { JNI_FALSE }), + JValue::Bool(if cancelled { JNI_TRUE } else { JNI_FALSE }), + JValue::Object(&e), + JValue::Object(&r), + ], + ); + let _ = env.delete_local_ref(e); + let _ = env.delete_local_ref(r); + }); + } +} + +impl Events for Listener { + fn log(&self, line: &str) { + self.text("onLog", &crate::amazon::net::scrub_urls(line)); + } + + fn status(&self, text: &str) { + self.text("onStatus", text); + } + + fn checking(&self, done_bytes: u64, total_bytes: u64, files: u32, files_total: u32) { + self.counts("onCheck", done_bytes, total_bytes, files, files_total); + } + + fn sizes(&self, pending_bytes: u64, total_bytes: u64) { + self.with_env((), |env| { + let _ = env.call_method( + self.target.as_obj(), + "onSizes", + "(JJ)V", + &[ + JValue::Long(pending_bytes.min(i64::MAX as u64) as i64), + JValue::Long(total_bytes.min(i64::MAX as u64) as i64), + ], + ); + }); + } + + fn progress(&self, done: u64, total: u64, files: u32, files_total: u32) { + self.counts("onProgress", done, total, files, files_total); + } + + fn before_replace(&self) -> bool { + self.with_env(false, |env| match env.call_method(self.target.as_obj(), "beforeReplace", "()Z", &[]) { + Ok(v) => v.z().unwrap_or(false), + Err(_) => false, + }) + } +} + +#[no_mangle] +pub extern "system" fn Java_com_droiddeck_launcher_stores_ea_EaNative_nativeInstall( + mut env: JNIEnv, + _class: JClass, + request: JString, + listener: JObject, +) -> jlong { + if listener.is_null() { + return 0; + } + let Ok(vm) = env.get_java_vm() else { return 0 }; + let Ok(target) = env.new_global_ref(&listener) else { return 0 }; + let listener = Listener { vm, target }; + let text = read_string(&mut env, &request); + let parsed = serde_json::from_str::(&text) + .map_err(|_| "The install request could not be read".to_string()) + .and_then(|v| install::Request::from_json(&v)); + let request = match parsed { + Ok(r) => r, + Err(e) => { + listener.complete(false, false, &e, ""); + return 0; + } + }; + let cancel = Arc::new(AtomicBool::new(false)); + let worker_cancel = Arc::clone(&cancel); + let spawned = std::thread::Builder::new().name("ea-install".into()).spawn(move || { + crate::priority::background(); + let result = catch_unwind(AssertUnwindSafe(|| install::run(&request, &worker_cancel, &listener))) + .unwrap_or_else(|_| Err(Stop::Failed("The EA install stopped on an internal error".into()))); + match result { + Ok(done) => listener.complete(true, false, "", &done.to_json().to_string()), + Err(Stop::Cancelled) => listener.complete(false, true, "cancelled", ""), + Err(Stop::Failed(e)) => listener.complete(false, false, &e, ""), + } + }); + if spawned.is_err() { + return 0; + } + Box::into_raw(Box::new(cancel)) as jlong +} + +#[no_mangle] +pub extern "system" fn Java_com_droiddeck_launcher_stores_ea_EaNative_nativeCancel(_env: JNIEnv, _class: JClass, handle: jlong) { + let ptr = handle as *const Arc; + if let Some(cancel) = unsafe { ptr.as_ref() } { + cancel.store(true, Ordering::SeqCst); + } +} + +#[no_mangle] +pub extern "system" fn Java_com_droiddeck_launcher_stores_ea_EaNative_nativeRelease(_env: JNIEnv, _class: JClass, handle: jlong) { + let ptr = handle as *mut Arc; + if !ptr.is_null() { + drop(unsafe { Box::from_raw(ptr) }); + } +} diff --git a/app/src/main/rust/stores/src/ea/install.rs b/app/src/main/rust/stores/src/ea/install.rs new file mode 100644 index 000000000..fb2cd80ef --- /dev/null +++ b/app/src/main/rust/stores/src/ea/install.rs @@ -0,0 +1,850 @@ +// SPDX-License-Identifier: GPL-3.0-or-later +//! Installs or updates an EA game from its build archive: files whose size and CRC already match +//! are kept, everything else is fetched entry by entry with ranged reads and inflated in place. + +use std::collections::{HashMap, HashSet}; +use std::fs::{self, File, OpenOptions}; +use std::io::{Read, Write}; +use std::os::unix::fs::FileExt; +use std::path::{Path, PathBuf}; +use std::sync::atomic::{AtomicBool, AtomicU64, AtomicUsize, Ordering}; +use std::sync::Mutex; +use std::time::{Duration, Instant, UNIX_EPOCH}; + +use flate2::{Crc, Decompress, FlushDecompress, Status}; +use serde_json::{json, Value}; + +use super::net; +use super::zip::{self, Entry}; +use crate::amazon::install::check::{free_bytes, space_problem}; +use crate::amazon::install::{Events, ReplaceGate, Stop}; +use crate::fetch_core::{distinct_host_count, run_fetch, FetchItem, FetchOptions, FetchSink, SinkError}; + +pub const LEDGER: &str = ".droiddeck-ea-files"; +pub const IN_PROGRESS_MARKER: &str = ".droiddeck-ea-downloading"; +const TEMP_SUFFIX: &str = ".ddpart"; +const PROGRESS_EVERY_MS: u64 = 50; +const CHECK_REPORT_EVERY: Duration = Duration::from_millis(200); +const REQUEST_TIMEOUT: Duration = Duration::from_secs(120); +const INFLATE_BUFFER: usize = 256 * 1024; +/// Records larger than this are fetched as parallel ranges into a scratch file, then inflated, +/// so one big file does not ride a single connection. +pub const PART_BYTES: u64 = 16 * 1024 * 1024; +const SPLIT_SUFFIX: &str = ".ddzip"; + +struct Split { + tmp: PathBuf, + file: Mutex>, + received: Vec, + left: AtomicUsize, +} + +#[derive(Clone, Copy)] +enum Work { + Whole(usize), + Part { job: usize, part: usize, start: u64, len: u64 }, +} + +/// The fetches for each job: whole records, or byte ranges of one when it is large. +fn works(jobs: &[&Planned], part_bytes: u64) -> Vec { + let mut out = Vec::new(); + for (i, p) in jobs.iter().enumerate() { + let span = p.range_end - p.entry.local_offset + 1; + if span <= part_bytes * 2 { + out.push(Work::Whole(i)); + continue; + } + let mut start = 0; + let mut part = 0; + while start < span { + let len = part_bytes.min(span - start); + out.push(Work::Part { job: i, part, start, len }); + start += len; + part += 1; + } + } + out +} +pub const REPLACE_REFUSED: &str = "The install record could not be switched to updating; nothing was replaced"; + +pub struct Request { + pub url: String, + pub game_dir: PathBuf, + pub max_workers: usize, + pub process_workers: usize, + pub ca_bundle: String, + pub part_bytes: u64, +} + +impl Request { + pub fn from_json(v: &Value) -> Result { + let text = |k: &str| v.get(k).and_then(|x| x.as_str()).unwrap_or("").to_string(); + let game_dir = text("gameDir"); + if game_dir.is_empty() || !game_dir.starts_with('/') { + return Err("No install folder was given".into()); + } + let url = text("url"); + if !url.starts_with("https://") && !url.starts_with("http://") { + return Err("EA gave no download for this game".into()); + } + Ok(Request { + url, + game_dir: PathBuf::from(game_dir), + max_workers: v.get("maxWorkers").and_then(|n| n.as_u64()).unwrap_or(8) as usize, + process_workers: v.get("processWorkers").and_then(|n| n.as_u64()).unwrap_or(2) as usize, + ca_bundle: text("caBundle"), + part_bytes: v.get("partBytes").and_then(|n| n.as_u64()).filter(|n| *n > 0).unwrap_or(PART_BYTES), + }) + } +} + +#[derive(Debug, Default)] +pub struct Finished { + pub total_bytes: u64, + pub download_bytes: u64, + pub file_count: usize, + pub files_written: usize, + pub bytes_written: u64, + pub removed: usize, +} + +impl Finished { + pub fn to_json(&self) -> Value { + json!({ + "totalBytes": self.total_bytes, + "downloadBytes": self.download_bytes, + "fileCount": self.file_count, + "filesWritten": self.files_written, + "bytesWritten": self.bytes_written, + "removed": self.removed, + }) + } +} + +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +struct Known { + crc: u32, + size: u64, + modified: u64, +} + +fn modified_ms(path: &Path) -> u64 { + fs::metadata(path) + .and_then(|m| m.modified()) + .ok() + .and_then(|t| t.duration_since(UNIX_EPOCH).ok()) + .map(|d| d.as_millis() as u64) + .unwrap_or(0) +} + +fn read_ledger(dir: &Path) -> HashMap { + let Ok(text) = fs::read_to_string(dir.join(LEDGER)) else { return HashMap::new() }; + let mut out = HashMap::new(); + for line in text.lines() { + let mut parts = line.splitn(4, '\t'); + let (Some(crc), Some(size), Some(modified), Some(path)) = (parts.next(), parts.next(), parts.next(), parts.next()) else { + continue; + }; + let (Ok(crc), Ok(size), Ok(modified)) = (u32::from_str_radix(crc, 16), size.parse(), modified.parse()) else { + continue; + }; + out.insert(path.to_string(), Known { crc, size, modified }); + } + out +} + +fn ledger_line(path: &str, k: Known) -> String { + format!("{:08x}\t{}\t{}\t{path}\n", k.crc, k.size, k.modified) +} + +fn crc_of(path: &Path, cancel: &AtomicBool) -> Result, ()> { + let Ok(mut f) = File::open(path) else { return Ok(None) }; + let mut crc = Crc::new(); + let mut buf = vec![0u8; 1024 * 1024]; + loop { + if cancel.load(Ordering::Relaxed) { + return Err(()); + } + match f.read(&mut buf) { + Ok(0) => return Ok(Some(crc.sum())), + Ok(n) => crc.update(&buf[..n]), + Err(e) if e.kind() == std::io::ErrorKind::Interrupted => {} + Err(_) => return Ok(None), + } + } +} + +/// Whether two paths name one file, as they do for a case-only rename on case-insensitive storage. +fn same_file(a: &Path, b: &Path) -> bool { + use std::os::unix::fs::MetadataExt; + match (fs::metadata(a), fs::metadata(b)) { + (Ok(x), Ok(y)) => x.dev() == y.dev() && x.ino() == y.ino(), + _ => false, + } +} + +fn temp_path(dest: &Path) -> PathBuf { + let mut name = dest.as_os_str().to_owned(); + name.push(TEMP_SUFFIX); + PathBuf::from(name) +} + +pub struct Planned { + pub entry: Entry, + pub path: String, + pub range_end: u64, +} + +/// Every file entry with its safe path and the last byte its local record can occupy. +pub fn plan(entries: &[Entry], directory_offset: u64) -> Result, String> { + let mut offsets: Vec = entries.iter().map(|e| e.local_offset).collect(); + offsets.push(directory_offset); + offsets.sort_unstable(); + offsets.dedup(); + let mut seen = HashSet::new(); + let mut out = Vec::new(); + for e in entries.iter().filter(|e| !e.is_dir()) { + if e.method != zip::STORED && e.method != zip::DEFLATED { + return Err(format!("{} uses an unsupported compression method ({})", e.name, e.method)); + } + let path = zip::safe_path(&e.name).ok_or_else(|| format!("The archive names a file outside the game folder: {}", e.name))?; + if !seen.insert(path.to_lowercase()) { + continue; + } + let at = offsets.partition_point(|&o| o <= e.local_offset); + let next = offsets.get(at).copied().unwrap_or(directory_offset); + let minimum = e.local_offset + zip::LOCAL_FIXED as u64 + e.compressed; + if next < minimum { + return Err(format!("{} overlaps the next record in the archive", e.name)); + } + out.push(Planned { entry: e.clone(), path, range_end: next - 1 }); + } + Ok(out) +} + +struct Slot { + header: Vec, + payload_from: Option, + consumed: u64, + file: File, + tmp: PathBuf, + crc: Crc, + written: u64, + inflate: Option, + buffer: Vec, +} + +struct Placer<'a> { + jobs: &'a [&'a Planned], + game_dir: &'a Path, + slots: Vec>>, + splits: Vec>, + works: Vec, + placed: Vec, + ledger: Mutex>, + events: &'a dyn Events, + gate: &'a ReplaceGate, + stop: &'a AtomicBool, + failure: Mutex>, + base_done: u64, + base_files: u32, + total: u64, + total_files: u32, + placed_bytes: AtomicU64, + in_flight: AtomicU64, + shown: AtomicU64, + bytes_written: AtomicU64, + files_placed: AtomicUsize, + last_emit: AtomicU64, + started: Instant, +} + +fn discard(slot: Slot) { + drop(slot.file); + let _ = fs::remove_file(&slot.tmp); +} + +impl Placer<'_> { + fn fail(&self, message: String) -> String { + let mut slot = self.failure.lock().expect("failure lock"); + if slot.is_none() { + *slot = Some(message.clone()); + } + drop(slot); + self.stop.store(true, Ordering::SeqCst); + message + } + + fn emit(&self, force: bool) { + let now = self.started.elapsed().as_millis() as u64; + let last = self.last_emit.load(Ordering::Relaxed); + if !force && now < last + PROGRESS_EVERY_MS { + return; + } + if !force && self.last_emit.compare_exchange(last, now, Ordering::Relaxed, Ordering::Relaxed).is_err() { + return; + } + self.last_emit.store(now, Ordering::Relaxed); + let value = self.base_done + self.placed_bytes.load(Ordering::Relaxed) + self.in_flight.load(Ordering::Relaxed); + let shown = self.shown.fetch_max(value, Ordering::Relaxed).max(value); + self.events.progress( + shown.min(self.total), + self.total, + self.base_files + self.files_placed.load(Ordering::Relaxed) as u32, + self.total_files, + ); + } + + fn open(&self, job: &Planned) -> Result { + let dest = self.game_dir.join(&job.path); + if let Some(parent) = dest.parent() { + fs::create_dir_all(parent).map_err(|e| format!("Could not create the folder for {}: {e}", job.path))?; + } + let tmp = temp_path(&dest); + let file = File::create(&tmp).map_err(|e| format!("Could not write {}: {e}", job.path))?; + Ok(Slot { + header: Vec::new(), + payload_from: None, + consumed: 0, + file, + tmp, + crc: Crc::new(), + written: 0, + inflate: (job.entry.method == zip::DEFLATED).then(|| Decompress::new(false)), + buffer: Vec::new(), + }) + } + + fn write_out(&self, job: &Planned, slot: &mut Slot, data: &[u8]) -> Result<(), SinkError> { + if slot.written + data.len() as u64 > job.entry.size { + return Err(SinkError::Retry(format!("{}: more data than the archive lists", job.path))); + } + slot.file + .write_all(data) + .map_err(|e| SinkError::Fatal(self.fail(format!("Could not write {}: {e}", job.path))))?; + slot.crc.update(data); + slot.written += data.len() as u64; + self.bytes_written.fetch_add(data.len() as u64, Ordering::Relaxed); + Ok(()) + } + + fn feed(&self, job: &Planned, slot: &mut Slot, payload: &[u8], finish: bool) -> Result<(), SinkError> { + let Some(mut inflate) = slot.inflate.take() else { + return self.write_out(job, slot, payload); + }; + let mut out = std::mem::take(&mut slot.buffer); + out.resize(INFLATE_BUFFER, 0); + let result = self.inflate_into(job, slot, &mut inflate, &mut out, payload, finish); + slot.inflate = Some(inflate); + slot.buffer = out; + result + } + + fn inflate_into(&self, job: &Planned, slot: &mut Slot, inflate: &mut Decompress, out: &mut [u8], mut input: &[u8], finish: bool) -> Result<(), SinkError> { + let mode = if finish { FlushDecompress::Finish } else { FlushDecompress::None }; + loop { + let (in_before, out_before) = (inflate.total_in(), inflate.total_out()); + let status = inflate + .decompress(input, out, mode) + .map_err(|e| SinkError::Retry(format!("{}: the compressed data is damaged ({e})", job.path)))?; + let used = (inflate.total_in() - in_before) as usize; + let produced = (inflate.total_out() - out_before) as usize; + input = &input[used..]; + if produced > 0 { + self.write_out(job, slot, &out[..produced])?; + } + if status == Status::StreamEnd || (used == 0 && produced == 0) { + return Ok(()); + } + if input.is_empty() && !finish && produced < out.len() { + return Ok(()); + } + } + } + + fn piece(&self, i: usize, offset: u64, data: &[u8]) -> Result<(), SinkError> { + let job = self.jobs[i]; + let mut guard = self.slots[i].lock().expect("slot lock"); + if offset == 0 { + if let Some(old) = guard.take() { + self.in_flight.fetch_sub(old.consumed, Ordering::Relaxed); + discard(old); + } + *guard = Some(self.open(job).map_err(|e| SinkError::Fatal(self.fail(e)))?); + } + let Some(slot) = guard.as_mut() else { + return Err(SinkError::Retry(format!("{}: data arrived before the start of the record", job.path))); + }; + self.consume(job, slot, data) + } + + fn consume(&self, job: &Planned, slot: &mut Slot, data: &[u8]) -> Result<(), SinkError> { + if slot.payload_from.is_none() { + slot.header.extend_from_slice(data); + match zip::local_header_len(&slot.header) { + None => return Ok(()), + Some(Err(e)) => return Err(SinkError::Retry(format!("{}: {e}", job.path))), + Some(Ok(len)) if slot.header.len() < len => return Ok(()), + Some(Ok(len)) => { + slot.payload_from = Some(len); + let header = std::mem::take(&mut slot.header); + let rest = header[len..].to_vec(); + return self.payload(job, slot, &rest); + } + } + } + self.payload(job, slot, data) + } + + fn payload(&self, job: &Planned, slot: &mut Slot, data: &[u8]) -> Result<(), SinkError> { + let left = job.entry.compressed.saturating_sub(slot.consumed); + let take = &data[..(left.min(data.len() as u64) as usize)]; + if take.is_empty() { + return Ok(()); + } + slot.consumed += take.len() as u64; + self.in_flight.fetch_add(take.len() as u64, Ordering::Relaxed); + self.feed(job, slot, take, false)?; + self.emit(false); + Ok(()) + } + + fn finish(&self, i: usize) -> Result { + let job = self.jobs[i]; + let taken = self.slots[i].lock().expect("slot lock").take(); + let Some(slot) = taken else { + return Err(SinkError::Retry(format!("{}: the record arrived empty", job.path))); + }; + self.complete(i, slot) + } + + fn complete(&self, i: usize, mut slot: Slot) -> Result { + let job = self.jobs[i]; + self.in_flight.fetch_sub(slot.consumed, Ordering::Relaxed); + if slot.consumed != job.entry.compressed { + let got = slot.consumed; + discard(slot); + return Err(SinkError::Retry(format!("{}: {got} of {} compressed bytes arrived", job.path, job.entry.compressed))); + } + if slot.inflate.is_some() { + if let Err(e) = self.feed(job, &mut slot, &[], true) { + discard(slot); + return Err(e); + } + } + if slot.written != job.entry.size || slot.crc.sum() != job.entry.crc32 { + let got = slot.written; + discard(slot); + return Err(SinkError::Retry(format!("{}: {got} bytes inflated with a CRC mismatch", job.path))); + } + let Slot { file, tmp, .. } = slot; + if let Err(e) = file.sync_all() { + drop(file); + let _ = fs::remove_file(&tmp); + return Err(SinkError::Fatal(self.fail(format!("Could not write {}: {e}", job.path)))); + } + drop(file); + if self.stop.load(Ordering::SeqCst) { + let _ = fs::remove_file(&tmp); + return Ok(0); + } + if !self.gate.allow(self.events) { + let _ = fs::remove_file(&tmp); + return Err(SinkError::Fatal(self.fail(REPLACE_REFUSED.into()))); + } + let dest = self.game_dir.join(&job.path); + if let Err(e) = fs::rename(&tmp, &dest) { + let _ = fs::remove_file(&tmp); + return Err(SinkError::Fatal(self.fail(format!("Could not put {} in place: {e}", job.path)))); + } + self.record(job, &dest); + self.placed[i].store(true, Ordering::SeqCst); + self.placed_bytes.fetch_add(job.entry.compressed, Ordering::Relaxed); + self.files_placed.fetch_add(1, Ordering::Relaxed); + self.emit(true); + Ok(0) + } + + fn part_chunk(&self, i: usize, part: usize, start: u64, offset: u64, data: &[u8]) -> Result<(), SinkError> { + let job = self.jobs[i]; + let Some(split) = self.splits[i].as_ref() else { + return Err(SinkError::Fatal(format!("{}: no split state", job.path))); + }; + let mut file = split.file.lock().expect("split lock"); + if file.is_none() { + let dest = self.game_dir.join(&job.path); + if let Some(parent) = dest.parent() { + fs::create_dir_all(parent).map_err(|e| SinkError::Fatal(self.fail(format!("Could not create the folder for {}: {e}", job.path))))?; + } + *file = Some(File::create(&split.tmp).map_err(|e| SinkError::Fatal(self.fail(format!("Could not write {}: {e}", job.path))))?); + } + let handle = file.as_ref().expect("split file"); + if offset == 0 { + let old = split.received[part].swap(0, Ordering::Relaxed); + self.in_flight.fetch_sub(old, Ordering::Relaxed); + } + handle + .write_all_at(data, start + offset) + .map_err(|e| SinkError::Fatal(self.fail(format!("Could not write {}: {e}", job.path))))?; + drop(file); + split.received[part].fetch_add(data.len() as u64, Ordering::Relaxed); + self.in_flight.fetch_add(data.len() as u64, Ordering::Relaxed); + self.emit(false); + Ok(()) + } + + fn part_finish(&self, i: usize, part: usize, len: u64) -> Result { + let job = self.jobs[i]; + let Some(split) = self.splits[i].as_ref() else { + return Err(SinkError::Fatal(format!("{}: no split state", job.path))); + }; + let got = split.received[part].load(Ordering::Relaxed); + if got != len { + return Err(SinkError::Retry(format!("{}: part {part} got {got} of {len} bytes", job.path))); + } + if split.left.fetch_sub(1, Ordering::SeqCst) != 1 { + return Ok(0); + } + let received: u64 = split.received.iter().map(|r| r.swap(0, Ordering::Relaxed)).sum(); + self.in_flight.fetch_sub(received, Ordering::Relaxed); + let result = self.assemble(i, split); + let _ = fs::remove_file(&split.tmp); + *split.file.lock().expect("split lock") = None; + result.map_err(|e| match e { + SinkError::Retry(m) => SinkError::Fatal(self.fail(m)), + fatal => fatal, + }) + } + + fn assemble(&self, i: usize, split: &Split) -> Result { + let job = self.jobs[i]; + let mut source = File::open(&split.tmp).map_err(|e| SinkError::Fatal(self.fail(format!("Could not read {}: {e}", job.path))))?; + let mut slot = self.open(job).map_err(|e| SinkError::Fatal(self.fail(e)))?; + let mut buf = vec![0u8; 1024 * 1024]; + loop { + if self.stop.load(Ordering::Relaxed) { + self.in_flight.fetch_sub(slot.consumed, Ordering::Relaxed); + discard(slot); + return Ok(0); + } + let n = match source.read(&mut buf) { + Ok(0) => break, + Ok(n) => n, + Err(e) if e.kind() == std::io::ErrorKind::Interrupted => continue, + Err(e) => { + self.in_flight.fetch_sub(slot.consumed, Ordering::Relaxed); + discard(slot); + return Err(SinkError::Fatal(self.fail(format!("Could not read {}: {e}", job.path)))); + } + }; + if let Err(e) = self.consume(job, &mut slot, &buf[..n]) { + self.in_flight.fetch_sub(slot.consumed, Ordering::Relaxed); + discard(slot); + return Err(e); + } + if slot.consumed >= job.entry.compressed { + break; + } + } + self.complete(i, slot) + } + + fn record(&self, job: &Planned, dest: &Path) { + let known = Known { crc: job.entry.crc32, size: job.entry.size, modified: modified_ms(dest) }; + if let Some(file) = self.ledger.lock().expect("ledger lock").as_mut() { + let _ = file.write_all(ledger_line(&job.path, known).as_bytes()); + } + } +} + +struct Sink<'p, 'a>(&'p Placer<'a>); + +impl FetchSink for Sink<'_, '_> { + fn process(&self, item: &FetchItem, _body: Vec) -> Result { + Err(SinkError::Fatal(format!("item {} arrived whole; streaming was expected", item.id))) + } + + fn on_chunk(&self, item: &FetchItem, offset: u64, data: &[u8]) -> Result<(), SinkError> { + match self.0.works[item.id as usize] { + Work::Whole(i) => self.0.piece(i, offset, data), + Work::Part { job, part, start, .. } => self.0.part_chunk(job, part, start, offset, data), + } + } + + fn on_finish(&self, item: &FetchItem, _total_len: u64) -> Result { + match self.0.works[item.id as usize] { + Work::Whole(i) => self.0.finish(i), + Work::Part { job, part, len, .. } => self.0.part_finish(job, part, len), + } + } +} + +fn origin(url: &str) -> String { + let after = url.find("://").map(|i| i + 3).unwrap_or(0); + let end = url[after..].find('/').map(|i| after + i).unwrap_or(url.len()); + url[..end].to_string() +} + +fn cancelled(cancel: &AtomicBool) -> Result<(), Stop> { + if cancel.load(Ordering::SeqCst) { + Err(Stop::Cancelled) + } else { + Ok(()) + } +} + +pub fn run(req: &Request, cancel: &AtomicBool, ev: &dyn Events) -> Result { + let failed = Stop::Failed; + let started = Instant::now(); + fs::create_dir_all(&req.game_dir).map_err(|e| failed(format!("Could not create the install folder: {e}")))?; + let _ = File::create(req.game_dir.join(IN_PROGRESS_MARKER)); + + ev.status("Reading the build…"); + let archive = net::read_archive(&req.url).map_err(|e| { + ev.log(&format!("archive: {e}")); + failed(format!("The EA build could not be read: {e}")) + })?; + cancelled(cancel)?; + let planned = plan(&archive.entries, archive.directory.offset).map_err(failed)?; + let total_size: u64 = planned.iter().map(|p| p.entry.size).sum(); + let total_compressed: u64 = planned.iter().map(|p| p.entry.compressed).sum(); + ev.log(&format!( + "archive bytes={} entries={} files={} size={total_size} compressed={total_compressed}", + archive.total, + archive.entries.len(), + planned.len() + )); + + for p in &planned { + let dest = req.game_dir.join(&p.path); + let _ = fs::remove_file(temp_path(&dest)); + let mut split = dest.into_os_string(); + split.push(SPLIT_SUFFIX); + let _ = fs::remove_file(split); + } + + ev.status("Checking files…"); + let mut ledger = read_ledger(&req.game_dir); + let mut pending: Vec<&Planned> = Vec::new(); + let mut kept: Vec<(String, Known)> = Vec::new(); + let mut checked = 0u64; + let mut last_report = Instant::now(); + for (n, p) in planned.iter().enumerate() { + let dest = req.game_dir.join(&p.path); + let size_ok = fs::symlink_metadata(&dest).map(|m| m.is_file() && m.len() == p.entry.size).unwrap_or(false); + let mut complete = false; + if size_ok { + let modified = modified_ms(&dest); + let known = ledger.get(&p.path).copied(); + if known == Some(Known { crc: p.entry.crc32, size: p.entry.size, modified }) { + complete = true; + } else { + match crc_of(&dest, cancel) { + Err(()) => return Err(Stop::Cancelled), + Ok(Some(crc)) if crc == p.entry.crc32 => { + complete = true; + kept.push((p.path.clone(), Known { crc, size: p.entry.size, modified })); + } + Ok(_) => {} + } + } + } + if !complete { + pending.push(p); + } + checked += p.entry.size; + if last_report.elapsed() >= CHECK_REPORT_EVERY || n + 1 == planned.len() { + ev.checking(checked, total_size, (n + 1) as u32, planned.len() as u32); + last_report = Instant::now(); + } + cancelled(cancel)?; + } + for (path, known) in kept { + ledger.insert(path, known); + } + + let pending_size: u64 = pending.iter().map(|p| p.entry.size).sum(); + let pending_compressed: u64 = pending.iter().map(|p| p.entry.compressed).sum(); + ev.log(&format!( + "resume kept_files={} pending_files={} pending_bytes={pending_size} pending_download={pending_compressed}", + planned.len() - pending.len(), + pending.len() + )); + ev.sizes(pending_size, total_size); + if let Some(message) = space_problem(pending_size, free_bytes(&req.game_dir)) { + return Err(failed(message)); + } + + let gate = ReplaceGate::new(); + let (empty, remote): (Vec<&Planned>, Vec<&Planned>) = pending.into_iter().partition(|p| p.entry.size == 0 && p.entry.crc32 == 0); + for p in &empty { + let dest = req.game_dir.join(&p.path); + if let Some(parent) = dest.parent() { + fs::create_dir_all(parent).map_err(|e| failed(format!("Could not create the folder for {}: {e}", p.path)))?; + } + if !gate.allow(ev) { + return Err(failed(REPLACE_REFUSED.into())); + } + File::create(&dest).map_err(|e| failed(format!("Could not write {}: {e}", p.path)))?; + ledger.insert(p.path.clone(), Known { crc: 0, size: 0, modified: modified_ms(&dest) }); + } + + let mut text = String::new(); + for (path, known) in &ledger { + text.push_str(&ledger_line(path, *known)); + } + fs::write(req.game_dir.join(LEDGER), text).map_err(|e| failed(format!("Could not write the install record: {e}")))?; + let ledger_file = OpenOptions::new().append(true).open(req.game_dir.join(LEDGER)).ok(); + + let host = origin(&req.url); + let works = works(&remote, req.part_bytes); + let splits: Vec> = remote + .iter() + .enumerate() + .map(|(i, p)| { + let parts = works.iter().filter(|w| matches!(w, Work::Part { job, .. } if *job == i)).count(); + (parts > 0).then(|| { + let mut name = req.game_dir.join(&p.path).into_os_string(); + name.push(SPLIT_SUFFIX); + Split { + tmp: PathBuf::from(name), + file: Mutex::new(None), + received: (0..parts).map(|_| AtomicU64::new(0)).collect(), + left: AtomicUsize::new(parts), + } + }) + }) + .collect(); + let placer = Placer { + jobs: &remote, + game_dir: &req.game_dir, + slots: remote.iter().map(|_| Mutex::new(None)).collect(), + splits, + works: works.clone(), + placed: remote.iter().map(|_| AtomicBool::new(false)).collect(), + ledger: Mutex::new(ledger_file), + events: ev, + gate: &gate, + stop: cancel, + failure: Mutex::new(None), + base_done: total_compressed - pending_compressed, + base_files: (planned.len() - remote.len()) as u32, + total: total_compressed, + total_files: planned.len() as u32, + placed_bytes: AtomicU64::new(0), + in_flight: AtomicU64::new(0), + shown: AtomicU64::new(0), + bytes_written: AtomicU64::new(0), + files_placed: AtomicUsize::new(0), + last_emit: AtomicU64::new(0), + started: Instant::now(), + }; + let cores = std::thread::available_parallelism().map(|n| n.get()).unwrap_or(2); + let window = req.max_workers.clamp(1, 64); + let hosts = vec![host.clone()]; + let options = FetchOptions { + max_workers: window, + per_host_cap: window, + timeout: REQUEST_TIMEOUT, + headers: vec![("User-Agent".to_string(), net::user_agent())], + ca_bundle_path: req.ca_bundle.clone(), + process_workers: req.process_workers.max(cores / 2).max(1), + label: "ea".to_string(), + stream: true, + }; + let items: Vec = works + .iter() + .enumerate() + .map(|(n, w)| { + let range = match *w { + Work::Whole(i) => (remote[i].entry.local_offset, remote[i].range_end), + Work::Part { job, start, len, .. } => { + let at = remote[job].entry.local_offset + start; + (at, at + len - 1) + } + }; + FetchItem { id: n as u64, urls: vec![req.url.clone()], reserve: 0, range: Some(range) } + }) + .collect(); + ev.status("Downloading…"); + ev.log(&format!("engine files={} requests={} window={window} hosts={}", remote.len(), items.len(), distinct_host_count(&hosts))); + placer.emit(true); + let log = |line: &str| ev.log(line); + let outcome = if items.is_empty() { + None + } else { + Some(run_fetch(items, &hosts, &options, &Sink(&placer), cancel, &|_: u64, _: u64| {}, &log)) + }; + for slot in &placer.slots { + if let Some(active) = slot.lock().expect("slot lock").take() { + discard(active); + } + } + for split in placer.splits.iter().flatten() { + *split.file.lock().expect("split lock") = None; + let _ = fs::remove_file(&split.tmp); + } + placer.emit(true); + let placed_all = placer.placed.iter().all(|p| p.load(Ordering::SeqCst)); + let bytes_written = placer.bytes_written.load(Ordering::Relaxed); + let files_written = placer.files_placed.load(Ordering::Relaxed) + empty.len(); + let elapsed = started.elapsed().as_secs_f64().max(0.001); + ev.log(&format!( + "summary files_written={files_written} bytes_written={bytes_written} elapsed_s={elapsed:.1} avg_mbps={:.1}", + bytes_written as f64 / 1_048_576.0 / elapsed + )); + if let Some(message) = placer.failure.lock().expect("failure lock").take() { + return Err(Stop::Failed(message)); + } + if !placed_all { + if let Some(error) = outcome.as_ref().and_then(|o| o.error.clone()) { + ev.log(&format!("download failed: {error}")); + return Err(Stop::Failed("A file failed to download".into())); + } + if cancel.load(Ordering::SeqCst) { + return Err(Stop::Cancelled); + } + return Err(Stop::Failed("The download ended before every file was written".into())); + } + + let wanted: HashSet<&str> = planned.iter().map(|p| p.path.as_str()).collect(); + let by_case: HashMap = planned.iter().map(|p| (p.path.to_lowercase(), p.path.as_str())).collect(); + let mut removed = 0; + let current = read_ledger(&req.game_dir); + let mut text = String::new(); + for (path, known) in ¤t { + if wanted.contains(path.as_str()) { + text.push_str(&ledger_line(path, *known)); + } else if by_case.get(&path.to_lowercase()).is_some_and(|kept| same_file(&req.game_dir.join(kept), &req.game_dir.join(path))) { + continue; + } else if zip::safe_path(path).as_deref() == Some(path.as_str()) && fs::remove_file(req.game_dir.join(path)).is_ok() { + removed += 1; + } + } + let _ = fs::write(req.game_dir.join(LEDGER), text); + if removed > 0 { + ev.log(&format!("removed {removed} files the new build no longer has")); + } + let _ = fs::remove_file(req.game_dir.join(IN_PROGRESS_MARKER)); + Ok(Finished { + total_bytes: total_size, + download_bytes: total_compressed, + file_count: planned.len(), + files_written, + bytes_written, + removed, + }) +} + +pub fn size(url: &str) -> Result { + let archive = net::read_archive(url)?; + let planned = plan(&archive.entries, archive.directory.offset)?; + Ok(json!({ + "totalBytes": planned.iter().map(|p| p.entry.size).sum::(), + "downloadBytes": planned.iter().map(|p| p.entry.compressed).sum::(), + "fileCount": planned.len(), + })) +} + +#[cfg(test)] +mod tests; diff --git a/app/src/main/rust/stores/src/ea/install/tests.rs b/app/src/main/rust/stores/src/ea/install/tests.rs new file mode 100644 index 000000000..baf15a434 --- /dev/null +++ b/app/src/main/rust/stores/src/ea/install/tests.rs @@ -0,0 +1,258 @@ +use std::io::{BufRead, BufReader, Write}; +use std::net::{TcpListener, TcpStream}; +use std::sync::atomic::{AtomicBool, AtomicUsize}; +use std::sync::{Arc, Mutex}; + +use flate2::write::DeflateEncoder; +use flate2::Compression; + +use super::*; + +struct Built { + bytes: Vec, +} + +fn build_zip(files: &[(&str, Vec, bool)]) -> Built { + let mut out = Vec::new(); + let mut central = Vec::new(); + for (name, data, deflate) in files { + let mut crc = Crc::new(); + crc.update(data); + let body = if *deflate { + let mut enc = DeflateEncoder::new(Vec::new(), Compression::default()); + enc.write_all(data).unwrap(); + enc.finish().unwrap() + } else { + data.clone() + }; + let method: u16 = if *deflate { 8 } else { 0 }; + let offset = out.len() as u32; + let extra = [0xAAu8, 0xBB, 2, 0, 1, 2]; + out.extend_from_slice(&0x0403_4b50u32.to_le_bytes()); + out.extend_from_slice(&[20, 0, 0, 0]); + out.extend_from_slice(&method.to_le_bytes()); + out.extend_from_slice(&[0, 0, 0, 0]); + out.extend_from_slice(&crc.sum().to_le_bytes()); + out.extend_from_slice(&(body.len() as u32).to_le_bytes()); + out.extend_from_slice(&(data.len() as u32).to_le_bytes()); + out.extend_from_slice(&(name.len() as u16).to_le_bytes()); + out.extend_from_slice(&(extra.len() as u16).to_le_bytes()); + out.extend_from_slice(name.as_bytes()); + out.extend_from_slice(&extra); + out.extend_from_slice(&body); + + central.extend_from_slice(&0x0201_4b50u32.to_le_bytes()); + central.extend_from_slice(&[20, 0, 20, 0, 0, 0]); + central.extend_from_slice(&method.to_le_bytes()); + central.extend_from_slice(&[0, 0, 0, 0]); + central.extend_from_slice(&crc.sum().to_le_bytes()); + central.extend_from_slice(&(body.len() as u32).to_le_bytes()); + central.extend_from_slice(&(data.len() as u32).to_le_bytes()); + central.extend_from_slice(&(name.len() as u16).to_le_bytes()); + central.extend_from_slice(&[0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0]); + central.extend_from_slice(&offset.to_le_bytes()); + central.extend_from_slice(name.as_bytes()); + } + let cd_offset = out.len() as u32; + let count = files.len() as u16; + out.extend_from_slice(¢ral); + out.extend_from_slice(&0x0605_4b50u32.to_le_bytes()); + out.extend_from_slice(&[0, 0, 0, 0]); + out.extend_from_slice(&count.to_le_bytes()); + out.extend_from_slice(&count.to_le_bytes()); + out.extend_from_slice(&(central.len() as u32).to_le_bytes()); + out.extend_from_slice(&cd_offset.to_le_bytes()); + out.extend_from_slice(&[0, 0]); + Built { bytes: out } +} + +struct Server { + url: String, + requests: Arc, +} + +fn serve(body: Vec) -> Server { + let listener = TcpListener::bind("127.0.0.1:0").unwrap(); + let addr = listener.local_addr().unwrap(); + let body = Arc::new(body); + let requests = Arc::new(AtomicUsize::new(0)); + let counter = Arc::clone(&requests); + std::thread::spawn(move || { + for stream in listener.incoming() { + let Ok(stream) = stream else { continue }; + let body = Arc::clone(&body); + counter.fetch_add(1, Ordering::SeqCst); + std::thread::spawn(move || answer(stream, &body)); + } + }); + Server { url: format!("http://{addr}/build/game.zip?sig=abc"), requests } +} + +fn answer(mut stream: TcpStream, body: &[u8]) { + let mut reader = BufReader::new(stream.try_clone().unwrap()); + let mut first = String::new(); + if reader.read_line(&mut first).is_err() { + return; + } + let mut range = None; + loop { + let mut line = String::new(); + match reader.read_line(&mut line) { + Ok(0) | Err(_) => break, + Ok(_) if line == "\r\n" => break, + Ok(_) => { + if let Some(v) = line.to_ascii_lowercase().strip_prefix("range: bytes=") { + range = Some(v.trim().to_string()); + } + } + } + } + let total = body.len() as u64; + let Some(range) = range else { + let _ = stream.write_all(format!("HTTP/1.1 200 OK\r\nContent-Length: {total}\r\nConnection: close\r\n\r\n").as_bytes()); + let _ = stream.write_all(body); + return; + }; + let (a, b) = range.split_once('-').unwrap(); + let (start, end) = if a.is_empty() { + let n: u64 = b.parse().unwrap(); + (total.saturating_sub(n), total - 1) + } else { + let start: u64 = a.parse().unwrap(); + let end = if b.is_empty() { total - 1 } else { b.parse::().unwrap().min(total - 1) }; + (start, end) + }; + let slice = &body[start as usize..=end as usize]; + let head = format!( + "HTTP/1.1 206 Partial Content\r\nContent-Length: {}\r\nContent-Range: bytes {start}-{end}/{total}\r\nConnection: close\r\n\r\n", + slice.len() + ); + let _ = stream.write_all(head.as_bytes()); + let _ = stream.write_all(slice); +} + +#[derive(Default)] +struct Recorder { + logs: Mutex>, + gates: AtomicUsize, + last: Mutex<(u64, u64)>, +} + +impl Events for Recorder { + fn log(&self, line: &str) { + self.logs.lock().unwrap().push(line.to_string()); + } + fn status(&self, _text: &str) {} + fn checking(&self, _d: u64, _t: u64, _f: u32, _ft: u32) {} + fn sizes(&self, _pending: u64, _total: u64) {} + fn progress(&self, done: u64, total: u64, _files: u32, _files_total: u32) { + *self.last.lock().unwrap() = (done, total); + } + fn before_replace(&self) -> bool { + self.gates.fetch_add(1, Ordering::SeqCst); + true + } +} + +fn sample() -> Vec<(&'static str, Vec, bool)> { + let mut seed = 0x1234_5678u32; + let big: Vec = (0..900_000u32).map(|_| { seed = seed.wrapping_mul(1_103_515_245).wrapping_add(12_345); (seed >> 16) as u8 }).collect(); + vec![ + ("__Installer/installerdata.xml", b"".to_vec(), true), + ("Game.exe", big, true), + ("Data\\stored.bin", vec![9u8; 4096], false), + ("Data/", Vec::new(), false), + ("empty.txt", Vec::new(), false), + ] +} + +fn request(url: &str, dir: &Path) -> Request { + Request { url: url.to_string(), game_dir: dir.to_path_buf(), max_workers: 4, process_workers: 2, ca_bundle: String::new(), part_bytes: 64 * 1024 } +} + +fn temp_dir(name: &str) -> PathBuf { + let dir = std::env::temp_dir().join(format!("droiddeck-ea-{name}-{}", std::process::id())); + let _ = fs::remove_dir_all(&dir); + fs::create_dir_all(&dir).unwrap(); + dir +} + +#[test] +fn installs_every_file_and_keeps_them_on_the_next_run() { + let files = sample(); + let zip = build_zip(&files); + let server = serve(zip.bytes); + let dir = temp_dir("install"); + let ev = Recorder::default(); + let done = run(&request(&server.url, &dir), &AtomicBool::new(false), &ev).unwrap(); + assert_eq!(done.file_count, 4); + assert_eq!(fs::read(dir.join("Game.exe")).unwrap(), files[1].1); + assert_eq!(fs::read(dir.join("Data/stored.bin")).unwrap(), vec![9u8; 4096]); + assert!(dir.join("empty.txt").is_file()); + assert!(dir.join("__Installer/installerdata.xml").is_file()); + assert!(!dir.join(IN_PROGRESS_MARKER).exists()); + let (shown, total) = *ev.last.lock().unwrap(); + assert_eq!(shown, total); + + let before = server.requests.load(Ordering::SeqCst); + let again = run(&request(&server.url, &dir), &AtomicBool::new(false), &Recorder::default()).unwrap(); + assert_eq!(again.files_written, 0); + assert!(server.requests.load(Ordering::SeqCst) - before <= 2); + let _ = fs::remove_dir_all(&dir); +} + +#[test] +fn an_update_replaces_changed_files_and_drops_removed_ones() { + let dir = temp_dir("update"); + let first = serve(build_zip(&sample()).bytes); + run(&request(&first.url, &dir), &AtomicBool::new(false), &Recorder::default()).unwrap(); + + let mut next = sample(); + next[1].1[10] ^= 0xFF; + next.retain(|f| f.0 != "Data\\stored.bin"); + let second = serve(build_zip(&next).bytes); + let ev = Recorder::default(); + let done = run(&request(&second.url, &dir), &AtomicBool::new(false), &ev).unwrap(); + assert_eq!(done.files_written, 1); + assert_eq!(done.removed, 1); + assert_eq!(fs::read(dir.join("Game.exe")).unwrap(), next[1].1); + assert!(!dir.join("Data/stored.bin").exists()); + assert_eq!(ev.gates.load(Ordering::SeqCst), 1); + let _ = fs::remove_dir_all(&dir); +} + +#[test] +fn a_damaged_file_on_disk_is_downloaded_again() { + let dir = temp_dir("repair"); + let server = serve(build_zip(&sample()).bytes); + run(&request(&server.url, &dir), &AtomicBool::new(false), &Recorder::default()).unwrap(); + let mut data = fs::read(dir.join("Game.exe")).unwrap(); + data[5] ^= 1; + fs::write(dir.join("Game.exe"), &data).unwrap(); + let done = run(&request(&server.url, &dir), &AtomicBool::new(false), &Recorder::default()).unwrap(); + assert_eq!(done.files_written, 1); + assert_eq!(fs::read(dir.join("Game.exe")).unwrap(), sample()[1].1); + let _ = fs::remove_dir_all(&dir); +} + +#[test] +fn reports_the_archive_size() { + let server = serve(build_zip(&sample()).bytes); + let answer = size(&server.url).unwrap(); + assert_eq!(answer["fileCount"], 4); + let expected: usize = sample().iter().map(|f| f.1.len()).sum(); + assert_eq!(answer["totalBytes"], expected as u64); +} + +#[test] +fn large_records_arrive_in_parallel_parts() { + let dir = temp_dir("parts"); + let server = serve(build_zip(&sample()).bytes); + let before = server.requests.load(Ordering::SeqCst); + let done = run(&request(&server.url, &dir), &AtomicBool::new(false), &Recorder::default()).unwrap(); + assert_eq!(done.files_written, 4); + assert!(server.requests.load(Ordering::SeqCst) - before > 6); + assert_eq!(fs::read(dir.join("Game.exe")).unwrap(), sample()[1].1); + assert!(fs::read_dir(&dir).unwrap().all(|e| !e.unwrap().file_name().to_string_lossy().ends_with(".ddzip"))); + let _ = fs::remove_dir_all(&dir); +} diff --git a/app/src/main/rust/stores/src/ea/mod.rs b/app/src/main/rust/stores/src/ea/mod.rs new file mode 100644 index 000000000..73ccb48ad --- /dev/null +++ b/app/src/main/rust/stores/src/ea/mod.rs @@ -0,0 +1,4 @@ +pub mod ffi; +pub mod install; +pub mod net; +pub mod zip; diff --git a/app/src/main/rust/stores/src/ea/net.rs b/app/src/main/rust/stores/src/ea/net.rs new file mode 100644 index 000000000..8b3189547 --- /dev/null +++ b/app/src/main/rust/stores/src/ea/net.rs @@ -0,0 +1,98 @@ +// SPDX-License-Identifier: GPL-3.0-or-later +//! Ranged reads of EA's build archives. + +use std::time::Duration; + +use super::zip::{self, Directory, Entry}; + +const CONNECT_TIMEOUT: Duration = Duration::from_secs(30); +const READ_TIMEOUT: Duration = Duration::from_secs(60); +const MAX_DIRECTORY: u64 = 512 * 1024 * 1024; + +pub fn user_agent() -> String { + crate::amazon::net::user_agent() +} + +pub struct Ranged { + pub body: Vec, + pub start: u64, + pub total: u64, +} + +fn content_range(value: &str) -> Option<(u64, u64)> { + let rest = value.trim().strip_prefix("bytes")?.trim_start(); + let (span, total) = rest.split_once('/')?; + let start = span.split_once('-')?.0.trim().parse().ok()?; + Some((start, total.trim().parse().ok()?)) +} + +pub fn get_range(url: &str, range: &str) -> Result { + let runtime = tokio::runtime::Builder::new_current_thread() + .enable_all() + .build() + .map_err(|e| format!("runtime: {e}"))?; + runtime.block_on(async { + let client = reqwest::Client::builder() + .user_agent(user_agent()) + .connect_timeout(CONNECT_TIMEOUT) + .read_timeout(READ_TIMEOUT) + .build() + .map_err(|e| format!("http client: {}", e.without_url()))?; + let response = client + .get(url) + .header(reqwest::header::RANGE, format!("bytes={range}")) + .send() + .await + .map_err(|e| format!("request failed: {}", e.without_url()))?; + let status = response.status().as_u16(); + if status == 403 || status == 404 { + return Err(format!("the download link was refused ({status})")); + } + if status != 206 { + return Err(format!("the server did not answer a ranged read ({status})")); + } + let (start, total) = response + .headers() + .get(reqwest::header::CONTENT_RANGE) + .and_then(|v| v.to_str().ok()) + .and_then(content_range) + .ok_or("the server gave no content range")?; + let body = response.bytes().await.map_err(|e| format!("answer could not be read: {}", e.without_url()))?.to_vec(); + Ok(Ranged { body, start, total }) + }) +} + +pub struct Archive { + pub total: u64, + pub directory: Directory, + pub entries: Vec, +} + +pub fn read_archive(url: &str) -> Result { + let tail = get_range(url, &format!("-{}", zip::TAIL_LEN))?; + let directory = zip::locate(&tail.body, tail.start)?; + if directory.size > MAX_DIRECTORY || directory.offset + directory.size > tail.total { + return Err("The archive directory is out of bounds".into()); + } + let bytes = if directory.offset >= tail.start { + let at = (directory.offset - tail.start) as usize; + let end = at + directory.size as usize; + tail.body.get(at..end).ok_or("The archive directory is cut short")?.to_vec() + } else { + let end = directory.offset + directory.size - 1; + get_range(url, &format!("{}-{end}", directory.offset))?.body + }; + let entries = zip::parse(&bytes, directory.entries)?; + Ok(Archive { total: tail.total, directory, entries }) +} + +#[cfg(test)] +mod tests { + use super::content_range; + + #[test] + fn reads_content_range() { + assert_eq!(content_range("bytes 100-199/5000"), Some((100, 5000))); + assert_eq!(content_range("bytes */5000"), None); + } +} diff --git a/app/src/main/rust/stores/src/ea/zip.rs b/app/src/main/rust/stores/src/ea/zip.rs new file mode 100644 index 000000000..d7a8203e7 --- /dev/null +++ b/app/src/main/rust/stores/src/ea/zip.rs @@ -0,0 +1,288 @@ +// SPDX-License-Identifier: GPL-3.0-or-later +//! The central directory of a remote ZIP archive, which is how EA publishes a game build. + +const EOCD: u32 = 0x0605_4b50; +const EOCD64: u32 = 0x0606_4b50; +const EOCD64_LOCATOR: u32 = 0x0706_4b50; +const CENTRAL: u32 = 0x0201_4b50; +const LOCAL: u32 = 0x0403_4b50; +const EOCD_LEN: usize = 22; +const LOCATOR_LEN: usize = 20; +const EOCD64_LEN: usize = 56; +pub const LOCAL_FIXED: usize = 30; +pub const TAIL_LEN: u64 = 128 * 1024; + +pub const STORED: u16 = 0; +pub const DEFLATED: u16 = 8; + +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct Entry { + pub name: String, + pub crc32: u32, + pub method: u16, + pub compressed: u64, + pub size: u64, + pub local_offset: u64, +} + +impl Entry { + pub fn is_dir(&self) -> bool { + self.name.ends_with('/') || self.name.ends_with('\\') + } +} + +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub struct Directory { + pub offset: u64, + pub size: u64, + pub entries: u64, +} + +fn u16_at(b: &[u8], at: usize) -> Option { + b.get(at..at + 2).map(|s| u16::from_le_bytes([s[0], s[1]])) +} + +fn u32_at(b: &[u8], at: usize) -> Option { + b.get(at..at + 4).map(|s| u32::from_le_bytes([s[0], s[1], s[2], s[3]])) +} + +fn u64_at(b: &[u8], at: usize) -> Option { + b.get(at..at + 8).map(|s| u64::from_le_bytes(s.try_into().unwrap_or([0; 8]))) +} + +/// Finds the central directory from the last bytes of the archive. `tail_start` is where `tail` +/// begins in the whole file. +pub fn locate(tail: &[u8], tail_start: u64) -> Result { + if tail.len() < EOCD_LEN { + return Err("The archive is too short to be a ZIP".into()); + } + let mut at = tail.len() - EOCD_LEN; + let found = loop { + if u32_at(tail, at) == Some(EOCD) { + let comment = u16_at(tail, at + 20).unwrap_or(0) as usize; + if at + EOCD_LEN + comment <= tail.len() { + break at; + } + } + if at == 0 { + return Err("The archive has no end of central directory".into()); + } + at -= 1; + }; + let entries = u16_at(tail, found + 10).unwrap_or(0) as u64; + let size = u32_at(tail, found + 12).unwrap_or(0) as u64; + let offset = u32_at(tail, found + 16).unwrap_or(0) as u64; + let wide = entries == 0xFFFF || size == 0xFFFF_FFFF || offset == 0xFFFF_FFFF; + if !wide { + return Ok(Directory { offset, size, entries }); + } + let locator = found.checked_sub(LOCATOR_LEN).filter(|&l| u32_at(tail, l) == Some(EOCD64_LOCATOR)); + let Some(locator) = locator else { + return Err("The archive needs ZIP64 but has no ZIP64 locator".into()); + }; + let record_at = u64_at(tail, locator + 8).unwrap_or(0); + let Some(rel) = record_at.checked_sub(tail_start).map(|r| r as usize) else { + return Err("The ZIP64 directory record lies before the fetched tail".into()); + }; + if u32_at(tail, rel) != Some(EOCD64) || rel + EOCD64_LEN > tail.len() { + return Err("The ZIP64 directory record is damaged".into()); + } + Ok(Directory { + entries: u64_at(tail, rel + 32).unwrap_or(0), + size: u64_at(tail, rel + 40).unwrap_or(0), + offset: u64_at(tail, rel + 48).unwrap_or(0), + }) +} + +fn wide_values(extra: &[u8], need_size: bool, need_compressed: bool, need_offset: bool) -> (Option, Option, Option) { + let mut at = 0; + while at + 4 <= extra.len() { + let id = u16_at(extra, at).unwrap_or(0); + let len = u16_at(extra, at + 2).unwrap_or(0) as usize; + let body = &extra[(at + 4).min(extra.len())..(at + 4 + len).min(extra.len())]; + if id == 0x0001 { + let mut p = 0; + let mut take = |want: bool| { + if !want { + return None; + } + let v = u64_at(body, p); + p += 8; + v + }; + let size = take(need_size); + let compressed = take(need_compressed); + let offset = take(need_offset); + return (size, compressed, offset); + } + at += 4 + len; + } + (None, None, None) +} + +pub fn parse(directory: &[u8], expected: u64) -> Result, String> { + let mut entries = Vec::with_capacity(expected.min(1 << 20) as usize); + let mut at = 0usize; + while at + 46 <= directory.len() { + if u32_at(directory, at) != Some(CENTRAL) { + break; + } + let flags = u16_at(directory, at + 8).unwrap_or(0); + let method = u16_at(directory, at + 10).unwrap_or(0); + let crc32 = u32_at(directory, at + 16).unwrap_or(0); + let compressed32 = u32_at(directory, at + 20).unwrap_or(0); + let size32 = u32_at(directory, at + 24).unwrap_or(0); + let name_len = u16_at(directory, at + 28).unwrap_or(0) as usize; + let extra_len = u16_at(directory, at + 30).unwrap_or(0) as usize; + let comment_len = u16_at(directory, at + 32).unwrap_or(0) as usize; + let offset32 = u32_at(directory, at + 42).unwrap_or(0); + let name_at = at + 46; + let end = name_at + name_len + extra_len + comment_len; + if end > directory.len() { + return Err("The archive directory is cut short".into()); + } + let raw = &directory[name_at..name_at + name_len]; + let name = if flags & 0x0800 != 0 { + String::from_utf8_lossy(raw).into_owned() + } else { + raw.iter().map(|&b| b as char).collect() + }; + let extra = &directory[name_at + name_len..name_at + name_len + extra_len]; + let (size, compressed, offset) = + wide_values(extra, size32 == 0xFFFF_FFFF, compressed32 == 0xFFFF_FFFF, offset32 == 0xFFFF_FFFF); + if flags & 0x0001 != 0 { + return Err(format!("{name} is encrypted")); + } + entries.push(Entry { + name, + crc32, + method, + compressed: compressed.unwrap_or(compressed32 as u64), + size: size.unwrap_or(size32 as u64), + local_offset: offset.unwrap_or(offset32 as u64), + }); + at = end; + } + if expected != 0 && entries.len() as u64 != expected { + return Err(format!("The archive directory lists {} of {expected} entries", entries.len())); + } + Ok(entries) +} + +/// Bytes before the compressed data of an entry, read from its local header. +pub fn local_header_len(header: &[u8]) -> Option> { + if header.len() < LOCAL_FIXED { + return None; + } + if u32_at(header, 0) != Some(LOCAL) { + return Some(Err("An entry has no local header where the directory points".into())); + } + let name = u16_at(header, 26).unwrap_or(0) as usize; + let extra = u16_at(header, 28).unwrap_or(0) as usize; + Some(Ok(LOCAL_FIXED + name + extra)) +} + +/// A relative path inside the install folder, or None for names that would leave it. +pub fn safe_path(name: &str) -> Option { + let unified = name.replace('\\', "/"); + if unified.starts_with('/') || unified.contains(':') || unified.contains('\0') { + return None; + } + let parts: Vec<&str> = unified.split('/').filter(|p| !p.is_empty() && *p != ".").collect(); + if parts.is_empty() || parts.iter().any(|p| *p == "..") { + return None; + } + Some(parts.join("/")) +} + +#[cfg(test)] +mod tests { + use super::*; + + fn central(name: &str, method: u16, crc: u32, compressed: u32, size: u32, offset: u32, extra: &[u8]) -> Vec { + let mut v = Vec::new(); + v.extend_from_slice(&CENTRAL.to_le_bytes()); + v.extend_from_slice(&[20, 0, 20, 0, 0, 0]); + v.extend_from_slice(&method.to_le_bytes()); + v.extend_from_slice(&[0, 0, 0, 0]); + v.extend_from_slice(&crc.to_le_bytes()); + v.extend_from_slice(&compressed.to_le_bytes()); + v.extend_from_slice(&size.to_le_bytes()); + v.extend_from_slice(&(name.len() as u16).to_le_bytes()); + v.extend_from_slice(&(extra.len() as u16).to_le_bytes()); + v.extend_from_slice(&[0, 0, 0, 0, 0, 0, 0, 0, 0, 0]); + v.extend_from_slice(&offset.to_le_bytes()); + v.extend_from_slice(name.as_bytes()); + v.extend_from_slice(extra); + v + } + + #[test] + fn reads_a_plain_directory() { + let mut dir = central("Game.exe", DEFLATED, 7, 10, 20, 0, &[]); + dir.extend(central("__Installer/", STORED, 0, 0, 0, 40, &[])); + let mut tail = vec![0u8; 5]; + tail.extend_from_slice(&dir); + let cd_size = dir.len() as u32; + tail.extend_from_slice(&EOCD.to_le_bytes()); + tail.extend_from_slice(&[0, 0, 0, 0, 2, 0, 2, 0]); + tail.extend_from_slice(&cd_size.to_le_bytes()); + tail.extend_from_slice(&1000u32.to_le_bytes()); + tail.extend_from_slice(&[0, 0]); + let found = locate(&tail, 995).unwrap(); + assert_eq!(found, Directory { offset: 1000, size: cd_size as u64, entries: 2 }); + let entries = parse(&dir, 2).unwrap(); + assert_eq!(entries[0].name, "Game.exe"); + assert_eq!(entries[0].size, 20); + assert!(entries[1].is_dir()); + } + + #[test] + fn reads_zip64_values() { + let mut extra = vec![1, 0, 24, 0]; + extra.extend_from_slice(&6_000_000_000u64.to_le_bytes()); + extra.extend_from_slice(&5_000_000_000u64.to_le_bytes()); + extra.extend_from_slice(&7_000_000_000u64.to_le_bytes()); + let dir = central("big.pak", DEFLATED, 1, 0xFFFF_FFFF, 0xFFFF_FFFF, 0xFFFF_FFFF, &extra); + let e = &parse(&dir, 1).unwrap()[0]; + assert_eq!((e.size, e.compressed, e.local_offset), (6_000_000_000, 5_000_000_000, 7_000_000_000)); + + let mut tail = Vec::new(); + let record_at = 9_000_000_000u64; + tail.extend_from_slice(&EOCD64.to_le_bytes()); + tail.extend_from_slice(&44u64.to_le_bytes()); + tail.extend_from_slice(&[45, 0, 45, 0, 0, 0, 0, 0, 0, 0, 0, 0]); + tail.extend_from_slice(&3u64.to_le_bytes()); + tail.extend_from_slice(&3u64.to_le_bytes()); + tail.extend_from_slice(&500u64.to_le_bytes()); + tail.extend_from_slice(&8_000_000_000u64.to_le_bytes()); + tail.extend_from_slice(&EOCD64_LOCATOR.to_le_bytes()); + tail.extend_from_slice(&0u32.to_le_bytes()); + tail.extend_from_slice(&record_at.to_le_bytes()); + tail.extend_from_slice(&1u32.to_le_bytes()); + tail.extend_from_slice(&EOCD.to_le_bytes()); + tail.extend_from_slice(&[0, 0, 0, 0, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0, 0]); + let found = locate(&tail, record_at).unwrap(); + assert_eq!(found, Directory { offset: 8_000_000_000, size: 500, entries: 3 }); + } + + #[test] + fn refuses_paths_that_leave_the_folder() { + assert_eq!(safe_path("Data\\Win32\\a.big").as_deref(), Some("Data/Win32/a.big")); + assert_eq!(safe_path("./x//y").as_deref(), Some("x/y")); + assert!(safe_path("../evil").is_none()); + assert!(safe_path("/etc/passwd").is_none()); + assert!(safe_path("C:/x").is_none()); + assert!(safe_path("a/../../b").is_none()); + } + + #[test] + fn local_header_length_counts_name_and_extra() { + let mut h = LOCAL.to_le_bytes().to_vec(); + h.extend_from_slice(&[0u8; 22]); + h.extend_from_slice(&5u16.to_le_bytes()); + h.extend_from_slice(&9u16.to_le_bytes()); + assert_eq!(local_header_len(&h), Some(Ok(44))); + assert_eq!(local_header_len(&h[..10]), None); + } +} diff --git a/app/src/main/rust/stores/src/lib.rs b/app/src/main/rust/stores/src/lib.rs index 87d278ab0..be8212844 100644 --- a/app/src/main/rust/stores/src/lib.rs +++ b/app/src/main/rust/stores/src/lib.rs @@ -3,6 +3,7 @@ #![allow(clippy::missing_safety_doc, clippy::result_large_err)] pub mod amazon; +pub mod ea; pub mod epic; pub mod fetch_core; pub mod fetch_tuning; diff --git a/app/src/test/java/com/droiddeck/launcher/stores/ea/EaTest.kt b/app/src/test/java/com/droiddeck/launcher/stores/ea/EaTest.kt new file mode 100644 index 000000000..a52485035 --- /dev/null +++ b/app/src/test/java/com/droiddeck/launcher/stores/ea/EaTest.kt @@ -0,0 +1,323 @@ +package com.droiddeck.launcher.stores.ea + +import com.droiddeck.launcher.stores.Store +import com.droiddeck.launcher.stores.StoreGameSidecar +import com.droiddeck.launcher.stores.StoreLaunch +import org.json.JSONObject +import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Assert.assertNotNull +import org.junit.Assert.assertNull +import org.junit.Assert.assertTrue +import org.junit.Test +import org.junit.runner.RunWith +import org.robolectric.RobolectricTestRunner +import java.io.File +import java.nio.file.Files +import java.time.LocalDate + +@RunWith(RobolectricTestRunner::class) +class EaTest { + private val wine = EaOoa.Hardware( + boardManufacturer = "Wine", boardSerial = "None", biosManufacturer = "The Wine Project", biosSerial = "0", + osInstallDate = "20240101000000.000000+000", osSerial = "12345-OEM", volumeSerial = "43000000", + gpuPnpId = "PCI\\VEN_5143&DEV_0001&SUBSYS_00000000&REV_00\\0&DEADBEEF&0&DEAD", + cpuVendor = "GenuineIntel", cpuEdx = "BFEBFBFF", cpuEax = "000906EA", cpuEcx = "7ffafbbf", brand = "FEX CPU", hostname = "myhost", + ) + + @Test fun machineHashFollowsTheActivationVersion() { + assertEquals("01804062be939efcd4462c65de292af3a56359b5", EaOoa.machineHash(wine, 4)) + assertEquals("41fa9c614390c5a82534becd338b798197cc5b", EaOoa.machineHash(wine, 3)) + } + + @Test fun launchCodeAndSessionKeyMatchTheProtocol() { + assertEquals(1196115787L, EaCrypto.rtpCode(LocalDate.of(2026, 11, 7))) + assertEquals("f057eb86a0bf8c57537b1aa6b98c6495", EaCrypto.hex(EaCrypto.lsxKey(0x6162))) + assertEquals(EaCrypto.hex(EaCrypto.LSX_DEFAULT_KEY), EaCrypto.hex(EaCrypto.lsxKey(0))) + val answer = EaCrypto.challengeAnswer("abc") + assertEquals("abc", String(EaCrypto.ecbDecrypt(EaCrypto.unhex(answer), EaCrypto.LSX_DEFAULT_KEY))) + assertEquals((answer[0].code shl 8) or answer[1].code, EaCrypto.challengeSeed("3", answer)) + assertNull(EaCrypto.challengeSeed("9", answer)) + } + + @Test fun licenceRoundTripsThroughTheFileFormat() { + val xml = "kh" + + "12349tokgsn" + val l = EaOoa.parseLicense(xml, "c2ln") + assertEquals("tok", l.gameToken) + assertEquals("1234", l.contentId) + val blob = EaOoa.licenseBlob(l, encoded = true) + assertEquals("c2ln", String(blob.copyOfRange(0, 4))) + assertEquals(0, blob[64].toInt()) + val back = String(EaCrypto.licenseDecrypt(blob.copyOfRange(65, blob.size))) + assertTrue(back.contains("tok")) + assertEquals("sig", String(EaOoa.licenseBlob(l, encoded = false).copyOfRange(0, 3))) + } + + @Test fun readsTheVersionOfTheActivationLibrary() { + val pe = ByteArray(64) + val fixed = byteArrayOf(0xBD.toByte(), 0x04, 0xEF.toByte(), 0xFE.toByte(), 0, 0, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0, 7, 0, 4, 0, 0, 0, 6, 0) + System.arraycopy(fixed, 0, pe, 10, fixed.size) + assertEquals(Triple(4, 7, 6), EaOoa.productVersion(pe)) + } + + @Test fun manifestNamesTheLauncherAndTouchup() { + val xml = """ + + + + [HKEY_LOCAL_MACHINE\SOFTWARE\EA Games\Game\Install Dir]trial.exetrue + [HKEY_LOCAL_MACHINE\SOFTWARE\EA Games\Game\Install Dir]bin\Game.exe-dx12false + + /__Installer/Touchup.exe-locale {locale} -installPath "{installLocation}" + """ + val m = EaManifest.parse(xml) + assertEquals("1.0.64.43203", m.version) + assertEquals("bin/Game.exe", EaPaths.relative(m.launcher()!!.path)) + assertEquals("-dx12", m.launcher()!!.parameters) + assertEquals("/__Installer/Touchup.exe", m.touchupPath) + val r = EaPaths.registered(m.launcher()!!.path)!! + assertEquals("HKEY_LOCAL_MACHINE\\SOFTWARE\\EA Games\\Game", r.key) + assertEquals("Install Dir", r.value) + val utf16 = byteArrayOf(0xFF.toByte(), 0xFE.toByte()) + "".toByteArray(Charsets.UTF_16LE) + assertEquals("1.0", EaManifest.parse(EaManifest.decode(utf16)).version) + } + + @Test fun findsFilesWhateverTheirCase() { + val dir = Files.createTempDirectory("ea").toFile() + File(dir, "Bin").mkdirs() + File(dir, "Bin/GAME.exe").writeText("x") + assertEquals("Bin/GAME.exe", EaPaths.relativeOnDisk(dir, "bin/game.exe")) + assertNull(EaPaths.relativeOnDisk(dir, "bin/other.exe")) + assertEquals(listOf("-a", "two words", "-c"), EaPaths.splitArgs("-a \"two words\" -c")) + dir.deleteRecursively() + } + + @Test fun lsxRequestsAreParsed() { + val m = EaLsx.parse("")!! + assertEquals("GetSetting", m.name) + assertEquals("4", m.id) + assertEquals("ENVIRONMENT", m.attrs["SettingId"]) + val c = EaLsx.parse("1Game")!! + assertEquals(listOf("ContentId" to "1", "Title" to "Game"), c.children) + assertNull(EaLsx.parse("")) + } + + @Test fun socketOwnerIsReadFromTheProcTable() { + val rows = listOf( + " sl local_address rem_address st tx_queue rx_queue tr tm->when retrnsmt uid timeout inode", + " 0: 0100007F:C350 0100007F:0C90 01 00000000:00000000 00:00000000 00000000 10234 0 1 1 0", + ) + assertEquals(10234, EaLsx.owner(rows, 0xC350)) + assertNull(EaLsx.owner(rows, 1234)) + assertNull(EaLsx.owner(emptyList(), 0xC350)) + } + + @Test fun rapidConnectionsNeverPassTheClientCap() { + fun session() = EaLsx.Session(java.net.Socket(), java.io.ByteArrayOutputStream(), false) + EaLsx.sessions.clear() + val first = (1..8).map { session().also { s -> assertTrue(EaLsx.admit(s)) } } + first[0].claimed = true + repeat(20) { assertTrue(EaLsx.admit(session())) } + assertEquals(8, EaLsx.sessions.count { !it.evicted }) + assertFalse(first[0].evicted) + EaLsx.sessions.forEach { it.trusted = true } + assertFalse(EaLsx.admit(session())) + EaLsx.sessions.clear() + } + + @Test fun anEvictedSessionCannotBecomeTrusted() { + val s = EaLsx.Session(java.net.Socket(), java.io.ByteArrayOutputStream(), true) + s.evicted = true + EaLsx.promote(s, true) + assertFalse(s.trusted) + val ok = EaLsx.Session(java.net.Socket(), java.io.ByteArrayOutputStream(), true) + EaLsx.promote(ok, true) + assertTrue(ok.trusted) + EaLsx.stop() + } + + @Test fun aPeerAndroidCannotNameIsTrustedOnlyForTheClaimedGame() { + val hidden = EaLsx.Session(java.net.Socket(), java.io.ByteArrayOutputStream(), false, unverifiable = true) + assertFalse(hidden.mayTrust) + hidden.claimed = true + assertTrue(hidden.mayTrust) + val other = EaLsx.Session(java.net.Socket(), java.io.ByteArrayOutputStream(), false) + other.claimed = true + assertFalse(other.mayTrust) + } + + @Test fun aSessionFromAnOlderLaunchCannotBecomeTrusted() { + val old = EaLsx.Session(java.net.Socket(), java.io.ByteArrayOutputStream(), true) + EaLsx.generation++ + EaLsx.promote(old, true) + assertFalse(old.trusted) + EaLsx.stop() + } + + @Test fun lateExitDoesNotStopANewerLaunch() { + val game = EaLsx.Game("Origin.OFR.2", "2", "Two", "1", "en_US", null, 0) + EaLsx.game = game + EaLsx.stop("Origin.OFR.1") + assertEquals(game, EaLsx.game) + EaLsx.stop("Origin.OFR.2") + assertNull(EaLsx.game) + } + + @Test fun redirectCarriesTheCode() { + assertEquals("QUOX", EaAuth.redirectValue("qrc:///html/login_successful.html?code=QUOX&state=1", "code")) + assertEquals("tok", EaAuth.redirectValue("qrc:/html/login_successful.html#access_token=tok&token_type=Bearer", "access_token")) + assertNull(EaAuth.redirectValue("https://signin.ea.com/p/x", "code")) + } + + @Test fun pcSignIsSigned() { + val sign = EaAuth.pcSign("ab12", java.util.Date(0), "v1") + val (payload, signature) = sign.split('.') + val json = JSONObject(String(java.util.Base64.getUrlDecoder().decode(payload))) + assertEquals("v1", json.getString("sv")) + assertEquals("1970-01-01 00:00:00:000", json.getString("ts")) + assertEquals(EaCrypto.base64Url(EaCrypto.hmacSha256("ISa3dpGOc8wW7Adn4auACSQmaccrOyR2".toByteArray(), payload.toByteArray())), signature) + } + + @Test fun libraryKeepsOneEntryPerGame() { + fun owned(id: String, slug: String, base: String = "", type: String = "BASE_GAME", trial: Boolean = false) = + EaApi.OwnedProduct(id, "p$id", "Game $id", slug, base, type, true, trial, emptyList()) + fun offer(id: String, type: String = "FullGame") = EaApi.Offer(id, "c$id", "", type, true, "", "", "", "", "", "1.0", "", emptyList()) + val list = listOf(owned("1", "game"), owned("2", "game-deluxe", base = "game"), owned("3", "other"), owned("4", "trial", trial = true), owned("5", "dlc", type = "EXTRA_CONTENT")) + val picked = EaLibrary.pick(list, list.associate { it.offerId to offer(it.offerId) }) + assertEquals(listOf("1", "3"), picked.map { it.first.offerId }) + } + + @Test fun ownedPageParses() { + val page = JSONObject( + """{"me":{"ownedGameProducts":{"next":"100","items":[{"originOfferId":"Origin.OFR.50.0001","product":{"id":"x","name":"N","downloadable":true,"gameSlug":"titanfall-2", + "baseItem":{"title":"Titanfall 2","gameType":"BASE_GAME","baseGameSlug":null},"gameProductUser":{"ownershipMethods":["PURCHASE"]}}}]}}}""", + ) + val (items, next) = EaApi.parseOwned(page) + assertEquals("100", next) + assertEquals("Titanfall 2", items.single().name) + assertEquals("", items.single().baseSlug) + val offers = EaApi.parseOffers(JSONObject("""{"legacyOffers":[{"offerId":"Origin.OFR.50.0001","contentId":"1035052","displayType":"FullGame","isDownloadable":true, + "downloads":[{"downloadType":"LIVE","gameVersion":"2.0.11.0"}],"softwareLocales":["en_US","fr_FR"],"executeParameters":null}]}""")) + assertEquals("2.0.11.0", offers.single().gameVersion) + assertEquals("", offers.single().executeParameters) + assertNotNull(EaApi.query("getPreloadedOwnedGames")) + } + + @Test fun launcherReadsTheSessionFileOnce() { + val text = StoreLaunch.launcherText(StoreGameSidecar(Store.EA, "Origin.OFR.1", "Game", exe = "bin/Game.exe", args = listOf("-dx12"))) + assertTrue(text.contains("for /f \"usebackq tokens=1,* delims==\" %%a in (\"%~dp0.droiddeck-ea-env\") do set \"%%a=%%b\"")) + assertTrue(text.contains("del \"%~dp0.droiddeck-ea-env\"")) + assertTrue(text.contains("start \"\" \"${StoreLaunch.EA_ORIGIN}\"\r\n\"%~dp0bin\\Game.exe\" -dx12\r\n")) + assertTrue(text.trimEnd().endsWith("taskkill /f /im Origin.exe >nul 2>&1")) + } + + @Test fun environmentCarriesTheSession() { + val account = EaAuth.Account("a", "r", 0, "1001", "2002", "Name", "EaId") + val env = EaLaunch.environment("Origin.OFR.1", "1035052", "fr_FR", "generic", "jwt", "opaque", account, "session") + assertEquals("generic", env["EAGenericAuthToken"]) + assertEquals("jwt", env["EAAccessTokenJWS"]) + assertEquals("3216", env["EALsxPort"]) + assertEquals("opaque", env["EALaunchUserAuthToken"]) + assertEquals("EaId", env["EALaunchEAID"]) + assertEquals("1035052", env["ContentId"]) + assertEquals("fr_FR", env["EAGameLocale"]) + } + + @Test fun activationCoreSitsBesideTheExe() { + val dir = Files.createTempDirectory("ea").toFile() + File(dir, "Binaries/Win32/Core").mkdirs() + File(dir, "Binaries/Win32/Core/EACore.dll").writeText("x") + val core = EaOoa.coreDir(dir, "Binaries/Win32/MassEffect3.exe") + assertEquals(File(dir, "Binaries/Win32/Core").canonicalPath, core?.canonicalPath) + assertTrue(EaOoa.legacyCore(core)) + assertNull(EaOoa.coreDir(dir, "Game.exe")) + File(dir, "Core").mkdirs() + assertEquals(File(dir, "Core").canonicalPath, EaOoa.coreDir(dir, "Game.exe")?.canonicalPath) + dir.deleteRecursively() + } + + @Test fun anIncompleteProbeIsNotHashed() { + val empty = EaOoa.Hardware.fromJson(JSONObject()) + assertTrue(!empty.complete(0) && !empty.complete(4)) + val wmiOnly = EaOoa.Hardware.fromJson(JSONObject().put("boardManufacturer", "Intel Corporation").put("biosManufacturer", "The Wine Project") + .put("osSerial", "00330").put("osInstallDate", "2026").put("hostname", "DROIDDECK")) + assertTrue(wmiOnly.complete(0)) + assertTrue(!wmiOnly.complete(4)) + assertTrue(wine.complete(4)) + assertTrue(wine.complete(2)) + val noInstallDate = EaOoa.Hardware.fromJson(JSONObject().put("boardManufacturer", "Wine").put("biosManufacturer", "Wine").put("osSerial", "1") + .put("volumeSerial", "43000000").put("cpuVendor", "GenuineIntel").put("cpuEdx", "BFEBFBFF").put("cpuEax", "000906EA").put("cpuEcx", "7ffafbbf")) + assertTrue(!noInstallDate.complete(2)) + assertTrue(noInstallDate.complete(3) && noInstallDate.complete(4)) + } + + @Test fun legacyManifestNamesItsContentAndTitle() { + val xml = """7107371183 + RUMass Effect 3 + /__Installer/touchup.exeinstall -locale {locale}""" + val m = EaManifest.parse(xml) + assertEquals(listOf("71073", "71183"), m.contentIds) + assertEquals("Mass Effect 3", m.title("en_US")) + assertEquals("RU", m.title("ru_RU")) + assertEquals("Mass Effect 3", m.title("de_DE")) + assertEquals("/__Installer/touchup.exe", m.touchupPath) + } + + @Test fun aNumericTrialFlagKeepsTheTrialExeOut() { + val xml = """ + [HKEY_LOCAL_MACHINE\SOFTWARE\EA Games\Need for Speed Heat\Install Dir]NeedForSpeedHeatTrial.exe1 + [HKEY_LOCAL_MACHINE\SOFTWARE\EA Games\Need for Speed Heat\Install Dir]NeedForSpeedHeat.exe0 + """ + assertTrue(EaManifest.parse(xml).launcher()!!.path.endsWith("NeedForSpeedHeat.exe")) + } + + @Test fun aSyncRequestedWhileOneRunsIsNotLost() { + val gate = SyncGate() + assertTrue(gate.enter()) + assertTrue(!gate.enter()) + assertTrue(gate.leave()) + assertTrue(!gate.leave()) + assertTrue(gate.enter()) + assertTrue(!gate.leave()) + } + + @Test fun aRequestRacingTheRunnersExitIsPickedUpBySomeone() { + repeat(200) { + val gate = SyncGate() + val runs = java.util.concurrent.atomic.AtomicInteger() + assertTrue(gate.enter()) + val latch = java.util.concurrent.CountDownLatch(1) + val requester = Thread { latch.await(); if (gate.enter()) { runs.incrementAndGet(); while (gate.leave()) runs.incrementAndGet() } } + requester.start() + latch.countDown() + while (gate.leave()) runs.incrementAndGet() + requester.join() + assertTrue("the second request was dropped", runs.get() >= 1) + } + } + + @Test fun aSyncThatSnapshottedBeforeSignOutCannotWriteItsLibrary() { + val app = org.robolectric.RuntimeEnvironment.getApplication() + val game = EaGame("Origin.OFR.1", "1", "Old account game", "old") + val (gen, _) = EaLibrary.snapshot(app) + EaLibrary.reset(app) + assertTrue(!EaLibrary.storeCache(app, listOf(game), gen)) + assertTrue(EaLibrary.cached(app).isEmpty()) + val (fresh, cache) = EaLibrary.snapshot(app) + assertTrue(cache.isEmpty()) + assertTrue(EaLibrary.storeCache(app, listOf(game), fresh)) + assertEquals("Old account game", EaLibrary.cached(app).single().title) + EaLibrary.reset(app) + } + + @Test fun aRefreshFinishingAfterSignOutDoesNotRestoreTheSignIn() { + val app = org.robolectric.RuntimeEnvironment.getApplication() + com.droiddeck.launcher.stores.StoreAccounts.write(app, Store.EA, JSONObject().put("access_token", "old").put("refresh_token", "r").put("user_id", "1")) + assertTrue(EaAuth.storeRefreshed(app, "newer", 3600, "r2")) + assertEquals("newer", EaAuth.load(app)?.accessToken) + EaAuth.signOut(app) + assertTrue(!EaAuth.storeRefreshed(app, "late", 3600, "r3")) + assertNull(EaAuth.load(app)) + } +} diff --git a/docs/development/ea-store.md b/docs/development/ea-store.md new file mode 100644 index 000000000..89a345c80 --- /dev/null +++ b/docs/development/ea-store.md @@ -0,0 +1,31 @@ +# EA store + +EA games install, update and start without the EA app. The research behind it is in +[ea-ubisoft-stores.md](ea-ubisoft-stores.md). + +## Pieces + +| Piece | Where | What it does | +|---|---|---| +| Sign-in | `stores/ea/EaAuth.kt`, `EaLogin` in `EaBackend.kt` | `JUNO_PC_CLIENT` OAuth with PKCE in the store login WebView; the code comes back on EA's `qrc:///html/login_successful.html` redirect. Tokens refresh with the refresh token. | +| Library | `EaApi.kt`, `EaLibrary.kt` | Owned games (`getPreloadedOwnedGames`, EA storefront), their offers (`getLegacyCatalogDefs`) and art (`GameImages`, SteamGridDB when EA has no portrait). Editions of one game collapse onto the base offer; trials, DLC and non-downloadable offers are left out. | +| Queries | `app/src/main/resources/ea/graphql/*.gql` | EA's own query texts, byte for byte. The gateway matches persisted queries by SHA-256, so these files must not be reformatted (`-text` in `.gitattributes`). | +| Downloads | Rust `src/ea/` in `libdroiddeckstores.so`, `EaInstaller.kt` | A build is one ZIP on EA's CDN. The engine reads its central directory with ranged reads, keeps files whose size and CRC match, and streams every other entry through inflate into place. `.droiddeck-ea-files` records what it placed, so a resume or update does not re-hash those files; files a new build drops are removed. | +| Updates | `EaUpdates` in `EaBackend.kt` | The game page compares the installed version with the offer's live `gameVersion` (every 30 minutes at most). Update runs the same install, which only fetches changed files. | +| Launch, guest | `droiddeck-store-launch` (`prepare_ea`) | Creates the prefix, reads the machine as Wine reports it (`wmic`, `vol`, `hostname`; cached in `pfx/.droiddeck-ea-hw.json`), writes EA app presence keys and the game's `Install Dir` keys in both registry views, runs the build's Touchup once per installed build, then asks the app for the session and writes any licence files. | +| Launch, app | `EaLaunch.kt`, `EaOoa.kt`, `EaLsx.kt` | Answers `ea-prepare`: starts the LSX endpoint on `127.0.0.1:3216`, mints the short-lived opaque token, writes `.droiddeck-ea-env` (read once and deleted by the launcher `.bat`), and for games with a `Core` folder requests an online-activation licence for the machine hash. | +| LSX | `EaLsx.kt` | The local endpoint EA's SDK talks to instead of the EA app: challenge, AES session key, config, profile, settings, game info, auth codes for the game's own client ids, licences with Denuvo tokens, entitlements. Friends, presence and the overlay answer empty. | + +## Machine hash + +The licence names a hash of the machine the game's activation layer computes itself. The hash +version follows the activation library's product version (`Core/activation.dll` or `awc.dll`): +5.x → 4, 4.7.6+ → 3, 4.7.x → 2, 4.x → 1. Versions 2 and 3 also need CPUID leaf 1 ECX, which Wine's +WMI does not expose, so those use `00000000` unless the probe learns it. + +## Not done yet + +- Cloud saves (`cloudsync.juno.ea.com`). +- Steam copies of EA games still install the EA app (#2036). +- Pre-DiP games (no `installerdata.xml`) fall back to the exe picker. +- Javelin anti-cheat games do not run under Proton. diff --git a/docs/development/ea-ubisoft-stores.md b/docs/development/ea-ubisoft-stores.md new file mode 100644 index 000000000..4d13710a3 --- /dev/null +++ b/docs/development/ea-ubisoft-stores.md @@ -0,0 +1,245 @@ +# EA app and Ubisoft Connect as stores: research and plan + +Research as of 2026-11-07, against `origin/main` at `914b3588e`. Nothing here is implemented yet. + +## Summary + +Both stores can follow the path Battle.net took (#979, then #1602): first the vendor client in its own +Proton prefix, then more of it done by DroidDeck itself. Unlike Battle.net, both now have open-source +clients that already handle sign-in, library and downloads without the vendor app. So the second step +can go further than Battle.net's did. + +| | EA | Ubisoft | +|---|---|---| +| Sign-in without the client | Yes: OAuth `JUNO_PC_CLIENT` in a WebView | Yes: UbiServices session (ticket + remember-me) through Ubisoft's WebAuth page | +| Owned library without the client | Yes: juno GraphQL `ownedGameProducts` | Yes: Demux ownership service (protobuf over TLS), or the GraphQL library | +| Downloads without the client | Yes: a build is one ZIP on EA's CDN, read with HTTP Range per entry (Maxima) | Yes: Demux download service → manifest → zstd/deflate slices (Optima, ubi-cli-mvp) | +| Launch without the client | **Yes, legitimately**: LSX server on `127.0.0.1:3216` plus a real OOA licence from `proxy.novafusion.ea.com` (Maxima) | **Only by replacing Ubisoft's DRM DLLs** (Optima, Re0xCat loaders). Don't ship that. Modern titles need the real Connect anyway | +| Cloud saves without the client | Yes: `cloudsync.juno.ea.com` (Maxima) | Not researched; Connect handles them | +| Effectively out of scope | Javelin kernel anti-cheat titles (Battlefield, FC, Apex) | Kernel AC and online-only titles that misbehave under Proton | + +**Recommendation** + +- **EA should go fully native in-app**: sign-in, library, downloads, launch and cloud saves in Kotlin + and Rust, with no EA app in the prefix. The EA app stays as an optional fallback, like the Epic + launcher option (`EpicClient`). The LSX and licence half also fixes Steam's EA titles, which today + install the whole EA app (#2036) just to answer `link2ea://`. +- **Ubisoft should be hybrid**: native sign-in, library and downloads in Rust, then **register the + files with the real Ubisoft Connect and launch through `uplay://`**. That means a Connect prefix + managed the way Battle.net's is. +- Ubisoft's launch step is the biggest open question (see "Unknowns"). If Connect can't be made to + adopt natively downloaded files cleanly, start with the Connect-driven download, as #979 did for + Battle.net. + +## What DroidDeck already has, and how much of it each store reuses + +| Existing piece | Where | EA | Ubisoft | +|---|---|---|---| +| `Store` enum + `StoreBackend` (signIn, syncLibrary, loadShelves, install, uninstall) | `stores/Store.kt`, `stores/StoresState.kt:355` | Reuse as is: new `EA` entry | Reuse as is: new `UBISOFT` entry | +| WebView sign-in with redirect capture (`StoreLoginActivity.Flow`) | `stores/LoginFlows.kt`, `StoreLoginActivity.kt` | Reuse: the redirect is `qrc:///html/login_successful.html?code=…`, caught in `isRedirect` | Reuse: capture the `ubi_v1` ticket from the WebAuth page, then exchange it for a full session | +| Keystore-backed account store | `stores/StoreAccounts.kt`, `CredentialCipher.kt` | Reuse | Reuse | +| Rust store engine `libdroiddeckstores.so` (JNI, `fetch_core.rs`, hand-rolled protobuf in `amazon/proto.rs`) | `app/src/main/rust/stores` | Reuse: ZIP central directory + Range fetch + inflate (`flate2` is already a dependency) | Reuse `fetch_core`. **New:** a TLS socket client for Demux, a protobuf schema, and a zstd decoder (`ruzstd` is pure Rust; the `zstd` crate needs `cc`, which the crate already has) | +| Download queue: pause, resume, cancel, tiers, Downloads tab | `stores/download/*` | Reuse | Reuse | +| Install roots: internal, other storage, Games folder | `StoreInstallRoot.kt` | Reuse | Reuse | +| Cloud saves: plan, paths, transport | `stores/CloudSaves.kt`, `CloudPlan.kt`, `CloudSavePaths.kt` | Reuse with a new EA transport | Later, or leave to Connect | +| Vendor client in its own prefix, a guest Python driver, a headless helper under gamescope, FEX choice, memory policy | `runtime/BattleNet.kt`, `tools/linuxfs/overlay/usr/local/bin/droiddeck-battlenet` | Fallback only | **Needed**: `droiddeck-ubisoft` modelled on `droiddeck-battlenet` | +| "Use the vendor launcher instead" option | `stores/epic/EpicClient*.kt` | Pattern for the EA app fallback | Pattern for "Open Ubisoft Connect" | +| Steam install-script seeding that lets EA app and Ubisoft Connect installers run | `droiddeck-seed-redists` (#1449, #2036) | Replaced by the LSX stub for Steam EA titles | Unchanged | +| Ubisoft registry handoff for Ubisoft titles bought on Epic | open PR #1445 | n/a | Same registry key (`HKLM\SOFTWARE\WOW6432Node\Ubisoft\Launcher\Installs\\InstallDir`); merge or share it | +| Library, Steam shortcuts, art, game page, sidecars | `frontend/Library.kt`, `SteamLiveShortcuts.kt`, `StoreArt.kt`, `StoreGameSidecar.kt` | Reuse | Reuse | +| Clean-room process for store modules | `docs/development/epic-cleanroom.md`, `amazon-cleanroom.md` | Use: spec from Maxima, then implement from the spec | Use: spec from Optima/ubi-cli-mvp/YoobieRE | + +Genuinely new engineering: + +1. **An LSX server (EA).** EA's local SDK protocol is XML over TCP on 3216, with a challenge handshake, + GetConfig, GetProfile, GetGameInfo, license and entitlement requests. Add a Wine-side stub registered + as the `link2ea://` / `origin2://` handler with EA app presence keys, plus OOA licence requests. + This half is new; the other EA parts are the familiar store pattern. +2. **The EA machine hash.** Licences are bound to a `machineHash` built from WMI/BIOS/disk/CPUID + values. They have to match what the game computes inside Wine under FEX on the phone. + Nobody has shown that on Android yet, so it has to be measured on device. +3. **A Demux client (Ubisoft).** This is a TLS socket to `dmx.upc.ubisoft.com` speaking protobuf, with + patch negotiation, auth, ownership tokens and download-service URL signing. No store we have uses + a socket protocol. +4. **A way for Connect to adopt files (Ubisoft).** Connect keeps its own DPAPI-encrypted sign-in, tied + to the Wine `MachineGuid`. Our session can't be handed to it (ubi-cli-mvp checked). Connect also has + to consider the game installed before `uplay://launch` works. + +## Prior art + +### EA + +| Project | Licence | State | What it gives us | +|---|---|---|---| +| [ArmchairDevelopers/Maxima](https://github.com/ArmchairDevelopers/Maxima) (Rust, `maxima-lib`) | GPL-3.0 | Pre-alpha by its own README; last commit 2026-01; open issues through 2026-11. Backs KYBER | The whole stack: OAuth (`JUNO_PC_CLIENT`, localhost 31033 redirect), juno GraphQL (`.gql` files for owned games, `downloadUrl`, `availableBuilds`, achievements), the ZIP/Range downloader, OOA licences (`ooa/mod.rs`, `machineHash`), the LSX server (`lsx/`), cloud sync, the hardware hash (`auth/hardware.rs`), Wine registry setup. Unsupported: Battlefield 3/4 (Battlelog), pre-DiP games (Dead Space 2, BFBC2), DLC installs | +| [Maxima #71](https://github.com/ArmchairDevelopers/Maxima/issues/71) | | 2026-11 | Games never connect to LSX unless `HKLM\SOFTWARE\WOW6432Node\Origin` is right, and `INSTALLED_LANGUAGE` must be answered. Check ours against it | +| [Maxima #49](https://github.com/ArmchairDevelopers/Maxima/issues/49) | | | ARM64: the hash must match what `Activation.dll` reads from CPUID under Rosetta/Box64/FEX. Directly our problem | +| unifideck `docs/feasibility/ea.md` | GPL-3.0 | 2026-07 | EA app installer runs unattended (`/quiet`), login renders on GE-Proton11-1, `origin2://game/launch?offerIds=&autoDownload=1` is the install idiom, ~2.5 GB client, aggressive self-update | +| Lutris `services/ea_app.py` | GPL-3.0 | Maintained | juno queries, `installerdata.xml` scan for installed games | +| BellezaEmporium/galaxy-integration-ead | **none** | Maintained | Facts only: OAuth params, `pc_sign` fallback | +| erri120/GameFinder wiki | GPL-3.0 | | Encrypted `IS` file format (only for reading EA app installs) | +| Heroic | | wontfix #352 | No EA support beyond Epic-hosted EA titles | + +### Ubisoft + +| Project | Licence | State | What it gives us | +|---|---|---|---| +| [xXJSONDeruloXx/ubi-cli-mvp](https://github.com/xXJSONDeruloXx/ubi-cli-mvp) (yours, TypeScript) | MIT | 2026-07 | The best-tested data plane: session login with `Ubi-Challenge` replay, `ubi_2fa_v1`, refresh; Demux ownership, ownership tokens and signed URLs; manifest decode; slice layouts (`slices_v3//` and flat `slices/`); zstd and both deflates; integrity and path safety. On the Connect side: `connect-install` (`uplay://install/`), **`connect-seed`** (let Connect start the official download, pause, exit, then fill the staging area with verified files), remembered-auth migration between prefixes, `ubi play` via `uplay://`. Docs: `research.md`, `validation.md`, `DEMUX_JOURNAL.md`, `optima-comparison.md` | +| [Starkka15/Optima](https://github.com/Starkka15/Optima) (Rust) | GPL-3.0 | 0.2.0, 2026-08 | Rust version of the same data plane (`auth.rs`, `demux.rs`, `install.rs`, `proto/*.proto`), plus launcherless launch through replacement `uplay_r1_loader` / `ubiorbitapi_r2_loader` DLLs, a self-signed certificate that mimics Ubisoft's (imported into the prefix's Root store so Watch_Dogs accepts it), and an EAX shim. Powers GameVault's Ubisoft extension | +| YoobieRE: `ubisoft-demux-node`, `ubisoft-dmx-decode`, `ubisoft-manifest-downloader`, `uplay-install-reverse`, `product-db-archive` | mixed or none | 2022–2024 | The original protocol reversing that both of the above build on | +| Re0xCat `uplay-r1-loader`, `ubiorbitapi-r2-loader` | | | The DRM-DLL replacements (see Risks) | +| [mubaraknumann/unifideck](https://github.com/mubaraknumann/unifideck) `stores/ubisoft/` | GPL-3.0 | Shipping, active | The full Connect-wrapper archetype in Python: three-prefix model (auth / template / per-game, shared `MachineGuid`), credential capture and fan-out, parsing Connect's binary `configurations` and `ownership` catalogs, leveldb `spaceId ↔ ubisoftConnectGameId`, a manual-install watcher, `uplay://launch//0`, session capture after exit. `docs/ubisoft-store-spec.md` is a good map of what breaks | +| Heroic | | | Installs Ubisoft Connect automatically for Ubisoft titles from Epic; no Ubisoft store | +| DroidDeck #1445 (open), #1449, #2286 / #2295 / #2317 / #2292 / #2050 | | | Our own Ubisoft-under-Proton knowledge: the `Installs` registry key, `UplayLaunch.exe` being a stub, the bundled `UplayInstaller.exe`, legacy installer layouts, SD-card staging, helper processes during the exit watch | + +Licences: Maxima, Optima, unifideck and Lutris are GPL-3.0, which is compatible with +DroidDeck (GPL-3.0). ubi-cli-mvp is MIT. Even so, the Epic and Amazon modules were written clean-room, +so do the same here: write the spec from these sources, implement from the spec, and credit them in +the README the way #1602 does. + +## Design + +### EA + +```text +StoreLoginActivity (OAuth JUNO_PC_CLIENT) ─► EaAccount (access + refresh token, persona) +EaLibrary ── juno GraphQL ownedGameProducts / legacyOffers ─► CatalogItem(offerId, contentId, slug) +EaInstaller (DownloadQueue job) ── downloadUrl ─► build ZIP ─► Rust: central directory, Range per entry, inflate, CRC + └─ writes __Installer/installerdata.xml as EA's own layout expects +Launch: droiddeck-ea (guest) ── registers stub for link2ea:// origin2:// + Origin/EA Desktop keys + app-side EaLsxServer on 3216 ◄── game (OriginSDK / EA SDK) + before launch: OOA licence (novafusion, machineHash) → ProgramData/Electronic Arts/EA Services/License + before/after: cloudsync pull / push through CloudSaves +``` + +- **Where the LSX server runs.** In the Android app. DroidDeck's guest shares + the network namespace, so an app-side server on 127.0.0.1:3216 is reachable from Wine. Keeping it + in Kotlin or Rust, not a guest daemon, matches how DroidDeck owns other store logic. Check this + against proot networking first. +- **The stub.** A protocol handler for `link2ea://` / `origin2://` that hands the request to DroidDeck. +- **Steam's EA titles.** Use the same stub and LSX path, keyed by `link2ea://` in the launch config, + plus entitlement refresh and Steam account linking. #2036's EA app install becomes + the fallback. +- **The EA app fallback.** Its own prefix (a `compatdata` id like Battle.net's `2596113170`), installed + with `EAappInstaller.exe /quiet`, opened from the store row. That's for titles the LSX path can't + start (pre-DiP, Battlelog) and for buying games. + +### Ubisoft + +```text +StoreLoginActivity (Ubisoft WebAuth) ─► ticket ─► full session (expiry, rememberMe); refresh ~3 h +UbisoftLibrary ── Demux ownership ─► products + config YAML (name, exe, uplay id, space id) +UbisoftInstaller (DownloadQueue job) ── Rust Demux: ownership token → download_service urlReq + → manifest (356-byte header, zlib) → slices (zstd/deflate) → files, verified +Register + launch: droiddeck-ubisoft (guest, like droiddeck-battlenet) + Connect in its own prefix, signed in once in its own window (DPAPI/MachineGuid bound) + adopt files: Installs\\InstallDir registry (+ whatever state Connect checks) + or ubi-cli-mvp's connect-seed: official download starts, pause, seed, let Connect verify + launch: upc.exe uplay://launch//0, Connect closed or minimised once the game runs (#979's 20 s rule) +``` + +- **One Connect prefix, not one per game.** unifideck uses per-game prefixes because Steam on the + Deck gives each shortcut its own. DroidDeck already runs Battle.net games inside Battle.net's prefix, + which avoids unifideck's whole credential fan-out problem. +- **Two stages, like Battle.net:** + 1. Connect in its prefix, install by `uplay://install/`, and Downloads shows an indeterminate + "Installing in Ubisoft Connect" until the folder settles. That is #979's level. + 2. Native downloads in the Rust engine with real progress, pause and resume, then Connect adopts the + files. Like #1602, but we own the transfer instead of driving the vendor's downloader. If Connect + has a scriptable local API (Battle.net's Agent had one), that's a cheaper middle stage, but none is + known. +- **Launcherless DRM shims: don't ship them.** See Risks. + +## Known + +- EA sign-in, library, downloads, licences, LSX and cloud sync all work outside the EA app on Linux + (Maxima). +- The EA app installs unattended and its login renders under GE-Proton 11 on x86 (unifideck). +- Ubisoft sign-in, refresh, Demux ownership, signed URLs, manifests and slice reconstruction work + (ubi-cli-mvp `validation.md`, Optima). A session ticket lasts about 3 hours and remember-me renews it. +- Connect's sign-in can't be created from our session. It is DPAPI-encrypted and tied to + `MachineGuid`, but it can be copied between prefixes that share that identity. +- Connect has repeatedly broken under Proton after its own updates (2023, and a single-source April 2026 + report). #1449 and the open Ubisoft PRs show the same churn here. +- Javelin titles don't run under Proton. EA's March 2026 job listing mentions future Linux/Proton work; + nothing has shipped. + +## Unknowns, in the order to settle them + +| # | Question | How to find out | Blocks | +|---|---|---|---| +| U1 | Does an EA OOA licence fetched with our `machineHash` validate inside DroidDeck-Proton under FEX on Adreno phones? Which WMI/CPUID values does the game see? | Prototype: Maxima's hash code against values read through Wine in our prefix; one DiP single-player title and one Denuvo title. | EA launch | +| U2 | Can the app-side LSX server on 127.0.0.1:3216 be reached from proot + Wine, and do games connect with our registry keys (Maxima #71)? | Spike with a Steam EA title already in the library | EA launch, Steam EA titles | +| U3 | What does EA's CDN serve for a current title: one ZIP, multiple, or delta? How do language selection and `installerdata.xml` work? | Maxima `content/` + `downloadUrl.gql`, one real download through the Rust engine | EA install | +| U4 | Does Connect accept a tree we downloaded once the `Installs` registry key is written, or does it re-verify or re-download? Does `connect-seed` work under FEX on a phone? | Spike with a small owned title (ubi-cli-mvp used Splinter Cell / product 109). #1445's registry work is the start | Ubisoft stage 2 | +| U5 | Does Ubisoft Connect (Qt + CEF) run acceptably under FEX in our prefix? How much RAM does it hold while a game runs? | Install it like Battle.net; measure with the Memory readout from #979 | Ubisoft stage 1 | +| U6 | Ubisoft's WebAuth page in an Android WebView: captcha, 2FA, `Ubi-Challenge` | Real sign-in on device | Ubisoft sign-in | +| U7 | Demux over TLS from Android (certificate pinning, patch-version negotiation drift) | Rust spike against `dmx.upc.ubisoft.com` | Ubisoft library and downloads | +| U8 | Ubisoft+ subscription entitlements in Demux ownership | Account with Ubisoft+ | Library completeness | +| U9 | EA Play subscription entitlements in `ownedGameProducts` | Account with EA Play | Library completeness | +| U10 | Ubisoft cloud saves outside Connect | Not researched; Connect handles them while it's in the path | Later | + +## Plan + +Each step is its own PR, tested on device as usual (REDMAGIC tablet, Fold7, Thor). + +### EA + +1. **Spike (no PR):** settle U1–U3 with throwaway code against one owned DiP title and one Steam EA title. +2. **Steam EA titles without the EA app:** the `link2ea` stub, app-side LSX server, OOA licence and + entitlement refresh. It replaces #2036's EA app install where it works and falls back to it otherwise. + This ships value first and proves the hard half. +3. **EA store: sign-in, library, shelves:** a `Store.EA` entry, OAuth flow, juno library, art, + a "not installable here" flag for Javelin titles. +4. **EA downloads:** a Rust `ea` module (ZIP/Range/inflate on `fetch_core`), with Downloads progress, + pause, resume, cancel, updates and uninstall. +5. **EA cloud saves** through `CloudSaves`. +6. **EA app fallback** prefix and store-row button, for pre-DiP and Battlelog titles. +7. Clean-room record: `docs/development/ea-cleanroom.md`. + +### Ubisoft + +1. **Spike (no PR):** U4, U5 and U7. +2. **Ubisoft Connect in its prefix (#979 level):** `droiddeck-ubisoft`, the store row, sign-in inside + Connect, the installed-games scan (registry `Installs` keys), Play by `uplay://launch`, Connect closed + once the game runs, install handed to Connect with indeterminate progress. Fold in #1445. +3. **Ubisoft store sign-in and library natively:** the WebAuth flow, session refresh, a Rust Demux + client, the owned library and art, without opening Connect. +4. **Native Ubisoft downloads:** a Rust `ubisoft` module (manifest, slices, zstd), then hand off to + Connect for registration (U4). Updates come from the manifest version. +5. Clean-room record: `docs/development/ubisoft-cleanroom.md`. + +The two stores share almost nothing beyond the existing store layer, so they can run in parallel. +EA step 2 has the best ratio of value to risk and could start first. + +## Risks + +- **DRM circumvention.** Optima's launcherless mode replaces Ubisoft's DRM loader DLLs, answers the + game's ownership calls itself, and installs a certificate mimicking Ubisoft's so Authenticode checks + pass. Gating it on real ownership doesn't change that it bypasses Ubisoft's access control. Keep it + out of DroidDeck. EA is different: Maxima requests real licences from EA's licence + service and speak EA's documented-by-reversing launcher protocol. Nothing is forged. +- **Terms of service.** Both publishers' terms broadly forbid unofficial clients. No bans are known + for Linux or alt-launcher users (Maxima, Lutris, unifideck, GameVault). This is the same position as + our Epic, GOG and Amazon modules. +- **Vendor churn:** juno schema changes (EA already killed the Origin-era integrations once), Demux + patch versions, and Connect or EA app updates that break under Proton. Plan for tests on recorded + responses, as Epic has. +- **Memory.** A resident Connect costs a phone RAM, so reuse #979's close-on-launch policy and zRAM work + (#1882). EA's native path avoids the client entirely. +- **FEX hardware fingerprint (EA, U1)**, if FEX's CPUID differs from what licences are bound to. +- **Kernel anti-cheat:** Javelin, and whatever Ubisoft titles use one. Flag them in the catalog + instead of letting launches fail. + +## Sources + +- DroidDeck: #979, #1602, #1610, #1611, #1662, #2087 (Battle.net); #1449, #2036 (Steam Ubisoft/EA + installers); #1445, #2050, #2286, #2292, #2295, #2317 (Ubisoft); `docs/development/epic-cleanroom.md`. +- Maxima: README, `maxima-lib/src/{core/launch.rs, core/endpoints.rs, ooa/mod.rs, core/auth/*, content/*, lsx/*}`, issues #49, #71. +- Optima: README, `TODO-hardening.md`, `src/install.rs`, `src/launch.rs`. +- ubi-cli-mvp: README, `docs/optima-comparison.md`, `research.md`, `validation.md`. +- unifideck: `docs/feasibility/{README,ea,battlenet}.md`, `docs/ubisoft-store-spec.md`. +- [Heroic: Ubisoft Connect on Linux and Mac](https://github.com/Heroic-Games-Launcher/HeroicGamesLauncher/wiki/How-to-install-Ubisoft-Connect-on-Linux-and-Mac). +- [GamingOnLinux: EA Javelin job listing mentions Linux and Proton (2026-03)](https://gamingonlinux.com/2026/03/ea-javelin-anticheat-job-listing-mentions-future-support-for-linux-and-proton). +- [GamingOnLinux: KYBER goes open source (2026-01)](https://www.gamingonlinux.com/2026/01/star-wars-battlefront-ii-private-server-and-mod-launcher-kyber-goes-open-source). +- [PC Gamer: Ubisoft's launcher broke Steam games on Linux and Steam Deck](https://www.pcgamer.com/ubisofts-launcher-broke-steam-games-on-linux-and-steam-deck). diff --git a/tools/linuxfs/overlay/usr/local/bin/droiddeck-store-launch b/tools/linuxfs/overlay/usr/local/bin/droiddeck-store-launch index b2e2b870e..1f7c12bd6 100755 --- a/tools/linuxfs/overlay/usr/local/bin/droiddeck-store-launch +++ b/tools/linuxfs/overlay/usr/local/bin/droiddeck-store-launch @@ -5,6 +5,7 @@ import binascii import json import os import re +import shlex import shutil import sys import time @@ -276,6 +277,10 @@ def exited(argv, code=0): subprocess.run([EPIC_CLIENT, "exited", "--code", str(code)], stdin=subprocess.DEVNULL, stdout=subprocess.DEVNULL, timeout=10) except (OSError, subprocess.TimeoutExpired): pass + if found is not None and launch_dir and str(found[1].get("store")) == "ea": + ask(Path(launch_dir) / "stores", {"op": "ea-exited", "store": "ea", "id": str(found[1].get("id"))}, timeout=1.0) + log("ea %s exited" % found[1].get("id")) + return 0 if found is None or not launch_dir or not cloud_on(found[1]): return 0 store, game = str(found[1].get("store")), str(found[1].get("id")) @@ -613,6 +618,344 @@ def apply_gog_registry(folder, compat_data, proton, timeout=90, installation=Non return "import-failed" + note +EA_HARDWARE = ".droiddeck-ea-hw.json" +EA_CLIENT = "C:\\Program Files (x86)\\Origin\\Origin.exe" +EA_WMI = ( + ("boardManufacturer", "Win32_BaseBoard", "Manufacturer"), + ("boardSerial", "Win32_BaseBoard", "SerialNumber"), + ("biosManufacturer", "Win32_BIOS", "Manufacturer"), + ("biosSerial", "Win32_BIOS", "SerialNumber"), + ("osInstallDate", "Win32_OperatingSystem", "InstallDate"), + ("osSerial", "Win32_OperatingSystem", "SerialNumber"), + ("gpuPnpId", "Win32_VideoController", "PNPDeviceID"), + ("cpuManufacturer", "Win32_Processor", "Manufacturer"), + ("processorId", "Win32_Processor", "ProcessorId"), + ("brand", "Win32_Processor", "Name"), +) +EA_ROOTS = {"HKLM": "HKEY_LOCAL_MACHINE", "HKCU": "HKEY_CURRENT_USER"} + + +def decode_output(data): + if data[:2] in (b"\xff\xfe", b"\xfe\xff") or (len(data) > 1 and data[1:2] == b"\x00"): + return data.decode("utf-16", "replace") + return data.decode("utf-8", "replace") + + +def wmi_value(text): + rows = [row.strip() for row in text.replace("\r", "\n").split("\n") if row.strip()] + return rows[1] if len(rows) > 1 else "" + + +CPUID_LEAVES = (0, 1, 0x80000002, 0x80000003, 0x80000004) + + +def cpuid_exe(): + """A 32-bit console program that writes the raw CPUID leaves an x86 game sees under the emulator.""" + import struct + base, rva = 0x400000, 0x1000 + buf, written = base + rva + 0x180, base + rva + 0x1D0 + iat = base + rva + 0x138 + code = b"\x53\x57\xbf" + struct.pack(" "%s"' % (cls, prop, windows_path(str(folder / (key + ".txt"))))) + lines.append('vol C: > "%s"' % windows_path(str(folder / "volume.txt"))) + lines.append('hostname > "%s"' % windows_path(str(folder / "hostname.txt"))) + (folder / "cpuid.exe").write_bytes(cpuid_exe()) + lines.append('"%s" > "%s"' % (windows_path(str(folder / "cpuid.exe")), windows_path(str(folder / "cpuid.bin")))) + bat = folder / "probe.bat" + bat.write_bytes(("\r\n".join(lines) + "\r\n").encode()) + try: + subprocess.run([proton, "run", "cmd.exe", "/d", "/c", windows_path(str(bat))], stdin=subprocess.DEVNULL, + stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL, timeout=timeout) + except subprocess.TimeoutExpired: + return {}, "timeout" + except OSError: + return {}, "failed" + for key, _, _ in EA_WMI: + try: + values[key] = wmi_value(decode_output((folder / (key + ".txt")).read_bytes())) + except OSError: + values[key] = "" + try: + found = re.search(r"([0-9A-Fa-f]{4})-([0-9A-Fa-f]{4})", decode_output((folder / "volume.txt").read_bytes())) + values["volumeSerial"] = (found.group(1) + found.group(2)).lower() if found else "" + except OSError: + values["volumeSerial"] = "" + try: + values["hostname"] = decode_output((folder / "hostname.txt").read_bytes()).strip().splitlines()[0].strip() + except (OSError, IndexError): + values["hostname"] = "" + try: + values.update(cpuid_values((folder / "cpuid.bin").read_bytes())) + except OSError: + pass + if probe_complete(values): + try: + cache.write_text(json.dumps(values)) + except OSError: + pass + return values, "probed" + return values, "incomplete" + + +def ea_registry_entries(folder, sidecar): + install = windows_path(str(folder)).rstrip("\\") + "\\" + extra = sidecar.get("extra") if isinstance(sidecar.get("extra"), dict) else {} + title = str(extra.get("displayName") or sidecar.get("title") or folder.name).strip() + locale = str(extra.get("locale") or "en_US") + entries = [ + ("HKEY_LOCAL_MACHINE\\SOFTWARE\\Electronic Arts\\EA Desktop", "InstallSuccessful", "true"), + ("HKEY_LOCAL_MACHINE\\SOFTWARE\\Electronic Arts\\Origin", "InstallSuccessful", "true"), + ("HKEY_LOCAL_MACHINE\\SOFTWARE\\Electronic Arts\\Origin", "ClientPath", EA_CLIENT), + ("HKEY_LOCAL_MACHINE\\SOFTWARE\\Origin", "ClientPath", EA_CLIENT), + ] + for content in [c.strip() for c in str(extra.get("contentIds") or extra.get("contentId") or "").split(",")]: + if re.fullmatch(r"[A-Za-z0-9._-]{1,64}", content): + key = "HKEY_LOCAL_MACHINE\\SOFTWARE\\Origin Games\\" + content + entries += [(key, "DisplayName", title), (key, "Install Dir", install), (key, "Locale", locale)] + try: + keys = json.loads(extra.get("registry") or "[]") + except ValueError: + keys = [] + for pair in keys if isinstance(keys, list) else []: + if not isinstance(pair, list) or len(pair) != 2: + continue + key, name = str(pair[0]), str(pair[1]) + root, _, rest = key.partition("\\") + key = EA_ROOTS.get(root.upper(), root) + ("\\" + rest if rest else "") + if key.upper().startswith(("HKEY_LOCAL_MACHINE\\", "HKEY_CURRENT_USER\\")) and name and "\n" not in key + name: + entries.append((key, name, install)) + if title: + entries.append((key, "DisplayName", title)) + entries.append((key, "Locale", locale)) + return entries + + +def ea_registry(folder, sidecar, compat_data, proton, timeout=90): + if not compat_data or not proton or not Path(proton).is_file(): + return "no-proton" + pfx = Path(compat_data) / "pfx" + text = "Windows Registry Editor Version 5.00\r\n\r\n" + "".join( + '[%s]\r\n"%s"="%s"\r\n\r\n' % (key, reg_escape(name), reg_escape(value)) for key, name, value in ea_registry_entries(folder, sidecar)) + stamp = hashlib.sha256(text.encode("utf-8")).hexdigest() + marker = pfx / (".droiddeck-ea-registry-" + re.sub(r"[^A-Za-z0-9._-]", "_", str(sidecar.get("id")))) + try: + if marker.is_file() and marker.read_text() == stamp and (pfx / "system.reg").is_file(): + return "ready" + with tempfile.TemporaryDirectory(prefix="droiddeck-ea-") as tmp: + source = Path(tmp) / "registry.reg" + source.write_bytes(b"\xff\xfe" + text.encode("utf-16-le")) + executable = pfx / "drive_c/windows/system32/reg.exe" + for view in ("/reg:32", "/reg:64"): + run = subprocess.run([proton, "run", str(executable) if executable.is_file() else "reg.exe", "import", windows_path(str(source)), view], + stdin=subprocess.DEVNULL, stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL, timeout=timeout) + if run.returncode: + return "import-failed" + marker.write_text(stamp) + return "written" + except subprocess.TimeoutExpired: + return "timeout" + except OSError: + return "import-failed" + + +def find_case(folder, relative): + current = Path(folder) + for part in [p for p in relative.replace("\\", "/").split("/") if p and p != "."]: + exact = current / part + if exact.exists(): + current = exact + continue + try: + current = next(c for c in current.iterdir() if c.name.lower() == part.lower()) + except (OSError, StopIteration): + return None + return current if current != Path(folder) else None + + +def touchup_args(raw, locale, install): + raw = raw.replace("{locale}", locale) + raw = raw.replace('"{installLocation}"', '"%s"' % install).replace("{installLocation}", install) + try: + return shlex.split(raw.replace("\\", "\\\\")) + except ValueError: + return raw.split() + + +def ea_touchup(folder, sidecar, compat_data, proton, timeout=900): + extra = sidecar.get("extra") if isinstance(sidecar.get("extra"), dict) else {} + relative = str(extra.get("touchup") or "") + if not relative: + return "none" + exe = find_case(folder, relative) + if exe is None or not exe.is_file(): + return "missing" + if not compat_data or not proton or not Path(proton).is_file(): + return "no-proton" + pfx = Path(compat_data) / "pfx" + stamp = str(sidecar.get("installVersion") or "") + " " + str(sidecar.get("installedAt") or "") + marker = pfx / (".droiddeck-ea-touchup-" + re.sub(r"[^A-Za-z0-9._-]", "_", str(sidecar.get("id")))) + try: + if marker.is_file() and marker.read_text() == stamp: + return "done" + except OSError: + pass + args = touchup_args(str(extra.get("touchupArgs") or ""), str(extra.get("locale") or "en_US"), windows_path(str(folder)).rstrip("\\")) + try: + run = subprocess.run([proton, "run", str(exe)] + args, cwd=str(exe.parent), stdin=subprocess.DEVNULL, + stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL, timeout=timeout) + result = "ok" if run.returncode == 0 else "exit-%d" % run.returncode + except subprocess.TimeoutExpired: + result = "timeout" + except OSError: + return "failed" + try: + marker.write_text(stamp) + except OSError: + pass + return result + + +def write_licenses(compat_data, licenses): + if not compat_data: + return 0 + root = Path(compat_data) / "pfx/drive_c" + written = 0 + for item in licenses or []: + relative = str(item.get("path") or "") if isinstance(item, dict) else "" + if not relative or relative.startswith("/") or ".." in relative.split("/"): + continue + target = root / relative + try: + target.parent.mkdir(parents=True, exist_ok=True) + target.write_bytes(base64.b64decode(item.get("data") or "")) + written += 1 + except (OSError, binascii.Error): + pass + return written + + +def prepare_ea(folder, sidecar, compat_data, launch_dir): + game = str(sidecar.get("id")) + proton = os.environ.get("BL_STORE_PROTON", "") + made = create_prefix(compat_data, proton) + ready = made in ("present", "created") or (compat_data and (Path(compat_data) / "pfx/user.reg").is_file()) + stub = install_origin_stub(compat_data) if ready else "no-prefix" + hardware, probe = ea_hardware(compat_data, proton) if ready else ({}, "no-prefix") + answer = None + if launch_dir: + answer = ask(Path(launch_dir) / "stores", {"op": "ea-prepare", "store": "ea", "id": game, "hardware": hardware}, timeout=40.0) + sidecar = sidecar_at(Path(folder)) or sidecar + log("ea %s prefix=%s stub=%s hardware=%s registry=%s touchup=%s" % ( + game, made, stub, probe, ea_registry(folder, sidecar, compat_data, proton), ea_touchup(folder, sidecar, compat_data, proton))) + if answer is None: + log("ea %s session=no reason=%s" % (game, "timeout" if launch_dir else "no-channel")) + return + written = write_licenses(compat_data, answer.get("licenses")) + log("ea %s session=%s reason=%s lsx=%s licence=%s files=%d" % ( + game, "yes" if answer.get("ok") else "no", answer.get("reason", ""), answer.get("lsx"), answer.get("license", ""), written)) + + def gog_support_actions(folder): found = [] for script in sorted(Path(folder).glob(GOG_SCRIPTS)): @@ -808,6 +1151,9 @@ def main(argv): got = bool(answer.get("code")) log("epic launch id=%s code=%s reason=%s" % (game, "yes" if got else "no", answer.get("reason", ""))) log("epic prefix=%s %s" % (prefix_id or "?", provision_prefix(compat_data))) + if store == "ea": + prepare_ea(folder, sidecar, compat_data, launch_dir) + return STORE_GAME if not cloud_on(sidecar): if store in CLOUD_STORES: log("cloud %s %s down result=skipped reason=off" % (store, game)) diff --git a/tools/tests/test_store_launch_ea.py b/tools/tests/test_store_launch_ea.py new file mode 100644 index 000000000..2dd72b324 --- /dev/null +++ b/tools/tests/test_store_launch_ea.py @@ -0,0 +1,129 @@ +import base64 +import importlib.machinery +import importlib.util +import json +from pathlib import Path +import tempfile +import unittest +from unittest.mock import patch + +BIN = Path(__file__).resolve().parents[1] / 'linuxfs/overlay/usr/local/bin' +loader = importlib.machinery.SourceFileLoader('droiddeck_store_launch_ea', str(BIN / 'droiddeck-store-launch')) +spec = importlib.util.spec_from_loader(loader.name, loader) +store = importlib.util.module_from_spec(spec) +loader.exec_module(store) + + +class EaHook(unittest.TestCase): + def test_wmi_output_reads_the_value_under_the_header(self): + text = 'Manufacturer \r\r\nThe Wine Project \r\r\n\r\r\n' + self.assertEqual(store.wmi_value(text), 'The Wine Project') + self.assertEqual(store.wmi_value(store.decode_output('Name\r\nFEX CPU\r\n'.encode('utf-16'))), 'FEX CPU') + self.assertEqual(store.wmi_value(''), '') + + def test_registry_points_the_game_keys_at_the_install_folder(self): + sidecar = {'id': 'Origin.OFR.50.0001', 'title': 'Mass Effect 3', 'extra': {'registry': json.dumps([ + [r'HKEY_LOCAL_MACHINE\SOFTWARE\EA Games\Game', 'Install Dir'], + [r'HKLM\SOFTWARE\Respawn\Titanfall2', 'Install Dir'], + [r'HKEY_CLASSES_ROOT\x', 'y'], + ])}} + entries = store.ea_registry_entries(Path('/root/Games/Stores/EA/Game'), sidecar) + self.assertIn((r'HKEY_LOCAL_MACHINE\SOFTWARE\Origin', 'ClientPath', store.EA_CLIENT), entries) + game = [e for e in entries if e[1] == 'Install Dir'] + self.assertEqual([e[0] for e in game], [r'HKEY_LOCAL_MACHINE\SOFTWARE\EA Games\Game', r'HKEY_LOCAL_MACHINE\SOFTWARE\Respawn\Titanfall2']) + self.assertEqual(game[0][2], 'Z:' + '\\'.join(['', 'root', 'Games', 'Stores', 'EA', 'Game', ''])) + self.assertIn((r'HKEY_LOCAL_MACHINE\SOFTWARE\EA Games\Game', 'DisplayName', 'Mass Effect 3'), entries) + self.assertIn((r'HKEY_LOCAL_MACHINE\SOFTWARE\Respawn\Titanfall2', 'DisplayName', 'Mass Effect 3'), entries) + + def test_legacy_games_are_registered_by_content_id(self): + sidecar = {'id': 'Origin.OFR.50.0005237', 'title': 'Mass Effect 3 (2012)', + 'extra': {'contentIds': '71073,71183, bad id', 'displayName': 'Mass Effect 3', 'locale': 'fr_FR'}} + entries = store.ea_registry_entries(Path('/root/Games/ME3'), sidecar) + key = r'HKEY_LOCAL_MACHINE\SOFTWARE\Origin Games' + '\\' + self.assertIn((key + '71073', 'DisplayName', 'Mass Effect 3'), entries) + self.assertIn((key + '71183', 'Locale', 'fr_FR'), entries) + self.assertFalse(any('bad id' in e[0] for e in entries)) + + def test_cpuid_probe_is_a_32_bit_console_program(self): + import struct + exe = store.cpuid_exe() + self.assertEqual(exe[:2], b'MZ') + self.assertEqual(exe[0x40:0x44], b'PE\0\0') + self.assertEqual(struct.unpack_from(' Date: Tue, 10 Nov 2026 15:08:37 -0500 Subject: [PATCH 33/33] feat(ubisoft): Ubisoft Connect store with native sign-in, library, downloads and launch (#2322) * feat(ubisoft): sign in to Ubisoft Connect and list the owned library * feat(ubisoft): download games natively through Demux and Ubisoft's CDN * fix(ubisoft): show the owned games on the store page * fix(ctl): reach the agent bridge by broadcast when the shell has no content command * feat(ubisoft): run Ubisoft games in one prefix with Ubisoft Connect installed and the game registered * feat(ubisoft): launch games through a native stand-in for Ubisoft Connect's local service * feat(ubisoft): document the launcher pipes, log why owned games have no download, drop the installer after extraction * feat(ubisoft): Steam-owned Ubisoft games install through Steam, and the launch session carries the game's application id * feat(ubisoft): answer Denuvo activations through Ubisoft's activation service and log unanswered pipe traffic * feat(ubisoft): report installed chunks to the game and skip add-ons the account does not own * fix(ubisoft): the overlay stays off, Ubisoft's install steps pick Windows components, and shared-prefix games keep their own settings * fix(ubisoft): write the manifest's registry values, and pick the game's exe over Ubisoft's installer stubs * fix(ubisoft): stream download slices, guard the launch hook, and keep per-game components in the shared prefix * fix(ubisoft): direct launches in the shared prefix keep their own game id * ci(ubisoft): build the launcher service after the prepared components are verified * fix(ubisoft): verify reused files, drop syncs from a signed-out account, and clear activation requests before launch * fix(ubisoft): fetch again a reused file whose slices cannot be checked --- .github/workflows/build.yml | 9 + .../main/java/com/droiddeck/launcher/App.kt | 1 + .../droiddeck/launcher/frontend/AddedGames.kt | 8 +- .../launcher/frontend/WinCompSources.kt | 15 +- .../launcher/session/SessionFiles.kt | 1 + .../droiddeck/launcher/stores/CloudSaves.kt | 6 +- .../droiddeck/launcher/stores/LoginFlows.kt | 2 + .../com/droiddeck/launcher/stores/Store.kt | 1 + .../com/droiddeck/launcher/stores/StoreExe.kt | 5 +- .../droiddeck/launcher/stores/StoreLaunch.kt | 22 +- .../launcher/stores/StoreLaunchRequests.kt | 9 + .../launcher/stores/StoreLoginActivity.kt | 19 +- .../droiddeck/launcher/stores/StoreSizes.kt | 4 +- .../stores/ubisoft/UbisoftActivation.kt | 74 ++++ .../launcher/stores/ubisoft/UbisoftAuth.kt | 175 ++++++++ .../launcher/stores/ubisoft/UbisoftBackend.kt | 246 +++++++++++ .../launcher/stores/ubisoft/UbisoftConnect.kt | 18 + .../stores/ubisoft/UbisoftInstaller.kt | 104 +++++ .../launcher/stores/ubisoft/UbisoftLaunch.kt | 86 ++++ .../launcher/stores/ubisoft/UbisoftNative.kt | 57 +++ .../com/droiddeck/launcher/ui/StoreChips.kt | 2 + .../com/droiddeck/launcher/ui/StoresPage.kt | 1 + .../launcher/ui/WinComponentsEditor.kt | 3 +- app/src/main/rust/stores/Cargo.lock | 18 + app/src/main/rust/stores/Cargo.toml | 5 +- app/src/main/rust/stores/src/lib.rs | 1 + app/src/main/rust/stores/src/ubisoft/demux.rs | 154 +++++++ app/src/main/rust/stores/src/ubisoft/ffi.rs | 269 ++++++++++++ .../main/rust/stores/src/ubisoft/install.rs | 408 ++++++++++++++++++ .../main/rust/stores/src/ubisoft/manifest.rs | 230 ++++++++++ app/src/main/rust/stores/src/ubisoft/mod.rs | 6 + app/src/main/rust/stores/src/ubisoft/pb.rs | 104 +++++ .../main/rust/stores/src/ubisoft/service.rs | 206 +++++++++ .../launcher/stores/ubisoft/UbisoftTest.kt | 60 +++ docs/development/ubisoft-launch.md | 90 ++++ tools/build_local.sh | 9 + .../overlay/usr/local/bin/droiddeck-game-env | 32 +- .../usr/local/bin/droiddeck-store-launch | 221 ++++++++++ .../overlay/usr/local/bin/steam-compatibility | 7 + tools/tests/test_game_environment.py | 21 + tools/tests/test_store_launch.py | 99 +++++ tools/ubisoft-launcher/.gitignore | 2 + tools/ubisoft-launcher/Cargo.lock | 105 +++++ tools/ubisoft-launcher/Cargo.toml | 19 + tools/ubisoft-launcher/build.sh | 10 + tools/ubisoft-launcher/src/launcher.rs | 358 +++++++++++++++ tools/ubisoft-launcher/src/main.rs | 331 ++++++++++++++ tools/ubisoft-launcher/src/pb.rs | 168 ++++++++ tools/ubisoft-launcher/src/win.rs | 183 ++++++++ 49 files changed, 3960 insertions(+), 24 deletions(-) create mode 100644 app/src/main/java/com/droiddeck/launcher/stores/ubisoft/UbisoftActivation.kt create mode 100644 app/src/main/java/com/droiddeck/launcher/stores/ubisoft/UbisoftAuth.kt create mode 100644 app/src/main/java/com/droiddeck/launcher/stores/ubisoft/UbisoftBackend.kt create mode 100644 app/src/main/java/com/droiddeck/launcher/stores/ubisoft/UbisoftConnect.kt create mode 100644 app/src/main/java/com/droiddeck/launcher/stores/ubisoft/UbisoftInstaller.kt create mode 100644 app/src/main/java/com/droiddeck/launcher/stores/ubisoft/UbisoftLaunch.kt create mode 100644 app/src/main/java/com/droiddeck/launcher/stores/ubisoft/UbisoftNative.kt create mode 100644 app/src/main/rust/stores/src/ubisoft/demux.rs create mode 100644 app/src/main/rust/stores/src/ubisoft/ffi.rs create mode 100644 app/src/main/rust/stores/src/ubisoft/install.rs create mode 100644 app/src/main/rust/stores/src/ubisoft/manifest.rs create mode 100644 app/src/main/rust/stores/src/ubisoft/mod.rs create mode 100644 app/src/main/rust/stores/src/ubisoft/pb.rs create mode 100644 app/src/main/rust/stores/src/ubisoft/service.rs create mode 100644 app/src/test/java/com/droiddeck/launcher/stores/ubisoft/UbisoftTest.kt create mode 100644 docs/development/ubisoft-launch.md create mode 100644 tools/ubisoft-launcher/.gitignore create mode 100644 tools/ubisoft-launcher/Cargo.lock create mode 100644 tools/ubisoft-launcher/Cargo.toml create mode 100755 tools/ubisoft-launcher/build.sh create mode 100644 tools/ubisoft-launcher/src/launcher.rs create mode 100644 tools/ubisoft-launcher/src/main.rs create mode 100644 tools/ubisoft-launcher/src/pb.rs create mode 100644 tools/ubisoft-launcher/src/win.rs diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index 0d225b45c..97a659744 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -555,6 +555,12 @@ jobs: SOURCE_TREE: ${{ needs.source.outputs.tree-sha }} run: python3 tools/components/prepare.py unpack --tree "$SOURCE_TREE" + - name: Ubisoft launcher service (x86_64-pc-windows-gnu) + run: | + set -euo pipefail + command -v x86_64-w64-mingw32-gcc >/dev/null || { sudo apt-get update -qq && sudo apt-get install -y -qq gcc-mingw-w64-x86-64; } + bash tools/ubisoft-launcher/build.sh + - uses: actions/download-artifact@v4 with: name: xbox-arm64 @@ -791,6 +797,9 @@ jobs: set -o pipefail cd app/src/main/rust/stores cargo test --locked 2>&1 | tail -60 + - name: Ubisoft launcher service host unit tests + timeout-minutes: 10 + run: cargo test --locked --manifest-path tools/ubisoft-launcher/Cargo.toml python-tests: name: Python and native regression tests diff --git a/app/src/main/java/com/droiddeck/launcher/App.kt b/app/src/main/java/com/droiddeck/launcher/App.kt index 7235bf95c..2137828dd 100644 --- a/app/src/main/java/com/droiddeck/launcher/App.kt +++ b/app/src/main/java/com/droiddeck/launcher/App.kt @@ -61,6 +61,7 @@ class App : Application() { com.droiddeck.launcher.stores.StoresState.register(com.droiddeck.launcher.stores.xbox.XboxBackend) com.droiddeck.launcher.sync.Syncthing.init(this) com.droiddeck.launcher.stores.StoresState.register(com.droiddeck.launcher.stores.battlenet.BattleNetBackend) + com.droiddeck.launcher.stores.StoresState.register(com.droiddeck.launcher.stores.ubisoft.UbisoftBackend) com.droiddeck.launcher.stores.ea.EaBackend.app = this com.droiddeck.launcher.stores.StoresState.register(com.droiddeck.launcher.stores.ea.EaBackend) } diff --git a/app/src/main/java/com/droiddeck/launcher/frontend/AddedGames.kt b/app/src/main/java/com/droiddeck/launcher/frontend/AddedGames.kt index 7057cadd3..c7d774bf2 100644 --- a/app/src/main/java/com/droiddeck/launcher/frontend/AddedGames.kt +++ b/app/src/main/java/com/droiddeck/launcher/frontend/AddedGames.kt @@ -417,7 +417,7 @@ object AddedGames { private fun scanGame(context: Context, folder: File, out: MutableList, single: AddedExes.Entry? = null, reuse: Boolean = false) { run { - val sidecar = StoreGameSidecar.read(folder) + val sidecar = StoreGameSidecar.read(folder)?.let { com.droiddeck.launcher.stores.StoreLaunch.adopt(folder, it) } sidecar?.takeIf { it.id.isNotEmpty() }?.let { SessionPrefs.followStoreGame(context, "${it.store.id}:${it.id}", folder.path) { old -> !File(old).exists() } } val ranking = rank(folder, reuse) val candidates = ranking.exes @@ -457,7 +457,11 @@ object AddedGames { steamId?.toLong() ?: ((appId shl 32) or 0x02000000L), candidates, steamId, source = sidecar?.store?.id ?: Library.ADDED, storeId = sidecar?.id, exeUncertain = sidecar == null && chosen == null && adopted == null && ranking.uncertain, - launchOptions = if (sidecar == null) SessionPrefs.addedGameLaunch(context, folder.path) else "", + launchOptions = when { + sidecar == null -> SessionPrefs.addedGameLaunch(context, folder.path) + sidecar.store == Store.UBISOFT -> com.droiddeck.launcher.stores.ubisoft.UbisoftConnect.launchOptions(context) + else -> "" + }, single = single != null)) } } diff --git a/app/src/main/java/com/droiddeck/launcher/frontend/WinCompSources.kt b/app/src/main/java/com/droiddeck/launcher/frontend/WinCompSources.kt index 22044c76d..e9910a67d 100644 --- a/app/src/main/java/com/droiddeck/launcher/frontend/WinCompSources.kt +++ b/app/src/main/java/com/droiddeck/launcher/frontend/WinCompSources.kt @@ -9,7 +9,7 @@ import java.io.File import java.util.Locale object WinCompSources { - enum class Origin { STEAM, GOG, EPIC, AMAZON, STEAM_NAME, FOLDER, XBOX } + enum class Origin { STEAM, GOG, EPIC, AMAZON, STEAM_NAME, FOLDER, XBOX, UBISOFT } data class Finding(val component: String?, val original: String, val origin: Origin, val auto: Boolean, val args: List = emptyList()) @@ -83,6 +83,13 @@ object WinCompSources { } } + /** The prerequisite installers a Ubisoft manifest lists, with `$INSTALL_DIR` dropped. */ + internal fun ubisoftInstallRuns(setup: String?): List { + val runs = setup?.let { runCatching { org.json.JSONObject(it).optJSONArray("installRuns") }.getOrNull() } ?: return emptyList() + return (0 until runs.length()).mapNotNull { runs.optJSONObject(it)?.optString("exe") } + .map { it.replace('\\', '/').removePrefix("\$INSTALL_DIR").trimStart('/') }.filter { it.isNotEmpty() } + } + fun forGame(context: Context, game: Library.SteamGame): List { val folder = game.gameFiles val out = ArrayList() @@ -112,6 +119,12 @@ object WinCompSources { if (parts.isEmpty()) out += Finding(null, original, Origin.AMAZON, false) parts.forEach { out += Finding(it, original, Origin.AMAZON, true) } } + store == Store.UBISOFT -> ubisoftInstallRuns(sidecar?.extra?.get(com.droiddeck.launcher.stores.ubisoft.UbisoftLaunch.SETUP)).forEach { exe -> + val parts = installer(exe) + val original = exe.substringAfterLast('/') + if (parts.isEmpty()) out += Finding(null, original, Origin.UBISOFT, false) + parts.forEach { out += Finding(it, original, Origin.UBISOFT, true) } + } else -> {} } if (game.library == Library.ADDED && folder != null) SteamMatch.get(context, folder.path)?.let { match -> diff --git a/app/src/main/java/com/droiddeck/launcher/session/SessionFiles.kt b/app/src/main/java/com/droiddeck/launcher/session/SessionFiles.kt index f3b8478a2..1984f8c93 100644 --- a/app/src/main/java/com/droiddeck/launcher/session/SessionFiles.kt +++ b/app/src/main/java/com/droiddeck/launcher/session/SessionFiles.kt @@ -196,6 +196,7 @@ object SessionFiles { "usr/local/lib/droiddeck-pipewire.tzst" to "usr/local/lib/droiddeck-pipewire.tzst", "usr/local/share/licenses/uruntime/LICENSE" to "usr/local/share/licenses/uruntime/LICENSE", "usr/local/lib/droiddeck-dirt/ws2_32.dll" to "usr/local/lib/droiddeck-dirt/ws2_32.dll", + "usr/local/lib/droiddeck-ubisoft/droiddeck-upc.exe" to "usr/local/lib/droiddeck-ubisoft/droiddeck-upc.exe", ) + mangoapp + xclipse.map { it to it } + maliCompat + msitools.map { it to it } + vr + gpuLoad + fexPreloads).filter { (asset, _) -> val dir = asset.substringBeforeLast('/') runCatching { context.assets.list("linuxfs/$dir")?.contains(asset.substringAfterLast('/')) == true }.getOrDefault(false) diff --git a/app/src/main/java/com/droiddeck/launcher/stores/CloudSaves.kt b/app/src/main/java/com/droiddeck/launcher/stores/CloudSaves.kt index 389caee48..f68ab0ce6 100644 --- a/app/src/main/java/com/droiddeck/launcher/stores/CloudSaves.kt +++ b/app/src/main/java/com/droiddeck/launcher/stores/CloudSaves.kt @@ -87,7 +87,7 @@ object CloudSaves { val item = sidecar.extra["catalogItemId"].orEmpty() if (ns.isEmpty() || item.isEmpty()) "" else EpicApiClient.getCustomAttribute(token, ns, item, "CloudSaveFolder") } - Store.AMAZON, Store.ITCH, Store.GAMEJOLT, Store.XBOX, Store.BATTLENET, Store.EA -> "" + Store.AMAZON, Store.ITCH, Store.GAMEJOLT, Store.XBOX, Store.BATTLENET, Store.EA, Store.UBISOFT -> "" } ?: return null p.edit() .putString("cloud_template_$id", fetched.ifEmpty { TEMPLATE_NONE }) @@ -148,7 +148,7 @@ object CloudSaves { val dir = when (store) { Store.GOG -> CloudSavePaths.gog(template, pfx, folder) Store.EPIC -> CloudSavePaths.epic(template, pfx, folder, EpicCredentialStore.load(context)?.accountId.orEmpty(), id) - Store.AMAZON, Store.ITCH, Store.GAMEJOLT, Store.XBOX, Store.BATTLENET, Store.EA -> null + Store.AMAZON, Store.ITCH, Store.GAMEJOLT, Store.XBOX, Store.BATTLENET, Store.EA, Store.UBISOFT -> null } return dir to (if (dir == null) "unresolved" else "ok") } @@ -173,7 +173,7 @@ object CloudSaves { val account = EpicCredentialStore.load(context)?.accountId.orEmpty() if (token == null || account.isEmpty()) null else EpicCloud(account, id, token) } - Store.AMAZON, Store.ITCH, Store.GAMEJOLT, Store.XBOX, Store.BATTLENET, Store.EA -> null + Store.AMAZON, Store.ITCH, Store.GAMEJOLT, Store.XBOX, Store.BATTLENET, Store.EA, Store.UBISOFT -> null } private fun gogScopedToken(context: Context, id: String, clientId: String): String? { diff --git a/app/src/main/java/com/droiddeck/launcher/stores/LoginFlows.kt b/app/src/main/java/com/droiddeck/launcher/stores/LoginFlows.kt index 640635788..980f496b2 100644 --- a/app/src/main/java/com/droiddeck/launcher/stores/LoginFlows.kt +++ b/app/src/main/java/com/droiddeck/launcher/stores/LoginFlows.kt @@ -13,12 +13,14 @@ internal object LoginFlows { Store.EPIC -> epicFlow() Store.AMAZON -> amazonFlow() Store.GAMEJOLT -> gameJoltFlow(browserUserAgent) + Store.UBISOFT -> ubisoftFlow(browserUserAgent) Store.EA -> eaFlow(browserUserAgent) Store.ITCH, Store.XBOX, Store.BATTLENET -> null } @Volatile var epicFlow: () -> StoreLoginActivity.Flow? = { null } @Volatile var amazonFlow: () -> StoreLoginActivity.Flow? = { null } + @Volatile var ubisoftFlow: (String) -> StoreLoginActivity.Flow? = { null } @Volatile var eaFlow: (String) -> StoreLoginActivity.Flow? = { null } @Volatile var gameJoltFlow: (String) -> StoreLoginActivity.Flow? = { null } diff --git a/app/src/main/java/com/droiddeck/launcher/stores/Store.kt b/app/src/main/java/com/droiddeck/launcher/stores/Store.kt index 8813675b2..b8a6e8931 100644 --- a/app/src/main/java/com/droiddeck/launcher/stores/Store.kt +++ b/app/src/main/java/com/droiddeck/launcher/stores/Store.kt @@ -8,6 +8,7 @@ enum class Store(val id: String, val label: String, val shortLabel: String) { ITCH("itch", "Itch.io", "Itch.io"), GAMEJOLT("gamejolt", "Game Jolt", "Game Jolt"), BATTLENET("battlenet", "Battle.net", "Battle.net"), + UBISOFT("ubisoft", "Ubisoft Connect", "Ubisoft"), EA("ea", "EA", "EA"); val folder: String get() = if (this == ITCH) "itch" else shortLabel diff --git a/app/src/main/java/com/droiddeck/launcher/stores/StoreExe.kt b/app/src/main/java/com/droiddeck/launcher/stores/StoreExe.kt index ad2d478c1..0e9792edc 100644 --- a/app/src/main/java/com/droiddeck/launcher/stores/StoreExe.kt +++ b/app/src/main/java/com/droiddeck/launcher/stores/StoreExe.kt @@ -3,7 +3,8 @@ package com.droiddeck.launcher.stores import java.io.File object StoreExe { - private val SKIP = listOf("redist", "unins", "setup", "crash", "report", "helper", "dotnet", "vcredist", "directx", "eac", "easyanticheat", "battleye", "launcher", "updater", "config") + private val SKIP = listOf("redist", "unins", "setup", "crash", "report", "helper", "dotnet", "vcredist", "directx", "eac", "easyanticheat", "battleye", "launcher", "updater", "config", "installer", "autorun") + private val SKIP_DIRS = setOf("installer_resources", "redist", "_commonredist", "support") fun pick(folder: File, hint: String?, title: String): String { val hinted = hint?.takeIf { it.isNotBlank() }?.replace('\\', '/')?.trimStart('/')?.let { File(folder, it) } @@ -28,7 +29,7 @@ object StoreExe { if (SKIP.none { lower.contains(it) }) out.add(f) } } - for (f in files) if (f.isDirectory && !f.name.startsWith(".")) collect(f, out, depth + 1) + for (f in files) if (f.isDirectory && !f.name.startsWith(".") && f.name.lowercase() !in SKIP_DIRS) collect(f, out, depth + 1) } private fun depth(root: File, f: File): Int = relative(root, f).count { it == '/' } diff --git a/app/src/main/java/com/droiddeck/launcher/stores/StoreLaunch.kt b/app/src/main/java/com/droiddeck/launcher/stores/StoreLaunch.kt index f1a527a39..222b53702 100644 --- a/app/src/main/java/com/droiddeck/launcher/stores/StoreLaunch.kt +++ b/app/src/main/java/com/droiddeck/launcher/stores/StoreLaunch.kt @@ -11,12 +11,14 @@ object StoreLaunch { const val LAUNCHER = ".droiddeck-launch.bat" const val EPIC_CODE = ".droiddeck-epic-code" const val EPIC_ARGS = ".droiddeck-epic-args" + const val UBISOFT_LAUNCH = ".droiddeck-ubisoft.json" + private const val UBISOFT_SERVICE = "Z:\\usr\\local\\lib\\droiddeck-ubisoft\\droiddeck-upc.exe" const val EA_ENV = ".droiddeck-ea-env" /** A sleeping stand-in that the launch hook installs; older EA SDKs require a running Origin process. */ const val EA_ORIGIN = "C:\\Program Files (x86)\\Origin\\Origin.exe" fun writeLauncher(folder: File, sidecar: StoreGameSidecar): StoreGameSidecar { - val needs = sidecar.args.isNotEmpty() || sidecar.env.isNotEmpty() || sidecar.store == Store.EPIC || sidecar.store == Store.EA + val needs = sidecar.args.isNotEmpty() || sidecar.env.isNotEmpty() || sidecar.store == Store.EPIC || sidecar.store == Store.EA || sidecar.store == Store.UBISOFT val file = File(folder, LAUNCHER) if (!needs) { file.delete(); return sidecar.copy(launcher = null) } file.writeText(launcherText(sidecar)) @@ -52,6 +54,13 @@ object StoreLaunch { lines += "del \"%~dp0$EPIC_ARGS\"" lines += "if exist \"%~dp0$EPIC_CODE\" del \"%~dp0$EPIC_CODE\"" lines += "set \"$CLIENT_VAR=\" & \"%~dp0$exe\" %$CLIENT_VAR%" + } else if (sidecar.store == Store.UBISOFT) { + lines += "if not exist \"%~dp0$UBISOFT_LAUNCH\" goto direct" + lines += "if not exist \"$UBISOFT_SERVICE\" goto direct" + lines += "\"$UBISOFT_SERVICE\" \"%~dp0$UBISOFT_LAUNCH\"" + lines += "goto :eof" + lines += ":direct" + lines += run } else if (sidecar.store == Store.EA) { lines += "if not exist \"%~dp0$EA_ENV\" goto run" lines += "for /f \"usebackq tokens=1,* delims==\" %%a in (\"%~dp0$EA_ENV\") do set \"%%a=%%b\"" @@ -84,6 +93,17 @@ object StoreLaunch { return Library.addedGame(context, game) } + /** Gives a Ubisoft game installed before it needed one the launcher that starts the launcher service. */ + fun adopt(folder: File, sidecar: StoreGameSidecar): StoreGameSidecar { + if (sidecar.store != Store.UBISOFT || !sidecar.isInstalled) return sidecar + val wrongExe = !sidecar.exeFile(folder).isFile || sidecar.exe.contains("installer", ignoreCase = true) || sidecar.exe.contains("autorun", ignoreCase = true) + if (sidecar.launcher == LAUNCHER && !wrongExe) return sidecar + val exe = if (wrongExe) StoreExe.pick(folder, null, sidecar.title).ifEmpty { sidecar.exe } else sidecar.exe + if (exe == sidecar.exe && sidecar.launcher == LAUNCHER && + runCatching { File(folder, LAUNCHER).readText() }.getOrNull() == launcherText(sidecar)) return sidecar + return runCatching { writeLauncher(folder, sidecar.copy(exe = exe)).also { it.write(folder) } }.getOrDefault(sidecar) + } + fun refreshLauncher(folder: File, sidecar: StoreGameSidecar) { if (sidecar.launcher != LAUNCHER) return val file = File(folder, LAUNCHER) diff --git a/app/src/main/java/com/droiddeck/launcher/stores/StoreLaunchRequests.kt b/app/src/main/java/com/droiddeck/launcher/stores/StoreLaunchRequests.kt index dbfa4a80d..18f5a161e 100644 --- a/app/src/main/java/com/droiddeck/launcher/stores/StoreLaunchRequests.kt +++ b/app/src/main/java/com/droiddeck/launcher/stores/StoreLaunchRequests.kt @@ -76,6 +76,15 @@ object StoreLaunchRequests { JSONObject().put("ok", true).put("code", code.written).put("reason", code.reason) } } + "ubisoft-launch" -> { + val id = request.optString("id") + if (id.isEmpty()) JSONObject().put("ok", false).put("reason", "no-id") + else { + lastLaunched = Store.UBISOFT to id + runCatching { com.droiddeck.launcher.stores.ubisoft.UbisoftLaunch.prepare(app, id) } + .getOrElse { JSONObject().put("ok", false).put("reason", it.javaClass.simpleName) } + } + } "cloud-down" -> { val store = Store.byId(request.optString("store")) val id = request.optString("id") diff --git a/app/src/main/java/com/droiddeck/launcher/stores/StoreLoginActivity.kt b/app/src/main/java/com/droiddeck/launcher/stores/StoreLoginActivity.kt index 228d97cd7..a07e0be85 100644 --- a/app/src/main/java/com/droiddeck/launcher/stores/StoreLoginActivity.kt +++ b/app/src/main/java/com/droiddeck/launcher/stores/StoreLoginActivity.kt @@ -47,6 +47,9 @@ class StoreLoginActivity : ComponentActivity() { ?.takeIf { "Chrome/" in it } ?: "Mozilla/5.0 (Linux; Android ${Build.VERSION.RELEASE}; ${Build.MODEL}) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/127.0.6533.103 Mobile Safari/537.36" + private const val PAGE_TRIES = 20 + private const val PAGE_RETRY_MS = 500L + fun generateState(): String { val bytes = ByteArray(24) java.security.SecureRandom().nextBytes(bytes) @@ -60,6 +63,8 @@ class StoreLoginActivity : ComponentActivity() { fun isRedirect(url: String): Boolean fun finish(activity: StoreLoginActivity, view: WebView, url: String): String? val readsPage: Boolean get() = false + val pageScript: String get() = "(function(){ try { return document.body.innerText; } catch(e){ return ''; } })()" + fun pageReady(result: String): Boolean = true fun forgetSignIn(done: () -> Unit) = done() } @@ -323,6 +328,14 @@ class StoreLoginActivity : ComponentActivity() { } } + private fun readPage(view: WebView, f: Flow, left: Int) { + view.evaluateJavascript(f.pageScript) { json -> + val result = json ?: "" + if (!f.pageReady(result) && left > 0) view.postDelayed({ readPage(view, f, left - 1) }, PAGE_RETRY_MS) + else Thread({ settle(f.finish(this, view, result)) }, "store-login").start() + } + } + private fun complete(view: WebView, url: String) { if (!captured.compareAndSet(false, true)) return val f = flow ?: return @@ -332,11 +345,7 @@ class StoreLoginActivity : ComponentActivity() { titleText.text = getString(R.string.stores_login_finishing) if (!f.readsPage) view.stopLoading() } - if (f.readsPage) { - view.evaluateJavascript("(function(){ try { return document.body.innerText; } catch(e){ return ''; } })()") { json -> - Thread({ settle(f.finish(this, view, json ?: "")) }, "store-login").start() - } - } else Thread({ settle(f.finish(this, view, url)) }, "store-login").start() + if (f.readsPage) readPage(view, f, PAGE_TRIES) else Thread({ settle(f.finish(this, view, url)) }, "store-login").start() } private fun settle(problem: String?) { diff --git a/app/src/main/java/com/droiddeck/launcher/stores/StoreSizes.kt b/app/src/main/java/com/droiddeck/launcher/stores/StoreSizes.kt index 65d72c6d9..5e8ce7c6d 100644 --- a/app/src/main/java/com/droiddeck/launcher/stores/StoreSizes.kt +++ b/app/src/main/java/com/droiddeck/launcher/stores/StoreSizes.kt @@ -56,7 +56,7 @@ object StoreSizes { Store.AMAZON -> AmazonPrefs.get(app).edit().putLong("size_${item.id}", it).apply() Store.ITCH, Store.GAMEJOLT -> {} Store.EA -> com.droiddeck.launcher.stores.ea.EaPrefs.get(app).edit().putLong("size_${item.id}", it).apply() - Store.GOG, Store.XBOX, Store.BATTLENET -> {} + Store.GOG, Store.XBOX, Store.BATTLENET, Store.UBISOFT -> {} } } }, pool).exceptionally { error -> @@ -113,7 +113,7 @@ object StoreSizes { AmazonManifest.parse(bytes).totalInstallSize } Store.EA -> com.droiddeck.launcher.stores.ea.EaSizes.size(context, item) - Store.GOG, Store.XBOX, Store.BATTLENET -> 0L + Store.GOG, Store.XBOX, Store.BATTLENET, Store.UBISOFT -> 0L Store.ITCH -> com.droiddeck.launcher.stores.itch.ItchLibrary.size(context, item) Store.GAMEJOLT -> com.droiddeck.launcher.stores.gamejolt.GameJoltBuilds.size(context, item) } } diff --git a/app/src/main/java/com/droiddeck/launcher/stores/ubisoft/UbisoftActivation.kt b/app/src/main/java/com/droiddeck/launcher/stores/ubisoft/UbisoftActivation.kt new file mode 100644 index 000000000..d11fde421 --- /dev/null +++ b/app/src/main/java/com/droiddeck/launcher/stores/ubisoft/UbisoftActivation.kt @@ -0,0 +1,74 @@ +package com.droiddeck.launcher.stores.ubisoft + +import android.content.Context +import com.droiddeck.launcher.runtime.LinuxRuntime +import com.droiddeck.launcher.session.SessionState +import com.droiddeck.launcher.stores.StoresState +import org.json.JSONObject +import java.io.File + +/** + * Answers Denuvo activations for a running Ubisoft game. droiddeck-upc.exe writes the challenge + * the game's aux DLL sends to `bridge-/denuvo-.req`; the answer goes to `.rsp`. + */ +object UbisoftActivation { + private const val POLL_MS = 250L + private const val IDLE_MS = 60_000L + private val watching = HashMap() + + fun bridge(context: Context, product: Long) = File(LinuxRuntime.rootDir(context), "root/.cache/droiddeck/ubisoft/bridge-$product") + + /** Starts answering the product's activations; stale requests are cleared before this returns. */ + fun watch(app: Context, product: Long) { + val dir = bridge(app, product) + synchronized(watching) { + val running = watching.containsKey(product) + watching[product] = System.currentTimeMillis() + if (running) return + dir.mkdirs() + dir.listFiles()?.forEach { it.delete() } + } + Thread({ + var idleSince = 0L + while (true) { + runCatching { dir.listFiles { f -> f.name.startsWith("denuvo-") && f.name.endsWith(".req") }?.forEach { answer(app, product, it) } } + .onFailure { StoresState.logLine("Ubisoft: activation for $product: ${it.javaClass.simpleName}") } + val now = System.currentTimeMillis() + if (SessionState.running) idleSince = 0L + else if (idleSince == 0L) idleSince = now + else if (now - idleSince > IDLE_MS) synchronized(watching) { + if (now - (watching[product] ?: 0L) > IDLE_MS) { watching.remove(product); return@Thread } + } + runCatching { Thread.sleep(POLL_MS) } + } + }, "ubisoft-activation-$product").start() + } + + private fun answer(app: Context, product: Long, request: File) { + val challenge = runCatching { request.readBytes() }.getOrNull() ?: return + request.delete() + val reply = File(request.parentFile, request.name.removeSuffix(".req") + ".rsp") + val token = activate(app, product, challenge) + val part = File(reply.path + ".part") + part.writeBytes(token ?: ByteArray(0)) + part.renameTo(reply) + } + + private fun activate(app: Context, product: Long, challenge: ByteArray): ByteArray? { + val s = UbisoftAuth.current(app) ?: return null.also { StoresState.logLine("Ubisoft: activation for $product: signed out") } + val answer = UbisoftNative.call("denuvo", JSONObject().put("ticket", s.ticket).put("sessionId", s.sessionId) + .put("productId", product).put("challenge", hex(challenge))) + answer?.optString("error")?.takeIf { it.isNotEmpty() }?.let { + StoresState.logLine("Ubisoft: activation for $product: $it") + return null + } + val token = answer?.optString("token")?.takeIf { it.isNotEmpty() }?.let(::unhex) + StoresState.logLine("Ubisoft: activation for $product: ${token?.size ?: 0} B game token") + return token + } + + internal fun hex(b: ByteArray): String = b.joinToString("") { "%02x".format(it) } + + internal fun unhex(s: String): ByteArray? = + if (s.length % 2 != 0) null else runCatching { ByteArray(s.length / 2) { s.substring(it * 2, it * 2 + 2).toInt(16).toByte() } }.getOrNull() +} diff --git a/app/src/main/java/com/droiddeck/launcher/stores/ubisoft/UbisoftAuth.kt b/app/src/main/java/com/droiddeck/launcher/stores/ubisoft/UbisoftAuth.kt new file mode 100644 index 000000000..5de002dad --- /dev/null +++ b/app/src/main/java/com/droiddeck/launcher/stores/ubisoft/UbisoftAuth.kt @@ -0,0 +1,175 @@ +package com.droiddeck.launcher.stores.ubisoft + +import android.content.Context +import android.util.Log +import com.droiddeck.launcher.stores.Store +import com.droiddeck.launcher.stores.StoreAccounts +import org.json.JSONArray +import org.json.JSONObject +import java.net.HttpURLConnection +import java.net.URL +import java.net.URLEncoder + +/** UbiServices sessions: a ticket from Ubisoft's own web sign-in, exchanged for a launcher session that remember-me renews. */ +object UbisoftAuth { + private const val TAG = "UbisoftAuth" + const val SESSIONS = "https://public-ubiservices.ubi.com/v3/profiles/sessions" + + /** The web app and genome connect.ubisoft.com's sign-in page accepts. */ + private const val WEB_APP_ID = "f35adcb5-1911-440c-b1c9-48fdc1701c68" + private const val WEB_GENOME_ID = "5b36b900-65d8-47f3-93c8-86bdaa48ab50" + + /** The Ubisoft Connect PC app id; its sessions are accepted by the library GraphQL and Demux. */ + const val APP_ID = "f68a4bb5-608a-4ff2-8123-be8ef797e0a6" + private const val REFRESH_MARGIN_MS = 10 * 60 * 1000L + private val lock = Any() + + class Session( + val ticket: String, + val sessionId: String, + val profileId: String, + val userId: String, + val name: String, + val expiresAt: Long, + val rememberMe: String, + ) + + class Reply(val status: Int, val body: String, val headers: Map) { + val ok get() = status in 200..299 + } + + private val loggedIn = "https://connect.ubisoft.com/logged-in.html?appId=$WEB_APP_ID&lang=en-US&genomeId=$WEB_GENOME_ID" + val loginUrl = "https://connect.ubisoft.com/login?appId=$WEB_APP_ID&genomeId=$WEB_GENOME_ID&lang=en-US&nextUrl=" + URLEncoder.encode(loggedIn, "UTF-8") + val SIGN_IN_URLS = listOf("https://connect.ubisoft.com", "https://account.ubisoft.com", "https://ubisoft.com") + + fun isLoggedInPage(url: String) = url.startsWith("https://connect.ubisoft.com/logged-in.html") + + /** Reads connect.ubisoft.com's stored sign-in; the WebAuth SDK keeps it under keys holding `loginData` and `rememberMe`. */ + const val PAGE_SCRIPT = "(function(){try{var o={};for(var i=0;i = emptyMap()): Reply { + val c = URL(url).openConnection() as HttpURLConnection + c.requestMethod = method + c.connectTimeout = 15_000 + c.readTimeout = 25_000 + c.setRequestProperty("Ubi-AppId", APP_ID) + c.setRequestProperty("Ubi-RequestedPlatformType", "uplay") + c.setRequestProperty("Content-Type", "application/json") + c.setRequestProperty("User-Agent", "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/122.0.0.0 Safari/537.36") + authorization?.let { c.setRequestProperty("Authorization", it) } + sessionId?.let { c.setRequestProperty("Ubi-SessionId", it) } + for ((k, v) in extra) c.setRequestProperty(k, v) + try { + if (body != null) { + c.doOutput = true + c.outputStream.use { it.write(body.toByteArray()) } + } + val status = c.responseCode + val text = (if (status in 200..299) c.inputStream else c.errorStream)?.use { it.readBytes().toString(Charsets.UTF_8) }.orEmpty() + val headers = c.headerFields.filterKeys { it != null }.mapKeys { it.key.lowercase() }.mapValues { it.value.joinToString(",") } + return Reply(status, text, headers) + } finally { + c.disconnect() + } + } + + /** POSTs a session request, replaying it once when UbiServices answers with a challenge. */ + private fun session(authorization: String): Reply { + val body = JSONObject().put("rememberMe", true).toString() + val first = request(SESSIONS, "POST", authorization, body = body) + val challenge = first.headers["ubi-challenge"] ?: return first + return request(SESSIONS, "POST", authorization, body = body, extra = mapOf("Ubi-Challenge" to challenge)) + } + + internal fun parseSession(body: String, rememberFallback: String = ""): Session? { + val o = runCatching { JSONObject(body) }.getOrNull() ?: return null + val ticket = o.optString("ticket").takeIf { it.isNotEmpty() && it != "null" } ?: return null + return Session( + ticket = ticket, + sessionId = o.optString("sessionId"), + profileId = o.optString("profileId"), + userId = o.optString("userId"), + name = o.optString("nameOnPlatform"), + expiresAt = parseTime(o.optString("expiration")), + rememberMe = o.optString("rememberMeTicket").takeIf { it.isNotEmpty() && it != "null" } ?: rememberFallback, + ) + } + + internal fun parseTime(s: String): Long = runCatching { java.time.OffsetDateTime.parse(s).toInstant().toEpochMilli() } + .recoverCatching { java.time.Instant.parse(s).toEpochMilli() }.getOrDefault(System.currentTimeMillis() + 60 * 60 * 1000L) + + /** Values the web sign-in stored, found wherever the SDK nests them. */ + internal fun webValues(dump: String): Map { + val out = HashMap() + fun walk(v: Any?) { + when (v) { + is JSONObject -> v.keys().forEach { k -> + val x = v.opt(k) + if (x is String && x.isNotEmpty() && k !in out) { + val nested = if (x.startsWith("{") || x.startsWith("[")) runCatching { org.json.JSONTokener(x).nextValue() }.getOrNull() else null + if (nested != null) walk(nested) else out[k] = x + } else walk(x) + } + is JSONArray -> for (i in 0 until v.length()) walk(v.opt(i)) + } + } + val top = runCatching { org.json.JSONTokener(dump).nextValue() }.getOrNull() + walk(if (top is String) runCatching { JSONObject(top) }.getOrNull() else top) + return out + } + + /** Turns the page's stored sign-in into a launcher session; returns null on success or a reason. */ + fun completeWebSignIn(context: Context, dump: String): String? { + val web = webValues(dump) + val ticket = web["ticket"] + val remember = web["rememberMeTicket"].orEmpty() + val fresh = when { + ticket != null -> session("ubi_v1 t=$ticket").let { r -> + parseSession(r.body, remember) ?: run { Log.w(TAG, "ticket exchange: ${r.status} ${r.body.take(200)}"); null } + } + else -> null + } ?: remember.takeIf { it.isNotEmpty() }?.let { rm -> parseSession(session("rm_v1 t=$rm").body, rm) } + ?: return "no ticket" + save(context, fresh) + return null + } + + private fun save(context: Context, s: Session) { + val name = s.name.ifEmpty { profileName(s) } + StoreAccounts.write(context.applicationContext, Store.UBISOFT, JSONObject() + .put("access_token", s.ticket) + .put("session_id", s.sessionId) + .put("profile_id", s.profileId) + .put("user_id", s.userId) + .put("display_name", name) + .put("expires_at", s.expiresAt) + .put("remember_me", s.rememberMe)) + } + + private fun profileName(s: Session): String = runCatching { + JSONObject(request("https://public-ubiservices.ubi.com/v3/profiles/me", authorization = "ubi_v1 t=${s.ticket}", sessionId = s.sessionId).body).optString("nameOnPlatform") + }.getOrDefault("") + + fun load(context: Context): Session? { + val o = StoreAccounts.read(context.applicationContext, Store.UBISOFT) ?: return null + val ticket = o.optString("access_token").takeIf { it.isNotEmpty() } ?: return null + return Session(ticket, o.optString("session_id"), o.optString("profile_id"), o.optString("user_id"), o.optString("display_name"), o.optLong("expires_at"), o.optString("remember_me")) + } + + /** A live session, renewed through remember-me shortly before it expires. */ + fun current(context: Context): Session? = synchronized(lock) { + val s = load(context) ?: return null + if (s.expiresAt - System.currentTimeMillis() > REFRESH_MARGIN_MS || s.rememberMe.isEmpty()) return s + val renewed = runCatching { session("rm_v1 t=${s.rememberMe}") }.getOrNull()?.let { parseSession(it.body, s.rememberMe) } + if (renewed == null) { + Log.w(TAG, "remember-me renewal failed") + return if (s.expiresAt > System.currentTimeMillis()) s else null + } + save(context, Session(renewed.ticket, renewed.sessionId, renewed.profileId.ifEmpty { s.profileId }, renewed.userId.ifEmpty { s.userId }, + renewed.name.ifEmpty { s.name }, renewed.expiresAt, renewed.rememberMe)) + load(context) + } + + fun signOut(context: Context) = synchronized(lock) { StoreAccounts.clear(context.applicationContext, Store.UBISOFT) } +} diff --git a/app/src/main/java/com/droiddeck/launcher/stores/ubisoft/UbisoftBackend.kt b/app/src/main/java/com/droiddeck/launcher/stores/ubisoft/UbisoftBackend.kt new file mode 100644 index 000000000..09008e192 --- /dev/null +++ b/app/src/main/java/com/droiddeck/launcher/stores/ubisoft/UbisoftBackend.kt @@ -0,0 +1,246 @@ +package com.droiddeck.launcher.stores.ubisoft + +import android.content.Context +import android.content.Intent +import android.util.Log +import android.webkit.WebView +import com.droiddeck.launcher.R +import com.droiddeck.launcher.stores.CatalogItem +import com.droiddeck.launcher.stores.InstalledStoreGame +import com.droiddeck.launcher.stores.LoginFlows +import com.droiddeck.launcher.stores.Store +import com.droiddeck.launcher.stores.StoreBackend +import com.droiddeck.launcher.stores.StoreInstallRoot +import com.droiddeck.launcher.stores.download.DownloadEntry +import com.droiddeck.launcher.stores.download.DownloadQueue +import com.droiddeck.launcher.stores.StoreLoginActivity +import com.droiddeck.launcher.stores.StoresState +import com.droiddeck.launcher.stores.WebCookies +import org.json.JSONObject + +object UbisoftBackend : StoreBackend { + override val store: Store = Store.UBISOFT + + init { + LoginFlows.ubisoftFlow = { ua -> UbisoftLogin(ua) } + } + + override fun signIn(context: Context) { + context.startActivity( + Intent(context, StoreLoginActivity::class.java) + .putExtra(StoreLoginActivity.EXTRA_STORE, store.id) + .addFlags(Intent.FLAG_ACTIVITY_NEW_TASK), + ) + } + + override fun signOut(context: Context) { + UbisoftAuth.signOut(context.applicationContext) + UbisoftLibrary.reset() + runCatching { WebCookies.expire(UbisoftAuth.SIGN_IN_URLS, ".ubisoft.com", listOf("/")) {} } + } + + override fun syncLibrary(context: Context, force: Boolean) { + val app = context.applicationContext + Thread({ UbisoftLibrary.sync(app, force) }, "ubisoft-sync").start() + } + + override fun loadShelves(context: Context, force: Boolean) {} + + override fun install(context: Context, item: CatalogItem, root: java.io.File) { + val app = context.applicationContext + item.extra[STEAM_APP]?.takeIf { item.extra["productId"] == null }?.let { steam -> + StoresState.logLine("Ubisoft: ${item.title} is owned on Steam; installing Steam app $steam") + installInSteam(app, steam) + return + } + if (!UbisoftNative.ready()) { + StoresState.logLine(UbisoftNative.ENGINE_MISSING) + StoresState.post { StoresState.problems[store] = UbisoftNative.ENGINE_MISSING } + return + } + val folder = StoreInstallRoot.folderFor(app, store, item.id, item.title, root) + val entry = DownloadEntry(store, item.id, item.title, cover = item.tallImageUrl ?: item.imageUrl, + bytesTotal = item.sizeBytes, location = StoreInstallRoot.labelFor(app, folder)) + DownloadQueue.enqueue(app, entry) { UbisoftInstaller(app, item, folder) } + } + + override fun uninstall(context: Context, game: InstalledStoreGame) {} + + const val STEAM_APP = "steamAppId" + + /** Steam asks to install a game it does not have when told to run it. */ + private fun installInSteam(app: Context, steamAppId: String) { + if (com.droiddeck.launcher.session.SessionState.running) { + if (!com.droiddeck.launcher.session.SessionService.launchGame(app, steamAppId)) StoresState.logLine("Ubisoft: Steam is busy; install Steam app $steamAppId there") + return + } + app.startActivity(Intent(app, com.droiddeck.launcher.SessionActivity::class.java) + .putExtra(com.droiddeck.launcher.session.SessionService.EXTRA_STEAM_URL, com.droiddeck.launcher.frontend.GameLaunchLink.steamUrl(steamAppId)) + .addFlags(Intent.FLAG_ACTIVITY_NEW_TASK)) + } +} + +internal class UbisoftLogin(override val userAgent: String) : StoreLoginActivity.Flow { + override val startUrl: String get() = UbisoftAuth.loginUrl + override val readsPage: Boolean get() = true + override val pageScript: String get() = UbisoftAuth.PAGE_SCRIPT + override fun pageReady(result: String): Boolean = result.contains("ticket", ignoreCase = true) + override fun isRedirect(url: String): Boolean = UbisoftAuth.isLoggedInPage(url) + + override fun forgetSignIn(done: () -> Unit) { + runCatching { WebCookies.expire(UbisoftAuth.SIGN_IN_URLS, ".ubisoft.com", listOf("/")) { done() } }.onFailure { done() } + } + + override fun finish(activity: StoreLoginActivity, view: WebView, url: String): String? = try { + UbisoftAuth.completeWebSignIn(activity, url)?.let { + Log.w("UbisoftLogin", "sign-in incomplete: $it") + activity.getString(R.string.stores_login_error_verification) + } + } catch (e: Exception) { + Log.w("UbisoftLogin", "sign-in failed: ${e.javaClass.simpleName}") + activity.getString(R.string.stores_login_error_generic) + } +} + +object UbisoftLibrary { + private const val GRAPHQL = "https://public-ubiservices.ubi.com/v1/profiles/me/uplay/graphql" + private const val PAGE = 50 + private const val QUERY = "query OwnedGames(\$limit: Int, \$offset: Int) { viewer { games(limit: \$limit, offset: \$offset) { totalCount nodes { id spaceId name coverUrl backgroundUrl bannerUrl releaseDate } } } }" + private val generation = java.util.concurrent.atomic.AtomicInteger() + private val running = java.util.concurrent.atomic.AtomicReference(null) + + /** Forgets the signed-out account: its sync results are dropped and a new account may sync at once. */ + fun reset() { + generation.incrementAndGet() + StoresState.post { StoresState.shelves.remove(Store.UBISOFT) } + } + + private fun post(gen: Int, block: () -> Unit) = StoresState.post { if (generation.get() == gen) block() } + + internal fun parsePage(body: String): Pair, Int> { + val o = JSONObject(body) + o.optJSONArray("errors")?.optJSONObject(0)?.let { throw IllegalStateException(it.optString("message")) } + val games = o.optJSONObject("data")?.optJSONObject("viewer")?.optJSONObject("games") ?: return emptyList() to 0 + val nodes = games.optJSONArray("nodes") + val items = (0 until (nodes?.length() ?: 0)).mapNotNull { nodes!!.optJSONObject(it) }.mapNotNull { n -> + val space = n.optString("spaceId").takeIf { it.isNotEmpty() } ?: return@mapNotNull null + fun s(k: String) = n.optString(k).takeIf { it.isNotEmpty() && it != "null" } + CatalogItem( + Store.UBISOFT, space, n.optString("name").trim(), s("backgroundUrl") ?: s("bannerUrl"), + tallImageUrl = s("coverUrl"), releaseDate = s("releaseDate")?.take(10).orEmpty(), owned = true, + extra = mapOf("spaceId" to space, "gameId" to n.optString("id")), + ) + } + return items to games.optInt("totalCount", items.size) + } + + /** Base games with a manifest, by UbiServices space; add-ons and bundles share spaces with their game. */ + internal fun ownedBySpace(owned: JSONObject?): Map { + val list = owned?.optJSONArray("games") ?: return emptyMap() + val games = (0 until list.length()).mapNotNull { list.optJSONObject(it) } + .filter { it.optString("manifest").isNotEmpty() && it.optInt("productType") == 0 && it.optBoolean("owned", true) } + val out = LinkedHashMap() + for (key in listOf("spaceId")) for (g in games) { + val space = g.optString(key).takeIf { it.isNotEmpty() } ?: continue + out.putIfAbsent(space, g) + } + return out + } + + private val STEAM_ID = Regex("""^\s*steam_app_id:\s*['"]?(\d+)""", RegexOption.MULTILINE) + private val THIRD_PARTY = Regex("""^\s*third_party_platform:\s*\r?\n\s+name:\s*['"]?Steam\b""", setOf(RegexOption.MULTILINE, RegexOption.IGNORE_CASE)) + + /** The Steam app of a product Ubisoft says is owned and installed through Steam. */ + internal fun steamApp(configuration: String): String? = + if (THIRD_PARTY.containsMatchIn(configuration)) STEAM_ID.find(configuration)?.groupValues?.get(1) else null + + /** Owned games Ubisoft delivers through Steam, by UbiServices space. */ + internal fun steamBySpace(owned: JSONObject?): Map { + val list = owned?.optJSONArray("games") ?: return emptyMap() + return (0 until list.length()).mapNotNull { list.optJSONObject(it) } + .filter { it.optInt("productType") == 0 && it.optBoolean("owned", true) && it.optString("spaceId").isNotEmpty() } + .mapNotNull { o -> steamApp(o.optString("configuration"))?.let { o.optString("spaceId") to it } }.toMap() + } + + private val RELATIVE = Regex("""relative:\s*['"]?([^'"\r\n]+?)['"]?\s*$""", RegexOption.MULTILINE) + + private val NAME = Regex("""^\s{1,4}name:\s*['"]?(.+?)['"]?\s*$""", RegexOption.MULTILINE) + + /** The product's name from its configuration, following a localisation key such as `l1` to its default text. */ + internal fun title(configuration: String): String? { + val raw = NAME.find(configuration)?.groupValues?.get(1)?.trim()?.takeIf { it.isNotEmpty() } ?: return null + if (!Regex("""l\d+""").matches(raw)) return raw + val local = configuration.indexOf("localizations:").takeIf { it >= 0 } ?: return raw + return Regex("""^\s+${raw}:\s*['"]?(.+?)['"]?\s*$""", RegexOption.MULTILINE).find(configuration, local)?.groupValues?.get(1)?.trim() ?: raw + } + + /** The first launch path under `start_game` in a product's configuration. */ + internal fun executable(configuration: String): String? { + val start = configuration.indexOf("start_game").takeIf { it >= 0 } ?: return null + return RELATIVE.find(configuration, start)?.groupValues?.get(1)?.trim()?.takeIf { it.endsWith(".exe", true) } + } + + fun sync(app: Context, force: Boolean) { + val gen = generation.get() + if (running.getAndSet(gen) == gen) return + try { + val s = UbisoftAuth.current(app) + if (s == null) { + post(gen) { StoresState.status.remove(Store.UBISOFT); StoresState.problems[Store.UBISOFT] = app.getString(R.string.stores_sync_sign_in) } + return + } + if (StoresState.library[Store.UBISOFT].isNullOrEmpty()) post(gen) { StoresState.status[Store.UBISOFT] = app.getString(R.string.stores_sync_game_list) } + val all = ArrayList() + var offset = 0 + while (true) { + val body = JSONObject().put("operationName", "OwnedGames").put("query", QUERY) + .put("variables", JSONObject().put("limit", PAGE).put("offset", offset)).toString() + val reply = UbisoftAuth.request(GRAPHQL, "POST", "Ubi_v1 t=${s.ticket}", s.sessionId, body) + if (reply.status == 401) StoresState.markSignInExpired(Store.UBISOFT) + if (!reply.ok) throw IllegalStateException("library ${reply.status}") + val (items, total) = parsePage(reply.body) + all += items + offset += PAGE + if (items.isEmpty() || offset >= total) break + } + val owned = UbisoftNative.call("owned", JSONObject().put("ticket", s.ticket).put("sessionId", s.sessionId)) + owned?.optString("error")?.takeIf { it.isNotEmpty() }?.let { StoresState.logLine("Ubisoft: ownership: $it") } + val bySpace = ownedBySpace(owned) + owned?.takeIf { it.optInt("manifestsAsked") > 0 }?.let { + StoresState.logLine("Ubisoft: latest manifests: ${it.optInt("manifestsFound")} of ${it.optInt("manifestsAsked")} ${it.optString("manifestError")}".trim()) + } + fun download(o: JSONObject) = listOfNotNull( + "productId" to o.optLong("productId").toString(), + o.optString("manifest").takeIf { it.isNotEmpty() }?.let { "manifest" to it }, + o.optString("appId").takeIf { it.isNotEmpty() }?.let { "appId" to it }, + executable(o.optString("configuration"))?.let { "exe" to it }, + ) + val listed = all.distinctBy { it.id } + val onSteam = steamBySpace(owned) + val matched = listed.map { g -> bySpace[g.id]?.let { g.copy(extra = g.extra + download(it)) } ?: onSteam[g.id]?.let { g.copy(extra = g.extra + (UbisoftBackend.STEAM_APP to it)) } ?: g } + .let { games -> games.filter { g -> g.extra["productId"] == null || games.first { it.extra["productId"] == g.extra["productId"] } === g } } + .let { games -> games.filter { g -> g.extra["productId"] != null || games.none { it !== g && it.title == g.title && it.extra["productId"] != null } } } + val products = matched.mapNotNull { it.extra["productId"] }.toSet() + val unlisted = bySpace.filterKeys { space -> listed.none { it.id == space } }.filterValues { it.optLong("productId").toString() !in products }.mapNotNull { (space, o) -> + val title = title(o.optString("configuration")) ?: return@mapNotNull null + CatalogItem(Store.UBISOFT, space, title, null, owned = true, extra = mapOf("spaceId" to space) + download(o)) + } + val games = (matched + unlisted).sortedBy { it.title.lowercase() } + StoresState.logLine("Ubisoft: ${bySpace.size} downloadable products; " + + games.filter { it.extra.containsKey("productId") }.joinToString(", ") { "${it.title}=${it.extra["productId"]}" }) + post(gen) { + StoresState.status.remove(Store.UBISOFT) + StoresState.problems.remove(Store.UBISOFT) + StoresState.library[Store.UBISOFT] = games + StoresState.shelves[Store.UBISOFT] = com.droiddeck.launcher.stores.StoreShelves( + trending = games.sortedByDescending { it.extra.containsKey("productId") }, art = games.mapNotNull { it.imageUrl }) + } + StoresState.logLine("Ubisoft: ${games.size} owned games; on Steam: " + games.filter { it.extra.containsKey(UbisoftBackend.STEAM_APP) }.joinToString(", ") { "${it.title}=${it.extra[UbisoftBackend.STEAM_APP]}" }) + } catch (e: Exception) { + StoresState.logLine("Ubisoft: library sync failed: ${e.javaClass.simpleName} ${e.message.orEmpty().take(200)}") + post(gen) { StoresState.status.remove(Store.UBISOFT); StoresState.problems[Store.UBISOFT] = app.getString(R.string.stores_sync_failed) } + } finally { + running.compareAndSet(gen, null) + } + } +} diff --git a/app/src/main/java/com/droiddeck/launcher/stores/ubisoft/UbisoftConnect.kt b/app/src/main/java/com/droiddeck/launcher/stores/ubisoft/UbisoftConnect.kt new file mode 100644 index 000000000..23cc92cd2 --- /dev/null +++ b/app/src/main/java/com/droiddeck/launcher/stores/ubisoft/UbisoftConnect.kt @@ -0,0 +1,18 @@ +package com.droiddeck.launcher.stores.ubisoft + +import android.content.Context +import com.droiddeck.launcher.frontend.AddedGames +import com.droiddeck.launcher.runtime.LinuxRuntime +import java.io.File + +/** The one prefix every Ubisoft game runs in, so Ubisoft Connect is installed and signed in once. */ +object UbisoftConnect { + const val APP_ID = 2929927072L + + fun prefix(context: Context) = File(LinuxRuntime.rootDir(context), "root/.local/share/Steam/steamapps/compatdata/$APP_ID") + + fun installed(context: Context) = File(prefix(context), "pfx/drive_c/Program Files (x86)/Ubisoft/Ubisoft Game Launcher/upc.exe").isFile + + fun launchOptions(context: Context): String = + AddedGames.guestPath(context, prefix(context))?.let { "STEAM_COMPAT_DATA_PATH=$it %command%" }.orEmpty() +} diff --git a/app/src/main/java/com/droiddeck/launcher/stores/ubisoft/UbisoftInstaller.kt b/app/src/main/java/com/droiddeck/launcher/stores/ubisoft/UbisoftInstaller.kt new file mode 100644 index 000000000..1bf4c4e36 --- /dev/null +++ b/app/src/main/java/com/droiddeck/launcher/stores/ubisoft/UbisoftInstaller.kt @@ -0,0 +1,104 @@ +package com.droiddeck.launcher.stores.ubisoft + +import android.content.Context +import com.droiddeck.launcher.R +import com.droiddeck.launcher.stores.CatalogItem +import com.droiddeck.launcher.stores.Store +import com.droiddeck.launcher.stores.StoreExe +import com.droiddeck.launcher.stores.StoreGameSidecar +import com.droiddeck.launcher.stores.StoreInstalls +import com.droiddeck.launcher.stores.StoreLanguage +import com.droiddeck.launcher.stores.amazon.AmazonNative +import com.droiddeck.launcher.stores.download.DownloadQueue +import com.droiddeck.launcher.stores.download.DownloadStage +import org.json.JSONObject +import java.io.File +import java.util.concurrent.atomic.AtomicBoolean +import java.util.concurrent.atomic.AtomicLong + +internal class UbisoftInstaller( + private val app: Context, + private val item: CatalogItem, + private val folder: File, +) : DownloadQueue.DownloadJob { + private val cancelled = AtomicBoolean(false) + + override fun run(handle: DownloadQueue.JobHandle): String? { + handle.stage(DownloadStage.MANIFEST, app.getString(R.string.stores_sync_sign_in)) + val s = UbisoftAuth.current(app) ?: throw IllegalStateException("Not signed in to Ubisoft") + val product = item.extra["productId"]?.toLongOrNull() ?: throw IllegalStateException("Ubisoft lists no download for ${item.title} on this account") + val cover = item.tallImageUrl ?: item.imageUrl + StoreInstalls.begin(folder, Store.UBISOFT, item.id, item.title, cover, item.imageUrl) + if (cancelled.get()) return null + val locale = StoreLanguage.localeFor(null, app) + val request = JSONObject() + .put("ticket", s.ticket) + .put("sessionId", s.sessionId) + .put("productId", product) + .put("manifest", item.extra["manifest"].orEmpty()) + .put("folder", folder.absolutePath) + .put("language", "${locale.language}-${locale.country}") + val shown = AtomicLong(-1) + val downloading = AtomicBoolean(false) + val listener = object : AmazonNative.InstallListener { + override fun onStatus(text: String) { handle.stage(DownloadStage.MANIFEST, text) } + override fun onCheck(doneBytes: Long, totalBytes: Long, files: Int, filesTotal: Int) { + handle.stageProgress(doneBytes, totalBytes, files, filesTotal, bytes = true) + } + override fun onSizes(pendingBytes: Long, totalBytes: Long) { handle.diskSize(totalBytes) } + override fun onProgress(done: Long, total: Long, files: Int, filesTotal: Int) { + if (downloading.compareAndSet(false, true)) handle.stage(DownloadStage.DOWNLOAD) + while (true) { + val before = shown.get() + if (done < before) return + if (shown.compareAndSet(before, done)) break + } + handle.progress(done, total, "Downloading ($files/$filesTotal files)") + } + override fun onLog(line: String) { handle.log(line) } + override fun beforeReplace(): Boolean = true + override fun onComplete(success: Boolean, cancelled: Boolean, error: String, result: String) = Unit + } + val outcome = UbisoftNative.install(request, listener, cancelled) + if (outcome.cancelled || cancelled.get() && !outcome.success) return null + if (!outcome.success) throw IllegalStateException(outcome.error.ifEmpty { "The Ubisoft install failed" }) + + handle.stage(DownloadStage.INSTALL, app.getString(R.string.stores_registering)) + val configured = resolve(folder, item.extra["exe"].orEmpty()) + val exe = configured ?: StoreExe.pick(folder, null, item.title).ifEmpty { "game.exe" } + handle.log("ubisoft: exe=$exe") + val sidecar = StoreGameSidecar( + Store.UBISOFT, item.id, item.title, exe = exe, + installVersion = item.extra["manifest"].orEmpty(), installedAt = System.currentTimeMillis(), + cover = cover, hero = item.imageUrl, cloud = false, + extra = item.extra.filterKeys { it in KEPT } + listOfNotNull( + outcome.result?.optJSONArray("chunks")?.let { UbisoftLaunch.CHUNKS to it.toString() }, + outcome.result?.optJSONObject("setup")?.let { UbisoftLaunch.SETUP to it.toString() }, + ), + ) + StoreInstalls.complete(app, folder, sidecar, onStep = { done, steps -> handle.stageProgress(done.toLong(), steps.toLong()) }) + return folder.path + } + + override fun cancel(deleteFiles: Boolean) { + cancelled.set(true) + if (deleteFiles) StoreInstalls.discard(app, folder, emptyList()) + } + + internal companion object { + val KEPT = setOf("productId", "spaceId", "manifest", "appId") + + /** A configured exe the way Windows finds it: any letter case, else the same file name anywhere in the install. */ + fun resolve(folder: File, configured: String): String? { + val rel = configured.replace('\\', '/').trimStart('/').takeIf { it.isNotEmpty() } ?: return null + var at = folder + for (part in rel.split('/')) { + at = at.listFiles()?.firstOrNull { it.name.equals(part, ignoreCase = true) } ?: return byName(folder, rel.substringAfterLast('/')) + } + return if (at.isFile) at.relativeTo(folder).invariantSeparatorsPath else byName(folder, rel.substringAfterLast('/')) + } + + private fun byName(folder: File, name: String): String? = + folder.walkTopDown().maxDepth(5).firstOrNull { it.isFile && it.name.equals(name, ignoreCase = true) }?.relativeTo(folder)?.invariantSeparatorsPath + } +} diff --git a/app/src/main/java/com/droiddeck/launcher/stores/ubisoft/UbisoftLaunch.kt b/app/src/main/java/com/droiddeck/launcher/stores/ubisoft/UbisoftLaunch.kt new file mode 100644 index 000000000..279d3b3e4 --- /dev/null +++ b/app/src/main/java/com/droiddeck/launcher/stores/ubisoft/UbisoftLaunch.kt @@ -0,0 +1,86 @@ +package com.droiddeck.launcher.stores.ubisoft + +import android.content.Context +import com.droiddeck.launcher.stores.StoreInstallRoot +import com.droiddeck.launcher.stores.Store +import com.droiddeck.launcher.stores.StoreGameSidecar +import com.droiddeck.launcher.stores.StoresState +import org.json.JSONArray +import org.json.JSONObject + +/** What the launcher service hands a starting Ubisoft game: the account, its products and a fresh game ticket. */ +object UbisoftLaunch { + private const val ENTITLEMENTS = "https://public-ubiservices.ubi.com/v1/profiles/me/global/ubiconnect/entitlement/api/entitlements" + private const val ME = "https://public-ubiservices.ubi.com/v3/users/me" + + fun prepare(app: Context, id: String): JSONObject { + val folder = StoreInstallRoot.gameFolders(app).firstOrNull { f -> + StoreGameSidecar.read(f)?.let { it.store == Store.UBISOFT && it.id == id } == true + } ?: return failure("not-installed") + val sidecar = StoreGameSidecar.read(folder) ?: return failure("not-installed") + val product = sidecar.extra["productId"]?.toLongOrNull() ?: return failure("no-product") + val s = UbisoftAuth.current(app) ?: return failure("signed-out") + val native = UbisoftNative.call("launch", JSONObject().put("ticket", s.ticket).put("sessionId", s.sessionId).put("productId", product)) + ?: return failure("engine-missing") + native.optString("error").takeIf { it.isNotEmpty() }?.let { + StoresState.logLine("Ubisoft: launch ticket for $product: $it") + return failure("ticket: $it") + } + val auth = "Ubi_v1 t=${s.ticket}" + val me = runCatching { UbisoftAuth.request(ME, "GET", auth, s.sessionId) }.getOrNull()?.takeIf { it.ok } + ?.let { runCatching { JSONObject(it.body) }.getOrNull() } + val account = JSONObject() + .put("userId", s.userId) + .put("username", me?.optString("username")?.takeIf { it.isNotEmpty() } ?: s.name) + .put("nameOnPlatform", me?.optString("nameOnPlatform")?.takeIf { it.isNotEmpty() } ?: s.name) + .put("email", me?.optString("email").orEmpty()) + .put("country", me?.optString("country")?.takeIf { it.isNotEmpty() } ?: "US") + val appId = runCatching { UbisoftAuth.request(ENTITLEMENTS, "GET", auth, s.sessionId) }.getOrNull()?.takeIf { it.ok } + ?.let { applicationId(it.body, product) } ?: sidecar.extra["appId"].orEmpty() + StoresState.logLine("Ubisoft: launch ticket for $product ready (${native.optJSONArray("owned")?.length() ?: 0} products, appId ${if (appId.isEmpty()) "unknown" else "found"})") + val language = com.droiddeck.launcher.stores.StoreLanguage.localeFor(null, app).let { "${it.language}-${it.country}" } + val cached = sidecar.extra[CHUNKS]?.let { runCatching { JSONArray(it) }.getOrNull() } + var setup = sidecar.extra[SETUP]?.let { runCatching { JSONObject(it) }.getOrNull() } + val chunks = if (cached != null && setup != null) cached else + UbisoftNative.call("chunks", JSONObject().put("ticket", s.ticket).put("sessionId", s.sessionId).put("productId", product) + .put("manifest", sidecar.extra["manifest"] ?: sidecar.installVersion).put("folder", folder.path).put("language", language)) + ?.let { r -> + r.optString("error").takeIf { it.isNotEmpty() }?.let { StoresState.logLine("Ubisoft: manifest for $product: $it") } + setup = r.optJSONObject("setup") + r.optJSONArray("chunks") + }?.also { c -> + runCatching { + StoreGameSidecar.read(folder)?.let { sc -> + sc.copy(extra = sc.extra + (CHUNKS to c.toString()) + listOfNotNull(setup?.let { SETUP to it.toString() })).write(folder) + } + com.droiddeck.launcher.stores.StoreLaunch.installedGame(app, Store.UBISOFT, id) + ?.let { com.droiddeck.launcher.frontend.AutoComponents.refreshLater(app, it) } + } + } ?: cached + setup?.let { StoresState.logLine("Ubisoft: setup for $product: ${it.toString().take(1500)}") } + UbisoftActivation.watch(app, product) + return JSONObject().put("ok", true) + .put("productId", product) + .put("ticket", native.optString("ticket")) + .put("owned", native.optJSONArray("owned") ?: JSONArray()) + .put("products", native.optJSONArray("products") ?: JSONArray()) + .put("account", account) + .put("appId", appId) + .put("language", language) + .put("chunks", chunks ?: JSONArray()) + .put("registry", setup?.optJSONArray("registry") ?: JSONArray()) + } + + const val CHUNKS = "chunks" + const val SETUP = "setup" + + /** The entitlement's `applicationId` for the product; the list sits under one of a few keys depending on the API version. */ + internal fun applicationId(body: String, product: Long): String? { + val root = runCatching { JSONObject(body) }.getOrNull() ?: return null + val list = sequenceOf("items", "entitlements", "products", "games", "results").firstNotNullOfOrNull { root.optJSONArray(it) } ?: return null + return (0 until list.length()).mapNotNull { list.optJSONObject(it) } + .firstOrNull { it.optLong("productId") == product }?.optString("applicationId")?.takeIf { it.isNotEmpty() } + } + + private fun failure(reason: String) = JSONObject().put("ok", false).put("reason", reason) +} diff --git a/app/src/main/java/com/droiddeck/launcher/stores/ubisoft/UbisoftNative.kt b/app/src/main/java/com/droiddeck/launcher/stores/ubisoft/UbisoftNative.kt new file mode 100644 index 000000000..7a7d2eb88 --- /dev/null +++ b/app/src/main/java/com/droiddeck/launcher/stores/ubisoft/UbisoftNative.kt @@ -0,0 +1,57 @@ +package com.droiddeck.launcher.stores.ubisoft + +import android.util.Log +import com.droiddeck.launcher.stores.StoresNative +import com.droiddeck.launcher.stores.amazon.AmazonNative +import org.json.JSONObject +import java.util.concurrent.CountDownLatch +import java.util.concurrent.TimeUnit +import java.util.concurrent.atomic.AtomicBoolean + +object UbisoftNative { + private const val TAG = "UbisoftNative" + const val ENGINE_MISSING = "Ubisoft: the store engine is not available" + + fun ready(): Boolean = StoresNative.ensureLoaded() + + fun call(op: String, request: JSONObject): JSONObject? { + if (!ready()) return null + val text = try { nativeCall(op, request.toString()) } catch (t: Throwable) { + Log.w(TAG, "$op: ${t.javaClass.simpleName}") + null + } ?: return null + return runCatching { JSONObject(text) }.getOrNull() + } + + fun install(request: JSONObject, events: AmazonNative.InstallListener, cancelled: AtomicBoolean): AmazonNative.InstallOutcome { + if (!ready()) return AmazonNative.InstallOutcome(false, false, ENGINE_MISSING, null) + val done = CountDownLatch(1) + val holder = arrayOfNulls(1) + val bridge = object : AmazonNative.InstallListener by events { + override fun onComplete(success: Boolean, cancelled: Boolean, error: String, result: String) { + holder[0] = AmazonNative.InstallOutcome(success, cancelled, error, result.takeIf { it.isNotEmpty() }?.let { runCatching { JSONObject(it) }.getOrNull() }) + done.countDown() + } + } + val handle = try { nativeInstall(request.toString(), bridge) } catch (t: Throwable) { + return AmazonNative.InstallOutcome(false, false, "The Ubisoft install could not start: ${t.javaClass.simpleName}", null) + } + if (handle == 0L) return holder[0] ?: AmazonNative.InstallOutcome(false, false, "The Ubisoft install could not start", null) + try { + var forwarded = false + while (true) { + val finished = try { done.await(250, TimeUnit.MILLISECONDS) } catch (e: InterruptedException) { cancelled.set(true); false } + if (finished) break + if (!forwarded && cancelled.get()) { nativeCancel(handle); forwarded = true } + } + } finally { + nativeRelease(handle) + } + return holder[0] ?: AmazonNative.InstallOutcome(false, false, "The Ubisoft install ended without a result", null) + } + + @JvmStatic private external fun nativeCall(op: String, request: String): String? + @JvmStatic private external fun nativeInstall(request: String, listener: AmazonNative.InstallListener): Long + @JvmStatic private external fun nativeCancel(handle: Long) + @JvmStatic private external fun nativeRelease(handle: Long) +} diff --git a/app/src/main/java/com/droiddeck/launcher/ui/StoreChips.kt b/app/src/main/java/com/droiddeck/launcher/ui/StoreChips.kt index 3eede9da1..2fea8fc89 100644 --- a/app/src/main/java/com/droiddeck/launcher/ui/StoreChips.kt +++ b/app/src/main/java/com/droiddeck/launcher/ui/StoreChips.kt @@ -33,6 +33,7 @@ internal fun sourceColours(source: String): SourceColours = when (source) { Store.ITCH.id -> SourceColours(Color(0xFF3A1A1E), Color(0xFFFFB3B3), Color(0xFFFF5C5C)) Store.GAMEJOLT.id -> SourceColours(Color(0xFF232E00), Color(0xFFE3FF66), Color(0xFFCCFF00)) Store.BATTLENET.id -> SourceColours(Color(0xFF0A2440), Color(0xFF9DD1FF), Color(0xFF148EFF)) + Store.UBISOFT.id -> SourceColours(Color(0xFF0B1E44), Color(0xFFA8C6FF), Color(0xFF2E7DFF)) Store.EA.id -> SourceColours(Color(0xFF3A0E12), Color(0xFFFF9A9A), Color(0xFFFF4747)) Library.SOURCE_STEAM -> SourceColours(Color(0xFF16293D), Color(0xFF7CC4FF), Color(0xFF1A9FFF)) FLATHUB -> SourceColours(Color(0xFF0B2E2B), Color(0xFF9FE3DD), FlathubTeal) @@ -49,6 +50,7 @@ internal fun sourceLabel(source: String): String = when (source) { Store.ITCH.id -> Store.ITCH.label Store.GAMEJOLT.id -> Store.GAMEJOLT.label Store.BATTLENET.id -> Store.BATTLENET.label + Store.UBISOFT.id -> Store.UBISOFT.shortLabel Store.EA.id -> Store.EA.label else -> stringResource(R.string.source_added) } diff --git a/app/src/main/java/com/droiddeck/launcher/ui/StoresPage.kt b/app/src/main/java/com/droiddeck/launcher/ui/StoresPage.kt index 88e473285..7bc7c186d 100644 --- a/app/src/main/java/com/droiddeck/launcher/ui/StoresPage.kt +++ b/app/src/main/java/com/droiddeck/launcher/ui/StoresPage.kt @@ -1157,6 +1157,7 @@ internal fun storePalette(base: Palette, store: Store?): Palette { Store.ITCH -> Color(0xFFFF5C5C) to Color(0xFFD93B3B) Store.GAMEJOLT -> Color(0xFFCCFF00) to Color(0xFFA3CC00) Store.BATTLENET -> Color(0xFF148EFF) to Color(0xFF0B6BD3) + Store.UBISOFT -> Color(0xFF2E7DFF) to Color(0xFF1A5BD6) Store.EA -> Color(0xFFFF4747) to Color(0xFFE03030) null -> return base } diff --git a/app/src/main/java/com/droiddeck/launcher/ui/WinComponentsEditor.kt b/app/src/main/java/com/droiddeck/launcher/ui/WinComponentsEditor.kt index 4dff5d4d6..3fd3c9477 100644 --- a/app/src/main/java/com/droiddeck/launcher/ui/WinComponentsEditor.kt +++ b/app/src/main/java/com/droiddeck/launcher/ui/WinComponentsEditor.kt @@ -56,7 +56,7 @@ import java.io.File private fun installable(name: String, all: Map): String = WinComponents.installable(name, all) @Composable -private fun originName(origin: String): String = if (origin == WinCompSources.Origin.XBOX.name) "Xbox" else stringResource( +private fun originName(origin: String): String = if (origin == WinCompSources.Origin.XBOX.name) "Xbox" else if (origin == WinCompSources.Origin.UBISOFT.name) "Ubisoft Connect" else stringResource( when (origin) { WinCompSources.Origin.GOG.name -> R.string.wincomp_src_gog WinCompSources.Origin.EPIC.name -> R.string.wincomp_src_epic @@ -71,6 +71,7 @@ private fun autoReason(r: WinComponents.AutoReason): String = when (r.kind) { WinCompSources.Origin.GOG.name -> stringResource(R.string.wincomp_auto_gog) WinCompSources.Origin.EPIC.name -> stringResource(R.string.wincomp_auto_epic) WinCompSources.Origin.AMAZON.name -> stringResource(R.string.wincomp_auto_amazon) + WinCompSources.Origin.UBISOFT.name -> "From Ubisoft's install steps for this game" WinCompSources.Origin.XBOX.name -> "Xbox games install it with the game (${r.args.getOrElse(0) { "VC14" }})" else -> "" } diff --git a/app/src/main/rust/stores/Cargo.lock b/app/src/main/rust/stores/Cargo.lock index 2fac37c5a..6a470b3be 100644 --- a/app/src/main/rust/stores/Cargo.lock +++ b/app/src/main/rust/stores/Cargo.lock @@ -195,10 +195,13 @@ dependencies = [ "libc", "lzma-rs", "reqwest", + "ruzstd", "serde_json", "sha1", "sha2", "tokio", + "tokio-rustls", + "webpki-roots", ] [[package]] @@ -897,6 +900,15 @@ version = "1.0.22" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b39cdef0fa800fc44525c84ccb54a029961a8215f9619753635a9c0d2538d46d" +[[package]] +name = "ruzstd" +version = "0.8.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a7c1c839d570d835527c9a5e4db7cb2198683a988cb9d7293fc8674e6bd58fc8" +dependencies = [ + "twox-hash", +] + [[package]] name = "ryu" version = "1.0.23" @@ -1243,6 +1255,12 @@ version = "0.2.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e421abadd41a4225275504ea4d6566923418b7f05506fbc9c0fe86ba7396114b" +[[package]] +name = "twox-hash" +version = "2.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "86a801b3cea342a06d468c8710662aa29e5e05e4f5c0d62f00bbb7f2ad7941c2" + [[package]] name = "typenum" version = "1.20.0" diff --git a/app/src/main/rust/stores/Cargo.toml b/app/src/main/rust/stores/Cargo.toml index 61d121a22..97a27659a 100644 --- a/app/src/main/rust/stores/Cargo.toml +++ b/app/src/main/rust/stores/Cargo.toml @@ -15,11 +15,14 @@ libc = "0.2" lzma-rs = { version = "0.3", default-features = false } flate2 = { version = "1", default-features = false, features = ["rust_backend"] } reqwest = { version = "0.12", default-features = false, features = ["rustls-tls", "gzip"] } -tokio = { version = "1", default-features = false, features = ["rt", "net", "time", "sync"] } +tokio = { version = "1", default-features = false, features = ["rt", "net", "time", "sync", "io-util"] } futures-util = { version = "0.3", default-features = false, features = ["std"] } serde_json = { version = "1", features = ["preserve_order"] } sha1 = "0.10" sha2 = "0.10" +tokio-rustls = { version = "0.26", default-features = false, features = ["ring", "tls12"] } +webpki-roots = "1" +ruzstd = "0.8" [build-dependencies] cc = "1" diff --git a/app/src/main/rust/stores/src/lib.rs b/app/src/main/rust/stores/src/lib.rs index be8212844..12a351138 100644 --- a/app/src/main/rust/stores/src/lib.rs +++ b/app/src/main/rust/stores/src/lib.rs @@ -3,6 +3,7 @@ #![allow(clippy::missing_safety_doc, clippy::result_large_err)] pub mod amazon; +pub mod ubisoft; pub mod ea; pub mod epic; pub mod fetch_core; diff --git a/app/src/main/rust/stores/src/ubisoft/demux.rs b/app/src/main/rust/stores/src/ubisoft/demux.rs new file mode 100644 index 000000000..b54fee443 --- /dev/null +++ b/app/src/main/rust/stores/src/ubisoft/demux.rs @@ -0,0 +1,154 @@ +use std::sync::Arc; +use std::time::Duration; + +use tokio::io::{AsyncReadExt, AsyncWriteExt}; +use tokio::net::TcpStream; +use tokio_rustls::client::TlsStream; +use tokio_rustls::rustls::{self, pki_types::ServerName, ClientConfig, RootCertStore}; +use tokio_rustls::TlsConnector; + +use super::pb::{self, Msg}; + +const HOST: &str = "dmx.upc.ubisoft.com"; +const PORT: u16 = 443; +const CLIENT_ID: &str = "uplay_pc"; +/// The Connect build number Demux expects first on a socket; older ones get ClientOutdatedPush and a closed socket. +pub const CLIENT_VERSION: u32 = 13247; +const READ_TIMEOUT: Duration = Duration::from_secs(20); +const MAX_FRAME: usize = 64 << 20; + +pub struct Demux { + tls: TlsStream, + next_id: u32, +} + +impl Demux { + pub async fn connect(ticket: &str) -> Result { + let mut roots = RootCertStore::empty(); + roots.extend(webpki_roots::TLS_SERVER_ROOTS.iter().cloned()); + let provider = Arc::new(rustls::crypto::ring::default_provider()); + let config = ClientConfig::builder_with_provider(provider) + .with_safe_default_protocol_versions() + .map_err(|e| format!("TLS setup: {e}"))? + .with_root_certificates(roots) + .with_no_client_auth(); + let tcp = tokio::time::timeout(READ_TIMEOUT, TcpStream::connect((HOST, PORT))) + .await + .map_err(|_| "Ubisoft's download service did not answer".to_string())? + .map_err(|e| format!("Could not reach Ubisoft's download service: {e}"))?; + let name = ServerName::try_from(HOST).map_err(|e| e.to_string())?.to_owned(); + let tls = TlsConnector::from(Arc::new(config)) + .connect(name, tcp) + .await + .map_err(|e| format!("TLS with Ubisoft's download service failed: {e}"))?; + let mut d = Demux { tls, next_id: 1 }; + d.send(Msg::new().msg(2, Msg::new().msg(4, Msg::new().uint(1, CLIENT_VERSION as u64)))).await?; + let id = d.id(); + let auth = Msg::new().uint(1, id as u64).msg( + 2, + Msg::new() + .msg(1, Msg::new().text(3, ticket)) + .text(2, CLIENT_ID) + .flag(4, false), + ); + d.send(Msg::new().msg(1, auth)).await?; + let rsp = d.response(id).await?; + let a = pb::fields(pb::sub(&pb::fields(&rsp)?, 2).ok_or("Ubisoft did not answer the sign-in")?)?; + if pb::num(&a, 1) != Some(1) { + let expired = pb::num(&a, 2) == Some(1); + return Err(if expired { "The Ubisoft sign-in expired".into() } else { "Ubisoft refused the sign-in".into() }); + } + Ok(d) + } + + fn id(&mut self) -> u32 { + let id = self.next_id; + self.next_id += 1; + id + } + + async fn send(&mut self, up: Msg) -> Result<(), String> { + let len = (up.0.len() as u32).to_be_bytes(); + self.tls.write_all(&len).await.map_err(|e| e.to_string())?; + self.tls.write_all(&up.0).await.map_err(|e| e.to_string())?; + self.tls.flush().await.map_err(|e| e.to_string()) + } + + async fn frame(&mut self) -> Result, String> { + let read = async { + let mut len = [0u8; 4]; + self.tls.read_exact(&mut len).await.map_err(|e| format!("Ubisoft closed the connection: {e}"))?; + let n = u32::from_be_bytes(len) as usize; + if n > MAX_FRAME { + return Err(format!("Ubisoft sent a {n}-byte frame")); + } + let mut body = vec![0u8; n]; + self.tls.read_exact(&mut body).await.map_err(|e| e.to_string())?; + Ok(body) + }; + tokio::time::timeout(READ_TIMEOUT, read).await.map_err(|_| "Ubisoft's download service stopped answering".to_string())? + } + + fn check_push(down: &[(u32, pb::Value<'_>)]) -> Result<(), String> { + if let Some(push) = pb::sub(down, 2) { + let p = pb::fields(push)?; + if pb::first(&p, 5).is_some() { + return Err("Ubisoft says this client version is outdated".into()); + } + } + Ok(()) + } + + async fn response(&mut self, id: u32) -> Result, String> { + for _ in 0..64 { + let frame = self.frame().await?; + let down = pb::fields(&frame)?; + Self::check_push(&down)?; + if let Some(rsp) = pb::sub(&down, 1) { + if pb::num(&pb::fields(rsp)?, 1) == Some(id as u64) { + return Ok(rsp.to_vec()); + } + } + } + Err("Ubisoft never answered the request".into()) + } + + pub async fn open(&mut self, service: &str) -> Result { + let id = self.id(); + self.send(Msg::new().msg(1, Msg::new().uint(1, id as u64).msg(3, Msg::new().text(2, service)))).await?; + let rsp = self.response(id).await?; + let o = pb::fields(pb::sub(&pb::fields(&rsp)?, 3).ok_or("no connection answer")?)?; + if pb::num(&o, 2) != Some(1) { + return Err(format!("Ubisoft refused the {service} connection")); + } + pb::num(&o, 1).map(|c| c as u32).ok_or_else(|| "no connection id".into()) + } + + /// One service request on a connection; the payload is length-prefixed inside the DataMessage both ways. + pub async fn call(&mut self, connection: u32, payload: Msg) -> Result, String> { + let mut framed = (payload.0.len() as u32).to_be_bytes().to_vec(); + framed.extend_from_slice(&payload.0); + self.send(Msg::new().msg(2, Msg::new().msg(1, Msg::new().uint(1, connection as u64).bytes(2, &framed)))).await?; + for _ in 0..64 { + let frame = self.frame().await?; + let down = pb::fields(&frame)?; + Self::check_push(&down)?; + let Some(push) = pb::sub(&down, 2) else { continue }; + let p = pb::fields(push)?; + if let Some(closed) = pb::sub(&p, 2) { + let c = pb::fields(closed)?; + if pb::num(&c, 1) == Some(connection as u64) { + return Err(format!("Ubisoft closed the service connection (code {})", pb::num(&c, 2).unwrap_or(0))); + } + } + let Some(data) = pb::sub(&p, 1) else { continue }; + let d = pb::fields(data)?; + if pb::num(&d, 1) != Some(connection as u64) { + continue; + } + let body = pb::sub(&d, 2).unwrap_or_default(); + return Ok(if body.len() >= 4 { body[4..].to_vec() } else { body.to_vec() }); + } + Err("Ubisoft never answered the service request".into()) + } +} diff --git a/app/src/main/rust/stores/src/ubisoft/ffi.rs b/app/src/main/rust/stores/src/ubisoft/ffi.rs new file mode 100644 index 000000000..30df1ccd4 --- /dev/null +++ b/app/src/main/rust/stores/src/ubisoft/ffi.rs @@ -0,0 +1,269 @@ +use std::panic::{catch_unwind, AssertUnwindSafe}; +use std::sync::atomic::{AtomicBool, Ordering}; +use std::sync::Arc; + +use jni::objects::{GlobalRef, JClass, JObject, JString, JValue}; +use jni::sys::{jlong, jstring, JNI_FALSE, JNI_TRUE}; +use jni::{JNIEnv, JavaVM}; +use serde_json::json; + +use super::install; +use crate::amazon::install::{Events, Stop}; + +fn read_string(env: &mut JNIEnv, value: &JString) -> String { + if value.is_null() { + return String::new(); + } + env.get_string(value).map(|s| s.to_string_lossy().into_owned()).unwrap_or_default() +} + +fn new_string(env: &mut JNIEnv, text: &str) -> jstring { + env.new_string(text).map(|s| s.into_raw()).unwrap_or(std::ptr::null_mut()) +} + +fn owned(ticket: &str, session: &str) -> Result { + let rt = tokio::runtime::Builder::new_current_thread().enable_all().build().map_err(|e| e.to_string())?; + rt.block_on(async { + let mut d = super::demux::Demux::connect(ticket).await?; + let (mut ownership, mut games) = super::service::Ownership::open(&mut d, ticket, session).await?; + let missing: Vec = games.iter().filter(|g| g.owned && g.product_type == 0 && g.manifest.is_empty()).map(|g| g.product_id).collect(); + let (found, manifest_error) = match ownership.latest_manifests(&mut d, &missing).await { + Ok(m) => (m, String::new()), + Err(e) => (Vec::new(), e), + }; + for (product, manifest) in &found { + if let Some(g) = games.iter_mut().find(|g| g.product_id == *product) { + g.manifest = manifest.clone(); + } + } + Ok(json!({ "games": games.iter().map(|g| g.to_json()).collect::>(), + "manifestsAsked": missing.len(), "manifestsFound": found.len(), "manifestError": manifest_error })) + }) +} + +fn launch(ticket: &str, session: &str, product: u32) -> Result { + let rt = tokio::runtime::Builder::new_current_thread().enable_all().build().map_err(|e| e.to_string())?; + rt.block_on(async { + let mut d = super::demux::Demux::connect(ticket).await?; + let (mut ownership, games) = super::service::Ownership::open(&mut d, ticket, session).await?; + if !games.iter().any(|g| g.product_id == product && g.owned) { + return Err("Ubisoft says this account does not own the game".into()); + } + let pc_ticket = ownership.pc_ticket(&mut d, product, ticket, session).await?; + let owned: Vec = games.iter().filter(|g| g.in_session()).map(|g| g.product_id).collect(); + let products: Vec<_> = games.iter().filter(|g| g.in_session()).map(|g| json!({ "id": g.product_id, "type": g.product_type })).collect(); + Ok(json!({ "ticket": pc_ticket, "owned": owned, "products": products })) + }) +} + +fn unhex(s: &str) -> Result, String> { + if s.len() % 2 != 0 { + return Err("odd hex length".into()); + } + (0..s.len()).step_by(2).map(|i| u8::from_str_radix(&s[i..i + 2], 16).map_err(|e| e.to_string())).collect() +} + +fn denuvo(ticket: &str, session: &str, product: u32, challenge: &str) -> Result { + let challenge = unhex(challenge).map_err(|e| format!("challenge: {e}"))?; + let rt = tokio::runtime::Builder::new_current_thread().enable_all().build().map_err(|e| e.to_string())?; + rt.block_on(async { + let mut d = super::demux::Demux::connect(ticket).await?; + let (mut ownership, _) = super::service::Ownership::open(&mut d, ticket, session).await?; + let token = ownership.token(&mut d, product).await?; + let game = super::service::denuvo_token(&mut d, &token, &challenge).await?; + Ok(json!({ "token": game.iter().map(|b| format!("{b:02x}")).collect::() })) + }) +} + +fn call(op: &str, request: &str) -> String { + let parsed: serde_json::Value = serde_json::from_str(request).unwrap_or(serde_json::Value::Null); + let s = |k: &str| parsed.get(k).and_then(|v| v.as_str()).unwrap_or("").to_string(); + let answer = match op { + "owned" => owned(&s("ticket"), &s("sessionId")).unwrap_or_else(|e| json!({ "error": e })), + "denuvo" => denuvo(&s("ticket"), &s("sessionId"), parsed.get("productId").and_then(|v| v.as_u64()).unwrap_or(0) as u32, &s("challenge")) + .unwrap_or_else(|e| json!({ "error": e })), + "chunks" => install::Request::from_json(&parsed) + .and_then(|r| install::chunks(&r)) + .map(|m| json!({ "chunks": install::chunks_json(&m.chunks), "setup": install::setup_json(&m) })) + .unwrap_or_else(|e| json!({ "error": e })), + "launch" => launch(&s("ticket"), &s("sessionId"), parsed.get("productId").and_then(|v| v.as_u64()).unwrap_or(0) as u32) + .unwrap_or_else(|e| json!({ "error": e })), + _ => json!({ "error": format!("unknown operation {op}") }), + }; + answer.to_string() +} + +#[no_mangle] +pub extern "system" fn Java_com_droiddeck_launcher_stores_ubisoft_UbisoftNative_nativeCall( + mut env: JNIEnv, + _class: JClass, + op: JString, + request: JString, +) -> jstring { + let op = read_string(&mut env, &op); + let request = read_string(&mut env, &request); + match catch_unwind(AssertUnwindSafe(|| call(&op, &request))) { + Ok(text) => new_string(&mut env, &text), + Err(_) => std::ptr::null_mut(), + } +} + +struct Listener { + vm: JavaVM, + target: GlobalRef, +} + +impl Listener { + fn with_env(&self, fallback: R, f: impl FnOnce(&mut JNIEnv) -> R) -> R { + let Ok(mut env) = self.vm.attach_current_thread_as_daemon() else { + return fallback; + }; + let out = f(&mut env); + if env.exception_check().unwrap_or(false) { + let _ = env.exception_clear(); + } + out + } + + fn text(&self, method: &str, text: &str) { + self.with_env((), |env| { + let Ok(s) = env.new_string(text) else { return }; + let s = JObject::from(s); + let _ = env.call_method(self.target.as_obj(), method, "(Ljava/lang/String;)V", &[JValue::Object(&s)]); + let _ = env.delete_local_ref(s); + }); + } + + fn counts(&self, method: &str, a: u64, b: u64, c: u32, d: u32) { + self.with_env((), |env| { + let _ = env.call_method( + self.target.as_obj(), + method, + "(JJII)V", + &[ + JValue::Long(a.min(i64::MAX as u64) as i64), + JValue::Long(b.min(i64::MAX as u64) as i64), + JValue::Int(c.min(i32::MAX as u32) as i32), + JValue::Int(d.min(i32::MAX as u32) as i32), + ], + ); + }); + } + + fn complete(&self, success: bool, cancelled: bool, error: &str, result: &str) { + self.with_env((), |env| { + let (Ok(e), Ok(r)) = (env.new_string(error), env.new_string(result)) else { return }; + let (e, r) = (JObject::from(e), JObject::from(r)); + let _ = env.call_method( + self.target.as_obj(), + "onComplete", + "(ZZLjava/lang/String;Ljava/lang/String;)V", + &[ + JValue::Bool(if success { JNI_TRUE } else { JNI_FALSE }), + JValue::Bool(if cancelled { JNI_TRUE } else { JNI_FALSE }), + JValue::Object(&e), + JValue::Object(&r), + ], + ); + let _ = env.delete_local_ref(e); + let _ = env.delete_local_ref(r); + }); + } +} + +impl Events for Listener { + fn log(&self, line: &str) { + self.text("onLog", &crate::amazon::net::scrub_urls(line)); + } + + fn status(&self, text: &str) { + self.text("onStatus", text); + } + + fn checking(&self, done_bytes: u64, total_bytes: u64, files: u32, files_total: u32) { + self.counts("onCheck", done_bytes, total_bytes, files, files_total); + } + + fn sizes(&self, pending_bytes: u64, total_bytes: u64) { + self.with_env((), |env| { + let _ = env.call_method( + self.target.as_obj(), + "onSizes", + "(JJ)V", + &[ + JValue::Long(pending_bytes.min(i64::MAX as u64) as i64), + JValue::Long(total_bytes.min(i64::MAX as u64) as i64), + ], + ); + }); + } + + fn progress(&self, done: u64, total: u64, files: u32, files_total: u32) { + self.counts("onProgress", done, total, files, files_total); + } + + fn before_replace(&self) -> bool { + self.with_env(false, |env| match env.call_method(self.target.as_obj(), "beforeReplace", "()Z", &[]) { + Ok(v) => v.z().unwrap_or(false), + Err(_) => false, + }) + } +} + +#[no_mangle] +pub extern "system" fn Java_com_droiddeck_launcher_stores_ubisoft_UbisoftNative_nativeInstall( + mut env: JNIEnv, + _class: JClass, + request: JString, + listener: JObject, +) -> jlong { + if listener.is_null() { + return 0; + } + let Ok(vm) = env.get_java_vm() else { return 0 }; + let Ok(target) = env.new_global_ref(&listener) else { return 0 }; + let listener = Listener { vm, target }; + let text = read_string(&mut env, &request); + let parsed = serde_json::from_str::(&text) + .map_err(|_| "The install request could not be read".to_string()) + .and_then(|v| install::Request::from_json(&v)); + let request = match parsed { + Ok(r) => r, + Err(e) => { + listener.complete(false, false, &e, ""); + return 0; + } + }; + let cancel = Arc::new(AtomicBool::new(false)); + let worker_cancel = Arc::clone(&cancel); + let spawned = std::thread::Builder::new().name("ubisoft-install".into()).spawn(move || { + crate::priority::background(); + let result = catch_unwind(AssertUnwindSafe(|| install::run(&request, &worker_cancel, &listener))) + .unwrap_or_else(|_| Err(Stop::Failed("The Ubisoft install stopped on an internal error".into()))); + match result { + Ok(done) => listener.complete(true, false, "", &done.to_json().to_string()), + Err(Stop::Cancelled) => listener.complete(false, true, "cancelled", ""), + Err(Stop::Failed(e)) => listener.complete(false, false, &e, ""), + } + }); + if spawned.is_err() { + return 0; + } + Box::into_raw(Box::new(cancel)) as jlong +} + +#[no_mangle] +pub extern "system" fn Java_com_droiddeck_launcher_stores_ubisoft_UbisoftNative_nativeCancel(_env: JNIEnv, _class: JClass, handle: jlong) { + let ptr = handle as *const Arc; + if let Some(cancel) = unsafe { ptr.as_ref() } { + cancel.store(true, Ordering::SeqCst); + } +} + +#[no_mangle] +pub extern "system" fn Java_com_droiddeck_launcher_stores_ubisoft_UbisoftNative_nativeRelease(_env: JNIEnv, _class: JClass, handle: jlong) { + let ptr = handle as *mut Arc; + if !ptr.is_null() { + drop(unsafe { Box::from_raw(ptr) }); + } +} diff --git a/app/src/main/rust/stores/src/ubisoft/install.rs b/app/src/main/rust/stores/src/ubisoft/install.rs new file mode 100644 index 000000000..fdb4357fa --- /dev/null +++ b/app/src/main/rust/stores/src/ubisoft/install.rs @@ -0,0 +1,408 @@ +use std::collections::HashMap; +use std::io::{Read, Seek, SeekFrom, Write}; +use std::path::{Component, Path, PathBuf}; +use std::sync::atomic::{AtomicBool, AtomicU64, Ordering}; +use std::sync::Mutex; +use std::time::{Duration, Instant}; + +use futures_util::stream::{self, StreamExt}; +use serde_json::{json, Value as Json}; +use sha1::{Digest, Sha1}; + +use super::demux::Demux; +use super::manifest::{self, File}; +use super::service::{Downloads, Ownership}; +use crate::amazon::install::{Events, Stop}; + +const SIGN_BATCH: usize = 200; +const WINDOW: usize = 16; +const TRIES: usize = 4; + +pub struct Request { + pub ticket: String, + pub session_id: String, + pub product_id: u32, + pub manifest: String, + pub folder: PathBuf, + pub language: String, +} + +impl Request { + pub fn from_json(v: &Json) -> Result { + let s = |k: &str| v.get(k).and_then(|x| x.as_str()).unwrap_or("").to_string(); + let product_id = v.get("productId").and_then(|x| x.as_u64()).ok_or("no product id")? as u32; + let folder = PathBuf::from(s("folder")); + if s("ticket").is_empty() || folder.as_os_str().is_empty() { + return Err("The install request is incomplete".into()); + } + Ok(Request { ticket: s("ticket"), session_id: s("sessionId"), product_id, manifest: s("manifest"), folder, language: s("language") }) + } +} + +pub struct Done { + pub files: usize, + pub bytes: u64, + pub chunks: Vec, + pub setup: Json, +} + +pub fn chunks_json(chunks: &[manifest::Chunk]) -> Json { + Json::Array(chunks.iter().map(|c| json!({ "id": c.id, "tags": c.tags })).collect()) +} + +pub fn setup_json(m: &manifest::Manifest) -> Json { + json!({ + "installRuns": m.install_runs.iter().map(|(exe, args, what)| json!({ "exe": exe, "arguments": args, "description": what })).collect::>(), + "registry": m.registry.iter().map(|(key, values)| json!({ "key": key, "values": values.iter().map(|(n, v)| json!({ "name": n, "value": v })).collect::>() })).collect::>(), + }) +} + +impl Done { + pub fn to_json(&self) -> Json { + json!({ "files": self.files, "bytes": self.bytes, "chunks": chunks_json(&self.chunks), "setup": self.setup.clone() }) + } +} + +pub fn safe_path(root: &Path, name: &str) -> Result { + let rel = Path::new(name.trim_start_matches('/')); + if rel.components().any(|c| !matches!(c, Component::Normal(_))) { + return Err(format!("The manifest names an unsafe path: {name}")); + } + Ok(root.join(rel)) +} + +fn sha1(b: &[u8]) -> Vec { + Sha1::digest(b).to_vec() +} + +/// The slice as served and as unpacked must both match the manifest, so a bad proxy or a truncated body never lands in the game. +pub fn checked(method: u64, raw: &[u8], s: &manifest::Slice) -> Result, String> { + if let Some(n) = s.download_size { + if raw.len() as u32 != n { + return Err(format!("a slice is {} bytes, the manifest says {n}", raw.len())); + } + } + if !s.download_sha1.is_empty() && sha1(raw) != s.download_sha1 { + return Err("a slice failed its download checksum".into()); + } + let out = manifest::decompress(method, raw)?; + if s.size != 0 && out.len() as u32 != s.size { + return Err(format!("a slice unpacked to {} bytes, the manifest says {}", out.len(), s.size)); + } + if s.content_sha1.len() == 20 && sha1(&out) != s.content_sha1 { + return Err("a slice failed its content checksum".into()); + } + Ok(out) +} + +/// Whether a file of the right size already holds every slice the manifest lists for it; a file that cannot be checked is fetched again. +fn already_installed(path: &Path, f: &File) -> bool { + if f.slices.iter().any(|s| s.content_sha1.len() != 20 || s.size == 0) { + return false; + } + let Ok(mut file) = std::fs::File::open(path) else { return false }; + let mut buf = Vec::new(); + f.slices.iter().all(|s| { + buf.resize(s.size as usize, 0); + file.seek(SeekFrom::Start(s.offset)).is_ok() && file.read_exact(&mut buf).is_ok() && sha1(&buf) == s.content_sha1 + }) +} + +fn slice_hash(s: &manifest::Slice) -> String { + manifest::hex_upper(if s.download_sha1.is_empty() { &s.content_sha1 } else { &s.download_sha1 }) +} + +struct Job { + file: usize, + slice: usize, + path: String, +} + +async fn fetch(client: &reqwest::Client, urls: &[String]) -> Result, String> { + let mut last = String::from("no URL"); + for attempt in 0..TRIES { + for u in urls { + match client.get(u).send().await { + Ok(r) if r.status().is_success() => match r.bytes().await { + Ok(b) => return Ok(b.to_vec()), + Err(e) => last = e.to_string(), + }, + Ok(r) => last = format!("HTTP {}", r.status().as_u16()), + Err(e) => last = e.to_string(), + } + } + tokio::time::sleep(Duration::from_millis(500 << attempt)).await; + } + Err(format!("a slice could not be downloaded ({last})")) +} + +/// A Demux login holding a download_service connection for one product. Ubisoft drops idle sockets +/// while slices download, so signing reconnects and retries when the connection is gone. +struct Session { + d: Demux, + dl: Downloads, +} + +const RECONNECTS: usize = 4; + +impl Session { + /// Opens a session, retrying a few times: Demux drops a connection now and then right after another closes. + async fn open(req: &Request) -> Result<(Self, Vec), String> { + let mut last = String::new(); + for attempt in 0..3u32 { + if attempt > 0 { + tokio::time::sleep(Duration::from_secs(2 << attempt)).await; + } + match Self::open_once(req).await { + Ok(s) => return Ok(s), + Err(e) => last = e, + } + } + Err(last) + } + + async fn open_once(req: &Request) -> Result<(Self, Vec), String> { + let mut d = Demux::connect(&req.ticket).await?; + let (mut own, owned) = Ownership::open(&mut d, &req.ticket, &req.session_id).await?; + let token = own.token(&mut d, req.product_id).await?; + let dl = Downloads::open(&mut d, &token).await?; + Ok((Session { d, dl }, owned)) + } + + async fn sign(&mut self, req: &Request, paths: &[String], events: &dyn Events) -> Result>, String> { + let mut last = String::new(); + for attempt in 0..=RECONNECTS { + if attempt > 0 { + events.log(&format!("ubisoft: reconnecting after: {last}")); + tokio::time::sleep(Duration::from_millis(500 << attempt.min(4))).await; + match Session::open(req).await { + Ok((fresh, _)) => *self = fresh, + Err(e) => { + last = e; + continue; + } + } + } + match self.dl.sign(&mut self.d, req.product_id, paths).await { + Ok(urls) => return Ok(urls), + Err(e) if e.contains("does not own") => return Err(e), + Err(e) => last = e, + } + } + Err(last) + } +} + +/// The manifest of an install, without downloading the game. +pub fn chunks(req: &Request) -> Result { + let rt = tokio::runtime::Builder::new_current_thread().enable_all().build().map_err(|e| e.to_string())?; + rt.block_on(async { + let (mut sess, owned) = Session::open(req).await?; + let manifest_id = if req.manifest.is_empty() { + owned.iter().find(|o| o.product_id == req.product_id).map(|o| o.manifest.clone()).unwrap_or_default() + } else { + req.manifest.clone() + }; + if manifest_id.is_empty() { + return Err("Ubisoft lists no download for this game".to_string()); + } + let client = reqwest::Client::builder().timeout(Duration::from_secs(120)).build().map_err(|e| e.to_string())?; + struct Quiet; + impl Events for Quiet { + fn log(&self, _: &str) {} + fn status(&self, _: &str) {} + fn checking(&self, _: u64, _: u64, _: u32, _: u32) {} + fn sizes(&self, _: u64, _: u64) {} + fn progress(&self, _: u64, _: u64, _: u32, _: u32) {} + fn before_replace(&self) -> bool { false } + } + let urls = sess.sign(req, &[format!("manifests/{manifest_id}.manifest")], &Quiet).await?; + let raw = fetch(&client, urls.first().map(|v| v.as_slice()).unwrap_or_default()).await?; + let language = if req.language.is_empty() { "en-US" } else { req.language.as_str() }; + let mut owned_ids: Vec = owned.iter().filter(|o| o.owned).map(|o| o.product_id).collect(); + owned_ids.push(req.product_id); + Ok(manifest::parse_owned(&manifest::decode(&raw)?, language, &owned_ids)?) + }) +} + +pub fn run(req: &Request, cancel: &AtomicBool, events: &dyn Events) -> Result { + let rt = tokio::runtime::Builder::new_current_thread().enable_all().build().map_err(|e| Stop::Failed(e.to_string()))?; + rt.block_on(install(req, cancel, events)) +} + +async fn install(req: &Request, cancel: &AtomicBool, events: &dyn Events) -> Result { + let fail = Stop::Failed; + events.status("Connecting to Ubisoft"); + let (mut sess, owned) = Session::open(req).await.map_err(fail)?; + let manifest_id = if req.manifest.is_empty() { + owned.iter().find(|o| o.product_id == req.product_id).map(|o| o.manifest.clone()).unwrap_or_default() + } else { + req.manifest.clone() + }; + if manifest_id.is_empty() { + return Err(Stop::Failed("Ubisoft lists no download for this game".into())); + } + let client = reqwest::Client::builder().timeout(Duration::from_secs(120)).build().map_err(|e| Stop::Failed(e.to_string()))?; + + events.status("Reading the game's file list"); + let urls = sess.sign(req, &[format!("manifests/{manifest_id}.manifest")], events).await.map_err(fail)?; + let raw = fetch(&client, urls.first().map(|v| v.as_slice()).unwrap_or_default()).await.map_err(fail)?; + let decoded = manifest::decode(&raw).map_err(fail)?; + let language = if req.language.is_empty() { "en-US" } else { req.language.as_str() }; + let mut owned_ids: Vec = owned.iter().filter(|o| o.owned).map(|o| o.product_id).collect(); + owned_ids.push(req.product_id); + let m = manifest::parse_owned(&decoded, language, &owned_ids).map_err(fail)?; + events.log(&format!("ubisoft: manifest {manifest_id} files={} chunks={} skipped={} compression={}", m.files.len(), m.chunks.len(), m.skipped.len(), m.compression)); + for name in &m.skipped { + if let Ok(path) = safe_path(&req.folder, name) { + if path.is_file() && std::fs::remove_file(&path).is_ok() { + events.log(&format!("ubisoft: removed {name}, which this install leaves out")); + } + } + } + + std::fs::create_dir_all(&req.folder).map_err(|e| Stop::Failed(format!("Could not create the game folder: {e}")))?; + let total: u64 = m.files.iter().map(|f| f.size).sum(); + let files_total = m.files.len() as u32; + let mut pending: Vec = Vec::new(); + let mut done_bytes = 0u64; + let mut done_files = 0u32; + for (i, f) in m.files.iter().enumerate() { + let path = safe_path(&req.folder, &f.name).map_err(fail)?; + if f.dir { + std::fs::create_dir_all(&path).map_err(|e| Stop::Failed(e.to_string()))?; + done_files += 1; + continue; + } + if std::fs::metadata(&path).map(|md| md.len() == f.size).unwrap_or(false) && already_installed(&path, f) { + done_bytes += f.size; + done_files += 1; + continue; + } + pending.push(i); + } + let pending_bytes: u64 = pending.iter().map(|&i| m.files[i].size).sum(); + events.sizes(pending_bytes, total); + events.log(&format!("ubisoft: {} of {} files to fetch, {pending_bytes} bytes", pending.len(), m.files.len())); + + let bucketed = match pending.iter().flat_map(|&i| m.files[i].slices.first()).next() { + None => true, + Some(s) => { + let h = slice_hash(s); + let probe = sess.sign(req, &[manifest::slice_path(true, &h), manifest::slice_path(false, &h)], events).await.map_err(fail)?; + let mut v3 = true; + for (layout, list) in [true, false].into_iter().zip(probe.iter()) { + if let Some(u) = list.first() { + if client.get(u).send().await.map(|r| r.status().is_success()).unwrap_or(false) { + v3 = layout; + break; + } + } + } + v3 + } + }; + + let mut parts: HashMap> = HashMap::new(); + let mut remaining: HashMap = HashMap::new(); + let mut jobs = Vec::new(); + for &i in &pending { + let f: &File = &m.files[i]; + let path = safe_path(&req.folder, &f.name).map_err(fail)?; + if let Some(parent) = path.parent() { + std::fs::create_dir_all(parent).map_err(|e| Stop::Failed(e.to_string()))?; + } + let part = path.with_extension(format!("{}part", path.extension().map(|e| format!("{}.", e.to_string_lossy())).unwrap_or_default())); + let file = std::fs::File::create(&part).map_err(|e| Stop::Failed(format!("Could not write {}: {e}", f.name)))?; + file.set_len(f.size).map_err(|e| Stop::Failed(e.to_string()))?; + parts.insert(i, Mutex::new(file)); + remaining.insert(i, f.slices.len()); + for (si, s) in f.slices.iter().enumerate() { + jobs.push(Job { file: i, slice: si, path: manifest::slice_path(bucketed, &slice_hash(s)) }); + } + } + + let progress = AtomicU64::new(done_bytes); + let mut finished_files = done_files; + let mut last_report = Instant::now(); + events.status("Downloading"); + for batch in jobs.chunks(SIGN_BATCH) { + if cancel.load(Ordering::SeqCst) { + return Err(Stop::Cancelled); + } + let paths: Vec = batch.iter().map(|j| j.path.clone()).collect(); + let signed = sess.sign(req, &paths, events).await.map_err(fail)?; + let mut slices = stream::iter(batch.iter().zip(signed.into_iter())) + .map(|(job, urls)| { + let client = &client; + let m = &m; + async move { + let s = &m.files[job.file].slices[job.slice]; + let raw = fetch(client, &urls).await?; + let out = checked(m.compression, &raw, s).map_err(|e| format!("{}: {e}", m.files[job.file].name))?; + Ok::<_, String>((job.file, job.slice, out)) + } + }) + .buffer_unordered(WINDOW); + while let Some(r) = slices.next().await { + if cancel.load(Ordering::SeqCst) { + return Err(Stop::Cancelled); + } + let (fi, si, data) = r.map_err(fail)?; + let s = &m.files[fi].slices[si]; + { + let mut f = parts[&fi].lock().unwrap(); + f.seek(SeekFrom::Start(s.offset)).and_then(|_| f.write_all(&data)).map_err(|e| Stop::Failed(format!("Could not write {}: {e}", m.files[fi].name)))?; + } + progress.fetch_add(data.len() as u64, Ordering::Relaxed); + let left = remaining.get_mut(&fi).unwrap(); + *left -= 1; + if *left == 0 { + let f = parts.remove(&fi).unwrap().into_inner().unwrap(); + f.sync_all().map_err(|e| Stop::Failed(e.to_string()))?; + drop(f); + let path = safe_path(&req.folder, &m.files[fi].name).map_err(fail)?; + let part = path.with_extension(format!("{}part", path.extension().map(|e| format!("{}.", e.to_string_lossy())).unwrap_or_default())); + std::fs::rename(&part, &path).map_err(|e| Stop::Failed(format!("Could not finish {}: {e}", m.files[fi].name)))?; + finished_files += 1; + } + if last_report.elapsed() > Duration::from_millis(250) { + events.progress(progress.load(Ordering::Relaxed), total, finished_files, files_total); + last_report = Instant::now(); + } + } + } + for i in pending.iter().copied().filter(|i| m.files[*i].slices.is_empty()) { + let path = safe_path(&req.folder, &m.files[i].name).map_err(fail)?; + parts.remove(&i); + let part = path.with_extension(format!("{}part", path.extension().map(|e| format!("{}.", e.to_string_lossy())).unwrap_or_default())); + std::fs::rename(&part, &path).map_err(|e| Stop::Failed(e.to_string()))?; + finished_files += 1; + } + events.progress(total, total, finished_files, files_total); + Ok(Done { files: m.files.len(), bytes: total, chunks: m.chunks.clone(), setup: setup_json(&m) }) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn refuses_paths_that_leave_the_folder() { + let root = Path::new("/games/x"); + assert!(safe_path(root, "../evil").is_err()); + assert!(safe_path(root, "a/../../evil").is_err()); + assert_eq!(safe_path(root, "/bin/game.exe").unwrap(), root.join("bin/game.exe")); + } + + #[test] + fn checks_a_slice_as_served_and_unpacked() { + let data = b"slice".to_vec(); + let ok = manifest::Slice { size: 5, download_size: Some(5), download_sha1: sha1(&data), content_sha1: Vec::new(), offset: 0 }; + assert_eq!(checked(0, &data, &ok).unwrap(), data); + let bad = manifest::Slice { download_sha1: vec![0; 20], ..ok.clone() }; + assert!(checked(0, &data, &bad).is_err()); + let short = manifest::Slice { download_size: Some(4), ..ok }; + assert!(checked(0, &data, &short).is_err()); + } +} diff --git a/app/src/main/rust/stores/src/ubisoft/manifest.rs b/app/src/main/rust/stores/src/ubisoft/manifest.rs new file mode 100644 index 000000000..0487a5856 --- /dev/null +++ b/app/src/main/rust/stores/src/ubisoft/manifest.rs @@ -0,0 +1,230 @@ +use std::io::Read; + +use super::pb; + +/// Bytes Ubisoft prepends to a downloaded `.manifest` before the zlib stream. +pub const HEADER: usize = 356; +pub const DEFLATE: u64 = 1; +pub const ZSTD: u64 = 3; + +#[derive(Clone, Debug, PartialEq)] +pub struct Slice { + pub size: u32, + pub download_size: Option, + pub download_sha1: Vec, + pub content_sha1: Vec, + pub offset: u64, +} + +#[derive(Clone, Debug, PartialEq)] +pub struct File { + pub name: String, + pub size: u64, + pub dir: bool, + pub slices: Vec, +} + +/// A chunk the install keeps, as the game's launcher session reports it. +#[derive(Clone, Debug, PartialEq)] +pub struct Chunk { + pub id: u32, + pub tags: String, +} + +#[derive(Debug)] +pub struct Manifest { + pub compression: u64, + pub files: Vec, + pub chunks: Vec, + /// Files of chunks left out: other languages, and add-ons the account does not own. + pub skipped: Vec, + /// Prerequisite installers Connect runs after an install: exe, arguments, description. + pub install_runs: Vec<(String, String, String)>, + /// Registry keys Connect writes for the game, with their string values as (name, value). + pub registry: Vec<(String, Vec<(String, String)>)>, +} + +fn file(b: &[u8]) -> Result { + let f = pb::fields(b)?; + let hashes = pb::subs(&f, 4); + let mut offset = 0u64; + let mut slices = Vec::new(); + for (i, s) in pb::subs(&f, 7).into_iter().enumerate() { + let s = pb::fields(s)?; + let size = pb::num(&s, 1).unwrap_or(0) as u32; + let at = pb::num(&s, 4).unwrap_or(offset); + slices.push(Slice { + size, + download_size: pb::num(&s, 2).map(|v| v as u32), + download_sha1: pb::sub(&s, 3).unwrap_or_default().to_vec(), + content_sha1: hashes.get(i).map(|h| h.to_vec()).unwrap_or_default(), + offset: at, + }); + offset = at + size as u64; + } + if slices.is_empty() { + for h in &hashes { + slices.push(Slice { size: 0, download_size: None, download_sha1: Vec::new(), content_sha1: h.to_vec(), offset: 0 }); + } + } + Ok(File { + name: pb::string(&f, 1).unwrap_or_default().replace('\\', "/"), + size: pb::num(&f, 2).unwrap_or(0), + dir: pb::num(&f, 3) == Some(1), + slices, + }) +} + +/// Files of the chunks to install: those for every language plus the language asked for, of the +/// game itself or an optional add-on in `owned` (every add-on when `owned` is empty). +pub fn parse(decoded: &[u8], language: &str) -> Result { + parse_owned(decoded, language, &[]) +} + +pub fn parse_owned(decoded: &[u8], language: &str, owned: &[u32]) -> Result { + let m = pb::fields(decoded)?; + let wanted = language.to_ascii_lowercase(); + let prefix = wanted.split(['-', '_']).next().unwrap_or("").to_string(); + let mut files = Vec::new(); + let mut chunks = Vec::new(); + let mut skipped = Vec::new(); + for c in pb::subs(&m, 4) { + let c = pb::fields(c)?; + let lang = pb::string(&c, 5).unwrap_or_default().to_ascii_lowercase(); + let mut products: Vec = c.iter().filter_map(|(n, v)| match v { pb::Value::Varint(x) if *n == 8 => Some(*x as u32), _ => None }).collect(); + products.extend(pb::num(&c, 4).map(|v| v as u32)); + let in_language = lang.is_empty() || lang == wanted || (!prefix.is_empty() && lang.split(['-', '_']).next() == Some(prefix.as_str())); + let optional = pb::num(&c, 2) == Some(1); + let owned_product = !optional || owned.is_empty() || products.is_empty() || products.iter().any(|p| owned.contains(p)); + if !(in_language && owned_product) { + for f in pb::subs(&c, 3) { + skipped.push(file(f)?.name); + } + continue; + } + chunks.push(Chunk { id: pb::num(&c, 1).unwrap_or(0) as u32, tags: pb::string(&c, 7).unwrap_or_default() }); + for f in pb::subs(&c, 3) { + files.push(file(f)?); + } + } + let kept: std::collections::HashSet<&str> = files.iter().map(|f| f.name.as_str()).collect(); + skipped.retain(|n| !kept.contains(n.as_str())); + let mut install_runs = Vec::new(); + for r in pb::subs(&m, 2) { + let r = pb::fields(r)?; + install_runs.push((pb::string(&r, 1).unwrap_or_default(), pb::string(&r, 3).unwrap_or_default(), pb::string(&r, 4).unwrap_or_default())); + } + let mut registry = Vec::new(); + for r in pb::subs(&m, 5) { + let r = pb::fields(r)?; + let mut values = Vec::new(); + for e in pb::subs(&r, 3) { + let e = pb::fields(e)?; + values.push((pb::string(&e, 1).unwrap_or_default(), pb::string(&e, 2).unwrap_or_default())); + } + registry.push((pb::string(&r, 1).unwrap_or_default(), values)); + } + Ok(Manifest { compression: pb::num(&m, 21).unwrap_or(0), files, chunks, skipped, install_runs, registry }) +} + +pub fn decode(downloaded: &[u8]) -> Result, String> { + if downloaded.len() <= HEADER { + return Err(format!("the manifest is only {} bytes", downloaded.len())); + } + let mut out = Vec::new(); + flate2::read::ZlibDecoder::new(&downloaded[HEADER..]).read_to_end(&mut out).map_err(|e| format!("the manifest did not inflate: {e}"))?; + Ok(out) +} + +pub fn hex_upper(b: &[u8]) -> String { + b.iter().map(|x| format!("{x:02X}")).collect() +} + +/// The CDN bucket of a slice: the first byte of its hash with nibbles swapped, mapped onto base32. +pub fn bucket(hash_upper: &str) -> char { + const BASE32: &[u8; 32] = b"0123456789abcdefghijklmnopqrstuv"; + let b = hash_upper.as_bytes(); + let swapped = format!("{}{}", *b.get(1).unwrap_or(&b'0') as char, *b.first().unwrap_or(&b'0') as char); + let v = u8::from_str_radix(&swapped, 16).unwrap_or(0); + BASE32[(v / 16) as usize + if v % 2 == 0 { 0 } else { 16 }] as char +} + +pub fn slice_path(bucketed: bool, hash_upper: &str) -> String { + if bucketed { format!("slices_v3/{}/{}", bucket(hash_upper), hash_upper) } else { format!("slices/{hash_upper}") } +} + +pub fn decompress(method: u64, data: &[u8]) -> Result, String> { + match method { + ZSTD => { + let mut out = Vec::new(); + ruzstd::decoding::StreamingDecoder::new(data).map_err(|e| format!("zstd: {e}"))?.read_to_end(&mut out).map_err(|e| format!("zstd: {e}"))?; + Ok(out) + } + DEFLATE => { + let zlib = data.len() >= 2 && data[0] == 0x78; + let inflate = |z: bool| { + let mut out = Vec::new(); + let r = if z { flate2::read::ZlibDecoder::new(data).read_to_end(&mut out) } else { flate2::read::DeflateDecoder::new(data).read_to_end(&mut out) }; + r.map(|_| out) + }; + inflate(zlib).or_else(|_| inflate(!zlib)).map_err(|e| format!("deflate: {e}")) + } + _ => Ok(data.to_vec()), + } +} + +#[cfg(test)] +mod tests { + use super::super::pb::Msg; + use super::*; + + #[test] + fn buckets_follow_the_swapped_first_byte() { + assert_eq!(bucket("10ABCDEF"), 'g'); + assert_eq!(bucket("01ABCDEF"), '1'); + assert_eq!(slice_path(true, "01AB"), "slices_v3/1/01AB"); + assert_eq!(slice_path(false, "01AB"), "slices/01AB"); + } + + #[test] + fn keeps_shared_and_wanted_language_chunks() { + let f = |name: &str| Msg::new().text(1, name).uint(2, 10).flag(3, false) + .bytes(4, &[0xAA]).msg(7, Msg::new().uint(1, 10).uint(2, 4).bytes(3, &[0xBB])); + let m = Msg::new() + .msg(4, Msg::new().uint(1, 1).uint(2, 0).msg(3, f("Game\\game.exe"))) + .msg(4, Msg::new().uint(1, 2).uint(2, 1).msg(3, f("en.pak")).text(5, "en-US")) + .msg(4, Msg::new().uint(1, 3).uint(2, 1).msg(3, f("fr.pak")).text(5, "fr-FR")) + .uint(21, ZSTD); + let parsed = parse(&m.0, "en-US").unwrap(); + let names: Vec<_> = parsed.files.iter().map(|f| f.name.as_str()).collect(); + assert_eq!(names, ["Game/game.exe", "en.pak"]); + assert_eq!(parsed.compression, ZSTD); + assert_eq!(parsed.files[0].slices[0], Slice { size: 10, download_size: Some(4), download_sha1: vec![0xBB], content_sha1: vec![0xAA], offset: 0 }); + } + + #[test] + fn leaves_out_add_ons_the_account_does_not_own() { + let f = |name: &str| Msg::new().text(1, name).uint(2, 10).flag(3, false); + let m = Msg::new() + .msg(4, Msg::new().uint(1, 1).uint(2, 0).msg(3, f("base.forge")).uint(4, 9999)) + .msg(4, Msg::new().uint(1, 2).uint(2, 1).msg(3, f("dlc_24/east.forge")).uint(4, 5450)) + .msg(4, Msg::new().uint(1, 3).uint(2, 1).msg(3, f("dlc_25/west.forge")).uint(8, 5451).uint(8, 5452)) + .uint(21, ZSTD); + let parsed = parse_owned(&m.0, "en-US", &[5405, 5452]).unwrap(); + let names: Vec<_> = parsed.files.iter().map(|f| f.name.as_str()).collect(); + assert_eq!(names, ["base.forge", "dlc_25/west.forge"]); + assert_eq!(parsed.skipped, ["dlc_24/east.forge"]); + assert_eq!(parsed.chunks.iter().map(|c| c.id).collect::>(), [1, 3]); + } + + #[test] + fn inflates_both_deflate_framings() { + use std::io::Write; + let mut z = flate2::write::ZlibEncoder::new(Vec::new(), flate2::Compression::default()); + z.write_all(b"hello").unwrap(); + assert_eq!(decompress(DEFLATE, &z.finish().unwrap()).unwrap(), b"hello"); + let mut r = flate2::write::DeflateEncoder::new(Vec::new(), flate2::Compression::default()); + r.write_all(b"hello").unwrap(); + assert_eq!(decompress(DEFLATE, &r.finish().unwrap()).unwrap(), b"hello"); + } +} diff --git a/app/src/main/rust/stores/src/ubisoft/mod.rs b/app/src/main/rust/stores/src/ubisoft/mod.rs new file mode 100644 index 000000000..eff0a806f --- /dev/null +++ b/app/src/main/rust/stores/src/ubisoft/mod.rs @@ -0,0 +1,6 @@ +pub mod demux; +pub mod ffi; +pub mod install; +pub mod manifest; +pub mod pb; +pub mod service; diff --git a/app/src/main/rust/stores/src/ubisoft/pb.rs b/app/src/main/rust/stores/src/ubisoft/pb.rs new file mode 100644 index 000000000..f967cb60c --- /dev/null +++ b/app/src/main/rust/stores/src/ubisoft/pb.rs @@ -0,0 +1,104 @@ +pub use crate::amazon::proto::{Fields, Value}; + +#[derive(Default)] +pub struct Msg(pub Vec); + +impl Msg { + pub fn new() -> Self { + Msg(Vec::new()) + } + + fn varint(&mut self, mut v: u64) { + loop { + let b = (v & 0x7f) as u8; + v >>= 7; + if v == 0 { + self.0.push(b); + return; + } + self.0.push(b | 0x80); + } + } + + fn key(&mut self, field: u32, wire: u8) { + self.varint((field as u64) << 3 | wire as u64); + } + + pub fn uint(mut self, field: u32, v: u64) -> Self { + self.key(field, 0); + self.varint(v); + self + } + + pub fn flag(self, field: u32, v: bool) -> Self { + self.uint(field, v as u64) + } + + pub fn bytes(mut self, field: u32, data: &[u8]) -> Self { + self.key(field, 2); + self.varint(data.len() as u64); + self.0.extend_from_slice(data); + self + } + + pub fn text(self, field: u32, s: &str) -> Self { + self.bytes(field, s.as_bytes()) + } + + pub fn msg(self, field: u32, m: Msg) -> Self { + self.bytes(field, &m.0) + } +} + +/// Every field of a message, in wire order; repeated fields appear once per value. +pub fn fields(data: &[u8]) -> Result)>, String> { + let mut out = Vec::new(); + let mut f = Fields::new(data); + while let Some(v) = f.next_field()? { + out.push(v); + } + Ok(out) +} + +pub fn first<'a>(all: &[(u32, Value<'a>)], field: u32) -> Option> { + all.iter().find(|(n, _)| *n == field).map(|(_, v)| *v) +} + +pub fn sub<'a>(all: &[(u32, Value<'a>)], field: u32) -> Option<&'a [u8]> { + match first(all, field) { + Some(Value::Bytes(b)) => Some(b), + _ => None, + } +} + +pub fn subs<'a>(all: &[(u32, Value<'a>)], field: u32) -> Vec<&'a [u8]> { + all.iter().filter_map(|(n, v)| match (n, v) { (n, Value::Bytes(b)) if *n == field => Some(*b), _ => None }).collect() +} + +pub fn num(all: &[(u32, Value<'_>)], field: u32) -> Option { + match first(all, field) { + Some(Value::Varint(v)) | Some(Value::Fixed64(v)) => Some(v), + Some(Value::Fixed32(v)) => Some(v as u64), + _ => None, + } +} + +pub fn string(all: &[(u32, Value<'_>)], field: u32) -> Option { + sub(all, field).map(|b| String::from_utf8_lossy(b).into_owned()) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn round_trips_nested_messages() { + let m = Msg::new().uint(1, 300).msg(2, Msg::new().text(1, "a").text(1, "b")).flag(3, true); + let all = fields(&m.0).unwrap(); + assert_eq!(num(&all, 1), Some(300)); + assert_eq!(num(&all, 3), Some(1)); + let inner = fields(sub(&all, 2).unwrap()).unwrap(); + let texts: Vec<_> = subs(&inner, 1).iter().map(|b| String::from_utf8_lossy(b).into_owned()).collect(); + assert_eq!(texts, ["a", "b"]); + } +} diff --git a/app/src/main/rust/stores/src/ubisoft/service.rs b/app/src/main/rust/stores/src/ubisoft/service.rs new file mode 100644 index 000000000..bfdddc29a --- /dev/null +++ b/app/src/main/rust/stores/src/ubisoft/service.rs @@ -0,0 +1,206 @@ +use serde_json::{json, Value as Json}; + +use super::demux::Demux; +use super::pb::{self, Msg}; + +pub struct Owned { + pub product_id: u32, + pub space_id: String, + pub manifest: String, + pub product_type: u32, + pub state: u32, + pub owned: bool, + pub configuration: String, + pub app_id: String, + pub partner: u32, + pub activation: u32, + pub locked_by_subscription: bool, + pub associations: Vec, + pub download_id: u32, +} + +impl Owned { + /// Whether Connect lists the product among the session's owned ids: owned games and add-ons that are released. + pub fn in_session(&self) -> bool { + self.owned && self.product_type <= 1 && (self.state == 0 || self.state == 3) + } + + pub fn to_json(&self) -> Json { + json!({ + "productId": self.product_id, "spaceId": self.space_id, "manifest": self.manifest, + "productType": self.product_type, "state": self.state, "owned": self.owned, + "configuration": self.configuration, "appId": self.app_id, + "partner": self.partner, "activation": self.activation, "lockedBySubscription": self.locked_by_subscription, + "associations": self.associations, "downloadId": self.download_id, + }) + } +} + +pub struct Ownership { + conn: u32, + next: u64, +} + +fn owned_game(b: &[u8]) -> Result { + let f = pb::fields(b)?; + Ok(Owned { + product_id: pb::num(&f, 1).unwrap_or(0) as u32, + space_id: pb::string(&f, 30).unwrap_or_default(), + manifest: pb::string(&f, 21).unwrap_or_default(), + product_type: pb::num(&f, 8).unwrap_or(0) as u32, + state: pb::num(&f, 13).unwrap_or(0) as u32, + owned: pb::num(&f, 20).map(|v| v == 1).unwrap_or(true), + configuration: pb::string(&f, 17).unwrap_or_default(), + app_id: pb::string(&f, 31).unwrap_or_default(), + partner: pb::num(&f, 38).unwrap_or(0) as u32, + activation: pb::num(&f, 36).unwrap_or(0) as u32, + locked_by_subscription: pb::num(&f, 37) == Some(1), + associations: f.iter().filter_map(|(n, v)| match v { pb::Value::Varint(x) if *n == 16 => Some(*x as u32), _ => None }).collect(), + download_id: pb::num(&f, 5).unwrap_or(0) as u32, + }) +} + +impl Ownership { + /// Opens ownership_service and initializes it, which also returns the account's products. + pub async fn open(d: &mut Demux, ticket: &str, session_id: &str) -> Result<(Self, Vec), String> { + let conn = d.open("ownership_service").await?; + let init = Msg::new().flag(2, true).uint(3, 7); + let mut req = Msg::new().uint(1, 1).msg(2, init).text(30, ticket); + if !session_id.is_empty() { + req = req.text(31, session_id); + } + let raw = d.call(conn, Msg::new().msg(1, req)).await?; + let rsp = pb::fields(pb::sub(&pb::fields(&raw)?, 1).ok_or("no ownership answer")?)?; + let init = pb::fields(pb::sub(&rsp, 2).ok_or("no ownership initialize answer")?)?; + if pb::num(&init, 1) != Some(1) { + return Err("Ubisoft would not list the account's games".into()); + } + let mut games = Vec::new(); + if let Some(list) = pb::sub(&init, 2) { + for g in pb::subs(&pb::fields(list)?, 1) { + games.push(owned_game(g)?); + } + } + Ok((Ownership { conn, next: 2 }, games)) + } + + /// The latest manifest of each product that has one, for games whose ownership entry leaves it out. + pub async fn latest_manifests(&mut self, d: &mut Demux, products: &[u32]) -> Result, String> { + if products.is_empty() { + return Ok(Vec::new()); + } + let id = self.next; + self.next += 1; + let mut req = Msg::new(); + for p in products { + req = req.uint(1, *p as u64); + } + let raw = d.call(self.conn, Msg::new().msg(1, Msg::new().uint(1, id).msg(10, req))).await?; + let rsp = pb::fields(pb::sub(&pb::fields(&raw)?, 1).ok_or("no ownership answer")?)?; + let m = pb::fields(pb::sub(&rsp, 10).ok_or("no manifest answer")?)?; + if pb::num(&m, 1) != Some(1) { + return Err("Ubisoft would not list the latest manifests".into()); + } + let mut out = Vec::new(); + for entry in pb::subs(&m, 2) { + let e = pb::fields(entry)?; + if pb::num(&e, 1) != Some(1) { + continue; + } + if let (Some(product), Some(manifest)) = (pb::num(&e, 2), pb::string(&e, 3).filter(|s| !s.is_empty())) { + out.push((product as u32, manifest)); + } + } + Ok(out) + } + + /// The ticket a game receives in its launcher session (`GetUplayPCTicket`). + pub async fn pc_ticket(&mut self, d: &mut Demux, product_id: u32, ticket: &str, session_id: &str) -> Result { + let id = self.next; + self.next += 1; + let info = Msg::new().uint(1, product_id as u64).uint(2, 0).uint(3, product_id as u64); + let mut req = Msg::new().uint(1, id).msg(14, info).text(30, ticket); + if !session_id.is_empty() { + req = req.text(31, session_id); + } + let raw = d.call(self.conn, Msg::new().msg(1, req)).await?; + let rsp = pb::fields(pb::sub(&pb::fields(&raw)?, 1).ok_or("no ownership answer")?)?; + let t = pb::fields(pb::sub(&rsp, 13).ok_or("no game ticket answer")?)?; + if pb::num(&t, 1) != Some(1) { + return Err(format!("Ubisoft would not issue a game ticket (code {})", pb::num(&t, 3).unwrap_or(0))); + } + pb::string(&t, 2).filter(|s| !s.is_empty()).ok_or_else(|| "Ubisoft sent an empty game ticket".into()) + } + + pub async fn token(&mut self, d: &mut Demux, product_id: u32) -> Result { + let id = self.next; + self.next += 1; + let raw = d.call(self.conn, Msg::new().msg(1, Msg::new().uint(1, id).msg(28, Msg::new().uint(1, product_id as u64)))).await?; + let rsp = pb::fields(pb::sub(&pb::fields(&raw)?, 1).ok_or("no ownership answer")?)?; + let t = pb::fields(pb::sub(&rsp, 27).ok_or("no ownership token answer")?)?; + if pb::num(&t, 1) != Some(1) { + return Err("Ubisoft says this account does not own the game".into()); + } + pb::string(&t, 2).filter(|s| !s.is_empty()).ok_or_else(|| "Ubisoft sent an empty ownership token".into()) + } +} + +pub struct Downloads { + conn: u32, + next: u64, +} + +impl Downloads { + pub async fn open(d: &mut Demux, ownership_token: &str) -> Result { + let conn = d.open("download_service").await?; + let raw = d.call(conn, Msg::new().msg(1, Msg::new().uint(1, 1).msg(2, Msg::new().text(5, ownership_token)))).await?; + let rsp = pb::fields(pb::sub(&pb::fields(&raw)?, 1).ok_or("no download service answer")?)?; + let ok = pb::sub(&rsp, 2).map(pb::fields).transpose()?.and_then(|i| pb::num(&i, 1)) == Some(1); + if !ok { + return Err("Ubisoft's download service refused the ownership token".into()); + } + Ok(Downloads { conn, next: 2 }) + } + + /// Signed CDN URLs for paths under the product's download root, in request order; empty where none was given. + pub async fn sign(&mut self, d: &mut Demux, product_id: u32, paths: &[String]) -> Result>, String> { + let id = self.next; + self.next += 1; + let mut request = Msg::new().uint(1, product_id as u64); + for p in paths { + request = request.text(2, p); + } + let raw = d.call(self.conn, Msg::new().msg(1, Msg::new().uint(1, id).msg(3, Msg::new().msg(1, request)))).await?; + let rsp = pb::fields(pb::sub(&pb::fields(&raw)?, 1).ok_or("no download service answer")?)?; + let urls = pb::fields(pb::sub(&rsp, 3).ok_or("no signed URLs")?)?; + let mut out = Vec::new(); + for r in pb::subs(&urls, 1) { + let r = pb::fields(r)?; + if pb::num(&r, 1).unwrap_or(0) != 0 { + return Err("Ubisoft says this account does not own the game".into()); + } + for group in pb::subs(&r, 2) { + out.push(pb::subs(&pb::fields(group)?, 1).iter().map(|u| String::from_utf8_lossy(u).into_owned()).collect()); + } + } + if out.len() != paths.len() { + return Err(format!("Ubisoft signed {} of {} paths", out.len(), paths.len())); + } + Ok(out) + } +} + +/// Denuvo's activation for a game the account owns: the game's challenge and the product's ownership +/// token go to `denuvo_service`, which answers with the game token the game's aux DLL waits for. +pub async fn denuvo_token(d: &mut Demux, ownership_token: &str, challenge: &[u8]) -> Result, String> { + let conn = d.open("denuvo_service").await?; + let req = Msg::new().uint(1, 1).msg(2, Msg::new().text(1, ownership_token).bytes(2, challenge)); + let raw = d.call(conn, Msg::new().msg(1, req)).await?; + let rsp = pb::fields(pb::sub(&pb::fields(&raw)?, 1).ok_or("no denuvo answer")?)?; + let result = pb::num(&rsp, 2).unwrap_or(0); + if result != 0 { + return Err(format!("Ubisoft's activation service refused the game (result {result})")); + } + let t = pb::fields(pb::sub(&rsp, 3).ok_or("no game token answer")?)?; + pb::sub(&t, 1).filter(|b| !b.is_empty()).map(<[u8]>::to_vec).ok_or_else(|| "Ubisoft sent an empty game token".into()) +} diff --git a/app/src/test/java/com/droiddeck/launcher/stores/ubisoft/UbisoftTest.kt b/app/src/test/java/com/droiddeck/launcher/stores/ubisoft/UbisoftTest.kt new file mode 100644 index 000000000..58e361db8 --- /dev/null +++ b/app/src/test/java/com/droiddeck/launcher/stores/ubisoft/UbisoftTest.kt @@ -0,0 +1,60 @@ +package com.droiddeck.launcher.stores.ubisoft + +import com.droiddeck.launcher.stores.Store +import com.droiddeck.launcher.stores.StoreGameSidecar +import com.droiddeck.launcher.stores.StoreLaunch +import org.json.JSONArray +import org.json.JSONObject +import org.junit.Assert.assertArrayEquals +import org.junit.Assert.assertEquals +import org.junit.Assert.assertNull +import org.junit.Assert.assertTrue +import org.junit.Test +import org.junit.runner.RunWith +import org.robolectric.RobolectricTestRunner +import org.robolectric.annotation.Config + +@RunWith(RobolectricTestRunner::class) +@Config(sdk = [33], application = android.app.Application::class) +class UbisoftTest { + private val steamConfig = "version: 2.0\r\nroot:\r\n name: Assassin's Creed Syndicate\r\n start_game:\r\n steam:\r\n steam_app_id: 368500\r\n" + + " third_party_platform:\r\n name: Steam\r\n override_uplayid: 1875\r\n" + + @Test fun aProductUbisoftDeliversThroughSteamNamesItsSteamApp() { + assertEquals("368500", UbisoftLibrary.steamApp(steamConfig)) + assertNull(UbisoftLibrary.steamApp("root:\n start_game:\n online:\n executables:\n - path:\n relative: bin/game.exe\n")) + } + + @Test fun steamOwnedGamesAreKeyedByTheirSpace() { + val owned = JSONObject().put("games", JSONArray() + .put(JSONObject().put("productId", 1957).put("spaceId", "space-s").put("productType", 0).put("owned", true).put("configuration", steamConfig)) + .put(JSONObject().put("productId", 109).put("spaceId", "space-sc").put("productType", 0).put("owned", true).put("manifest", "AB").put("configuration", "root: {}")) + .put(JSONObject().put("productId", 1961).put("spaceId", "space-addon").put("productType", 1).put("owned", true).put("configuration", steamConfig))) + assertEquals(mapOf("space-s" to "368500"), UbisoftLibrary.steamBySpace(owned)) + assertEquals(setOf("space-sc"), UbisoftLibrary.ownedBySpace(owned).keys) + } + + @Test fun theEntitlementNamesTheGamesApplication() { + val body = JSONObject().put("items", JSONArray() + .put(JSONObject().put("productId", 82).put("applicationId", "app-82")) + .put(JSONObject().put("productId", 109).put("applicationId", "app-109"))).toString() + assertEquals("app-109", UbisoftLaunch.applicationId(body, 109)) + assertNull(UbisoftLaunch.applicationId(body, 1651)) + } + + @Test fun theLauncherStartsTheServiceOnlyWithAFreshLaunchFile() { + val text = StoreLaunch.launcherText(StoreGameSidecar(Store.UBISOFT, "space-sc", "Splinter Cell", "system/SplinterCell.exe")) + val lines = text.trimEnd().split("\r\n") + assertTrue(lines.contains("if not exist \"%~dp0.droiddeck-ubisoft.json\" goto direct")) + assertTrue(lines.contains("\"Z:\\usr\\local\\lib\\droiddeck-ubisoft\\droiddeck-upc.exe\" \"%~dp0.droiddeck-ubisoft.json\"")) + assertEquals("\"%~dp0system\\SplinterCell.exe\"", lines.last()) + assertTrue(lines.indexOf(":direct") < lines.lastIndex) + } + + @Test fun activationPayloadsSurviveTheHexHop() { + val bytes = byteArrayOf(0, 1, 0x7f, -1, -128, 42) + assertEquals("00017fff802a", UbisoftActivation.hex(bytes)) + assertArrayEquals(bytes, UbisoftActivation.unhex("00017fff802a")) + assertNull(UbisoftActivation.unhex("abc")) + } +} diff --git a/docs/development/ubisoft-launch.md b/docs/development/ubisoft-launch.md new file mode 100644 index 000000000..0fb49d23b --- /dev/null +++ b/docs/development/ubisoft-launch.md @@ -0,0 +1,90 @@ +# Ubisoft launch without Ubisoft Connect + +A Ubisoft game never holds its own ticket. Its DRM loader opens Ubisoft's API DLL from the Connect +folder, and that DLL asks the running launcher for the session over local named pipes. DroidDeck +answers those pipes with `droiddeck-upc.exe`, a small Windows program that runs in the game's prefix. +The game and every Ubisoft DLL stay unmodified, and there is no Connect client, CEF or background +service. The session the game receives is real: its ticket comes from Ubisoft's ownership service for +the signed-in account. + +This file is the specification `tools/ubisoft-launcher` was written from. The facts come from the +protobuf descriptors embedded in Ubisoft's own `uplay_r1.dll` (`proto_uplaydll/UplayDll.proto`) and +from the public notes in [Foxtrot47/Ubisoft-Connect-RE](https://github.com/Foxtrot47/Ubisoft-Connect-RE) +(`docs/06-launch.md`, `docs/10-denuvo.md`). No code was taken from either. + +## Pieces + +| Piece | Where | Job | +|---|---|---| +| Ubisoft runtime | `C:\Program Files (x86)\Ubisoft\Ubisoft Game Launcher\` in the shared prefix | Ubisoft's own `uplay_r1(64).dll`, `uplay_aux_r1(64).dll`, `uplay_r2(64).dll`, `ubiorbitapi_r2(64).dll`, `UbisoftGameLauncher(64).exe`, the starter's imports (`discord-rpc.x86/x64.dll`, `steam_api(64).dll`) and `overlay(64).dll`. `droiddeck-store-launch` extracts exactly these 16 files with `7z` from the official `UbisoftConnectInstaller.exe`, then deletes the installer | +| Registry | `HKLM\Software\[Wow6432Node\]Ubisoft\Launcher` | `InstallDir` points at the runtime folder in both views; `Installs\\InstallDir` points at the game, with forward slashes and a trailing slash, as Connect writes it | +| Shared prefix | `compatdata/2929927072` | Every Ubisoft game runs here (`UbisoftConnect.launchOptions` sets `STEAM_COMPAT_DATA_PATH`), so the runtime is extracted once | +| Launch file | `/.droiddeck-ubisoft.json` | Written by `droiddeck-store-launch` before every launch from the app's `ubisoft-launch` answer: the account, the owned product ids, a fresh PC ticket, the entitlement's application id and the save folder. Removed first, so a failed refresh falls back to starting the game directly | +| Launcher | `/.droiddeck-launch.bat` | Runs `Z:\usr\local\lib\droiddeck-ubisoft\droiddeck-upc.exe ` when both exist | +| Tickets | app, Rust `ubisoft/service.rs` | Demux `ownership_service`: initialize (owned products), then `GetUplayPCTicketReq` (field 14) answered in field 13 `{1: success, 2: ticket}` | + +## Pipes + +All pipes are `\\.\pipe\terminal__`, where `` is +`ProcessIdToSessionId(GetCurrentProcessId())` (1 under Wine). They are byte-mode duplex pipes with +unlimited instances, created before the game starts. Every message is a 4-byte big-endian length +followed by a proto2 message, except on `uplay_service_ipc_pipe_`. + +| Pipe | Client | Exchange | +|---|---|---| +| `game_start_ipc_pipe_` | `UbisoftGameLauncher.exe` | `game_starter.Upstream{1:{1:{1: build, 2: product, 10: exe, 11: arguments, ...}}}` → `Downstream{2:{1:{1:0 (overlay off), 2:0, 3: / + "/" + , 4: /, 8:3, 9:{1:{1,113,0,1,0} 1:{3,123,0,0,0}}}}}` | +| `uplay_api_process_ipc_pipe_` | game | `Req.initProcessReq{1: product, 2: game pid, 5: env set}` → `Rsp.initProcessRsp(13){1:1, 3:0 (overlay off), 4:3, 6:0}`. Kept open. Answering it is what releases the starter (below) | +| `uplay_ipc_pipe_` | game (`uplay_r1.dll`) | `Req.initReq(1)` → `Rsp.initRsp(1)`, answered every time it is asked. `Req.installerReq(13)` with `initInstallerReq` → `Rsp.installerRsp(10){1:{1:1, 2:"", 3: language}}` | +| `uplay_aux_ipc_pipe_` | `uplay_aux_r1(64).dll` (Denuvo) | `{1: rid, 2: init}` → `{1: rid, 2:{1:1, 2: pid, 3:3367, 3:3368, 3:3962}}`; `{1: rid, 3:{2:{2: challenge}}}` → `{1: rid, 3:{2:{2:1, 3:0, 4: game token, 5:""}}}` | +| `uplay_service_ipc_pipe_` | Ubisoft helpers | Server sends `{3:{1:1, 3:{1: }}}` once, then only reads, unframed | +| `upc_api_process_ipc_pipe_standard`, `uplay_protocol_ipc_pipe_`, `uplay_overlay_ipc_pipe_`, `orbit_ipc_pipe_`, `uplay_crash_reporter_ipc_pipe_` | — | Held open, never answered | + +### The session (`InitRsp`) + +| Field | Value | +|---|---| +| 1 result | 1 (`InitResult_Success`) | +| 2 account | `{1: userId, 2: username, 3: nameOnPlatform, 4: email, 5: "" (password), 6: country}` | +| 3 connected | 1 | +| 4 gameUplayId | the product | +| 5 uplayIds | every owned product whose ownership entry is owned, of type 0 or 1, in state 0 or 3 | +| 6 gameCdKeys | `{1: product, 2: ""}` | +| 7 isInOfflineMode | 0 | +| 8 upcTicket | the PC ticket from `GetUplayPCTicket`, never the Denuvo token | +| 11, 12, 15, 16, 18, 19, 21, 23 | the values a real launch carries (`{1:0}`, 0, overlay `{1:0, 2:3}`, hardware score `{6211, 22395, 1.0f}`, empty, `{1:1, 2:1}`, `{2:1}`, empty) | +| 17 storage | `{1: "C:/users/steamuser/Saved Games/Ubisoft///"}`; the game appends file names directly, so the trailing slash matters | +| 22 ubiServices | `{1: applicationId}` from the account's entitlements, left out when unknown | + +The overlay is reported off in all three places. `uplay_r1.dll` still loads `overlay.dll` from the +runtime folder right after the session arrives, whatever the session says; without that file it gives up +with `UplayAPI [ERROR]: Startup failed: cannot load uplay library` (Assassin's Creed Chronicles: China +then sits on a black screen and aborts with code 3 after about 160 s), so the runtime includes it. + +## Start sequence + +1. `droiddeck-upc.exe` creates the pipes and an auto-reset event marked inheritable, then starts the + game's own exe with handle inheritance, in the install folder, with no extra arguments. +2. The exe sees it was not started by Connect and starts `UbisoftGameLauncher.exe + -gamelauncher_wait_handle -upc_uplay_id ...` (it exits with code 3; that is normal). +3. The starter asks `game_start` and starts the real game from the reply. +4. The game announces itself on `uplay_api_process`; the answer sets the event, which lets the starter + exit. Without it the starter times out and its job object ends the game after about 15 seconds. +5. `uplay_r1.dll` asks `uplay_ipc` for the session. +6. `droiddeck-upc.exe` waits for the announced process, allows 15 s for a restart, then exits with the + game's code so Steam sees the game end. + +A log of every exchange is written to `~/.cache/droiddeck/ubisoft/upc-.log` in the guest. + +## Denuvo + +For protected titles the aux pipe carries the activation challenge. `droiddeck-upc.exe` writes it to +`~/.cache/droiddeck/ubisoft/bridge-/denuvo-.req` and waits 90 s for `.rsp`. Answering it +needs `denuvo_service` with an ownership token (`OwnershipTokenReq`, field 28) and is the next step; +titles without Denuvo never open the aux pipe. + +## Not covered yet + +- R2 (`UPC_*`) titles use `uplay_r2.dll`; their pipe traffic has not been captured, so whether the + same server answers them is unverified. +- Orbit (`ubiorbitapi_r2`) titles. +- Achievements, friends, overlay, cloud saves. diff --git a/tools/build_local.sh b/tools/build_local.sh index 041d6b313..d7f2ca2a2 100755 --- a/tools/build_local.sh +++ b/tools/build_local.sh @@ -424,6 +424,15 @@ if ! restore_step comet "${comet_key}" "${comet_out}"; then DROIDDECK_BUILD_IMAGE="${image_name}" bash tools/comet/build-local.sh save_step comet "${comet_key}" "${comet_out}" fi +if [[ "${DROIDDECK_SKIP_RUST:-0}" != 1 ]]; then + upc_out="${linuxfs_dir}/usr/local/lib/droiddeck-ubisoft" + upc_key=$(inputs_hash "x86_64-pc-windows-gnu" tools/ubisoft-launcher/src tools/ubisoft-launcher/Cargo.toml tools/ubisoft-launcher/Cargo.lock tools/ubisoft-launcher/build.sh) + if ! restore_step ubisoft "${upc_key}" "${upc_out}"; then + command -v x86_64-w64-mingw32-gcc >/dev/null 2>&1 || { echo "x86_64-w64-mingw32-gcc is required for the Ubisoft launcher service (droiddeck-upc.exe)." >&2; exit 1; } + CARGO_TARGET_DIR="${cache_dir}/ubisoft-target" bash tools/ubisoft-launcher/build.sh "${upc_out}" + save_step ubisoft "${upc_key}" "${upc_out}" + fi +fi python3 tools/powervr/fetch.py bash tools/packs/build.sh app/build/packs python3 tools/release/runtime_inputs.py record diff --git a/tools/linuxfs/overlay/usr/local/bin/droiddeck-game-env b/tools/linuxfs/overlay/usr/local/bin/droiddeck-game-env index 5ed1f7f11..16b12bfe3 100755 --- a/tools/linuxfs/overlay/usr/local/bin/droiddeck-game-env +++ b/tools/linuxfs/overlay/usr/local/bin/droiddeck-game-env @@ -17,12 +17,12 @@ SEED_REDISTS = "/usr/local/bin/droiddeck-seed-redists" PACK_KEYS = ("WINESERVER", "BL_SYNC_PACK", "WINEESYNC", "PROTON_NO_ESYNC", "PROTON_NO_FSYNC") -def game_id(env): - prefix = env.get("STEAM_COMPAT_DATA_PATH", "").rstrip("/") - if not prefix: - return None - value = prefix.rsplit("/", 1)[-1] - if not re.fullmatch(r"-?[0-9]+", value): +# Prefixes several games share; the game itself is the Steam app being launched. +SHARED_PREFIXES = {"2929927072"} + + +def app_number(value): + if not re.fullmatch(r"-?[0-9]+", value or ""): return None number = int(value) if not -(2**31) <= number < 2**32 or number == 0: @@ -30,6 +30,26 @@ def game_id(env): return str(number % 2**32) +def game_id(env): + prefix = env.get("STEAM_COMPAT_DATA_PATH", "").rstrip("/") + if not prefix: + return None + found = app_number(prefix.rsplit("/", 1)[-1]) + if found in SHARED_PREFIXES: + return app_number(env.get("SteamAppId", "")) or shortcut_number(env.get("SteamGameId", "")) or found + return found + + +def shortcut_number(value): + """The shortcut appid inside Steam's 64-bit shortcut game id `(appid << 32) | 0x02000000`.""" + if not re.fullmatch(r"[0-9]{1,20}", value or ""): + return None + number = int(value) + if number >= 2**64 or number & 0xFFFFFFFF != 0x02000000: + return None + return app_number(str(number >> 32)) + + DOTNET_FEX = { "FEX_TSOENABLED": "1", "FEX_VECTORTSOENABLED": "1", diff --git a/tools/linuxfs/overlay/usr/local/bin/droiddeck-store-launch b/tools/linuxfs/overlay/usr/local/bin/droiddeck-store-launch index 1f7c12bd6..ca6ddebc8 100755 --- a/tools/linuxfs/overlay/usr/local/bin/droiddeck-store-launch +++ b/tools/linuxfs/overlay/usr/local/bin/droiddeck-store-launch @@ -289,6 +289,218 @@ def exited(argv, code=0): return 0 +UBISOFT_DIR = "drive_c/Program Files (x86)/Ubisoft/Ubisoft Game Launcher" +UBISOFT_WINDOWS_DIR = "C:\\Program Files (x86)\\Ubisoft\\Ubisoft Game Launcher\\" +UBISOFT_INSTALLER_URL = "https://static3.cdn.ubi.com/orbit/launcher_installer/UbisoftConnectInstaller.exe" +UBISOFT_CACHE = Path("/root/.cache/droiddeck/ubisoft") +UBISOFT_LAUNCH = ".droiddeck-ubisoft.json" +UBISOFT_SERVICE = Path("/usr/local/lib/droiddeck-ubisoft/droiddeck-upc.exe") +UBISOFT_ASK_WAIT = 45.0 +UBISOFT_EXTRACT_WAIT = 300 +# Ubisoft's own game-side runtime from the Connect installer, unmodified: the API and aux DLLs the +# game's loader opens from InstallDir, the starter it re-executes into and its imports, and the overlay +# DLLs uplay_r1.dll loads even with the overlay off (its startup fails without them). +UBISOFT_RUNTIME = ("uplay_r1.dll", "uplay_r164.dll", "uplay_aux_r1.dll", "uplay_aux_r164.dll", + "uplay_r2.dll", "uplay_r264.dll", "ubiorbitapi_r2.dll", "ubiorbitapi_r264.dll", + "UbisoftGameLauncher.exe", "UbisoftGameLauncher64.exe", "discord-rpc.x86.dll", + "discord-rpc.x64.dll", "steam_api.dll", "steam_api64.dll", "overlay.dll", "overlay64.dll") +UBISOFT_VIEWS = ("[Software\\\\Ubisoft\\\\Launcher%s]", "[Software\\\\Wow6432Node\\\\Ubisoft\\\\Launcher%s]") +MIN_INSTALLER = 1 << 20 + + +def ubisoft_runtime_dir(compat_data): + return Path(compat_data) / "pfx" / UBISOFT_DIR + + +def ubisoft_runtime_ready(compat_data): + folder = ubisoft_runtime_dir(compat_data) + return all((folder / name).is_file() for name in UBISOFT_RUNTIME) + + +def fetch_installer(url=UBISOFT_INSTALLER_URL, folder=UBISOFT_CACHE): + target = folder / "UbisoftConnectInstaller.exe" + if target.is_file() and target.stat().st_size >= MIN_INSTALLER: + return target + import urllib.request + folder.mkdir(parents=True, exist_ok=True) + part = target.with_name(target.name + ".part") + request = urllib.request.Request(url, headers={"User-Agent": "Mozilla/5.0"}) + with urllib.request.urlopen(request, timeout=60) as reply, open(part, "wb") as out: + shutil.copyfileobj(reply, out) + if part.stat().st_size < MIN_INSTALLER: + part.unlink() + raise OSError("installer too small") + os.replace(part, target) + return target + + +def install_runtime(compat_data, wait=UBISOFT_EXTRACT_WAIT): + if ubisoft_runtime_ready(compat_data): + return "present" + if not (Path(compat_data) / "pfx").is_dir(): + return "no-prefix" + try: + installer = fetch_installer() + except OSError as error: + return "download-failed-" + type(error).__name__ + target = ubisoft_runtime_dir(compat_data) + target.mkdir(parents=True, exist_ok=True) + with tempfile.TemporaryDirectory(prefix="droiddeck-ubisoft-", dir=str(UBISOFT_CACHE)) as tmp: + try: + subprocess.run(["7z", "e", "-y", "-o" + tmp, str(installer)] + list(UBISOFT_RUNTIME), stdin=subprocess.DEVNULL, + stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL, timeout=wait) + except subprocess.TimeoutExpired: + return "extract-timeout" + except OSError: + return "no-7z" + missing = [name for name in UBISOFT_RUNTIME if not (Path(tmp) / name).is_file()] + if missing: + return "extract-missing-" + ",".join(missing) + for name in UBISOFT_RUNTIME: + shutil.copyfile(Path(tmp) / name, target / name) + if not ubisoft_runtime_ready(compat_data): + return "copy-failed" + try: + installer.unlink() + except OSError: + pass + return "installed" + + +def ubisoft_product(sidecar): + extra = sidecar.get("extra") if isinstance(sidecar.get("extra"), dict) else {} + try: + return int(str(extra.get("productId") or "")) + except ValueError: + return None + + +def ubisoft_install_dir(folder): + return windows_path(str(folder)).replace("\\", "/").rstrip("/") + "/" + + +def register_ubisoft(folder, product, compat_data): + reg = Path(compat_data) / "pfx" / "system.reg" + if not reg.is_file(): + return "no-registry" + if not wait_for_server(reg.parent): + return "server-running" + for view in UBISOFT_VIEWS: + set_value(reg, view % "", "InstallDir", UBISOFT_WINDOWS_DIR) + set_value(reg, view % ("\\\\Installs\\\\%d" % product), "InstallDir", ubisoft_install_dir(folder)) + return "registered %d" % product + + +UBISOFT_HIVES = {"HKEY_LOCAL_MACHINE": "system.reg", "HKEY_CURRENT_USER": "user.reg"} + + +def manifest_value(values, folder, language): + """The value Connect writes for a manifest registry entry: the install folder, the entry for the + language being played, or the only entry there is.""" + entries = [v for v in values if isinstance(v, dict)] + if not entries: + return None + names = [str(v.get("name") or "") for v in entries] + if "$INSTALL_DIR" in names: + return windows_path(str(folder)).rstrip("\\") + "\\" + for v in entries: + if str(v.get("value") or "").lower() == language.lower(): + return str(v.get("value")) + prefix = language.split("-")[0].lower() + for v in entries: + if str(v.get("value") or "").lower().split("-")[0] == prefix: + return str(v.get("value")) + return str(entries[0].get("value") or entries[0].get("name") or "") + + +def apply_manifest_registry(compat_data, registry, folder, language): + written = 0 + for item in registry or []: + if not isinstance(item, dict): + continue + path = str(item.get("key") or "").replace("/", "\\") + hive, _, rest = path.partition("\\") + target = UBISOFT_HIVES.get(hive.upper()) + if not target or "\\" not in rest: + continue + subkey, _, name = rest.rpartition("\\") + value = manifest_value(item.get("values"), folder, language) + if value is None or not name: + continue + reg = Path(compat_data) / "pfx" / target + parts = subkey.split("\\") + views = [parts] + if target == "system.reg" and len(parts) > 1 and parts[0].lower() == "software" and parts[1].lower() != "wow6432node": + views.append(parts[:1] + ["Wow6432Node"] + parts[1:]) + for view in views: + key = "[" + "\\\\".join(view) + "]" + if set_value(reg, key, name, value): + written += 1 + return written + + +def ubisoft_launch(folder, sidecar, product, compat_data, answer): + """The launch file droiddeck-upc.exe reads: the game, the account and this launch's ticket.""" + account = answer.get("account") if isinstance(answer.get("account"), dict) else {} + user = str(account.get("userId") or "user") + saves = Path(compat_data) / "pfx" / "drive_c/users/steamuser/Saved Games/Ubisoft" / user / str(product) + saves.mkdir(parents=True, exist_ok=True) + exe = str(sidecar.get("exe") or "").replace("\\", "/") + workdir = folder / str(sidecar.get("workdir")) if sidecar.get("workdir") else folder + UBISOFT_CACHE.mkdir(parents=True, exist_ok=True) + return { + "productId": product, + "exe": windows_path(str(folder / exe)), + "args": " ".join(str(a) for a in sidecar.get("args") or []), + "workdir": windows_path(str(workdir)), + "account": account, + "ticket": str(answer.get("ticket") or ""), + "owned": [int(i) for i in answer.get("owned") or [] if str(i).isdigit()], + "products": [p for p in answer.get("products") or [] if isinstance(p, dict) and isinstance(p.get("id"), int)], + "appId": str(answer.get("appId") or ""), + "chunks": [c for c in answer.get("chunks") or [] if isinstance(c, dict) and isinstance(c.get("id"), int)], + "saves": "C:/users/steamuser/Saved Games/Ubisoft/%s/%d/" % (user, product), + "language": str(answer.get("language") or "en-US"), + "bridge": windows_path(str(UBISOFT_CACHE / ("bridge-%d" % product))), + "log": windows_path(str(UBISOFT_CACHE / ("upc-%d.log" % product))), + } + + +def prepare_ubisoft(folder, sidecar, compat_data, launch_dir, proton="", ask_app=None): + launch = folder / UBISOFT_LAUNCH + try: + launch.unlink() + except OSError: + pass + if not compat_data: + return "no-prefix" + product = ubisoft_product(sidecar) + if product is None: + return "no-product" + if not UBISOFT_SERVICE.is_file(): + return "no-service" + made = create_prefix(compat_data, proton) if not (Path(compat_data) / "pfx/user.reg").is_file() else "present" + if made not in ("present", "created"): + return "prefix " + made + runtime = install_runtime(compat_data) + if not ubisoft_runtime_ready(compat_data): + return "runtime " + runtime + registry = register_ubisoft(folder, product, compat_data) + if not launch_dir: + return "runtime %s, %s, no-channel" % (runtime, registry) + answer = (ask_app or ask)(Path(launch_dir) / "stores", {"op": "ubisoft-launch", "store": "ubisoft", "id": str(sidecar.get("id"))}, + timeout=UBISOFT_ASK_WAIT) + if not isinstance(answer, dict) or not answer.get("ok") or not answer.get("ticket"): + reason = "timeout" if answer is None else str(answer.get("reason") or "refused") + return "runtime %s, %s, session %s" % (runtime, registry, reason) + written = 0 + if answer.get("registry") and wait_for_server(Path(compat_data) / "pfx"): + written = apply_manifest_registry(compat_data, answer.get("registry"), folder, str(answer.get("language") or "en-US")) + tmp = launch.with_name(launch.name + ".tmp") + tmp.write_text(json.dumps(ubisoft_launch(folder, sidecar, product, compat_data, answer))) + os.replace(tmp, launch) + return "runtime %s, %s, %d manifest value(s), session ready" % (runtime, registry, written) + COMET_DIR = Path("/usr/local/lib/droiddeck-comet") @@ -1114,6 +1326,15 @@ def main(argv): prepare_gog(sidecar, compat_data, launch_dir) log("gog registry: " + apply_gog_registry(folder, compat_data, os.environ.get("BL_STORE_PROTON", ""))) log("gog support data: " + gog_support_data(folder, compat_data, os.environ.get("BL_STORE_PROTON", ""))) + if store == "ubisoft": + try: + log("ubisoft %s: %s" % (game, prepare_ubisoft(folder, sidecar, compat_data, launch_dir, os.environ.get("BL_STORE_PROTON", "")))) + except OSError as e: + try: + (folder / UBISOFT_LAUNCH).unlink() + except OSError: + pass + log("ubisoft %s: not prepared (%s), starting directly" % (game, e)) if store == "epic": handled = os.environ.get(EPIC_HANDLED) == "1" and (folder / EPIC_ARGS).is_file() if not handled: diff --git a/tools/linuxfs/overlay/usr/local/bin/steam-compatibility b/tools/linuxfs/overlay/usr/local/bin/steam-compatibility index 4a2cbcb15..426b6c173 100755 --- a/tools/linuxfs/overlay/usr/local/bin/steam-compatibility +++ b/tools/linuxfs/overlay/usr/local/bin/steam-compatibility @@ -152,6 +152,13 @@ bl_components() { else cm_app="" fi + if [ "$cm_app" = 2929927072 ] && [[ "${SteamAppId:-}" =~ ^[0-9]{1,10}$ ]] && (( 10#$SteamAppId > 0 && 10#$SteamAppId <= 4294967295 )); then + cm_app=$((10#$SteamAppId)) + elif [ "$cm_app" = 2929927072 ] && [[ "${SteamGameId:-}" =~ ^[1-9][0-9]{9,19}$ ]] && + { [ ${#SteamGameId} -lt 20 ] || [[ ! "$SteamGameId" > 18446744073709551615 ]]; } && + (( (10#$SteamGameId & 0xFFFFFFFF) == 0x02000000 && ((10#$SteamGameId >> 32) & 0xFFFFFFFF) != 0 )); then + cm_app=$(( (10#$SteamGameId >> 32) & 0xFFFFFFFF )) + fi if [ -z "$cm_app" ]; then echo "droiddeck-proton: components: invalid game prefix, left as it is" >&2 return 0 diff --git a/tools/tests/test_game_environment.py b/tools/tests/test_game_environment.py index ce902817a..b1cdcedad 100644 --- a/tools/tests/test_game_environment.py +++ b/tools/tests/test_game_environment.py @@ -1124,6 +1124,23 @@ class GameEnvironmentTest(unittest.TestCase): scope = MODULE["game_id"](env) expected = "untouched" if scope is None else scope if scope in {"42", "4294967295", "2147483648"} else "*" self.assertEqual(target.read_text(), expected) + for app, expected in (("42", "42"), ("4294967295", "4294967295"), ("43", "*"), ("", "*"), ("x", "*")): + with self.subTest(shared=app): + env = dict(os.environ, HOME=str(root), STEAM_COMPAT_DATA_PATH="/steam/compatdata/2929927072", SteamAppId=app) + target.write_text("untouched") + subprocess.run(["bash", "-c", script, "test", str(base)], env=env, check=True, capture_output=True) + self.assertEqual(MODULE["game_id"](env), app if app in {"42", "4294967295", "43"} else "2929927072") + self.assertEqual(target.read_text(), expected) + for game, expected in ( + (str(42 << 32 | 0x02000000), "42"), (str(4294967295 << 32 | 0x02000000), "4294967295"), + (str(42 << 32 | 0x02000001), "*"), (str(42 << 32 | 0x02000000 | 1 << 64), "*"), ("33554432", "*"), ("x", "*"), + ): + with self.subTest(direct=game): + env = dict(os.environ, HOME=str(root), STEAM_COMPAT_DATA_PATH="/steam/compatdata/2929927072", SteamAppId="0", SteamGameId=game) + target.write_text("untouched") + subprocess.run(["bash", "-c", script, "test", str(base)], env=env, check=True, capture_output=True) + self.assertEqual(MODULE["game_id"](env), expected if expected != "*" else "2929927072") + self.assertEqual(target.read_text(), expected) def test_engine_fixes_follow_the_games_files(self): with tempfile.TemporaryDirectory() as tmp: @@ -1628,6 +1645,10 @@ class GameEnvironmentTest(unittest.TestCase): self.assertIsNone(MODULE["game_id"]({"STEAM_COMPAT_DATA_PATH": prefix})) self.assertEqual(MODULE["game_id"]({"STEAM_COMPAT_DATA_PATH": "/a/compatdata/42/"}), "42") self.assertEqual(MODULE["game_id"]({"STEAM_COMPAT_DATA_PATH": "/a/compatdata/-1"}), "4294967295") + shared = {"STEAM_COMPAT_DATA_PATH": "/a/compatdata/2929927072", "SteamAppId": "2424921676"} + self.assertEqual(MODULE["game_id"](shared), "2424921676") + self.assertEqual(MODULE["game_id"]({"STEAM_COMPAT_DATA_PATH": "/a/compatdata/2929927072"}), "2929927072") + self.assertEqual(MODULE["game_id"]({"STEAM_COMPAT_DATA_PATH": "/a/compatdata/42", "SteamAppId": "7"}), "42") def test_launch_reads_updates_preserves_argv_and_does_not_execute_values(self): with tempfile.TemporaryDirectory() as tmp: diff --git a/tools/tests/test_store_launch.py b/tools/tests/test_store_launch.py index 863eb679a..36a1b7c9a 100644 --- a/tools/tests/test_store_launch.py +++ b/tools/tests/test_store_launch.py @@ -797,3 +797,102 @@ class GogRegistry(unittest.TestCase): store.main([str(folder / store.LAUNCHER)]) apply.assert_called_once_with(folder, root + '/compat', '/chosen/proton') support.assert_called_once_with(folder, root + '/compat', '/chosen/proton') + + +class Ubisoft(unittest.TestCase): + def setUp(self): + self.tmp = tempfile.TemporaryDirectory() + self.root = Path(self.tmp.name) + self.compat_data = self.root / 'compatdata/2929927072' + (self.compat_data / 'pfx').mkdir(parents=True) + (self.compat_data / 'pfx/system.reg').write_text('WINE REGISTRY Version 2\n') + (self.compat_data / 'pfx/user.reg').write_text('WINE REGISTRY Version 2\n') + self.folder = self.root / 'Games/Stores/Ubisoft/Splinter Cell' + (self.folder / 'system').mkdir(parents=True) + self.sidecar = {'store': 'ubisoft', 'id': 'space-1', 'exe': 'system/SplinterCell.exe', 'extra': {'productId': '109'}} + service = self.root / 'droiddeck-upc.exe' + service.write_bytes(b'MZ') + for patcher in (patch.object(store, 'UBISOFT_SERVICE', service), patch.object(store, 'UBISOFT_CACHE', self.root / 'cache'), + patch.object(store, 'wait_for_server', return_value=True)): + patcher.start() + self.addCleanup(patcher.stop) + + def tearDown(self): + self.tmp.cleanup() + + def runtime(self): + folder = store.ubisoft_runtime_dir(self.compat_data) + folder.mkdir(parents=True, exist_ok=True) + for name in store.UBISOFT_RUNTIME: + (folder / name).write_bytes(b'MZ') + + def answer(self, *_args, **_kwargs): + return {'ok': True, 'ticket': 'PC', 'owned': [82, 109], 'appId': 'app', + 'account': {'userId': 'u1', 'username': 'name', 'nameOnPlatform': 'name', 'email': 'e@x', 'country': 'US'}} + + def prepare(self, ask_app): + return store.prepare_ubisoft(self.folder, self.sidecar, str(self.compat_data), str(self.root / 'launch'), '/proton', ask_app=ask_app) + + def test_an_extracted_runtime_is_not_extracted_again(self): + self.runtime() + with patch.object(store.subprocess, 'run') as run: + self.assertEqual('present', store.install_runtime(str(self.compat_data))) + run.assert_not_called() + + def test_the_runtime_comes_from_the_installer_through_7z(self): + installer = self.root / 'UbisoftConnectInstaller.exe' + + def run(command, **_kwargs): + self.assertEqual(['7z', 'e', '-y'], command[:3]) + self.assertEqual(str(installer), command[4]) + out = Path(command[3][2:]) + for name in command[5:]: + (out / name).write_bytes(b'MZ') + + (self.root / 'cache').mkdir() + with patch.object(store, 'fetch_installer', return_value=installer), patch.object(store.subprocess, 'run', side_effect=run): + self.assertEqual('installed', store.install_runtime(str(self.compat_data))) + self.assertTrue(store.ubisoft_runtime_ready(str(self.compat_data))) + + def test_a_launch_registers_the_game_and_writes_the_session_for_the_service(self): + self.runtime() + result = self.prepare(self.answer) + self.assertEqual('runtime present, registered 109, 0 manifest value(s), session ready', result) + reg = (self.compat_data / 'pfx/system.reg').read_text() + for view in ('[Software\\\\Ubisoft\\\\Launcher]', '[Software\\\\Wow6432Node\\\\Ubisoft\\\\Launcher]'): + self.assertIn(view, reg) + self.assertIn('[Software\\\\Wow6432Node\\\\Ubisoft\\\\Launcher\\\\Installs\\\\109]', reg) + self.assertIn('"InstallDir"="C:\\\\Program Files (x86)\\\\Ubisoft\\\\Ubisoft Game Launcher\\\\"', reg) + launch = json.loads((self.folder / store.UBISOFT_LAUNCH).read_text()) + self.assertEqual(109, launch['productId']) + self.assertEqual('PC', launch['ticket']) + self.assertEqual([82, 109], launch['owned']) + self.assertEqual(store.windows_path(str(self.folder / 'system/SplinterCell.exe')), launch['exe']) + self.assertEqual('C:/users/steamuser/Saved Games/Ubisoft/u1/109/', launch['saves']) + self.assertTrue((self.compat_data / 'pfx/drive_c/users/steamuser/Saved Games/Ubisoft/u1/109').is_dir()) + + def test_without_a_session_the_launch_file_is_removed_and_the_game_starts_as_is(self): + self.runtime() + (self.folder / store.UBISOFT_LAUNCH).write_text('{}') + result = self.prepare(lambda *_a, **_k: {'ok': False, 'reason': 'signed-out'}) + self.assertEqual('runtime present, registered 109, session signed-out', result) + self.assertFalse((self.folder / store.UBISOFT_LAUNCH).exists()) + + def test_a_game_without_a_product_is_left_alone(self): + self.sidecar['extra'] = {} + self.assertEqual('no-product', self.prepare(self.answer)) + + def test_the_manifest_registry_lands_in_both_views_with_the_install_folder_and_language(self): + self.runtime() + registry = [{'key': r'HKEY_LOCAL_MACHINE\SOFTWARE\Ubisoft\Launcher\Installs\109\Language', + 'values': [{'name': 'French', 'value': 'fr-FR'}, {'name': 'English', 'value': 'en-US'}]}, + {'key': r'HKEY_LOCAL_MACHINE\SOFTWARE\Ubisoft\Splinter Cell\GamePath', 'values': [{'name': '$INSTALL_DIR', 'value': ''}]}] + answer = dict(self.answer(), registry=registry, language='en-US') + result = self.prepare(lambda *_a, **_k: answer) + self.assertEqual('runtime present, registered 109, 4 manifest value(s), session ready', result) + reg = (self.compat_data / 'pfx/system.reg').read_text() + self.assertIn(r'[SOFTWARE\\Wow6432Node\\Ubisoft\\Launcher\\Installs\\109]', reg) + self.assertIn(r'[SOFTWARE\\Ubisoft\\Launcher\\Installs\\109]', reg) + self.assertIn('"Language"="en-US"', reg) + folder = store.windows_path(str(self.folder)).replace('\\', '\\\\') + self.assertIn('"GamePath"="%s\\\\"' % folder, reg) diff --git a/tools/ubisoft-launcher/.gitignore b/tools/ubisoft-launcher/.gitignore new file mode 100644 index 000000000..90de6fc98 --- /dev/null +++ b/tools/ubisoft-launcher/.gitignore @@ -0,0 +1,2 @@ +target/ +out/ diff --git a/tools/ubisoft-launcher/Cargo.lock b/tools/ubisoft-launcher/Cargo.lock new file mode 100644 index 000000000..a2bc2229e --- /dev/null +++ b/tools/ubisoft-launcher/Cargo.lock @@ -0,0 +1,105 @@ +# This file is automatically @generated by Cargo. +# It is not intended for manual editing. +version = 4 + +[[package]] +name = "droiddeck-ubisoft-launcher" +version = "0.1.0" +dependencies = [ + "serde_json", +] + +[[package]] +name = "itoa" +version = "1.0.19" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3bd1574e617a9a6c1f8f8181bd7e841e1f20e4277607ef28945765188d6284d4" + +[[package]] +name = "memchr" +version = "2.8.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cf8baf1c55e62ffcace7a9f06f4bd9cd3f0c4beb022d3b367256b91b87513d98" + +[[package]] +name = "proc-macro2" +version = "1.0.107" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "985e7ec9bb745e6ce6535b544d84d6cd6f7ad8bd711c398938ae983b91a766d9" +dependencies = [ + "unicode-ident", +] + +[[package]] +name = "quote" +version = "1.0.47" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1fbf4db142a473a8d80c26bbf18454ed458bf8d26c8219c331daecfdbd079001" +dependencies = [ + "proc-macro2", +] + +[[package]] +name = "serde" +version = "1.0.229" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4148590afebada386688f18773da617792bf2ef03ffc1e4cbd2b1d45b023e0ba" +dependencies = [ + "serde_core", +] + +[[package]] +name = "serde_core" +version = "1.0.229" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "67dca2c9c51e58a4791a4b1ed58308b39c64224d349a935ab5039aa360942a48" +dependencies = [ + "serde_derive", +] + +[[package]] +name = "serde_derive" +version = "1.0.229" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e7a5d71263a5a7d47b41f6b3f06ba276f10cc18b0931f1799f710578e2309348" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "serde_json" +version = "1.0.154" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e7e9cc8b1b85264074fbcc02a88680c4096b1e47df8f739dceb03bf482f04bd6" +dependencies = [ + "itoa", + "memchr", + "serde", + "serde_core", + "zmij", +] + +[[package]] +name = "syn" +version = "3.0.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d78c8dee4c7bf0e14673097256fed6142ce9d3b85a408189d07482442145823b" +dependencies = [ + "proc-macro2", + "quote", + "unicode-ident", +] + +[[package]] +name = "unicode-ident" +version = "1.0.27" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a2c754d6c33795a1c324727428e5a7dedb5b06195f9890bdbcba760d3e246563" + +[[package]] +name = "zmij" +version = "1.0.23" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "29666d0abbfad1e3dc4dcf6144730dd3a3ab225bbbdac83319345b1b44ccfc1b" diff --git a/tools/ubisoft-launcher/Cargo.toml b/tools/ubisoft-launcher/Cargo.toml new file mode 100644 index 000000000..e90819761 --- /dev/null +++ b/tools/ubisoft-launcher/Cargo.toml @@ -0,0 +1,19 @@ +[package] +name = "droiddeck-ubisoft-launcher" +version = "0.1.0" +edition = "2021" +license = "GPL-3.0-only" +publish = false + +[[bin]] +name = "droiddeck-upc" +path = "src/main.rs" + +[dependencies] +serde_json = "1" + +[profile.release] +opt-level = "s" +lto = true +panic = "abort" +strip = true diff --git a/tools/ubisoft-launcher/build.sh b/tools/ubisoft-launcher/build.sh new file mode 100755 index 000000000..437d2c2df --- /dev/null +++ b/tools/ubisoft-launcher/build.sh @@ -0,0 +1,10 @@ +#!/usr/bin/env bash +# Builds droiddeck-upc.exe for Wine and stages it with the guest scripts. +set -euo pipefail +here=$(cd "$(dirname "$0")" && pwd) +out=${1:-"$here/../../app/src/main/assets/linuxfs/usr/local/lib/droiddeck-ubisoft"} +target=x86_64-pc-windows-gnu +rustup target add "$target" >/dev/null 2>&1 || true +cargo build --manifest-path "$here/Cargo.toml" --release --locked --target "$target" +mkdir -p "$out" +install -m644 "${CARGO_TARGET_DIR:-$here/target}/$target/release/droiddeck-upc.exe" "$out/droiddeck-upc.exe" diff --git a/tools/ubisoft-launcher/src/launcher.rs b/tools/ubisoft-launcher/src/launcher.rs new file mode 100644 index 000000000..c58a69c8c --- /dev/null +++ b/tools/ubisoft-launcher/src/launcher.rs @@ -0,0 +1,358 @@ +//! What Ubisoft Connect answers on its local pipes, built from the launch configuration. +//! +//! Field numbers follow `mg.protocol.uplaydll` and `game_starter` as embedded in Ubisoft's own +//! `uplay_r1.dll`; see docs/development/ubisoft-launch.md. + +use crate::pb::{self, Msg, Value}; +use serde_json::Value as Json; + +pub const GAME_START: &str = "game_start_ipc_pipe_"; +pub const API_PROCESS: &str = "uplay_api_process_ipc_pipe_"; +pub const SESSION: &str = "uplay_ipc_pipe_"; +pub const AUX: &str = "uplay_aux_ipc_pipe_"; +pub const SERVICE: &str = "uplay_service_ipc_pipe_"; +/// Pipes Connect creates that the game may open; they are held open without answers. +pub const QUIET: &[&str] = &[ + "upc_api_process_ipc_pipe_standard", + "uplay_protocol_ipc_pipe_", + "uplay_overlay_ipc_pipe_", + "orbit_ipc_pipe_", + "uplay_crash_reporter_ipc_pipe_", +]; +/// The add-on list Connect returns from the aux init; the game echoes it back. +const AUX_ADDONS: [u64; 3] = [3367, 3368, 3962]; +/// `OverlayInjectionMethod_None`. +const OVERLAY_NONE: u64 = 1; +const HWSCORE: &str = "C:/ProgramData/Ubisoft/Ubisoft Game Launcher/cache//statistics/hwscore"; + +pub struct Config { + pub product: u32, + pub exe: String, + pub args: String, + pub workdir: String, + pub user_id: String, + pub username: String, + pub name_on_platform: String, + pub email: String, + pub country: String, + pub ticket: String, + pub owned: Vec, + pub app_id: String, + pub saves: String, + pub language: String, + pub bridge: String, + pub log: String, + /// Installed chunks of the game's manifest: id and tags. + pub chunks: Vec<(u32, String)>, + /// Owned products and their ownership type (0 game, 1 add-on). + pub products: Vec<(u32, u32)>, +} + +fn s(j: &Json, k: &str) -> String { + j.get(k).and_then(|v| v.as_str()).unwrap_or_default().to_string() +} + +impl Config { + pub fn parse(text: &str) -> Result { + let j: Json = serde_json::from_str(text).map_err(|e| format!("config: {e}"))?; + let product = j.get("productId").and_then(|v| v.as_u64()).ok_or("config: no productId")? as u32; + let account = j.get("account").cloned().unwrap_or(Json::Null); + let mut owned: Vec = j.get("owned").and_then(|v| v.as_array()).map(|a| a.iter().filter_map(|v| v.as_u64()).map(|v| v as u32).collect()).unwrap_or_default(); + if !owned.contains(&product) { + owned.push(product); + } + let mut saves = s(&j, "saves").replace('\\', "/"); + if !saves.ends_with('/') { + saves.push('/'); + } + let c = Config { + product, + exe: s(&j, "exe"), + args: s(&j, "args"), + workdir: s(&j, "workdir"), + user_id: s(&account, "userId"), + username: s(&account, "username"), + name_on_platform: s(&account, "nameOnPlatform"), + email: s(&account, "email"), + country: s(&account, "country"), + ticket: s(&j, "ticket"), + owned, + app_id: s(&j, "appId"), + saves, + language: Some(s(&j, "language")).filter(|l| !l.is_empty()).unwrap_or_else(|| "en-US".into()), + bridge: s(&j, "bridge"), + log: s(&j, "log"), + chunks: j.get("chunks").and_then(|v| v.as_array()).map(|a| a.iter().filter_map(|c| { + Some((c.get("id")?.as_u64()? as u32, c.get("tags").and_then(|t| t.as_str()).unwrap_or("").to_string())) + }).collect()).unwrap_or_default(), + products: j.get("products").and_then(|v| v.as_array()).map(|a| a.iter().filter_map(|p| { + Some((p.get("id")?.as_u64()? as u32, p.get("type").and_then(|t| t.as_u64()).unwrap_or(0) as u32)) + }).collect()).unwrap_or_default(), + }; + if c.exe.is_empty() || c.ticket.is_empty() { + return Err("config: exe and ticket are required".into()); + } + Ok(c) + } + + fn root(&self) -> String { + let mut r = self.workdir.replace('\\', "/"); + if !r.ends_with('/') { + r.push('/'); + } + r + } + + /// The executable the way Connect hands it back: the install root, a slash, then the path under it. + pub fn reply_exe(&self, requested: &str) -> String { + let root = self.root(); + let base = root.trim_end_matches('/'); + let exe = requested.replace('\\', "/"); + let rel = if !base.is_empty() && exe.to_lowercase().starts_with(&(base.to_lowercase() + "/")) { &exe[base.len() + 1..] } else { &exe[..] }; + format!("{root}/{rel}") + } + + /// `game_starter.Downstream` granting the start the starter asked for. + pub fn game_start(&self, request: &[u8]) -> Vec { + let requested = pb::find(request, 10, 4).map(|b| String::from_utf8_lossy(b).into_owned()).unwrap_or_else(|| self.exe.clone()); + let progress = Msg::new() + .msg(1, Msg::new().uint(1, 1).uint(2, 113).uint(3, 0).uint(4, 1).uint(5, 0)) + .msg(1, Msg::new().uint(1, 3).uint(2, 123).uint(3, 0).uint(4, 0).uint(5, 0)); + let granted = Msg::new() + .uint(1, 0) + .uint(2, 0) + .text(3, &self.reply_exe(&requested)) + .text(4, &self.root()) + .uint(8, 3) + .msg(9, progress); + Msg::new().msg(2, Msg::new().msg(1, granted)).0 + } + + /// `Chunks`: every installed chunk, marked downloaded. + fn chunks(&self) -> Msg { + let mut m = Msg::new(); + for (id, tags) in &self.chunks { + m = m.msg(1, Msg::new().uint(1, *id as u64).uint(2, 1).text(3, tags)); + } + m + } + + /// `Rsp.initProcessRsp` for the game announcing its process. The overlay is reported off + /// everywhere: with it on, `uplay_r1.dll` loads `overlay.dll` and fails its startup without it. + pub fn init_process(&self) -> Vec { + Msg::new().msg(13, Msg::new().uint(1, 1).uint(3, 0).uint(4, OVERLAY_NONE).uint(6, 0)).0 + } + + /// `Rsp.initRsp`: the account, its products and the PC ticket. + pub fn session(&self) -> Vec { + let account = Msg::new() + .text(1, &self.user_id) + .text(2, &self.username) + .text(3, &self.name_on_platform) + .text(4, &self.email) + .text(5, "") + .text(6, &self.country); + let mut body = Msg::new().uint(1, 1).msg(2, account).uint(3, 1).uint(4, self.product as u64); + for id in &self.owned { + body = body.uint(5, *id as u64); + } + body = body + .msg(6, Msg::new().uint(1, self.product as u64).text(2, "")) + .uint(7, 0) + .text(8, &self.ticket) + .msg(14, Msg::new().uint(1, 1).msg(2, self.chunks()).text(3, &self.language)) + .msg(11, Msg::new().uint(1, 0)) + .uint(12, 0) + .msg(15, Msg::new().uint(1, 0).uint(2, OVERLAY_NONE)) + .msg(16, Msg::new().uint(1, 6211).uint(2, 22395).float(3, 1.0)) + .msg(17, Msg::new().text(1, &self.saves)) + .bytes(18, b"") + .msg(19, Msg::new().uint(1, 1).uint(2, 1)) + .msg(21, Msg::new().uint(2, 1)); + if !self.app_id.is_empty() { + body = body.msg(22, Msg::new().text(1, &self.app_id)); + } + Msg::new().msg(1, body.bytes(23, b"")).0 + } + + /// `Rsp.installerRsp.initInstallerRsp`. + pub fn installer(&self) -> Vec { + Msg::new().msg(10, Msg::new().msg(1, Msg::new().uint(1, 1).msg(2, self.chunks()).text(3, &self.language))).0 + } + + /// `Rsp.productRsp.getProductListRsp`: every owned product as owned, playable and purchased. + pub fn product_list(&self, request_id: u64) -> Vec { + let mut list = Msg::new().uint(1, request_id).uint(2, 1); + let products: Vec<(u32, u32)> = if self.products.is_empty() { self.owned.iter().map(|id| (*id, 0)).collect() } else { self.products.clone() }; + for (id, kind) in products { + list = list.msg(3, Msg::new().uint(1, id as u64).uint(2, if kind == 1 { 2 } else { 1 }).uint(3, 1).uint(4, 3).uint(6, 1)); + } + Msg::new().msg(15, Msg::new().msg(1, list)).0 + } + + pub fn service_hello(&self) -> Vec { + Msg::new().msg(3, Msg::new().uint(1, 1).msg(3, Msg::new().text(1, HWSCORE))).0 + } +} + +/// Requests answered with only their id and a result: (`Req` field, its request) to (`Rsp` field, its +/// response, `OperationResult`). 1 is ok and 4 not found. +const ACKNOWLEDGED: &[(u32, u32, u32, u32, u64)] = &[ + (8, 1, 5, 1, 1), // achievements: list (none) + (8, 2, 5, 2, 4), // achievements: image + (8, 3, 5, 3, 1), // achievements: earn + (9, 1, 6, 1, 1), // overlay: show + (9, 2, 6, 2, 1), // overlay: shop url + (9, 4, 6, 3, 1), // overlay: browser url + (9, 5, 6, 4, 1), // overlay: notification + (9, 6, 6, 5, 1), // overlay: friend invitation + (9, 7, 6, 6, 1), // overlay: friend selection + (9, 8, 6, 8, 1), // overlay: micro app + (10, 1, 7, 1, 4), // avatar + (20, 1, 14, 1, 1), // store products (none) + (23, 3, 15, 5, 1), // track add-on + (25, 1, 17, 1, 4), // user lookup + (25, 3, 17, 3, 1), // rich presence + (25, 4, 17, 4, 1), // played with +]; + +/// The id-and-result answer to a request in [ACKNOWLEDGED], with a name for the log. +pub fn acknowledge(body: &[u8]) -> Option<(Vec, String)> { + let f = pb::fields(body)?; + for &(req, sub, rsp, rsp_sub, result) in ACKNOWLEDGED { + let Some(inner) = pb::sub(&f, req).and_then(pb::fields) else { continue }; + let Some(leaf) = pb::sub(&inner, sub).and_then(pb::fields) else { continue }; + let id = pb::num(&leaf, 1).unwrap_or(0); + let answer = Msg::new().msg(rsp, Msg::new().msg(rsp_sub, Msg::new().uint(1, id).uint(2, result))).0; + return Some((answer, format!("{req}.{sub} id {id} -> {result}"))); + } + None +} + +pub enum SessionReq { + Init, + Installer, + ProductList(u64), + Other, +} + +pub fn session_request(body: &[u8]) -> SessionReq { + match pb::fields(body) { + Some(f) if pb::sub(&f, 13).and_then(pb::fields).is_some_and(|i| pb::has(&i, 1)) => SessionReq::Installer, + Some(f) if pb::has(&f, 1) => SessionReq::Init, + Some(f) => match pb::sub(&f, 23).and_then(pb::fields).and_then(|p| pb::sub(&p, 1).and_then(pb::fields)) { + Some(list) => SessionReq::ProductList(pb::num(&list, 1).unwrap_or(0)), + None => SessionReq::Other, + }, + _ => SessionReq::Other, + } +} + +/// The product and game process the game announces on the api-process pipe. +pub fn announced(body: &[u8]) -> Option<(u32, u32)> { + let f = pb::fields(body)?; + let r = pb::fields(pb::sub(&f, 2)?)?; + Some((pb::num(&r, 1)? as u32, pb::num(&r, 2)? as u32)) +} + +pub enum AuxReq<'a> { + Init(u64), + Activate(u64, &'a [u8]), + Other(u64), +} + +/// `UplayAuxDll.Req`: an init, or an activation carrying the game's Denuvo request token. +pub fn aux_request(body: &[u8]) -> AuxReq<'_> { + let Some(f) = pb::fields(body) else { return AuxReq::Other(0) }; + let rid = pb::num(&f, 1).unwrap_or(0); + let Some(activate) = pb::sub(&f, 3) else { + return if pb::has(&f, 2) { AuxReq::Init(rid) } else { AuxReq::Other(rid) }; + }; + let token = pb::fields(activate) + .and_then(|a| pb::sub(&a, 2).and_then(pb::fields)) + .and_then(|t| t.iter().find_map(|(n, v)| match v { Value::Bytes(b) if *n == 2 => Some(*b), _ => None })); + match token { + Some(t) => AuxReq::Activate(rid, t), + None => AuxReq::Other(rid), + } +} + +pub fn aux_init(rid: u64, pid: u32) -> Vec { + let mut init = Msg::new().uint(1, 1).uint(2, pid as u64); + for a in AUX_ADDONS { + init = init.uint(3, a); + } + Msg::new().uint(1, rid).msg(2, init).0 +} + +pub fn aux_token(rid: u64, game_token: &[u8]) -> Vec { + let answer = Msg::new().uint(2, 1).uint(3, 0).bytes(4, game_token).bytes(5, b""); + Msg::new().uint(1, rid).msg(3, Msg::new().msg(2, answer)).0 +} + +#[cfg(test)] +mod tests { + use super::*; + + fn config() -> Config { + Config::parse(r#"{"productId":109,"exe":"Z:/games/sc/system/splintercell.exe","workdir":"Z:/games/sc","ticket":"T", + "account":{"userId":"u1","username":"name","nameOnPlatform":"name","email":"e@x","country":"US"}, + "owned":[82],"appId":"a-b","saves":"Z:/saves/u1/109","chunks":[{"id":0,"tags":""},{"id":64,"tags":"custo05"}]}"#).unwrap() + } + + #[test] + fn the_owned_list_always_holds_the_game_and_saves_end_with_a_slash() { + let c = config(); + assert_eq!(c.owned, vec![82, 109]); + assert_eq!(c.saves, "Z:/saves/u1/109/"); + } + + #[test] + fn the_start_reply_names_the_exe_under_the_root_with_connects_double_slash() { + let c = config(); + assert_eq!(c.reply_exe("Z:\\games\\sc\\system\\splintercell.exe"), "Z:/games/sc//system/splintercell.exe"); + let req = Msg::new().msg(1, Msg::new().msg(1, Msg::new().uint(1, 13389).uint(2, 109).text(10, "Z:/games/sc/system/splintercell.exe"))); + let rsp = c.game_start(&req.0); + let granted = pb::fields(pb::sub(&pb::fields(pb::sub(&pb::fields(&rsp).unwrap(), 2).unwrap()).unwrap(), 1).unwrap()).unwrap(); + assert_eq!(pb::num(&granted, 1), Some(0)); + assert_eq!(pb::sub(&granted, 3), Some(&b"Z:/games/sc//system/splintercell.exe"[..])); + assert_eq!(pb::sub(&granted, 4), Some(&b"Z:/games/sc/"[..])); + assert_eq!(pb::num(&granted, 8), Some(3)); + } + + #[test] + fn the_session_carries_the_ticket_products_and_save_root() { + let rsp = config().session(); + let init = pb::fields(pb::sub(&pb::fields(&rsp).unwrap(), 1).unwrap()).unwrap(); + assert_eq!(pb::num(&init, 1), Some(1)); + assert_eq!(pb::num(&init, 4), Some(109)); + let owned: Vec = init.iter().filter_map(|(n, v)| match v { Value::Int(x) if *n == 5 => Some(*x), _ => None }).collect(); + assert_eq!(owned, vec![82, 109]); + assert_eq!(pb::sub(&init, 8), Some(&b"T"[..])); + let saves = pb::fields(pb::sub(&init, 17).unwrap()).unwrap(); + assert_eq!(pb::sub(&saves, 1), Some(&b"Z:/saves/u1/109/"[..])); + let account = pb::fields(pb::sub(&init, 2).unwrap()).unwrap(); + assert_eq!(pb::sub(&account, 1), Some(&b"u1"[..])); + assert_eq!(pb::sub(&account, 5), Some(&b""[..])); + let install = pb::fields(pb::sub(&init, 14).unwrap()).unwrap(); + assert_eq!(pb::num(&install, 1), Some(1)); + let chunks = pb::fields(pb::sub(&install, 2).unwrap()).unwrap(); + let second = pb::fields(match &chunks[1].1 { Value::Bytes(b) => b, _ => panic!() }).unwrap(); + assert_eq!((pb::num(&second, 1), pb::num(&second, 2), pb::sub(&second, 3)), (Some(64), Some(1), Some(&b"custo05"[..]))); + } + + #[test] + fn requests_are_told_apart() { + assert!(matches!(session_request(&Msg::new().msg(1, Msg::new().uint(1, 109)).0), SessionReq::Init)); + assert!(matches!(session_request(&Msg::new().msg(13, Msg::new().bytes(1, b"")).0), SessionReq::Installer)); + assert!(matches!(session_request(&[0xba, 0x01, 0x06, 0x0a, 0x04, 0x08, 0x80, 0x80, 0x04]), SessionReq::ProductList(65536))); + let (answer, _) = acknowledge(&[0x42, 0x08, 0x0a, 0x06, 0x08, 0x80, 0x80, 0x08, 0x10, 0x00]).unwrap(); + let rsp = pb::fields(pb::sub(&pb::fields(pb::sub(&pb::fields(&answer).unwrap(), 5).unwrap()).unwrap(), 1).unwrap()).unwrap(); + assert_eq!((pb::num(&rsp, 1), pb::num(&rsp, 2)), (Some(0x20000), Some(1))); + assert!(acknowledge(&Msg::new().msg(1, Msg::new().uint(1, 1)).0).is_none()); + assert_eq!(announced(&Msg::new().msg(2, Msg::new().uint(1, 109).uint(2, 4242).uint(5, 1)).0), Some((109, 4242))); + let activate = Msg::new().uint(1, 2).msg(3, Msg::new().msg(2, Msg::new().bytes(2, b"challenge").uint(3, 0))); + assert!(matches!(aux_request(&activate.0), AuxReq::Activate(2, b"challenge"))); + assert!(matches!(aux_request(&Msg::new().uint(1, 1).msg(2, Msg::new().uint(1, 109)).0), AuxReq::Init(1))); + } +} diff --git a/tools/ubisoft-launcher/src/main.rs b/tools/ubisoft-launcher/src/main.rs new file mode 100644 index 000000000..6677a4a07 --- /dev/null +++ b/tools/ubisoft-launcher/src/main.rs @@ -0,0 +1,331 @@ +//! Stands in for Ubisoft Connect on the local pipes a Ubisoft game talks to, and starts the game. +//! +//! Usage: droiddeck-upc.exe + +#![cfg_attr(windows, windows_subsystem = "windows")] + +mod launcher; +mod pb; +#[cfg(windows)] +mod win; + +#[cfg(not(windows))] +fn main() { + eprintln!("droiddeck-upc runs inside Wine"); + std::process::exit(2); +} + +#[cfg(windows)] +fn main() { + std::process::exit(service::run()); +} + +#[cfg(windows)] +mod service { + use crate::launcher::{self, AuxReq, Config, SessionReq}; + use crate::win::{self, Handle}; + use std::fs::OpenOptions; + use std::io::Write; + use std::path::PathBuf; + use std::sync::atomic::{AtomicU32, Ordering}; + use std::sync::{Arc, Mutex}; + use std::thread; + use std::time::{Duration, Instant}; + + const MAX_FRAME: usize = 16 << 20; + const ANNOUNCE_WAIT: Duration = Duration::from_secs(120); + const RESTART_WAIT: Duration = Duration::from_secs(15); + const DENUVO_WAIT: Duration = Duration::from_secs(90); + + struct State { + cfg: Config, + prefix: String, + log: Mutex>, + event: Handle, + game: AtomicU32, + started: Instant, + } + + impl State { + fn say(&self, line: &str) { + let text = format!("[{:7.2}] {line}\n", self.started.elapsed().as_secs_f32()); + eprint!("droiddeck-upc: {text}"); + if let Some(f) = self.log.lock().unwrap().as_mut() { + let _ = f.write_all(text.as_bytes()); + } + } + } + + struct Frames<'a> { + pipe: &'a Handle, + buf: Vec, + } + + impl<'a> Frames<'a> { + fn new(pipe: &'a Handle) -> Self { + Frames { pipe, buf: Vec::new() } + } + + fn fill(&mut self) -> bool { + let mut chunk = vec![0u8; 65536]; + match self.pipe.read(&mut chunk) { + Some(n) => { + self.buf.extend_from_slice(&chunk[..n]); + true + } + None => false, + } + } + + /// The next message without its 4-byte big-endian length. + fn next(&mut self) -> Option> { + while self.buf.len() < 4 { + if !self.fill() { + return None; + } + } + let n = u32::from_be_bytes(self.buf[..4].try_into().unwrap()) as usize; + if n > MAX_FRAME { + return None; + } + while self.buf.len() < 4 + n { + if !self.fill() { + return None; + } + } + let body = self.buf[4..4 + n].to_vec(); + self.buf.drain(..4 + n); + Some(body) + } + } + + fn send(pipe: &Handle, body: &[u8]) -> bool { + let mut framed = (body.len() as u32).to_be_bytes().to_vec(); + framed.extend_from_slice(body); + pipe.write_all(&framed) + } + + fn hold(state: &State, name: &str, frames: &mut Frames) { + while let Some(body) = frames.next() { + state.say(&format!("{name}: {} B left unanswered", body.len())); + } + } + + fn game_start(state: &State, pipe: Handle) { + let mut frames = Frames::new(&pipe); + let Some(req) = frames.next() else { return }; + send(&pipe, &state.cfg.game_start(&req)); + state.say("game_start: start granted"); + hold(state, "game_start", &mut frames); + } + + fn api_process(state: &State, pipe: Handle) { + let mut frames = Frames::new(&pipe); + let Some(req) = frames.next() else { return }; + match launcher::announced(&req) { + Some((product, pid)) => { + state.say(&format!("api_process: product {product} is process {pid}")); + if pid != 0 { + state.game.store(pid, Ordering::SeqCst); + } + } + None => state.say(&format!("api_process: {} B announcement not understood", req.len())), + } + send(&pipe, &state.cfg.init_process()); + state.event.set(); + hold(state, "api_process", &mut frames); + } + + fn session(state: &State, pipe: Handle) { + let mut frames = Frames::new(&pipe); + let mut pushes = 0; + while let Some(req) = frames.next() { + match launcher::session_request(&req) { + SessionReq::Init => { + pushes += 1; + send(&pipe, &state.cfg.session()); + state.say(&format!("session: init answered ({pushes})")); + } + SessionReq::Installer => { + send(&pipe, &state.cfg.installer()); + state.say("session: installer init answered"); + } + SessionReq::ProductList(id) => { + send(&pipe, &state.cfg.product_list(id)); + state.say(&format!("session: product list {id} answered")); + } + SessionReq::Other => match launcher::acknowledge(&req) { + Some((answer, what)) => { + send(&pipe, &answer); + state.say(&format!("session: {what}")); + } + None => state.say(&format!("session: {} B request {} not handled", req.len(), hex(&req, 24))), + }, + } + } + state.say("session: closed"); + } + + fn denuvo(state: &State, rid: u64, challenge: &[u8]) -> Option> { + if state.cfg.bridge.is_empty() { + return None; + } + let dir = PathBuf::from(&state.cfg.bridge); + let _ = std::fs::create_dir_all(&dir); + let rsp = dir.join(format!("denuvo-{rid}.rsp")); + let _ = std::fs::remove_file(&rsp); + let part = dir.join(format!("denuvo-{rid}.req.part")); + std::fs::write(&part, challenge).ok()?; + std::fs::rename(&part, dir.join(format!("denuvo-{rid}.req"))).ok()?; + let deadline = Instant::now() + DENUVO_WAIT; + while Instant::now() < deadline { + if let Ok(token) = std::fs::read(&rsp) { + let _ = std::fs::remove_file(&rsp); + return (!token.is_empty()).then_some(token); + } + thread::sleep(Duration::from_millis(200)); + } + None + } + + fn aux(state: &State, pipe: Handle) { + let mut frames = Frames::new(&pipe); + while let Some(req) = frames.next() { + match launcher::aux_request(&req) { + AuxReq::Init(rid) => { + send(&pipe, &launcher::aux_init(rid, win::pid())); + state.say("aux: init answered"); + } + AuxReq::Activate(rid, challenge) => { + state.say(&format!("aux: activation {rid} with a {} B challenge", challenge.len())); + match denuvo(state, rid, challenge) { + Some(token) => { + send(&pipe, &launcher::aux_token(rid, &token)); + state.say(&format!("aux: activation {rid} answered ({} B)", token.len())); + } + None => state.say(&format!("aux: no game token for activation {rid}")), + } + } + AuxReq::Other(rid) => state.say(&format!("aux: request {rid} not handled ({} B)", req.len())), + } + } + } + + fn service(state: &State, pipe: Handle) { + send(&pipe, &state.cfg.service_hello()); + let mut chunk = vec![0u8; 65536]; + while pipe.read(&mut chunk).is_some() {} + } + + fn quiet(state: &State, name: &str, pipe: Handle) { + state.say(&format!("{name}: connected")); + let mut chunk = vec![0u8; 65536]; + while let Some(n) = pipe.read(&mut chunk) { + state.say(&format!("{name}: {n} B unanswered {}", hex(&chunk[..n], 64))); + } + } + + fn serve(state: Arc, name: &'static str) { + thread::spawn(move || { + let full = format!("{}{name}", state.prefix); + loop { + let Some(pipe) = win::accept(&full) else { + state.say(&format!("{name}: cannot serve the pipe (error {})", win::last_error())); + return; + }; + let s = state.clone(); + thread::spawn(move || match name { + launcher::GAME_START => game_start(&s, pipe), + launcher::API_PROCESS => api_process(&s, pipe), + launcher::SESSION => session(&s, pipe), + launcher::AUX => aux(&s, pipe), + launcher::SERVICE => service(&s, pipe), + _ => quiet(&s, name, pipe), + }); + } + }); + } + + fn hex(b: &[u8], n: usize) -> String { + b.iter().take(n).map(|x| format!("{x:02x}")).collect() + } + + fn command_line(cfg: &Config) -> String { + let mut c = format!("\"{}\"", cfg.exe); + if !cfg.args.trim().is_empty() { + c.push(' '); + c.push_str(cfg.args.trim()); + } + c + } + + pub fn run() -> i32 { + let Some(path) = std::env::args().nth(1) else { + eprintln!("usage: droiddeck-upc "); + return 2; + }; + let parsed = std::fs::read_to_string(&path).map_err(|e| e.to_string()).and_then(|t| Config::parse(&t)); + let _ = std::fs::remove_file(&path); + let cfg = match parsed { + Ok(c) => c, + Err(e) => { + eprintln!("droiddeck-upc: {e}"); + return 2; + } + }; + let log = (!cfg.log.is_empty()).then(|| OpenOptions::new().create(true).append(true).open(&cfg.log).ok()).flatten(); + let state = Arc::new(State { + prefix: format!(r"\\.\pipe\terminal_{}_", win::session_id()), + cfg, + log: Mutex::new(log), + event: win::inheritable_event(), + game: AtomicU32::new(0), + started: Instant::now(), + }); + state.say(&format!("product {} on {}*", state.cfg.product, state.prefix)); + for name in [launcher::GAME_START, launcher::API_PROCESS, launcher::SESSION, launcher::AUX, launcher::SERVICE] + .into_iter() + .chain(launcher::QUIET.iter().copied()) + { + serve(state.clone(), name); + } + thread::sleep(Duration::from_millis(300)); + + let (child, pid) = match win::spawn(&command_line(&state.cfg), &state.cfg.workdir) { + Ok(c) => c, + Err(e) => { + state.say(&format!("cannot start {} (error {e})", state.cfg.exe)); + return 1; + } + }; + state.say(&format!("started {} as process {pid}", state.cfg.exe)); + while !child.wait(1000) {} + let mut code = child.exit_code(); + state.say(&format!("process {pid} exited with 0x{code:08x}")); + + let mut waiting = Instant::now(); + let mut limit = ANNOUNCE_WAIT; + loop { + let game = state.game.swap(0, Ordering::SeqCst); + if game != 0 { + match win::open_process(game) { + Some(h) => { + while !h.wait(1000) {} + code = h.exit_code(); + state.say(&format!("game process {game} exited with 0x{code:08x}")); + } + None => state.say(&format!("game process {game} is already gone")), + } + waiting = Instant::now(); + limit = RESTART_WAIT; + continue; + } + if waiting.elapsed() >= limit { + break; + } + thread::sleep(Duration::from_millis(500)); + } + state.say("done"); + code as i32 + } +} diff --git a/tools/ubisoft-launcher/src/pb.rs b/tools/ubisoft-launcher/src/pb.rs new file mode 100644 index 000000000..d0ae27d16 --- /dev/null +++ b/tools/ubisoft-launcher/src/pb.rs @@ -0,0 +1,168 @@ +//! The protobuf wire format, enough to build and read the launcher messages by field number. + +pub fn varint(mut n: u64, out: &mut Vec) { + loop { + let b = (n & 0x7f) as u8; + n >>= 7; + if n == 0 { + out.push(b); + return; + } + out.push(b | 0x80); + } +} + +#[derive(Default, Clone)] +pub struct Msg(pub Vec); + +impl Msg { + pub fn new() -> Self { + Msg(Vec::new()) + } + + fn key(mut self, field: u32, wire: u8) -> Self { + varint(((field as u64) << 3) | wire as u64, &mut self.0); + self + } + + pub fn uint(self, field: u32, value: u64) -> Self { + let mut m = self.key(field, 0); + varint(value, &mut m.0); + m + } + + pub fn bytes(self, field: u32, value: &[u8]) -> Self { + let mut m = self.key(field, 2); + varint(value.len() as u64, &mut m.0); + m.0.extend_from_slice(value); + m + } + + pub fn text(self, field: u32, value: &str) -> Self { + self.bytes(field, value.as_bytes()) + } + + pub fn msg(self, field: u32, value: Msg) -> Self { + self.bytes(field, &value.0) + } + + pub fn float(self, field: u32, value: f32) -> Self { + let mut m = self.key(field, 5); + m.0.extend_from_slice(&value.to_le_bytes()); + m + } +} + +#[derive(Debug, Clone, PartialEq)] +pub enum Value<'a> { + Int(u64), + Bytes(&'a [u8]), +} + +fn read_varint(b: &[u8], i: &mut usize) -> Option { + let mut r = 0u64; + let mut shift = 0; + loop { + let c = *b.get(*i)?; + *i += 1; + if shift >= 64 { + return None; + } + r |= ((c & 0x7f) as u64) << shift; + if c & 0x80 == 0 { + return Some(r); + } + shift += 7; + } +} + +/// The top-level fields of a message, or `None` when it does not parse. +pub fn fields(b: &[u8]) -> Option)>> { + let mut out = Vec::new(); + let mut i = 0; + while i < b.len() { + let key = read_varint(b, &mut i)?; + let field = (key >> 3) as u32; + match key & 7 { + 0 => out.push((field, Value::Int(read_varint(b, &mut i)?))), + 1 => { + let v = b.get(i..i + 8)?; + i += 8; + out.push((field, Value::Int(u64::from_le_bytes(v.try_into().ok()?)))); + } + 2 => { + let n = read_varint(b, &mut i)? as usize; + let v = b.get(i..i.checked_add(n)?)?; + i += n; + out.push((field, Value::Bytes(v))); + } + 5 => { + let v = b.get(i..i + 4)?; + i += 4; + out.push((field, Value::Int(u32::from_le_bytes(v.try_into().ok()?) as u64))); + } + _ => return None, + } + } + Some(out) +} + +pub fn sub<'a>(f: &[(u32, Value<'a>)], field: u32) -> Option<&'a [u8]> { + f.iter().find_map(|(n, v)| match v { + Value::Bytes(b) if *n == field => Some(*b), + _ => None, + }) +} + +pub fn num(f: &[(u32, Value<'_>)], field: u32) -> Option { + f.iter().find_map(|(n, v)| match v { + Value::Int(x) if *n == field => Some(*x), + _ => None, + }) +} + +pub fn has(f: &[(u32, Value<'_>)], field: u32) -> bool { + f.iter().any(|(n, _)| *n == field) +} + +/// The first length-delimited `field` at any depth up to `depth`, searching breadth first. +pub fn find(b: &[u8], field: u32, depth: u32) -> Option<&[u8]> { + let f = fields(b)?; + if let Some(v) = sub(&f, field) { + return Some(v); + } + if depth == 0 { + return None; + } + f.iter().find_map(|(_, v)| match v { + Value::Bytes(inner) => find(inner, field, depth - 1), + _ => None, + }) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn round_trip() { + let m = Msg::new().uint(1, 300).text(2, "hi").msg(3, Msg::new().uint(4, 1)).float(5, 1.0); + let f = fields(&m.0).unwrap(); + assert_eq!(num(&f, 1), Some(300)); + assert_eq!(sub(&f, 2), Some(&b"hi"[..])); + assert_eq!(num(&fields(sub(&f, 3).unwrap()).unwrap(), 4), Some(1)); + assert_eq!(num(&f, 5), Some(0x3f80_0000)); + } + + #[test] + fn finds_nested_fields() { + let m = Msg::new().msg(1, Msg::new().msg(1, Msg::new().uint(2, 109).text(10, "C:/game.exe"))); + assert_eq!(find(&m.0, 10, 4), Some(&b"C:/game.exe"[..])); + assert_eq!(find(&m.0, 11, 4), None); + } + + #[test] + fn rejects_truncated_input() { + assert!(fields(&[0x0a, 0x05, 1, 2]).is_none()); + } +} diff --git a/tools/ubisoft-launcher/src/win.rs b/tools/ubisoft-launcher/src/win.rs new file mode 100644 index 000000000..aa08c76a7 --- /dev/null +++ b/tools/ubisoft-launcher/src/win.rs @@ -0,0 +1,183 @@ +//! The handful of kernel32 calls the launcher needs. + +#![allow(non_snake_case, clippy::upper_case_acronyms)] + +use std::ffi::c_void; +use std::os::windows::ffi::OsStrExt; + +pub type HANDLE = *mut c_void; +pub const INVALID: HANDLE = -1isize as HANDLE; +const PIPE_ACCESS_DUPLEX: u32 = 3; +const PIPE_TYPE_BYTE: u32 = 0; +const PIPE_READMODE_BYTE: u32 = 0; +const PIPE_WAIT: u32 = 0; +const PIPE_UNLIMITED_INSTANCES: u32 = 255; +const ERROR_PIPE_CONNECTED: u32 = 535; +const HANDLE_FLAG_INHERIT: u32 = 1; +const SYNCHRONIZE: u32 = 0x0010_0000; +const PROCESS_QUERY_LIMITED_INFORMATION: u32 = 0x1000; +pub const WAIT_OBJECT_0: u32 = 0; +const BUFFER: u32 = 65536; + +#[repr(C)] +struct STARTUPINFOW { + cb: u32, + lpReserved: *mut u16, + lpDesktop: *mut u16, + lpTitle: *mut u16, + dwX: u32, + dwY: u32, + dwXSize: u32, + dwYSize: u32, + dwXCountChars: u32, + dwYCountChars: u32, + dwFillAttribute: u32, + dwFlags: u32, + wShowWindow: u16, + cbReserved2: u16, + lpReserved2: *mut u8, + hStdInput: HANDLE, + hStdOutput: HANDLE, + hStdError: HANDLE, +} + +#[repr(C)] +struct PROCESS_INFORMATION { + hProcess: HANDLE, + hThread: HANDLE, + dwProcessId: u32, + dwThreadId: u32, +} + +#[link(name = "kernel32")] +extern "system" { + fn CreateNamedPipeW(name: *const u16, open: u32, mode: u32, max: u32, out: u32, inp: u32, timeout: u32, sa: *mut c_void) -> HANDLE; + fn ConnectNamedPipe(h: HANDLE, ov: *mut c_void) -> i32; + fn ReadFile(h: HANDLE, buf: *mut u8, n: u32, read: *mut u32, ov: *mut c_void) -> i32; + fn WriteFile(h: HANDLE, buf: *const u8, n: u32, written: *mut u32, ov: *mut c_void) -> i32; + fn FlushFileBuffers(h: HANDLE) -> i32; + fn CloseHandle(h: HANDLE) -> i32; + fn GetLastError() -> u32; + fn CreateEventW(sa: *mut c_void, manual: i32, initial: i32, name: *const u16) -> HANDLE; + fn SetEvent(h: HANDLE) -> i32; + fn SetHandleInformation(h: HANDLE, mask: u32, flags: u32) -> i32; + fn CreateProcessW(app: *const u16, cmd: *mut u16, pa: *mut c_void, ta: *mut c_void, inherit: i32, flags: u32, + env: *mut c_void, cwd: *const u16, si: *mut STARTUPINFOW, pi: *mut PROCESS_INFORMATION) -> i32; + fn WaitForSingleObject(h: HANDLE, ms: u32) -> u32; + fn GetExitCodeProcess(h: HANDLE, code: *mut u32) -> i32; + fn OpenProcess(access: u32, inherit: i32, pid: u32) -> HANDLE; + fn ProcessIdToSessionId(pid: u32, session: *mut u32) -> i32; + fn GetCurrentProcessId() -> u32; +} + +pub fn wide(s: &str) -> Vec { + std::ffi::OsStr::new(s).encode_wide().chain(Some(0)).collect() +} + +pub fn last_error() -> u32 { + unsafe { GetLastError() } +} + +pub fn pid() -> u32 { + unsafe { GetCurrentProcessId() } +} + +pub fn session_id() -> u32 { + let mut s = 0; + if unsafe { ProcessIdToSessionId(GetCurrentProcessId(), &mut s) } == 0 { + return 1; + } + s +} + +pub struct Handle(pub HANDLE); +unsafe impl Send for Handle {} +unsafe impl Sync for Handle {} + +impl Drop for Handle { + fn drop(&mut self) { + if !self.0.is_null() && self.0 != INVALID { + unsafe { CloseHandle(self.0) }; + } + } +} + +impl Handle { + pub fn read(&self, buf: &mut [u8]) -> Option { + let mut n = 0u32; + let ok = unsafe { ReadFile(self.0, buf.as_mut_ptr(), buf.len() as u32, &mut n, std::ptr::null_mut()) }; + (ok != 0 && n > 0).then_some(n as usize) + } + + pub fn write_all(&self, mut data: &[u8]) -> bool { + while !data.is_empty() { + let mut n = 0u32; + if unsafe { WriteFile(self.0, data.as_ptr(), data.len() as u32, &mut n, std::ptr::null_mut()) } == 0 || n == 0 { + return false; + } + data = &data[n as usize..]; + } + unsafe { FlushFileBuffers(self.0) }; + true + } + + pub fn wait(&self, ms: u32) -> bool { + unsafe { WaitForSingleObject(self.0, ms) == WAIT_OBJECT_0 } + } + + pub fn exit_code(&self) -> u32 { + let mut c = 0; + unsafe { GetExitCodeProcess(self.0, &mut c) }; + c + } + + pub fn set(&self) { + unsafe { SetEvent(self.0) }; + } +} + +/// A new instance of the named pipe, waiting for its client; `None` when it cannot be created. +pub fn accept(name: &str) -> Option { + let w = wide(name); + let h = unsafe { + CreateNamedPipeW(w.as_ptr(), PIPE_ACCESS_DUPLEX, PIPE_TYPE_BYTE | PIPE_READMODE_BYTE | PIPE_WAIT, + PIPE_UNLIMITED_INSTANCES, BUFFER, BUFFER, 0, std::ptr::null_mut()) + }; + if h == INVALID { + return None; + } + let pipe = Handle(h); + if unsafe { ConnectNamedPipe(h, std::ptr::null_mut()) } == 0 && last_error() != ERROR_PIPE_CONNECTED { + return None; + } + Some(pipe) +} + +/// An auto-reset event the started process inherits. +pub fn inheritable_event() -> Handle { + let h = unsafe { CreateEventW(std::ptr::null_mut(), 0, 0, std::ptr::null()) }; + unsafe { SetHandleInformation(h, HANDLE_FLAG_INHERIT, HANDLE_FLAG_INHERIT) }; + Handle(h) +} + +pub fn spawn(command_line: &str, cwd: &str) -> Result<(Handle, u32), u32> { + let mut cmd = wide(command_line); + let dir = wide(cwd); + let mut si: STARTUPINFOW = unsafe { std::mem::zeroed() }; + si.cb = std::mem::size_of::() as u32; + let mut pi: PROCESS_INFORMATION = unsafe { std::mem::zeroed() }; + let ok = unsafe { + CreateProcessW(std::ptr::null(), cmd.as_mut_ptr(), std::ptr::null_mut(), std::ptr::null_mut(), 1, 0, + std::ptr::null_mut(), if cwd.is_empty() { std::ptr::null() } else { dir.as_ptr() }, &mut si, &mut pi) + }; + if ok == 0 { + return Err(last_error()); + } + drop(Handle(pi.hThread)); + Ok((Handle(pi.hProcess), pi.dwProcessId)) +} + +pub fn open_process(pid: u32) -> Option { + let h = unsafe { OpenProcess(SYNCHRONIZE | PROCESS_QUERY_LIMITED_INFORMATION, 0, pid) }; + (!h.is_null()).then(|| Handle(h)) +}