mirror of
https://github.com/Droid-Deck/DroidDeck.git
synced 2026-11-11 02:51:10 +00:00
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
This commit is contained in:
1 parent
d8ed02f2cf
commit
3c96df4ab1
49 files changed
+3960
-24
No files matched your search
@@ -555,6 +555,12 @@ jobs:
|
|||||||
SOURCE_TREE: ${{ needs.source.outputs.tree-sha }}
|
SOURCE_TREE: ${{ needs.source.outputs.tree-sha }}
|
||||||
run: python3 tools/components/prepare.py unpack --tree "$SOURCE_TREE"
|
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
|
- uses: actions/download-artifact@v4
|
||||||
with:
|
with:
|
||||||
name: xbox-arm64
|
name: xbox-arm64
|
||||||
@@ -791,6 +797,9 @@ jobs:
|
|||||||
set -o pipefail
|
set -o pipefail
|
||||||
cd app/src/main/rust/stores
|
cd app/src/main/rust/stores
|
||||||
cargo test --locked 2>&1 | tail -60
|
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:
|
python-tests:
|
||||||
name: Python and native regression tests
|
name: Python and native regression tests
|
||||||
|
|||||||
@@ -61,6 +61,7 @@ class App : Application() {
|
|||||||
com.droiddeck.launcher.stores.StoresState.register(com.droiddeck.launcher.stores.xbox.XboxBackend)
|
com.droiddeck.launcher.stores.StoresState.register(com.droiddeck.launcher.stores.xbox.XboxBackend)
|
||||||
com.droiddeck.launcher.sync.Syncthing.init(this)
|
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.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.ea.EaBackend.app = this
|
||||||
com.droiddeck.launcher.stores.StoresState.register(com.droiddeck.launcher.stores.ea.EaBackend)
|
com.droiddeck.launcher.stores.StoresState.register(com.droiddeck.launcher.stores.ea.EaBackend)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -417,7 +417,7 @@ object AddedGames {
|
|||||||
|
|
||||||
private fun scanGame(context: Context, folder: File, out: MutableList<Game>, single: AddedExes.Entry? = null, reuse: Boolean = false) {
|
private fun scanGame(context: Context, folder: File, out: MutableList<Game>, single: AddedExes.Entry? = null, reuse: Boolean = false) {
|
||||||
run {
|
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() } }
|
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 ranking = rank(folder, reuse)
|
||||||
val candidates = ranking.exes
|
val candidates = ranking.exes
|
||||||
@@ -457,7 +457,11 @@ object AddedGames {
|
|||||||
steamId?.toLong() ?: ((appId shl 32) or 0x02000000L), candidates, steamId,
|
steamId?.toLong() ?: ((appId shl 32) or 0x02000000L), candidates, steamId,
|
||||||
source = sidecar?.store?.id ?: Library.ADDED, storeId = sidecar?.id,
|
source = sidecar?.store?.id ?: Library.ADDED, storeId = sidecar?.id,
|
||||||
exeUncertain = sidecar == null && chosen == null && adopted == null && ranking.uncertain,
|
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))
|
single = single != null))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -9,7 +9,7 @@ import java.io.File
|
|||||||
import java.util.Locale
|
import java.util.Locale
|
||||||
|
|
||||||
object WinCompSources {
|
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<String> = emptyList())
|
data class Finding(val component: String?, val original: String, val origin: Origin, val auto: Boolean, val args: List<String> = emptyList())
|
||||||
|
|
||||||
@@ -83,6 +83,13 @@ object WinCompSources {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** The prerequisite installers a Ubisoft manifest lists, with `$INSTALL_DIR` dropped. */
|
||||||
|
internal fun ubisoftInstallRuns(setup: String?): List<String> {
|
||||||
|
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<Finding> {
|
fun forGame(context: Context, game: Library.SteamGame): List<Finding> {
|
||||||
val folder = game.gameFiles
|
val folder = game.gameFiles
|
||||||
val out = ArrayList<Finding>()
|
val out = ArrayList<Finding>()
|
||||||
@@ -112,6 +119,12 @@ object WinCompSources {
|
|||||||
if (parts.isEmpty()) out += Finding(null, original, Origin.AMAZON, false)
|
if (parts.isEmpty()) out += Finding(null, original, Origin.AMAZON, false)
|
||||||
parts.forEach { out += Finding(it, original, Origin.AMAZON, true) }
|
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 -> {}
|
else -> {}
|
||||||
}
|
}
|
||||||
if (game.library == Library.ADDED && folder != null) SteamMatch.get(context, folder.path)?.let { match ->
|
if (game.library == Library.ADDED && folder != null) SteamMatch.get(context, folder.path)?.let { match ->
|
||||||
|
|||||||
@@ -196,6 +196,7 @@ object SessionFiles {
|
|||||||
"usr/local/lib/droiddeck-pipewire.tzst" to "usr/local/lib/droiddeck-pipewire.tzst",
|
"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/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-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, _) ->
|
) + mangoapp + xclipse.map { it to it } + maliCompat + msitools.map { it to it } + vr + gpuLoad + fexPreloads).filter { (asset, _) ->
|
||||||
val dir = asset.substringBeforeLast('/')
|
val dir = asset.substringBeforeLast('/')
|
||||||
runCatching { context.assets.list("linuxfs/$dir")?.contains(asset.substringAfterLast('/')) == true }.getOrDefault(false)
|
runCatching { context.assets.list("linuxfs/$dir")?.contains(asset.substringAfterLast('/')) == true }.getOrDefault(false)
|
||||||
|
|||||||
@@ -87,7 +87,7 @@ object CloudSaves {
|
|||||||
val item = sidecar.extra["catalogItemId"].orEmpty()
|
val item = sidecar.extra["catalogItemId"].orEmpty()
|
||||||
if (ns.isEmpty() || item.isEmpty()) "" else EpicApiClient.getCustomAttribute(token, ns, item, "CloudSaveFolder")
|
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
|
} ?: return null
|
||||||
p.edit()
|
p.edit()
|
||||||
.putString("cloud_template_$id", fetched.ifEmpty { TEMPLATE_NONE })
|
.putString("cloud_template_$id", fetched.ifEmpty { TEMPLATE_NONE })
|
||||||
@@ -148,7 +148,7 @@ object CloudSaves {
|
|||||||
val dir = when (store) {
|
val dir = when (store) {
|
||||||
Store.GOG -> CloudSavePaths.gog(template, pfx, folder)
|
Store.GOG -> CloudSavePaths.gog(template, pfx, folder)
|
||||||
Store.EPIC -> CloudSavePaths.epic(template, pfx, folder, EpicCredentialStore.load(context)?.accountId.orEmpty(), id)
|
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")
|
return dir to (if (dir == null) "unresolved" else "ok")
|
||||||
}
|
}
|
||||||
@@ -173,7 +173,7 @@ object CloudSaves {
|
|||||||
val account = EpicCredentialStore.load(context)?.accountId.orEmpty()
|
val account = EpicCredentialStore.load(context)?.accountId.orEmpty()
|
||||||
if (token == null || account.isEmpty()) null else EpicCloud(account, id, token)
|
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? {
|
private fun gogScopedToken(context: Context, id: String, clientId: String): String? {
|
||||||
|
|||||||
@@ -13,12 +13,14 @@ internal object LoginFlows {
|
|||||||
Store.EPIC -> epicFlow()
|
Store.EPIC -> epicFlow()
|
||||||
Store.AMAZON -> amazonFlow()
|
Store.AMAZON -> amazonFlow()
|
||||||
Store.GAMEJOLT -> gameJoltFlow(browserUserAgent)
|
Store.GAMEJOLT -> gameJoltFlow(browserUserAgent)
|
||||||
|
Store.UBISOFT -> ubisoftFlow(browserUserAgent)
|
||||||
Store.EA -> eaFlow(browserUserAgent)
|
Store.EA -> eaFlow(browserUserAgent)
|
||||||
Store.ITCH, Store.XBOX, Store.BATTLENET -> null
|
Store.ITCH, Store.XBOX, Store.BATTLENET -> null
|
||||||
}
|
}
|
||||||
|
|
||||||
@Volatile var epicFlow: () -> StoreLoginActivity.Flow? = { null }
|
@Volatile var epicFlow: () -> StoreLoginActivity.Flow? = { null }
|
||||||
@Volatile var amazonFlow: () -> 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 eaFlow: (String) -> StoreLoginActivity.Flow? = { null }
|
||||||
@Volatile var gameJoltFlow: (String) -> StoreLoginActivity.Flow? = { null }
|
@Volatile var gameJoltFlow: (String) -> StoreLoginActivity.Flow? = { null }
|
||||||
|
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ enum class Store(val id: String, val label: String, val shortLabel: String) {
|
|||||||
ITCH("itch", "Itch.io", "Itch.io"),
|
ITCH("itch", "Itch.io", "Itch.io"),
|
||||||
GAMEJOLT("gamejolt", "Game Jolt", "Game Jolt"),
|
GAMEJOLT("gamejolt", "Game Jolt", "Game Jolt"),
|
||||||
BATTLENET("battlenet", "Battle.net", "Battle.net"),
|
BATTLENET("battlenet", "Battle.net", "Battle.net"),
|
||||||
|
UBISOFT("ubisoft", "Ubisoft Connect", "Ubisoft"),
|
||||||
EA("ea", "EA", "EA");
|
EA("ea", "EA", "EA");
|
||||||
|
|
||||||
val folder: String get() = if (this == ITCH) "itch" else shortLabel
|
val folder: String get() = if (this == ITCH) "itch" else shortLabel
|
||||||
|
|||||||
@@ -3,7 +3,8 @@ package com.droiddeck.launcher.stores
|
|||||||
import java.io.File
|
import java.io.File
|
||||||
|
|
||||||
object StoreExe {
|
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 {
|
fun pick(folder: File, hint: String?, title: String): String {
|
||||||
val hinted = hint?.takeIf { it.isNotBlank() }?.replace('\\', '/')?.trimStart('/')?.let { File(folder, it) }
|
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)
|
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 == '/' }
|
private fun depth(root: File, f: File): Int = relative(root, f).count { it == '/' }
|
||||||
|
|||||||
@@ -11,12 +11,14 @@ object StoreLaunch {
|
|||||||
const val LAUNCHER = ".droiddeck-launch.bat"
|
const val LAUNCHER = ".droiddeck-launch.bat"
|
||||||
const val EPIC_CODE = ".droiddeck-epic-code"
|
const val EPIC_CODE = ".droiddeck-epic-code"
|
||||||
const val EPIC_ARGS = ".droiddeck-epic-args"
|
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"
|
const val EA_ENV = ".droiddeck-ea-env"
|
||||||
/** A sleeping stand-in that the launch hook installs; older EA SDKs require a running Origin process. */
|
/** 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"
|
const val EA_ORIGIN = "C:\\Program Files (x86)\\Origin\\Origin.exe"
|
||||||
|
|
||||||
fun writeLauncher(folder: File, sidecar: StoreGameSidecar): StoreGameSidecar {
|
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)
|
val file = File(folder, LAUNCHER)
|
||||||
if (!needs) { file.delete(); return sidecar.copy(launcher = null) }
|
if (!needs) { file.delete(); return sidecar.copy(launcher = null) }
|
||||||
file.writeText(launcherText(sidecar))
|
file.writeText(launcherText(sidecar))
|
||||||
@@ -52,6 +54,13 @@ object StoreLaunch {
|
|||||||
lines += "del \"%~dp0$EPIC_ARGS\""
|
lines += "del \"%~dp0$EPIC_ARGS\""
|
||||||
lines += "if exist \"%~dp0$EPIC_CODE\" del \"%~dp0$EPIC_CODE\""
|
lines += "if exist \"%~dp0$EPIC_CODE\" del \"%~dp0$EPIC_CODE\""
|
||||||
lines += "set \"$CLIENT_VAR=\" & \"%~dp0$exe\" %$CLIENT_VAR%"
|
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) {
|
} else if (sidecar.store == Store.EA) {
|
||||||
lines += "if not exist \"%~dp0$EA_ENV\" goto run"
|
lines += "if not exist \"%~dp0$EA_ENV\" goto run"
|
||||||
lines += "for /f \"usebackq tokens=1,* delims==\" %%a in (\"%~dp0$EA_ENV\") do set \"%%a=%%b\""
|
lines += "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)
|
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) {
|
fun refreshLauncher(folder: File, sidecar: StoreGameSidecar) {
|
||||||
if (sidecar.launcher != LAUNCHER) return
|
if (sidecar.launcher != LAUNCHER) return
|
||||||
val file = File(folder, LAUNCHER)
|
val file = File(folder, LAUNCHER)
|
||||||
|
|||||||
@@ -76,6 +76,15 @@ object StoreLaunchRequests {
|
|||||||
JSONObject().put("ok", true).put("code", code.written).put("reason", code.reason)
|
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" -> {
|
"cloud-down" -> {
|
||||||
val store = Store.byId(request.optString("store"))
|
val store = Store.byId(request.optString("store"))
|
||||||
val id = request.optString("id")
|
val id = request.optString("id")
|
||||||
|
|||||||
@@ -47,6 +47,9 @@ class StoreLoginActivity : ComponentActivity() {
|
|||||||
?.takeIf { "Chrome/" in it }
|
?.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"
|
?: "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 {
|
fun generateState(): String {
|
||||||
val bytes = ByteArray(24)
|
val bytes = ByteArray(24)
|
||||||
java.security.SecureRandom().nextBytes(bytes)
|
java.security.SecureRandom().nextBytes(bytes)
|
||||||
@@ -60,6 +63,8 @@ class StoreLoginActivity : ComponentActivity() {
|
|||||||
fun isRedirect(url: String): Boolean
|
fun isRedirect(url: String): Boolean
|
||||||
fun finish(activity: StoreLoginActivity, view: WebView, url: String): String?
|
fun finish(activity: StoreLoginActivity, view: WebView, url: String): String?
|
||||||
val readsPage: Boolean get() = false
|
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()
|
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) {
|
private fun complete(view: WebView, url: String) {
|
||||||
if (!captured.compareAndSet(false, true)) return
|
if (!captured.compareAndSet(false, true)) return
|
||||||
val f = flow ?: return
|
val f = flow ?: return
|
||||||
@@ -332,11 +345,7 @@ class StoreLoginActivity : ComponentActivity() {
|
|||||||
titleText.text = getString(R.string.stores_login_finishing)
|
titleText.text = getString(R.string.stores_login_finishing)
|
||||||
if (!f.readsPage) view.stopLoading()
|
if (!f.readsPage) view.stopLoading()
|
||||||
}
|
}
|
||||||
if (f.readsPage) {
|
if (f.readsPage) readPage(view, f, PAGE_TRIES) else Thread({ settle(f.finish(this, view, url)) }, "store-login").start()
|
||||||
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()
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun settle(problem: String?) {
|
private fun settle(problem: String?) {
|
||||||
|
|||||||
@@ -56,7 +56,7 @@ object StoreSizes {
|
|||||||
Store.AMAZON -> AmazonPrefs.get(app).edit().putLong("size_${item.id}", it).apply()
|
Store.AMAZON -> AmazonPrefs.get(app).edit().putLong("size_${item.id}", it).apply()
|
||||||
Store.ITCH, Store.GAMEJOLT -> {}
|
Store.ITCH, Store.GAMEJOLT -> {}
|
||||||
Store.EA -> com.droiddeck.launcher.stores.ea.EaPrefs.get(app).edit().putLong("size_${item.id}", it).apply()
|
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 ->
|
}, pool).exceptionally { error ->
|
||||||
@@ -113,7 +113,7 @@ object StoreSizes {
|
|||||||
AmazonManifest.parse(bytes).totalInstallSize
|
AmazonManifest.parse(bytes).totalInstallSize
|
||||||
}
|
}
|
||||||
Store.EA -> com.droiddeck.launcher.stores.ea.EaSizes.size(context, item)
|
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.ITCH -> com.droiddeck.launcher.stores.itch.ItchLibrary.size(context, item)
|
||||||
Store.GAMEJOLT -> com.droiddeck.launcher.stores.gamejolt.GameJoltBuilds.size(context, item)
|
Store.GAMEJOLT -> com.droiddeck.launcher.stores.gamejolt.GameJoltBuilds.size(context, item)
|
||||||
} }
|
} }
|
||||||
|
|||||||
@@ -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-<product>/denuvo-<id>.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<Long, Long>()
|
||||||
|
|
||||||
|
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()
|
||||||
|
}
|
||||||
@@ -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<String, String>) {
|
||||||
|
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<localStorage.length;i++){var k=localStorage.key(i);" +
|
||||||
|
"if(/loginData|rememberMe|lastProfile/i.test(k))o[k]=localStorage.getItem(k);}return JSON.stringify(o);}catch(e){return '';}})()"
|
||||||
|
|
||||||
|
internal fun request(url: String, method: String = "GET", authorization: String? = null, sessionId: String? = null, body: String? = null, extra: Map<String, String> = 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<String, String> {
|
||||||
|
val out = HashMap<String, String>()
|
||||||
|
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) }
|
||||||
|
}
|
||||||
@@ -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<Int?>(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<List<CatalogItem>, 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<CatalogItem>() 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<String, JSONObject> {
|
||||||
|
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<String, JSONObject>()
|
||||||
|
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<String, String> {
|
||||||
|
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<CatalogItem>()
|
||||||
|
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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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()
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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)
|
||||||
|
}
|
||||||
@@ -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<AmazonNative.InstallOutcome>(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)
|
||||||
|
}
|
||||||
@@ -33,6 +33,7 @@ internal fun sourceColours(source: String): SourceColours = when (source) {
|
|||||||
Store.ITCH.id -> SourceColours(Color(0xFF3A1A1E), Color(0xFFFFB3B3), Color(0xFFFF5C5C))
|
Store.ITCH.id -> SourceColours(Color(0xFF3A1A1E), Color(0xFFFFB3B3), Color(0xFFFF5C5C))
|
||||||
Store.GAMEJOLT.id -> SourceColours(Color(0xFF232E00), Color(0xFFE3FF66), Color(0xFFCCFF00))
|
Store.GAMEJOLT.id -> SourceColours(Color(0xFF232E00), Color(0xFFE3FF66), Color(0xFFCCFF00))
|
||||||
Store.BATTLENET.id -> SourceColours(Color(0xFF0A2440), Color(0xFF9DD1FF), Color(0xFF148EFF))
|
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))
|
Store.EA.id -> SourceColours(Color(0xFF3A0E12), Color(0xFFFF9A9A), Color(0xFFFF4747))
|
||||||
Library.SOURCE_STEAM -> SourceColours(Color(0xFF16293D), Color(0xFF7CC4FF), Color(0xFF1A9FFF))
|
Library.SOURCE_STEAM -> SourceColours(Color(0xFF16293D), Color(0xFF7CC4FF), Color(0xFF1A9FFF))
|
||||||
FLATHUB -> SourceColours(Color(0xFF0B2E2B), Color(0xFF9FE3DD), FlathubTeal)
|
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.ITCH.id -> Store.ITCH.label
|
||||||
Store.GAMEJOLT.id -> Store.GAMEJOLT.label
|
Store.GAMEJOLT.id -> Store.GAMEJOLT.label
|
||||||
Store.BATTLENET.id -> Store.BATTLENET.label
|
Store.BATTLENET.id -> Store.BATTLENET.label
|
||||||
|
Store.UBISOFT.id -> Store.UBISOFT.shortLabel
|
||||||
Store.EA.id -> Store.EA.label
|
Store.EA.id -> Store.EA.label
|
||||||
else -> stringResource(R.string.source_added)
|
else -> stringResource(R.string.source_added)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1157,6 +1157,7 @@ internal fun storePalette(base: Palette, store: Store?): Palette {
|
|||||||
Store.ITCH -> Color(0xFFFF5C5C) to Color(0xFFD93B3B)
|
Store.ITCH -> Color(0xFFFF5C5C) to Color(0xFFD93B3B)
|
||||||
Store.GAMEJOLT -> Color(0xFFCCFF00) to Color(0xFFA3CC00)
|
Store.GAMEJOLT -> Color(0xFFCCFF00) to Color(0xFFA3CC00)
|
||||||
Store.BATTLENET -> Color(0xFF148EFF) to Color(0xFF0B6BD3)
|
Store.BATTLENET -> Color(0xFF148EFF) to Color(0xFF0B6BD3)
|
||||||
|
Store.UBISOFT -> Color(0xFF2E7DFF) to Color(0xFF1A5BD6)
|
||||||
Store.EA -> Color(0xFFFF4747) to Color(0xFFE03030)
|
Store.EA -> Color(0xFFFF4747) to Color(0xFFE03030)
|
||||||
null -> return base
|
null -> return base
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -56,7 +56,7 @@ import java.io.File
|
|||||||
private fun installable(name: String, all: Map<String, WinComponents.Component>): String = WinComponents.installable(name, all)
|
private fun installable(name: String, all: Map<String, WinComponents.Component>): String = WinComponents.installable(name, all)
|
||||||
|
|
||||||
@Composable
|
@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) {
|
when (origin) {
|
||||||
WinCompSources.Origin.GOG.name -> R.string.wincomp_src_gog
|
WinCompSources.Origin.GOG.name -> R.string.wincomp_src_gog
|
||||||
WinCompSources.Origin.EPIC.name -> R.string.wincomp_src_epic
|
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.GOG.name -> stringResource(R.string.wincomp_auto_gog)
|
||||||
WinCompSources.Origin.EPIC.name -> stringResource(R.string.wincomp_auto_epic)
|
WinCompSources.Origin.EPIC.name -> stringResource(R.string.wincomp_auto_epic)
|
||||||
WinCompSources.Origin.AMAZON.name -> stringResource(R.string.wincomp_auto_amazon)
|
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" }})"
|
WinCompSources.Origin.XBOX.name -> "Xbox games install it with the game (${r.args.getOrElse(0) { "VC14" }})"
|
||||||
else -> ""
|
else -> ""
|
||||||
}
|
}
|
||||||
|
|||||||
Generated
+18
@@ -195,10 +195,13 @@ dependencies = [
|
|||||||
"libc",
|
"libc",
|
||||||
"lzma-rs",
|
"lzma-rs",
|
||||||
"reqwest",
|
"reqwest",
|
||||||
|
"ruzstd",
|
||||||
"serde_json",
|
"serde_json",
|
||||||
"sha1",
|
"sha1",
|
||||||
"sha2",
|
"sha2",
|
||||||
"tokio",
|
"tokio",
|
||||||
|
"tokio-rustls",
|
||||||
|
"webpki-roots",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
@@ -897,6 +900,15 @@ version = "1.0.22"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "b39cdef0fa800fc44525c84ccb54a029961a8215f9619753635a9c0d2538d46d"
|
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]]
|
[[package]]
|
||||||
name = "ryu"
|
name = "ryu"
|
||||||
version = "1.0.23"
|
version = "1.0.23"
|
||||||
@@ -1243,6 +1255,12 @@ version = "0.2.5"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "e421abadd41a4225275504ea4d6566923418b7f05506fbc9c0fe86ba7396114b"
|
checksum = "e421abadd41a4225275504ea4d6566923418b7f05506fbc9c0fe86ba7396114b"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "twox-hash"
|
||||||
|
version = "2.1.5"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "86a801b3cea342a06d468c8710662aa29e5e05e4f5c0d62f00bbb7f2ad7941c2"
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "typenum"
|
name = "typenum"
|
||||||
version = "1.20.0"
|
version = "1.20.0"
|
||||||
|
|||||||
@@ -15,11 +15,14 @@ libc = "0.2"
|
|||||||
lzma-rs = { version = "0.3", default-features = false }
|
lzma-rs = { version = "0.3", default-features = false }
|
||||||
flate2 = { version = "1", default-features = false, features = ["rust_backend"] }
|
flate2 = { version = "1", default-features = false, features = ["rust_backend"] }
|
||||||
reqwest = { version = "0.12", default-features = false, features = ["rustls-tls", "gzip"] }
|
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"] }
|
futures-util = { version = "0.3", default-features = false, features = ["std"] }
|
||||||
serde_json = { version = "1", features = ["preserve_order"] }
|
serde_json = { version = "1", features = ["preserve_order"] }
|
||||||
sha1 = "0.10"
|
sha1 = "0.10"
|
||||||
sha2 = "0.10"
|
sha2 = "0.10"
|
||||||
|
tokio-rustls = { version = "0.26", default-features = false, features = ["ring", "tls12"] }
|
||||||
|
webpki-roots = "1"
|
||||||
|
ruzstd = "0.8"
|
||||||
|
|
||||||
[build-dependencies]
|
[build-dependencies]
|
||||||
cc = "1"
|
cc = "1"
|
||||||
|
|||||||
@@ -3,6 +3,7 @@
|
|||||||
#![allow(clippy::missing_safety_doc, clippy::result_large_err)]
|
#![allow(clippy::missing_safety_doc, clippy::result_large_err)]
|
||||||
|
|
||||||
pub mod amazon;
|
pub mod amazon;
|
||||||
|
pub mod ubisoft;
|
||||||
pub mod ea;
|
pub mod ea;
|
||||||
pub mod epic;
|
pub mod epic;
|
||||||
pub mod fetch_core;
|
pub mod fetch_core;
|
||||||
|
|||||||
@@ -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<TcpStream>,
|
||||||
|
next_id: u32,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Demux {
|
||||||
|
pub async fn connect(ticket: &str) -> Result<Self, String> {
|
||||||
|
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<Vec<u8>, 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<Vec<u8>, 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<u32, String> {
|
||||||
|
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<Vec<u8>, 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())
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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<serde_json::Value, String> {
|
||||||
|
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<u32> = 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::<Vec<_>>(),
|
||||||
|
"manifestsAsked": missing.len(), "manifestsFound": found.len(), "manifestError": manifest_error }))
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
fn launch(ticket: &str, session: &str, product: u32) -> Result<serde_json::Value, String> {
|
||||||
|
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<u32> = 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<Vec<u8>, 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<serde_json::Value, String> {
|
||||||
|
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::<String>() }))
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
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<R>(&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::<serde_json::Value>(&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<AtomicBool>;
|
||||||
|
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<AtomicBool>;
|
||||||
|
if !ptr.is_null() {
|
||||||
|
drop(unsafe { Box::from_raw(ptr) });
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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<Self, String> {
|
||||||
|
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<manifest::Chunk>,
|
||||||
|
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::<Vec<_>>(),
|
||||||
|
"registry": m.registry.iter().map(|(key, values)| json!({ "key": key, "values": values.iter().map(|(n, v)| json!({ "name": n, "value": v })).collect::<Vec<_>>() })).collect::<Vec<_>>(),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
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<PathBuf, String> {
|
||||||
|
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<u8> {
|
||||||
|
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<Vec<u8>, 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<Vec<u8>, 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<super::service::Owned>), 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<super::service::Owned>), 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<Vec<Vec<String>>, 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<manifest::Manifest, String> {
|
||||||
|
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<u32> = 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<Done, Stop> {
|
||||||
|
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<Done, Stop> {
|
||||||
|
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<u32> = 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<usize> = 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<usize, Mutex<std::fs::File>> = HashMap::new();
|
||||||
|
let mut remaining: HashMap<usize, usize> = 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<String> = 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());
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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<u32>,
|
||||||
|
pub download_sha1: Vec<u8>,
|
||||||
|
pub content_sha1: Vec<u8>,
|
||||||
|
pub offset: u64,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Clone, Debug, PartialEq)]
|
||||||
|
pub struct File {
|
||||||
|
pub name: String,
|
||||||
|
pub size: u64,
|
||||||
|
pub dir: bool,
|
||||||
|
pub slices: Vec<Slice>,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 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<File>,
|
||||||
|
pub chunks: Vec<Chunk>,
|
||||||
|
/// Files of chunks left out: other languages, and add-ons the account does not own.
|
||||||
|
pub skipped: Vec<String>,
|
||||||
|
/// 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<File, String> {
|
||||||
|
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<Manifest, String> {
|
||||||
|
parse_owned(decoded, language, &[])
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn parse_owned(decoded: &[u8], language: &str, owned: &[u32]) -> Result<Manifest, String> {
|
||||||
|
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<u32> = 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<Vec<u8>, 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<Vec<u8>, 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::<Vec<_>>(), [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");
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,6 @@
|
|||||||
|
pub mod demux;
|
||||||
|
pub mod ffi;
|
||||||
|
pub mod install;
|
||||||
|
pub mod manifest;
|
||||||
|
pub mod pb;
|
||||||
|
pub mod service;
|
||||||
@@ -0,0 +1,104 @@
|
|||||||
|
pub use crate::amazon::proto::{Fields, Value};
|
||||||
|
|
||||||
|
#[derive(Default)]
|
||||||
|
pub struct Msg(pub Vec<u8>);
|
||||||
|
|
||||||
|
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<Vec<(u32, Value<'_>)>, 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<Value<'a>> {
|
||||||
|
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<u64> {
|
||||||
|
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<String> {
|
||||||
|
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"]);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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<u32>,
|
||||||
|
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<Owned, String> {
|
||||||
|
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<Owned>), 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<Vec<(u32, String)>, 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<String, String> {
|
||||||
|
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<String, String> {
|
||||||
|
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<Self, String> {
|
||||||
|
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<Vec<Vec<String>>, 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<Vec<u8>, 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())
|
||||||
|
}
|
||||||
@@ -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"))
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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\<productId>\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 | `<game>/.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 | `<game>/.droiddeck-launch.bat` | Runs `Z:\usr\local\lib\droiddeck-ubisoft\droiddeck-upc.exe <launch file>` 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_<session>_<name>`, where `<session>` 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: <root>/ + "/" + <exe under root>, 4: <root>/, 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: <hwscore path>}}}` 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/<userId>/<product>/"}`; 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 <event> -upc_uplay_id <product> ...` (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-<product>.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-<product>/denuvo-<rid>.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.
|
||||||
@@ -424,6 +424,15 @@ if ! restore_step comet "${comet_key}" "${comet_out}"; then
|
|||||||
DROIDDECK_BUILD_IMAGE="${image_name}" bash tools/comet/build-local.sh
|
DROIDDECK_BUILD_IMAGE="${image_name}" bash tools/comet/build-local.sh
|
||||||
save_step comet "${comet_key}" "${comet_out}"
|
save_step comet "${comet_key}" "${comet_out}"
|
||||||
fi
|
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
|
python3 tools/powervr/fetch.py
|
||||||
bash tools/packs/build.sh app/build/packs
|
bash tools/packs/build.sh app/build/packs
|
||||||
python3 tools/release/runtime_inputs.py record
|
python3 tools/release/runtime_inputs.py record
|
||||||
|
|||||||
@@ -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")
|
PACK_KEYS = ("WINESERVER", "BL_SYNC_PACK", "WINEESYNC", "PROTON_NO_ESYNC", "PROTON_NO_FSYNC")
|
||||||
|
|
||||||
|
|
||||||
def game_id(env):
|
# Prefixes several games share; the game itself is the Steam app being launched.
|
||||||
prefix = env.get("STEAM_COMPAT_DATA_PATH", "").rstrip("/")
|
SHARED_PREFIXES = {"2929927072"}
|
||||||
if not prefix:
|
|
||||||
return None
|
|
||||||
value = prefix.rsplit("/", 1)[-1]
|
def app_number(value):
|
||||||
if not re.fullmatch(r"-?[0-9]+", value):
|
if not re.fullmatch(r"-?[0-9]+", value or ""):
|
||||||
return None
|
return None
|
||||||
number = int(value)
|
number = int(value)
|
||||||
if not -(2**31) <= number < 2**32 or number == 0:
|
if not -(2**31) <= number < 2**32 or number == 0:
|
||||||
@@ -30,6 +30,26 @@ def game_id(env):
|
|||||||
return str(number % 2**32)
|
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 = {
|
DOTNET_FEX = {
|
||||||
"FEX_TSOENABLED": "1",
|
"FEX_TSOENABLED": "1",
|
||||||
"FEX_VECTORTSOENABLED": "1",
|
"FEX_VECTORTSOENABLED": "1",
|
||||||
|
|||||||
@@ -289,6 +289,218 @@ def exited(argv, code=0):
|
|||||||
return 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")
|
COMET_DIR = Path("/usr/local/lib/droiddeck-comet")
|
||||||
|
|
||||||
|
|
||||||
@@ -1114,6 +1326,15 @@ def main(argv):
|
|||||||
prepare_gog(sidecar, compat_data, launch_dir)
|
prepare_gog(sidecar, compat_data, launch_dir)
|
||||||
log("gog registry: " + apply_gog_registry(folder, compat_data, os.environ.get("BL_STORE_PROTON", "")))
|
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", "")))
|
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":
|
if store == "epic":
|
||||||
handled = os.environ.get(EPIC_HANDLED) == "1" and (folder / EPIC_ARGS).is_file()
|
handled = os.environ.get(EPIC_HANDLED) == "1" and (folder / EPIC_ARGS).is_file()
|
||||||
if not handled:
|
if not handled:
|
||||||
|
|||||||
@@ -152,6 +152,13 @@ bl_components() {
|
|||||||
else
|
else
|
||||||
cm_app=""
|
cm_app=""
|
||||||
fi
|
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
|
if [ -z "$cm_app" ]; then
|
||||||
echo "droiddeck-proton: components: invalid game prefix, left as it is" >&2
|
echo "droiddeck-proton: components: invalid game prefix, left as it is" >&2
|
||||||
return 0
|
return 0
|
||||||
|
|||||||
@@ -1124,6 +1124,23 @@ class GameEnvironmentTest(unittest.TestCase):
|
|||||||
scope = MODULE["game_id"](env)
|
scope = MODULE["game_id"](env)
|
||||||
expected = "untouched" if scope is None else scope if scope in {"42", "4294967295", "2147483648"} else "*"
|
expected = "untouched" if scope is None else scope if scope in {"42", "4294967295", "2147483648"} else "*"
|
||||||
self.assertEqual(target.read_text(), expected)
|
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):
|
def test_engine_fixes_follow_the_games_files(self):
|
||||||
with tempfile.TemporaryDirectory() as tmp:
|
with tempfile.TemporaryDirectory() as tmp:
|
||||||
@@ -1628,6 +1645,10 @@ class GameEnvironmentTest(unittest.TestCase):
|
|||||||
self.assertIsNone(MODULE["game_id"]({"STEAM_COMPAT_DATA_PATH": prefix}))
|
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/42/"}), "42")
|
||||||
self.assertEqual(MODULE["game_id"]({"STEAM_COMPAT_DATA_PATH": "/a/compatdata/-1"}), "4294967295")
|
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):
|
def test_launch_reads_updates_preserves_argv_and_does_not_execute_values(self):
|
||||||
with tempfile.TemporaryDirectory() as tmp:
|
with tempfile.TemporaryDirectory() as tmp:
|
||||||
|
|||||||
@@ -797,3 +797,102 @@ class GogRegistry(unittest.TestCase):
|
|||||||
store.main([str(folder / store.LAUNCHER)])
|
store.main([str(folder / store.LAUNCHER)])
|
||||||
apply.assert_called_once_with(folder, root + '/compat', '/chosen/proton')
|
apply.assert_called_once_with(folder, root + '/compat', '/chosen/proton')
|
||||||
support.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)
|
||||||
@@ -0,0 +1,2 @@
|
|||||||
|
target/
|
||||||
|
out/
|
||||||
Generated
+105
@@ -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"
|
||||||
@@ -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
|
||||||
Executable
+10
@@ -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"
|
||||||
@@ -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<u32>,
|
||||||
|
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<Config, String> {
|
||||||
|
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<u32> = 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<u8> {
|
||||||
|
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<u8> {
|
||||||
|
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<u8> {
|
||||||
|
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<u8> {
|
||||||
|
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<u8> {
|
||||||
|
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<u8> {
|
||||||
|
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<u8>, 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<u8> {
|
||||||
|
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<u8> {
|
||||||
|
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<u64> = 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)));
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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 <launch.json>
|
||||||
|
|
||||||
|
#![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<Option<std::fs::File>>,
|
||||||
|
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<u8>,
|
||||||
|
}
|
||||||
|
|
||||||
|
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<Vec<u8>> {
|
||||||
|
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<Vec<u8>> {
|
||||||
|
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<State>, 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 <launch.json>");
|
||||||
|
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
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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<u8>) {
|
||||||
|
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<u8>);
|
||||||
|
|
||||||
|
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<u64> {
|
||||||
|
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<Vec<(u32, Value<'_>)>> {
|
||||||
|
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<u64> {
|
||||||
|
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());
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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<u16> {
|
||||||
|
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<usize> {
|
||||||
|
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<Handle> {
|
||||||
|
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::<STARTUPINFOW>() 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<Handle> {
|
||||||
|
let h = unsafe { OpenProcess(SYNCHRONIZE | PROCESS_QUERY_LIMITED_INFORMATION, 0, pid) };
|
||||||
|
(!h.is_null()).then(|| Handle(h))
|
||||||
|
}
|
||||||
Reference in new issue
Block a user