From 3c96df4ab1d85a2776ccbcf6048ed1c6aa3066c8 Mon Sep 17 00:00:00 2001 From: Kurt Himebauch <136133082+xXJSONDeruloXx@users.noreply.github.com> Date: Tue, 10 Nov 2026 15:08:37 -0500 Subject: [PATCH] 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)) +}