From db0d02cd9fc527e06ceb664b4acfd9bb932041ee Mon Sep 17 00:00:00 2001 From: MaxsTechReview Date: Mon, 13 Jul 2026 13:52:15 -0400 Subject: [PATCH 1/2] Share one Ubisoft Connect login across all containers Each WinNative container is a separate Wine prefix, so a Ubisoft Connect sign-in (stored as files: a session/refresh token) would otherwise have to be repeated per container. Redirect each container's two Ubisoft data directories to a single backing store (imagefs/home/.ubisoft-store) via symlinks at launch, mirroring the existing Steam-client sharing (updateSteamDirectoryVisibility). One sign-in -- including later token refreshes and launcher self-updates -- is then visible to every container. - shareUbisoftConnectLogin() runs each launch in setupWineSystemFiles, after the Steam-visibility block; applied unconditionally and idempotently. - Safe migration: an existing real dir seeds an empty store; a populated store is authoritative and never clobbered (stray local copy preserved as .local-, not deleted); the per-launch symlink refresh never deletes the store contents. - seedUbisoftLauncherRegistry() writes Ubisoft\Launcher\InstallDir (both the Wow6432Node and 64-bit views) so games can locate upc.exe even when Ubisoft Connect was installed from a different container. - Store lives under home/ so it survives ImageFS reinstalls (clearRootDir preserves only home/). --- .../display/XServerDisplayActivity.java | 142 ++++++++++++++++++ 1 file changed, 142 insertions(+) diff --git a/app/src/main/runtime/display/XServerDisplayActivity.java b/app/src/main/runtime/display/XServerDisplayActivity.java index c8508b6f3..ca30ca207 100644 --- a/app/src/main/runtime/display/XServerDisplayActivity.java +++ b/app/src/main/runtime/display/XServerDisplayActivity.java @@ -208,6 +208,22 @@ 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\\"; + 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. @@ -6221,6 +6237,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) { @@ -10231,6 +10253,126 @@ 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(); + } 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); + } + } + private void updateSteamRegistryVisibility(boolean visible) { if (container == null) return; File userRegFile = new File(container.getRootDir(), ".wine/user.reg"); From 3e2eee2a61ffccdf10720719409cf1c4111aac95 Mon Sep 17 00:00:00 2001 From: MaxsTechReview Date: Tue, 14 Jul 2026 09:36:46 -0400 Subject: [PATCH 2/2] Ubisoft Connect: store install UI, CEF DLL overrides, hide launcher MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Builds on the shared-store login work: - Settings > Stores gets a dedicated "Ubisoft Connect" section with a Download button that fetches the Ubisoft Connect installer and installs it (silently, /S) into the default container via ComponentInstaller. The install lands in the shared store, so it covers every container. Once installed the card shows "Installed" and the button is grayed out. No standalone sign-in (the UC login is game-triggered, like GameHub) and no container picker (the shared store makes the choice moot). - Apply GameHub's Ubisoft CEF fix: per-exe HKCU\Software\Wine\AppDefaults\ \DllOverrides = builtin for dxgi/d3d9/d3d10/d3d10_1/d3d10core/d3d11 on the Uplay helper processes (upc.exe, UbisoftConnect.exe, UplayWebCore.exe, SocialClubHelper.exe, UplayService.exe), applied every launch. This routes their embedded Chromium through WineD3D -> GL(Zink) -> Vulkan instead of the game's DXVK device, so the login/store UI can render. - Seed HKLM Ubisoft\Launcher\InstallDir so games locate the launcher. - Hide the "Ubisoft Connect" launcher shortcut from the game library (ContainerManager.loadShortcuts/upgradeShortcuts skip it) — it's pre-installed plumbing, not a user game. --- .../feature/settings/stores/StoresFragment.kt | 71 +++++++++ .../feature/settings/stores/StoresScreen.kt | 142 ++++++++++++++++++ .../feature/stores/steam/utils/PrefManager.kt | 7 + .../feature/stores/ubisoft/UbisoftConnect.kt | 57 +++++++ app/src/main/res/values/strings.xml | 7 + .../runtime/container/ContainerManager.java | 11 ++ .../display/XServerDisplayActivity.java | 36 +++++ 7 files changed, 331 insertions(+) create mode 100644 app/src/main/feature/stores/ubisoft/UbisoftConnect.kt 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 8a15bb920..effd0c7cf 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 d55a29024..d6a6542ce 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 ca30ca207..97522ce66 100644 --- a/app/src/main/runtime/display/XServerDisplayActivity.java +++ b/app/src/main/runtime/display/XServerDisplayActivity.java @@ -224,6 +224,18 @@ public class XServerDisplayActivity extends FixedFontScaleAppCompatActivity { 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. @@ -10273,6 +10285,7 @@ private void shareUbisoftConnectLogin() { 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); } @@ -10373,6 +10386,29 @@ private void seedUbisoftLauncherRegistry() { } } + /** + * 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");