diff --git a/app/src/main/feature/settings/stores/StoresFragment.kt b/app/src/main/feature/settings/stores/StoresFragment.kt index d4633c1d2..af18a295e 100644 --- a/app/src/main/feature/settings/stores/StoresFragment.kt +++ b/app/src/main/feature/settings/stores/StoresFragment.kt @@ -3,6 +3,7 @@ package com.winlator.cmod.feature.settings import android.content.Intent import android.net.Uri import android.os.Bundle +import android.widget.Toast import android.view.LayoutInflater import android.view.View import android.view.ViewGroup @@ -26,8 +27,13 @@ import com.winlator.cmod.feature.stores.gog.service.GOGService import com.winlator.cmod.feature.stores.gog.ui.auth.GOGOAuthActivity import com.winlator.cmod.feature.stores.steam.SteamLoginActivity import com.winlator.cmod.feature.stores.steam.enums.Language +import com.winlator.cmod.feature.setup.SetupWizardActivity import com.winlator.cmod.feature.stores.steam.service.SteamService import com.winlator.cmod.feature.stores.steam.utils.PrefManager +import com.winlator.cmod.feature.stores.ubisoft.UbisoftConnect +import com.winlator.cmod.runtime.container.Container +import com.winlator.cmod.runtime.container.ContainerManager +import com.winlator.cmod.runtime.content.component.ComponentInstaller import com.winlator.cmod.shared.android.DirectoryPickerDialog import com.winlator.cmod.shared.io.AssetPaths import com.winlator.cmod.shared.io.FileUtils @@ -35,6 +41,7 @@ import com.winlator.cmod.shared.theme.WinNativeTheme import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.launch +import kotlinx.coroutines.withContext import org.json.JSONObject class StoresFragment : Fragment() { @@ -134,6 +141,7 @@ class StoresFragment : Fragment() { refresh() } }, + onUbisoftInstall = { installUbisoftConnect() }, onSharedFolderChanged = { PrefManager.useSingleDownloadFolder = it refresh() @@ -171,6 +179,7 @@ class StoresFragment : Fragment() { // Helpers private fun refresh() { val ctx = context ?: return + val prev = storeState val containerLanguageLabels = Language.displayLabels() val containerLanguageIndex = Language.indexForContainerLang(PrefManager.containerLanguage) storeState = @@ -188,9 +197,71 @@ class StoresFragment : Fragment() { gogFolder = resolveUri(PrefManager.gogDownloadFolder, ctx), containerLanguageLabels = containerLanguageLabels, containerLanguageIndex = containerLanguageIndex, + isUbisoftInstalled = UbisoftConnect.isInstalled(ctx), + // Preserve the in-flight download/install state across refreshes. + ubisoftBusy = prev.ubisoftBusy, + ubisoftStatus = prev.ubisoftStatus, ) } + // The installer runs in the user's default container; the install lands in the shared store + // (shared by every container), so which container runs it doesn't matter. + private fun defaultUbisoftContainer(ctx: android.content.Context): Container? = + try { + val cm = ContainerManager(ctx) + cm.getContainerById(SetupWizardActivity.getDefaultX86ContainerId(ctx)) + ?: cm.getContainers().firstOrNull() + } catch (_: Exception) { + null + } + + // Download + silently install Ubisoft Connect (lands in the shared store, shared by all containers). + private fun installUbisoftConnect() { + val ctx = context ?: return + if (storeState.ubisoftBusy) return + val container = defaultUbisoftContainer(ctx) + if (container == null) { + Toast.makeText(ctx, R.string.stores_accounts_ubisoft_no_container, Toast.LENGTH_SHORT).show() + return + } + val appCtx = ctx.applicationContext + storeState = storeState.copy(ubisoftBusy = true, ubisoftStatus = "") + CoroutineScope(Dispatchers.IO).launch { + val listener = + object : ComponentInstaller.Listener { + override fun onStatus(text: String) = postUbisoftStatus(text) + + override fun onProgress(fraction: Float) { + if (fraction in 0f..1f) postUbisoftStatus("Downloading ${(fraction * 100).toInt()}%") + } + } + val error = + try { + ComponentInstaller(appCtx, container, "ubisoft-connect", UbisoftConnect.installManifest(), listener).run() + null + } catch (e: Exception) { + e.message ?: "error" + } + withContext(Dispatchers.Main) { + storeState = storeState.copy(ubisoftBusy = false, ubisoftStatus = "") + if (error != null) { + Toast.makeText( + appCtx, + appCtx.getString(R.string.stores_accounts_ubisoft_install_failed, error), + Toast.LENGTH_LONG, + ).show() + } + refresh() + } + } + } + + private fun postUbisoftStatus(text: String) { + view?.post { + if (storeState.ubisoftBusy) storeState = storeState.copy(ubisoftStatus = text) + } + } + private fun loadServerOptions(): List> = try { val json = diff --git a/app/src/main/feature/settings/stores/StoresScreen.kt b/app/src/main/feature/settings/stores/StoresScreen.kt index 56e414afe..c248b6e14 100644 --- a/app/src/main/feature/settings/stores/StoresScreen.kt +++ b/app/src/main/feature/settings/stores/StoresScreen.kt @@ -112,6 +112,9 @@ data class StoreState( val gogFolder: String = "", val containerLanguageLabels: List = emptyList(), val containerLanguageIndex: Int = 0, + val isUbisoftInstalled: Boolean = false, + val ubisoftBusy: Boolean = false, + val ubisoftStatus: String = "", ) @Composable @@ -124,6 +127,7 @@ fun StoresScreen( onEpicSignOut: () -> Unit, onGogSignIn: () -> Unit, onGogSignOut: () -> Unit, + onUbisoftInstall: () -> Unit, onSharedFolderChanged: (Boolean) -> Unit, onDownloadSpeedChanged: (Int) -> Unit, onDownloadServerChanged: (Int) -> Unit, @@ -189,6 +193,19 @@ fun StoresScreen( onSignOut = onGogSignOut, ) + SectionLabel( + stringResource(R.string.stores_accounts_ubisoft_integration_title), + modifier = Modifier.padding(top = 8.dp), + ) + UbisoftStoreCard( + name = stringResource(R.string.stores_accounts_ubisoft_integration_title), + accentColor = Color(0xFF148EFF), + isInstalled = state.isUbisoftInstalled, + isBusy = state.ubisoftBusy, + statusText = state.ubisoftStatus, + onDownload = onUbisoftInstall, + ) + SectionLabel(stringResource(R.string.stores_accounts_download_settings), modifier = Modifier.padding(top = 8.dp)) SettingsToggleCard( @@ -483,6 +500,131 @@ private fun StoreCard( } } +// Ubisoft Connect card: Download when the client isn't installed, Sign In once it is. +@Composable +private fun UbisoftStoreCard( + name: String, + accentColor: Color, + isInstalled: Boolean, + isBusy: Boolean, + statusText: String, + onDownload: () -> Unit, +) { + Box( + modifier = + Modifier + .fillMaxWidth() + .clip(RoundedCornerShape(14.dp)) + .background(CardDark) + .border(1.dp, CardBorder, RoundedCornerShape(14.dp)), + ) { + Row( + modifier = + Modifier + .fillMaxWidth() + .padding(horizontal = 14.dp, vertical = 11.dp), + verticalAlignment = Alignment.CenterVertically, + ) { + Box( + modifier = + Modifier + .size(38.dp) + .clip(RoundedCornerShape(11.dp)) + .background(IconBoxBg), + contentAlignment = Alignment.Center, + ) { + Icon( + imageVector = Icons.Outlined.Gamepad, + contentDescription = name, + tint = accentColor, + modifier = Modifier.size(21.dp), + ) + } + + Spacer(Modifier.width(14.dp)) + + Column(modifier = Modifier.weight(1f)) { + Text( + text = name, + color = TextPrimary, + fontSize = 15.sp, + fontWeight = FontWeight.SemiBold, + ) + Spacer(Modifier.height(4.dp)) + Row(verticalAlignment = Alignment.CenterVertically) { + if (!isBusy) { + Box( + modifier = + Modifier + .size(6.dp) + .clip(CircleShape) + .background( + if (isInstalled) StatusGreen else TextSecondary.copy(alpha = 0.4f), + ), + ) + Spacer(Modifier.width(6.dp)) + } + Text( + text = + when { + isBusy -> statusText.ifEmpty { stringResource(R.string.stores_accounts_ubisoft_working) } + isInstalled -> stringResource(R.string.stores_accounts_ubisoft_installed) + else -> stringResource(R.string.stores_accounts_ubisoft_not_installed) + }, + color = if (isInstalled && !isBusy) StatusGreen else TextSecondary, + fontSize = 12.sp, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + ) + } + } + + if (isBusy) { + Box( + modifier = + Modifier + .clip(RoundedCornerShape(8.dp)) + .background(Color(0xFF222232)) + .border(1.dp, TextSecondary.copy(alpha = 0.25f), RoundedCornerShape(8.dp)) + .padding(horizontal = 12.dp, vertical = 7.dp), + contentAlignment = Alignment.Center, + ) { + Text( + text = stringResource(R.string.stores_accounts_ubisoft_working), + color = TextSecondary, + fontSize = 12.sp, + fontWeight = FontWeight.SemiBold, + ) + } + } else if (isInstalled) { + // Already downloaded + installed: grayed-out, non-interactive Download button. + Box( + modifier = + Modifier + .clip(RoundedCornerShape(8.dp)) + .background(Color(0xFF1B1B27)) + .border(1.dp, TextSecondary.copy(alpha = 0.18f), RoundedCornerShape(8.dp)) + .padding(horizontal = 12.dp, vertical = 7.dp), + contentAlignment = Alignment.Center, + ) { + Text( + text = stringResource(R.string.stores_accounts_ubisoft_download), + color = TextSecondary.copy(alpha = 0.5f), + fontSize = 12.sp, + fontWeight = FontWeight.SemiBold, + ) + } + } else { + ActionButton( + label = stringResource(R.string.stores_accounts_ubisoft_download), + textColor = accentColor, + onClick = onDownload, + ) + } + } + } +} + @Composable private fun ActionButton( label: String, diff --git a/app/src/main/feature/stores/steam/utils/PrefManager.kt b/app/src/main/feature/stores/steam/utils/PrefManager.kt index e03f53821..4666e8eee 100644 --- a/app/src/main/feature/stores/steam/utils/PrefManager.kt +++ b/app/src/main/feature/stores/steam/utils/PrefManager.kt @@ -247,6 +247,13 @@ object PrefManager { setInt("download_speed", value) } + // Container the Ubisoft Connect installer / sign-in runs in (0 = fall back to default). + var ubisoftContainerId: Int + get() = getInt("ubisoft_container_id", 0) + set(value) { + setInt("ubisoft_container_id", value) + } + var clientId: Long get() = getLong("client_id", 0L) set(value) { diff --git a/app/src/main/feature/stores/ubisoft/UbisoftConnect.kt b/app/src/main/feature/stores/ubisoft/UbisoftConnect.kt new file mode 100644 index 000000000..8e9bb37b3 --- /dev/null +++ b/app/src/main/feature/stores/ubisoft/UbisoftConnect.kt @@ -0,0 +1,57 @@ +package com.winlator.cmod.feature.stores.ubisoft + +import android.content.Context +import com.winlator.cmod.runtime.display.environment.ImageFs +import java.io.File + +/** + * Ubisoft Connect pre-setup helpers. + * + * Ubisoft games launched from Steam (Assassin's Creed, etc.) require the Ubisoft Connect client + * (upc.exe / UbisoftConnect.exe). This object downloads and installs that client, and lets the user + * sign in once. The install and login always land in the single shared store at + * `imagefs/home/.ubisoft-store/` because every container symlinks its Ubisoft directories there + * (see XServerDisplayActivity.shareUbisoftConnectLogin), so one install + one sign-in covers every + * container. + */ +object UbisoftConnect { + const val INSTALLER_URL = + "https://github.com/maxjivi05/proton-wine/releases/download/Ubisoft/UbisoftConnectInstaller.exe" + const val INSTALLER_FILE = "UbisoftConnectInstaller.exe" + + // The Windows install location Ubisoft Connect uses; this path is symlinked to the shared store. + private const val LAUNCHER_WINDOWS_DIR = "C:\\Program Files (x86)\\Ubisoft\\Ubisoft Game Launcher" + + // Keep in sync with XServerDisplayActivity.UBISOFT_STORE_RELATIVE_PATH + UBISOFT_LAUNCHER_STORE_NAME. + private const val STORE_LAUNCHER_REL = "home/.ubisoft-store/launcher" + + /** The single shared directory that every container's launcher dir is symlinked to. */ + fun sharedLauncherDir(context: Context): File = + File(ImageFs.find(context).rootDir, STORE_LAUNCHER_REL) + + /** True once the Ubisoft Connect client binaries are present in the shared store. */ + fun isInstalled(context: Context): Boolean { + val dir = sharedLauncherDir(context) + return File(dir, "UbisoftConnect.exe").isFile || File(dir, "upc.exe").isFile + } + + /** Windows path of the executable to run for interactive sign-in. */ + fun signInWinPath(context: Context): String { + val dir = sharedLauncherDir(context) + val exe = if (File(dir, "UbisoftConnect.exe").isFile) "UbisoftConnect.exe" else "upc.exe" + return "$LAUNCHER_WINDOWS_DIR\\$exe" + } + + /** + * One-step ComponentInstaller manifest: download the installer, then run it silently (`/S`, the + * same switch Bottles uses). It installs into the symlinked launcher dir, i.e. the shared store. + */ + fun installManifest(): String = + """ + Steps: + - action: install_exe + url: "$INSTALLER_URL" + file_name: "$INSTALLER_FILE" + arguments: "/S" + """.trimIndent() +} diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 15014a5bf..f4a41b8cd 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -208,6 +208,13 @@ Stores Steam + Ubisoft Connect + Installed + Not installed + Download + Working… + No container available — create one first + Ubisoft Connect install failed: %1$s Shared Downloads Folder Use a single folder across all stores Default Downloads Folder diff --git a/app/src/main/runtime/container/ContainerManager.java b/app/src/main/runtime/container/ContainerManager.java index 57ba32ad9..5cabc1542 100644 --- a/app/src/main/runtime/container/ContainerManager.java +++ b/app/src/main/runtime/container/ContainerManager.java @@ -405,6 +405,8 @@ public ArrayList loadShortcuts() { if (files != null) { for (File file : files) { String fileName = file.getName(); + // Hide the Ubisoft Connect launcher from the library — it's pre-installed plumbing, not a game. + if (isUbisoftConnectShortcut(fileName)) continue; if (fileName.endsWith(".lnk")) { String filePath = file.getPath(); File desktopFile = @@ -424,6 +426,14 @@ public ArrayList loadShortcuts() { return shortcuts; } + // The Ubisoft Connect installer drops a "Ubisoft Connect" desktop shortcut; it's launcher + // plumbing (pre-installed via Settings > Stores), not a user game, so keep it out of the library. + private static boolean isUbisoftConnectShortcut(String fileName) { + if (fileName == null) return false; + String lower = fileName.toLowerCase(java.util.Locale.ROOT); + return lower.startsWith("ubisoft connect.") || lower.startsWith("uplay."); + } + public void upgradeShortcuts(final Runnable onDone) { if (!shortcutUpgradeRunning.compareAndSet(false, true)) return; @@ -436,6 +446,7 @@ public void upgradeShortcuts(final Runnable onDone) { File[] files = desktopDir.listFiles(); if (files == null) continue; for (File file : files) { + if (isUbisoftConnectShortcut(file.getName())) continue; if (file.getName().endsWith(".lnk")) { File desktopFile = new File(file.getPath().substring(0, file.getPath().lastIndexOf(".")) + ".desktop"); boolean needsUpgrade = true; diff --git a/app/src/main/runtime/display/XServerDisplayActivity.java b/app/src/main/runtime/display/XServerDisplayActivity.java index c4c1f0b41..5bf66b8aa 100644 --- a/app/src/main/runtime/display/XServerDisplayActivity.java +++ b/app/src/main/runtime/display/XServerDisplayActivity.java @@ -210,6 +210,34 @@ public class XServerDisplayActivity extends FixedFontScaleAppCompatActivity { private static final String PREVIOUS_STEAM_CLIENT_STORE_RELATIVE_PATH = ".steam-client-store"; private static final String PREVIOUS_CONTAINER_STEAM_CLIENT_STORE_RELATIVE_PATH = ".wine/.steam-client-store"; private static final String LEGACY_STEAM_CLIENT_STORE_RELATIVE_PATH = ".wine/drive_c/WinNative/SteamClient"; + + // --- Ubisoft Connect shared-login store (mirrors the Steam client store above) --- + // Ubisoft Connect keeps its session token as files under two per-prefix directories, so a + // sign-in in one container is invisible to the others. We point both directories at a single + // backing store shared by every container. It lives under "home/" because ImageFS reinstalls + // preserve only the "home" dir (see ImageFsInstaller.clearRootDir), so the login survives updates. + private static final String UBISOFT_STORE_RELATIVE_PATH = "home/.ubisoft-store"; + private static final String UBISOFT_LAUNCHER_STORE_NAME = "launcher"; + private static final String UBISOFT_LOCALAPPDATA_STORE_NAME = "localappdata"; + private static final String UBISOFT_LAUNCHER_PREFIX_PATH = + ".wine/drive_c/Program Files (x86)/Ubisoft/Ubisoft Game Launcher"; + private static final String UBISOFT_LOCALAPPDATA_PREFIX_PATH = + ".wine/drive_c/users/" + ImageFs.USER + "/AppData/Local/Ubisoft Game Launcher"; + private static final String UBISOFT_LAUNCHER_WINDOWS_PATH = + "C:\\Program Files (x86)\\Ubisoft\\Ubisoft Game Launcher\\"; + + // Ubisoft/Uplay helper processes whose embedded Chromium (CEF) renders the launcher/login UI. + // GameHub forces these to builtin D3D so CEF renders via WineD3D->GL(Zink)->Vulkan instead of + // grabbing the game's DXVK device (which black-screens/crashes CEF). Mirrors GameHub's mk.n() + // over hqk.j = {SocialClubHelper, UplayWebCore, upc, UplayService}; UbisoftConnect.exe added + // because the modern client hosts the same CEF UI. + private static final String[] UBISOFT_HELPER_EXES = { + "upc.exe", "UbisoftConnect.exe", "UplayWebCore.exe", "SocialClubHelper.exe", "UplayService.exe", + }; + private static final String[] UBISOFT_HELPER_D3D_DLLS = { + "dxgi", "d3d9", "d3d10", "d3d10_1", "d3d10core", "d3d11", + }; + public static final String EXTRA_LAUNCHED_FROM_PINNED_SHORTCUT = "launched_from_pinned_shortcut"; // CEF GPU flags avoid steamwebhelper taking DXVK's dxgi path in FEX. @@ -6104,6 +6132,12 @@ private void setupWineSystemFiles() { setSteamClientVisibility(false); } + // Redirect this container's Ubisoft Connect data dirs to one shared backing store so a + // single sign-in persists across every container. Applied unconditionally (and idempotently) + // because a Ubisoft title can arrive via Steam, GOG, or a custom shortcut. See + // shareUbisoftConnectLogin(). + shareUbisoftConnectLogin(); + boolean steamLauncherActive = com.winlator.cmod.feature.stores.steam.utils .PrefManager.INSTANCE.getWnPlanW(); if (isSteamGame && !steamLauncherActive) { @@ -10054,6 +10088,150 @@ private void moveSteamDirectoryIntoBackingStore(File steamLink, File steamStore) } } + // --------------------------------------------------------------------------------------------- + // Ubisoft Connect shared login + // + // Ubisoft Connect stores its login as a session/refresh token in file form under two per-prefix + // directories (the launcher install dir and %LOCALAPPDATA%). Because every WinNative container is + // a separate Wine prefix, that login would otherwise have to be repeated per container. We instead + // point both directories at a single backing store (getSharedUbisoftStore) via symlinks, so one + // sign-in — including later token refreshes and launcher self-updates — is seen by all containers. + // This mirrors updateSteamDirectoryVisibility()/moveSteamDirectoryIntoBackingStore() above. + // --------------------------------------------------------------------------------------------- + + private void shareUbisoftConnectLogin() { + if (container == null) return; + try { + File storeRoot = getSharedUbisoftStore(); + redirectUbisoftDir(new File(container.getRootDir(), UBISOFT_LAUNCHER_PREFIX_PATH), + new File(storeRoot, UBISOFT_LAUNCHER_STORE_NAME)); + redirectUbisoftDir(new File(container.getRootDir(), UBISOFT_LOCALAPPDATA_PREFIX_PATH), + new File(storeRoot, UBISOFT_LOCALAPPDATA_STORE_NAME)); + seedUbisoftLauncherRegistry(); + seedUbisoftHelperDllOverrides(); + } catch (Exception e) { + Log.e("XServerDisplayActivity", "Error sharing Ubisoft Connect login", e); + } + } + + private File getSharedUbisoftStore() { + if (imageFs != null) { + return new File(imageFs.getRootDir(), UBISOFT_STORE_RELATIVE_PATH); + } + return new File(getFilesDir(), "imagefs/" + UBISOFT_STORE_RELATIVE_PATH); + } + + /** + * Replaces {@code prefixDir} with a symlink to {@code store}. A pre-existing real directory is + * migrated into the store first (see {@link #migrateUbisoftDirIntoStore}); an already-correct + * symlink is simply refreshed. Safe to call on every launch. + */ + private void redirectUbisoftDir(File prefixDir, File store) { + try { + if (prefixDir.exists() && !FileUtils.isSymlink(prefixDir)) { + migrateUbisoftDirIntoStore(prefixDir, store); + } + + if (!store.exists()) store.mkdirs(); + + File parent = prefixDir.getParentFile(); + if (parent != null && !parent.exists()) parent.mkdirs(); + + // FileUtils.delete() on a symlink removes only the link, never the store contents. + if (prefixDir.exists()) FileUtils.delete(prefixDir); + FileUtils.symlink(store, prefixDir); + Log.d("XServerDisplayActivity", "Ubisoft symlink → " + store.getAbsolutePath() + + " at " + prefixDir.getAbsolutePath()); + } catch (Exception e) { + Log.e("XServerDisplayActivity", "Error redirecting Ubisoft dir " + prefixDir, e); + } + } + + /** + * Migrates a container-local Ubisoft directory into the shared store. If the store is empty it is + * seeded from this container's existing install/login. If the store already holds a login it is + * authoritative and never overwritten — the stray local copy is set aside (not deleted) so no data + * is lost. + */ + private void migrateUbisoftDirIntoStore(File prefixDir, File store) { + File parent = store.getParentFile(); + if (parent != null && !parent.exists()) parent.mkdirs(); + + if (!isNonEmptyDir(store)) { + if (!store.exists() && prefixDir.renameTo(store)) { + Log.d("XServerDisplayActivity", "Seeded shared Ubisoft store from " + prefixDir.getAbsolutePath()); + return; + } + if (!store.exists() && !store.mkdirs()) { + Log.w("XServerDisplayActivity", "Unable to create shared Ubisoft store: " + store.getAbsolutePath()); + return; + } + if (FileUtils.copy(prefixDir, store)) { + FileUtils.delete(prefixDir); + Log.d("XServerDisplayActivity", "Copied existing Ubisoft data into shared store"); + } else { + Log.w("XServerDisplayActivity", "Failed to seed shared Ubisoft store from " + prefixDir.getAbsolutePath()); + } + return; + } + + File backup = new File(prefixDir.getParentFile(), + prefixDir.getName() + ".local-" + System.currentTimeMillis()); + if (prefixDir.renameTo(backup)) { + Log.d("XServerDisplayActivity", "Preserved stray local Ubisoft data at " + backup.getAbsolutePath()); + } else { + FileUtils.delete(prefixDir); + Log.d("XServerDisplayActivity", "Discarded stray local Ubisoft data (shared store is authoritative)"); + } + } + + private static boolean isNonEmptyDir(File dir) { + if (dir == null || !dir.isDirectory()) return false; + String[] entries = dir.list(); + return entries != null && entries.length > 0; + } + + /** + * Writes the launcher InstallDir into this container's registry so Ubisoft games can locate + * upc.exe even when Ubisoft Connect was installed from a different container (the registry is + * per-prefix and is not covered by the shared store). + */ + private void seedUbisoftLauncherRegistry() { + if (container == null) return; + File systemRegFile = new File(container.getRootDir(), ".wine/system.reg"); + if (!systemRegFile.isFile()) return; + try (WineRegistryEditor reg = new WineRegistryEditor(systemRegFile)) { + // WineRegistryEditor creates missing keys by default; matches seedVcRedistWithEditor. + reg.setStringValue("Software\\Wow6432Node\\Ubisoft\\Launcher", "InstallDir", UBISOFT_LAUNCHER_WINDOWS_PATH); + reg.setStringValue("Software\\Ubisoft\\Launcher", "InstallDir", UBISOFT_LAUNCHER_WINDOWS_PATH); + } catch (Exception e) { + Log.e("XServerDisplayActivity", "Error seeding Ubisoft launcher registry", e); + } + } + + /** + * Forces the Ubisoft/Uplay CEF helper processes onto Wine's builtin D3D (WineD3D) via per-exe + * AppDefaults DLL overrides, so their embedded Chromium renders through WineD3D -> GL(Zink) -> + * Vulkan instead of the game's DXVK device (which black-screens/crashes CEF). This is GameHub's + * exact Ubisoft fix: HKCU\Software\Wine\AppDefaults\<exe>\DllOverrides = builtin for the D3D DLLs + * (mk.n / hqk.j). Written to user.reg every launch. + */ + private void seedUbisoftHelperDllOverrides() { + if (container == null) return; + File userRegFile = new File(container.getRootDir(), ".wine/user.reg"); + if (!userRegFile.isFile()) return; + try (WineRegistryEditor reg = new WineRegistryEditor(userRegFile)) { + for (String exe : UBISOFT_HELPER_EXES) { + String key = "Software\\Wine\\AppDefaults\\" + exe + "\\DllOverrides"; + for (String dll : UBISOFT_HELPER_D3D_DLLS) { + reg.setStringValue(key, dll, "builtin"); + } + } + } catch (Exception e) { + Log.e("XServerDisplayActivity", "Error seeding Ubisoft helper DLL overrides", e); + } + } + private void updateSteamRegistryVisibility(boolean visible) { if (container == null) return; File userRegFile = new File(container.getRootDir(), ".wine/user.reg");