diff --git a/app/src/main/java/app/gamenative/ui/component/dialog/WorkshopManagerDialog.kt b/app/src/main/java/app/gamenative/ui/component/dialog/WorkshopManagerDialog.kt index e978e5c0ab..1585844104 100644 --- a/app/src/main/java/app/gamenative/ui/component/dialog/WorkshopManagerDialog.kt +++ b/app/src/main/java/app/gamenative/ui/component/dialog/WorkshopManagerDialog.kt @@ -7,17 +7,31 @@ import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.width import androidx.compose.foundation.rememberScrollState import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.foundation.verticalScroll import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.automirrored.filled.ArrowBack +import androidx.compose.material.icons.automirrored.filled.ArrowForward import androidx.compose.material.icons.filled.Clear +import androidx.compose.material.icons.filled.Computer +import androidx.compose.material.icons.filled.Description +import androidx.compose.material.icons.filled.Folder +import androidx.compose.material.icons.filled.FolderOff +import androidx.compose.material.icons.filled.FolderOpen +import androidx.compose.material.icons.filled.Gamepad +import androidx.compose.material.icons.filled.Inventory2 import androidx.compose.material.icons.filled.Search +import androidx.compose.material.icons.filled.Settings +import androidx.compose.material.icons.filled.SnippetFolder +import androidx.compose.material.icons.filled.SportsEsports import androidx.compose.material3.Button import androidx.compose.material3.Checkbox import androidx.compose.material3.CircularProgressIndicator @@ -27,9 +41,11 @@ import androidx.compose.material3.Icon import androidx.compose.material3.IconButton import androidx.compose.material3.ListItem import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.OutlinedButton import app.gamenative.ui.component.NoExtractOutlinedTextField import androidx.compose.material3.Surface import androidx.compose.material3.Text +import androidx.compose.material3.TextButton import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.derivedStateOf @@ -46,10 +62,12 @@ import androidx.compose.ui.geometry.Offset import androidx.compose.ui.graphics.Brush import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.Shadow +import androidx.compose.ui.graphics.vector.ImageVector import androidx.compose.ui.layout.ContentScale import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.res.painterResource import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.unit.dp import androidx.compose.ui.window.Dialog import androidx.compose.ui.window.DialogProperties @@ -65,6 +83,7 @@ import com.skydoves.landscapist.ImageOptions import com.skydoves.landscapist.coil.CoilImage import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.withContext +import java.io.File import java.text.SimpleDateFormat import java.util.Date import java.util.Locale @@ -74,8 +93,12 @@ import java.util.Locale fun WorkshopManagerDialog( visible: Boolean, currentEnabledIds: Set, + workshopModPath: String, + gameRootDir: File?, + winePrefix: String, onGetDisplayInfo: @Composable (Context) -> GameDisplayInfo, onSave: (Set) -> Unit, + onModPathChanged: (String) -> Unit, onDismissRequest: () -> Unit, ) { if (!visible) return @@ -90,6 +113,7 @@ fun WorkshopManagerDialog( var isLoading by remember { mutableStateOf(true) } var fetchFailed by remember { mutableStateOf(false) } var searchQuery by remember { mutableStateOf("") } + var showFolderPicker by remember { mutableStateOf(false) } LaunchedEffect(visible) { scrollState.animateScrollTo(0) @@ -121,7 +145,7 @@ fun WorkshopManagerDialog( isLoading = false } - val allSelected by remember(selectedIds.toMap()) { + val allSelected by remember { derivedStateOf { workshopItems.isNotEmpty() && workshopItems.all { selectedIds[it.publishedFileId] == true } } @@ -301,25 +325,82 @@ fun WorkshopManagerDialog( } } - // Select All toggle - Row( + // Mod Folder + Select All row + Column( modifier = Modifier .fillMaxWidth() .padding(horizontal = 16.dp, vertical = 8.dp), - horizontalArrangement = Arrangement.End ) { - Button( - onClick = { - val newState = !allSelected - workshopItems.forEach { item -> - selectedIds[item.publishedFileId] = newState - } + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.SpaceBetween, + verticalAlignment = Alignment.CenterVertically, + ) { + OutlinedButton( + onClick = { showFolderPicker = true }, + ) { + Icon( + imageVector = if (workshopModPath.isNotEmpty()) Icons.Default.FolderOpen else Icons.Default.Folder, + contentDescription = null, + modifier = Modifier.size(18.dp), + ) + Spacer(Modifier.width(6.dp)) + Text( + text = "Override Mod Folder", + maxLines = 1, + overflow = TextOverflow.Ellipsis, + ) } + Button( + onClick = { + val newState = !allSelected + workshopItems.forEach { item -> + selectedIds[item.publishedFileId] = newState + } + } ) { Text( text = if (allSelected) "Deselect all" else "Select all" ) } + } + + // Mod folder path subtitle + val modFolderDisplay = if (workshopModPath.isNotEmpty()) { + // Convert Linux path to Windows-style for readability + val driveCMarker = "/drive_c/" + val driveCIdx = workshopModPath.indexOf(driveCMarker) + val steamappsMarker = "/steamapps/common/" + val steamappsIdx = workshopModPath.indexOf(steamappsMarker) + when { + // Wine path: /.../.wine/drive_c/users/... → C:\users\... + driveCIdx >= 0 -> { + "C:\\" + workshopModPath.substring(driveCIdx + driveCMarker.length) + .replace('/', '\\') + } + // Game directory: /.../steamapps/common/GameName/... → Game\subfolder + steamappsIdx >= 0 -> { + workshopModPath.substring(steamappsIdx + steamappsMarker.length) + .replace('/', '\\') + } + // Fallback: show full path + else -> workshopModPath + } + } else { + "Automatic" + } + Text( + text = modFolderDisplay, + style = MaterialTheme.typography.bodySmall, + color = if (workshopModPath.isNotEmpty()) { + MaterialTheme.colorScheme.primary + } else { + MaterialTheme.colorScheme.onSurfaceVariant + }, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + modifier = Modifier.padding(top = 4.dp), + ) } filteredItems.forEach { item -> @@ -401,44 +482,431 @@ fun WorkshopManagerDialog( // Save button if (!isLoading && !fetchFailed) { - Column( - modifier = Modifier.fillMaxWidth() + Row( + modifier = Modifier + .fillMaxWidth() + .padding(8.dp), + horizontalArrangement = Arrangement.SpaceBetween, + verticalAlignment = Alignment.CenterVertically, + ) { + val selectedCount = selectedIds.count { it.value } + val totalSelectedSize = workshopItems + .filter { selectedIds[it.publishedFileId] == true } + .sumOf { it.fileSizeBytes } + val sizeText = if (totalSelectedSize > 0) { + " (${StorageUtils.formatBinarySize(totalSelectedSize)})" + } else { + "" + } + Text( + modifier = Modifier.weight(0.5f), + text = "$selectedCount of ${workshopItems.size} mods selected$sizeText", + color = Color.White.copy(alpha = 0.7f), + ) + Button( + onClick = { + val enabledIds = selectedIds + .filter { it.value } + .keys + onSave(enabledIds) + } + ) { + Text("Save") + } + } + } + } + }, + ) + + if (showFolderPicker) { + FolderPickerDialog( + currentPath = workshopModPath, + gameRootDir = gameRootDir, + winePrefix = winePrefix, + onSelect = { path -> + onModPathChanged(path) + showFolderPicker = false + }, + onClear = { + onModPathChanged("") + showFolderPicker = false + }, + onDismiss = { showFolderPicker = false }, + ) + } +} + +// ── Folder Picker Dialog ────────────────────────────────────────────────────── + +private data class FolderRoot( + val label: String, + val icon: ImageVector, + val dir: File, +) + +@Composable +private fun FolderPickerDialog( + currentPath: String, + gameRootDir: File?, + winePrefix: String, + onSelect: (String) -> Unit, + onClear: () -> Unit, + onDismiss: () -> Unit, +) { + val roots = remember(gameRootDir, winePrefix) { + buildList { + if (gameRootDir?.isDirectory == true) { + add(FolderRoot("Game Directory", Icons.Default.SportsEsports, gameRootDir)) + } + if (winePrefix.isNotEmpty()) { + val usersDir = File(winePrefix, "drive_c/users") + val steamuser = File(usersDir, "steamuser") + val userDir = if (steamuser.isDirectory) { + steamuser + } else if (usersDir.isDirectory) { + usersDir.listFiles() + ?.firstOrNull { it.isDirectory && !it.name.equals("Public", ignoreCase = true) } + ?: steamuser + } else { + steamuser + } + val driveC = File(winePrefix, "drive_c") + if (driveC.isDirectory) add(FolderRoot("C: Drive", Icons.Default.Computer, driveC)) + val docs = File(userDir, "Documents") + if (docs.isDirectory) add(FolderRoot("My Documents", Icons.Default.Description, docs)) + val myGames = File(userDir, "Documents/My Games") + if (myGames.isDirectory) add(FolderRoot("My Games", Icons.Default.Gamepad, myGames)) + val roaming = File(userDir, "AppData/Roaming") + if (roaming.isDirectory) add(FolderRoot("AppData / Roaming", Icons.Default.Settings, roaming)) + val local = File(userDir, "AppData/Local") + if (local.isDirectory) add(FolderRoot("AppData / Local", Icons.Default.SnippetFolder, local)) + val localLow = File(userDir, "AppData/LocalLow") + if (localLow.isDirectory) add(FolderRoot("AppData / LocalLow", Icons.Default.Inventory2, localLow)) + } + } + } + + var currentDir by remember { mutableStateOf(null) } + var currentRootLabel by remember { mutableStateOf("") } + var subDirs by remember { mutableStateOf>(emptyList()) } + var loading by remember { mutableStateOf(false) } + + LaunchedEffect(currentDir) { + val dir = currentDir + if (dir != null && dir.isDirectory) { + loading = true + try { + subDirs = withContext(Dispatchers.IO) { + dir.listFiles() + ?.filter { it.isDirectory && !it.name.startsWith(".") } + ?.sortedBy { it.name.lowercase() } + ?: emptyList() + } + } catch (e: Exception) { + if (e is kotlinx.coroutines.CancellationException) throw e + subDirs = emptyList() + } finally { + loading = false + } + } else { + subDirs = emptyList() + } + } + + // Breadcrumb path relative to the root + val breadcrumb = remember(currentDir, currentRootLabel) { + val dir = currentDir ?: return@remember "" + val rootDir = roots.find { it.label == currentRootLabel }?.dir ?: return@remember dir.name + val rel = dir.absolutePath.removePrefix(rootDir.absolutePath).trimStart('/', '\\') + val segments = rel.split(Regex("[/\\\\]+")).filterNot { it.isBlank() } + if (segments.isEmpty()) currentRootLabel else "$currentRootLabel / ${segments.joinToString(" / ")}" + } + + Dialog( + onDismissRequest = onDismiss, + properties = DialogProperties(usePlatformDefaultWidth = false), + ) { + Surface( + modifier = Modifier + .fillMaxWidth(0.92f) + .height(480.dp), + shape = MaterialTheme.shapes.medium, + color = MaterialTheme.colorScheme.surface, + ) { + Column(modifier = Modifier.fillMaxSize()) { + // ── Header ────────────────────────────────────────── + Box( + modifier = Modifier + .fillMaxWidth() + .padding(24.dp), + ) { + Column { + Text( + text = if (currentDir == null) "Select Mod Folder" else "Browse", + style = MaterialTheme.typography.headlineSmall, + color = MaterialTheme.colorScheme.onSurface, + ) + if (currentPath.isNotEmpty() && currentDir == null) { + val currentFolderName = currentPath + .substringAfterLast('/').substringAfterLast('\\') + .ifEmpty { "—" } + Spacer(Modifier.height(4.dp)) + Row(verticalAlignment = Alignment.CenterVertically) { + Icon( + Icons.Default.FolderOpen, + contentDescription = null, + modifier = Modifier.size(14.dp), + tint = MaterialTheme.colorScheme.primary, + ) + Spacer(Modifier.width(6.dp)) + Text( + text = currentFolderName, + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.primary, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + ) + } + } + if (currentDir != null) { + Spacer(Modifier.height(4.dp)) + Text( + text = breadcrumb, + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurface.copy(alpha = 0.6f), + maxLines = 1, + overflow = TextOverflow.Ellipsis, + ) + } + } + } + + // ── Back navigation bar (only when browsing) ──────── + if (currentDir != null) { + Surface( + modifier = Modifier.fillMaxWidth(), + color = MaterialTheme.colorScheme.surfaceVariant, ) { Row( modifier = Modifier - .fillMaxWidth() - .padding(8.dp), - horizontalArrangement = Arrangement.SpaceBetween, - verticalAlignment = Alignment.CenterVertically + .clickable { + val parent = currentDir?.parentFile + val rootDir = roots + .find { it.label == currentRootLabel } + ?.dir + if (parent != null && rootDir != null && + parent.absolutePath.startsWith(rootDir.absolutePath) + ) { + currentDir = parent + } else { + currentDir = null + } + } + .padding(horizontal = 24.dp, vertical = 10.dp), + verticalAlignment = Alignment.CenterVertically, ) { - val selectedCount = selectedIds.count { it.value } - val totalSelectedSize = workshopItems - .filter { selectedIds[it.publishedFileId] == true } - .sumOf { it.fileSizeBytes } - val sizeText = if (totalSelectedSize > 0) { - " (${StorageUtils.formatBinarySize(totalSelectedSize)})" - } else { - "" - } + Icon( + Icons.AutoMirrored.Filled.ArrowBack, + contentDescription = "Back", + modifier = Modifier.size(18.dp), + tint = MaterialTheme.colorScheme.primary, + ) + Spacer(Modifier.width(10.dp)) Text( - modifier = Modifier.weight(0.5f), - text = "$selectedCount of ${workshopItems.size} mods selected$sizeText", - color = Color.White.copy(alpha = 0.7f) + text = currentDir?.name ?: "", + style = MaterialTheme.typography.titleSmall, + fontWeight = FontWeight.SemiBold, + color = MaterialTheme.colorScheme.onSurface, + maxLines = 1, + overflow = TextOverflow.Ellipsis, ) - Button( - onClick = { - val enabledIds = selectedIds - .filter { it.value } - .keys - onSave(enabledIds) + } + } + HorizontalDivider( + thickness = 0.5.dp, + color = MaterialTheme.colorScheme.outline.copy(alpha = 0.3f), + ) + } + + // ── Content ───────────────────────────────────────── + Column( + modifier = Modifier + .weight(1f) + .fillMaxWidth() + .verticalScroll(rememberScrollState()), + ) { + if (currentDir == null) { + // Root location cards + Spacer(Modifier.height(8.dp)) + roots.forEach { root -> + Surface( + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = 12.dp, vertical = 4.dp), + shape = RoundedCornerShape(12.dp), + color = MaterialTheme.colorScheme.surfaceVariant, + ) { + Row( + modifier = Modifier + .clickable { + currentDir = root.dir + currentRootLabel = root.label + } + .padding(horizontal = 16.dp, vertical = 14.dp), + verticalAlignment = Alignment.CenterVertically, + ) { + Box( + modifier = Modifier + .size(36.dp) + .background( + color = MaterialTheme.colorScheme.primary.copy(alpha = 0.15f), + shape = RoundedCornerShape(10.dp), + ), + contentAlignment = Alignment.Center, + ) { + Icon( + root.icon, + contentDescription = null, + modifier = Modifier.size(20.dp), + tint = MaterialTheme.colorScheme.primary, + ) + } + Spacer(Modifier.width(14.dp)) + Text( + text = root.label, + style = MaterialTheme.typography.bodyLarge, + color = MaterialTheme.colorScheme.onSurface, + ) + Spacer(Modifier.weight(1f)) + Icon( + Icons.AutoMirrored.Filled.ArrowForward, + contentDescription = null, + modifier = Modifier.size(18.dp), + tint = MaterialTheme.colorScheme.onSurface.copy(alpha = 0.3f), + ) } + } + } + if (roots.isEmpty()) { + Box( + modifier = Modifier + .fillMaxWidth() + .padding(32.dp), + contentAlignment = Alignment.Center, ) { - Text("Save") + Text( + text = "No browsable locations found.", + color = MaterialTheme.colorScheme.onSurface.copy(alpha = 0.4f), + ) } } + Spacer(Modifier.height(8.dp)) + } else { + // Sub-directory listing + if (loading) { + Box( + modifier = Modifier + .fillMaxWidth() + .padding(48.dp), + contentAlignment = Alignment.Center, + ) { + CircularProgressIndicator( + modifier = Modifier.size(28.dp), + color = MaterialTheme.colorScheme.primary, + strokeWidth = 3.dp, + ) + } + } else if (subDirs.isEmpty()) { + Box( + modifier = Modifier + .fillMaxWidth() + .padding(48.dp), + contentAlignment = Alignment.Center, + ) { + Column(horizontalAlignment = Alignment.CenterHorizontally) { + Icon( + Icons.Default.FolderOff, + contentDescription = null, + modifier = Modifier.size(32.dp), + tint = MaterialTheme.colorScheme.onSurface.copy(alpha = 0.25f), + ) + Spacer(Modifier.height(8.dp)) + Text( + text = "No subdirectories", + color = MaterialTheme.colorScheme.onSurface.copy(alpha = 0.4f), + style = MaterialTheme.typography.bodyMedium, + ) + } + } + } else { + Spacer(Modifier.height(4.dp)) + subDirs.forEach { dir -> + Row( + modifier = Modifier + .fillMaxWidth() + .clickable { currentDir = dir } + .padding(horizontal = 24.dp, vertical = 12.dp), + verticalAlignment = Alignment.CenterVertically, + ) { + Icon( + Icons.Default.Folder, + contentDescription = null, + modifier = Modifier.size(20.dp), + tint = MaterialTheme.colorScheme.primary, + ) + Spacer(Modifier.width(14.dp)) + Text( + text = dir.name, + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurface, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + ) + } + HorizontalDivider( + modifier = Modifier.padding(horizontal = 24.dp), + thickness = 0.5.dp, + color = MaterialTheme.colorScheme.outline.copy(alpha = 0.3f), + ) + } + Spacer(Modifier.height(4.dp)) + } + } + } + + // ── Footer ────────────────────────────────────────── + HorizontalDivider( + thickness = 0.5.dp, + color = MaterialTheme.colorScheme.outline.copy(alpha = 0.3f), + ) + Row( + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = 24.dp, vertical = 12.dp), + horizontalArrangement = Arrangement.End, + verticalAlignment = Alignment.CenterVertically, + ) { + if (currentPath.isNotEmpty()) { + TextButton(onClick = onClear) { + Text("Clear", color = MaterialTheme.colorScheme.error) + } + } + TextButton(onClick = onDismiss) { + Text("Cancel") + } + if (currentDir != null) { + Button( + onClick = { + currentDir?.absolutePath?.let { onSelect(it) } + }, + modifier = Modifier.padding(start = 8.dp), + ) { + Text("Select") + } } } } - }, - ) + } + } } diff --git a/app/src/main/java/app/gamenative/ui/screen/library/appscreen/SteamAppScreen.kt b/app/src/main/java/app/gamenative/ui/screen/library/appscreen/SteamAppScreen.kt index a2db6b78c6..125229ac8c 100644 --- a/app/src/main/java/app/gamenative/ui/screen/library/appscreen/SteamAppScreen.kt +++ b/app/src/main/java/app/gamenative/ui/screen/library/appscreen/SteamAppScreen.kt @@ -1251,11 +1251,31 @@ class SteamAppScreen : BaseAppScreen() { val appDao = remember { SteamService.instance?.appDao } var currentEnabledIds by remember { mutableStateOf?>(null) } + // Load container for mod path override + val containerId = "STEAM_$gameId" + var workshopModPath by remember(gameId) { mutableStateOf("") } + val wsGameRootDir = remember(gameId) { + if (SteamService.isAppInstalled(gameId)) File(SteamService.getAppDirPath(gameId)) else null + } + val wsWinePrefix = remember(gameId) { + runCatching { + val container = ContainerUtils.getContainer(context, containerId) + container.getRootDir()?.let { File(it, ".wine").absolutePath } ?: "" + }.getOrDefault("") + } + LaunchedEffect(gameId) { val idsString = withContext(Dispatchers.IO) { appDao?.getEnabledWorkshopItemIds(gameId) } currentEnabledIds = WorkshopManager.parseEnabledIds(idsString) + // Load saved mod path override + withContext(Dispatchers.IO) { + runCatching { + val container = ContainerUtils.getContainer(context, containerId) + workshopModPath = container.getExtra("workshopModPath", "") + } + } } val loadedIds = currentEnabledIds @@ -1263,6 +1283,9 @@ class SteamAppScreen : BaseAppScreen() { WorkshopManagerDialog( visible = true, currentEnabledIds = loadedIds, + workshopModPath = workshopModPath, + gameRootDir = wsGameRootDir, + winePrefix = wsWinePrefix, onGetDisplayInfo = { context -> return@WorkshopManagerDialog getGameDisplayInfo(context, libraryItem) }, @@ -1289,6 +1312,17 @@ class SteamAppScreen : BaseAppScreen() { } } }, + onModPathChanged = { newPath -> + workshopModPath = newPath + CoroutineScope(Dispatchers.IO).launch { + runCatching { + val container = ContainerUtils.getContainer(context, containerId) + container.putExtra("workshopModPath", if (newPath.isEmpty()) null else newPath) + container.saveData() + Timber.tag("Workshop").i("Workshop mod path override set to: '$newPath' for gameId=$gameId") + } + } + }, onDismissRequest = { hideWorkshopDialog(gameId) } diff --git a/app/src/main/java/app/gamenative/workshop/WorkshopItem.kt b/app/src/main/java/app/gamenative/workshop/WorkshopItem.kt index b5ac462de3..1380aa80a3 100644 --- a/app/src/main/java/app/gamenative/workshop/WorkshopItem.kt +++ b/app/src/main/java/app/gamenative/workshop/WorkshopItem.kt @@ -13,6 +13,8 @@ data class WorkshopItem( val fileUrl: String = "", val fileName: String = "", val previewUrl: String = "", + val description: String = "", + val tags: String = "", ) { companion object { /** File extensions recognised as valid workshop content (used to skip magic-byte detection). */ diff --git a/app/src/main/java/app/gamenative/workshop/WorkshopManager.kt b/app/src/main/java/app/gamenative/workshop/WorkshopManager.kt index d6ac76919c..9ff2063c40 100644 --- a/app/src/main/java/app/gamenative/workshop/WorkshopManager.kt +++ b/app/src/main/java/app/gamenative/workshop/WorkshopManager.kt @@ -196,6 +196,11 @@ object WorkshopManager { fileUrl = details.fileUrl ?: "", fileName = details.filename ?: "", previewUrl = details.previewUrl ?: "", + description = details.fileDescription ?: "", + tags = details.tagsList + ?.mapNotNull { tag -> tag.tag?.takeIf { it.isNotBlank() } } + ?.joinToString(",") + ?: "", ).also { Timber.tag(TAG).d( "Item ${it.publishedFileId} '${it.title}': " + @@ -485,6 +490,85 @@ object WorkshopManager { } } + /** + * Extracts workshop items that consist of a single ZIP archive. + * + * Many Workshop authors upload a single `.zip` file as their mod content + * (e.g. Door Kickers' `modupload.zip`). On a real Steam client the game + * expects the extracted contents, not the archive. This function detects + * item directories whose only substantive file is a `.zip`, extracts it + * in-place, and deletes the archive. + * + * A `.zip_extracted` marker file prevents re-extraction on subsequent runs. + * Preview images (`preview.jpg`/`preview.png`) are preserved. + */ + fun extractZipMods(workshopContentDir: File) { + if (!workshopContentDir.exists()) return + var extractedCount = 0 + + workshopContentDir.listFiles()?.forEach { itemDir -> + if (!itemDir.isDirectory) return@forEach + + // Skip if already extracted + if (File(itemDir, ".zip_extracted").exists()) return@forEach + + // Find content files (skip hidden files and preview images) + val contentFiles = itemDir.listFiles()?.filter { f -> + f.isFile && + !f.name.startsWith(".") && + !f.name.startsWith("preview", ignoreCase = true) + } ?: return@forEach + + // Only auto-extract when the sole content file is a ZIP + if (contentFiles.size != 1) return@forEach + val zipFile = contentFiles.first() + if (!zipFile.name.endsWith(".zip", ignoreCase = true)) return@forEach + + // Verify it's actually a ZIP via magic bytes + val magic = ByteArray(4) + try { + zipFile.inputStream().use { it.read(magic) } + } catch (_: Exception) { return@forEach } + if (magic[0] != 0x50.toByte() || magic[1] != 0x4B.toByte() || + magic[2] != 0x03.toByte() || magic[3] != 0x04.toByte() + ) return@forEach + + try { + java.util.zip.ZipInputStream(zipFile.inputStream().buffered()).use { zis -> + var entry = zis.nextEntry + while (entry != null) { + val outFile = File(itemDir, entry.name) + // Guard against zip-slip (path traversal) + if (!outFile.canonicalPath.startsWith(itemDir.canonicalPath + File.separator)) { + Timber.tag(TAG).w("Skipping zip entry with path traversal: ${entry.name}") + entry = zis.nextEntry + continue + } + if (entry.isDirectory) { + outFile.mkdirs() + } else { + outFile.parentFile?.mkdirs() + outFile.outputStream().use { fos -> + zis.copyTo(fos) + } + } + entry = zis.nextEntry + } + } + // Successfully extracted — remove the zip and leave a marker + zipFile.delete() + File(itemDir, ".zip_extracted").createNewFile() + extractedCount++ + Timber.tag(TAG).d("Extracted ZIP mod: ${zipFile.name} in ${itemDir.name}") + } catch (e: Exception) { + Timber.tag(TAG).w(e, "Failed to extract ZIP: ${zipFile.name} in ${itemDir.name}") + } + } + if (extractedCount > 0) { + Timber.tag(TAG).i("Extracted $extractedCount ZIP workshop mods") + } + } + /** * Detects workshop content files that are missing their file extension and * renames them based on magic-byte signatures. @@ -1444,6 +1528,26 @@ object WorkshopManager { entry.put("preview_filename", previewFile.name) } + // Enriched metadata: gbe_fork's GetPublishedFileDetails() and + // ISteamUGC queries read these fields from mods.json to populate + // item details returned to games (fixes "no item details" for + // games like Dimension Jump). + if (item != null) { + if (item.description.isNotEmpty()) { + entry.put("description", item.description) + } + if (item.tags.isNotEmpty()) { + entry.put("tags", item.tags) + } + if (item.previewUrl.isNotEmpty()) { + entry.put("preview_url", item.previewUrl) + } + entry.put( + "workshop_item_url", + "https://steamcommunity.com/sharedfiles/filedetails/?id=${item.publishedFileId}" + ) + } + modsObj.put(itemDir.name, entry) } return modsObj @@ -1791,7 +1895,7 @@ object WorkshopManager { // ── Strategy cache ──────────────────────────────────────────────────────── /** Bump when detection logic changes to invalidate all cached strategies. */ - private const val STRATEGY_CACHE_VERSION = 13 + private const val STRATEGY_CACHE_VERSION = 15 private fun strategyCacheFile(gameRootDir: File): File = File(gameRootDir, ".gamenative_mod_strategy.json") @@ -1831,6 +1935,7 @@ object WorkshopManager { put("dirs", arr) } if (fanOut != null) put("fanOut", fanOut) + put("stdSeen", result.stdSeen) } strategyCacheFile(gameRootDir).writeText(json.toString(2)) Timber.tag(TAG).d("Saved strategy cache for ${gameRootDir.name}") @@ -1884,7 +1989,8 @@ object WorkshopManager { else -> return null } Timber.tag(TAG).d("Loaded cached strategy for ${gameRootDir.name}: $type [$confidence]") - WorkshopModPathDetector.DetectionResult(strategy, confidence, reason) + val stdSeen = json.optBoolean("stdSeen", false) + WorkshopModPathDetector.DetectionResult(strategy, confidence, reason, stdSeen) } catch (e: Exception) { Timber.tag(TAG).w(e, "Failed to load cached strategy, re-detecting") file.delete() @@ -1937,6 +2043,7 @@ object WorkshopManager { items: List = emptyList(), winePrefix: String = "", gameName: String = "", + workshopModPath: String = "", ) { if (!workshopContentDir.exists()) { Timber.tag(TAG).d("Workshop content dir doesn't exist yet, skipping symlink config") @@ -1986,6 +2093,115 @@ object WorkshopManager { val isSkyrim = appId == 72850 || gameName.contains("skyrim", ignoreCase = true) val isSourceEngine = isSourceEngine(gameRootDir) + // ── Manual mod path override ──────────────────────────────────────── + // When the user has set a custom mod folder, symlink all workshop + // items into that directory. mods.json is still populated for games + // that use ISteamUGC. All automatic detection is bypassed. + val hasManualModPath = workshopModPath.isNotEmpty() + if (hasManualModPath) { + val targetDir = File(workshopModPath) + + // ── Clean ALL workshop symlinks from every possible location ───── + // When switching from one manual path to another (e.g. LocalLow→Local), + // or from auto-detection to manual, stale symlinks in previous + // locations must be removed to avoid mods appearing in multiple places. + val workshopContentAbs = workshopContentDir.absolutePath + val targetCanonical = runCatching { targetDir.canonicalPath }.getOrElse { targetDir.absolutePath } + + // Helper: remove workshop symlinks from a directory, unless it's the new target + fun cleanWorkshopSymlinksFrom(dir: File) { + if (!dir.isDirectory) return + val dirCanonical = runCatching { dir.canonicalPath }.getOrElse { dir.absolutePath } + if (dirCanonical == targetCanonical) return // skip — we'll recreate these below + dir.listFiles()?.forEach { entry -> + if (Files.isSymbolicLink(entry.toPath())) { + try { + val linkTarget = Files.readSymbolicLink(entry.toPath()) + val resolved = if (linkTarget.isAbsolute) linkTarget + else entry.toPath().parent.resolve(linkTarget) + val resolvedStr = runCatching { resolved.toRealPath().toString() } + .getOrElse { resolved.normalize().toAbsolutePath().toString() } + if (resolvedStr.contains("workshop/content/") || + resolvedStr.startsWith(workshopContentAbs) + ) { + Files.deleteIfExists(entry.toPath()) + } + } catch (_: Exception) { } + } + } + } + + // 1) Clean game directory tree (shallow walk for known mod dirs) + gameRootDir.walkTopDown().maxDepth(4).forEach { dir -> + if (!dir.isDirectory) return@forEach + if (dir.absolutePath.contains("steam_settings")) return@forEach + if (dir.name.lowercase() in WorkshopModPathDetector.ALL_MOD_DIR_NAMES || + dir == gameRootDir + ) { + cleanWorkshopSymlinksFrom(dir) + } + } + + // 2) Clean AppData / Documents trees (the other roots + // the folder picker offers) + if (winePrefix.isNotEmpty()) { + listOf( + appDataRoaming(winePrefix), + appDataLocal(winePrefix), + appDataLocalLow(winePrefix), + documentsDir(winePrefix), + documentsMyGames(winePrefix), + ).forEach { root -> + if (!root.isDirectory) return@forEach + // Walk the root itself + up to 5 levels deep to catch + // symlinks at any nesting level (e.g. Documents/Dev/Game/mods/) + root.walkTopDown().maxDepth(5).forEach { dir -> + if (dir.isDirectory) { + cleanWorkshopSymlinksFrom(dir) + } + } + } + } + Timber.tag(TAG).d("Cleaned workshop symlinks from all previous locations") + + // ── Create fresh symlinks in the chosen target ────────────────── + try { + if (!targetDir.isDirectory) targetDir.mkdirs() + // Clean the target itself (in case of stale entries) + cleanWorkshopSymlinksFrom(targetDir).also { + // The helper skips targetCanonical, so clean it explicitly + targetDir.listFiles()?.forEach { entry -> + if (Files.isSymbolicLink(entry.toPath())) { + try { + val linkTarget = Files.readSymbolicLink(entry.toPath()) + val resolved = if (linkTarget.isAbsolute) linkTarget + else entry.toPath().parent.resolve(linkTarget) + val resolvedStr = runCatching { resolved.toRealPath().toString() } + .getOrElse { resolved.normalize().toAbsolutePath().toString() } + if (resolvedStr.contains("workshop/content/") || + resolvedStr.startsWith(workshopContentAbs) + ) { + Files.deleteIfExists(entry.toPath()) + } + } catch (_: Exception) { } + } + } + } + // Create fresh symlinks + modDirs.forEach { itemDir -> + val linkPath = targetDir.toPath().resolve(itemDir.name) + if (!Files.exists(linkPath)) { + Files.createSymbolicLink(linkPath, itemDir.toPath()) + } + } + Timber.tag(TAG).i( + "Manual mod path: symlinked ${modDirs.size} items into ${targetDir.absolutePath}" + ) + } catch (e: Exception) { + Timber.tag(TAG).w(e, "Failed to symlink mods into manual path: ${targetDir.absolutePath}") + } + } + // ── Early strategy detection ──────────────────────────────────────── // Detect whether the game reads mods from its own directory structure // (SymlinkIntoDir) BEFORE writing gbe_fork mods in Phases 2/4. If @@ -2010,17 +2226,52 @@ object WorkshopManager { val unityModTargets by lazy { detectUnityModTargets(gameRootDir, winePrefix) } val modsJsonText by lazy { buildModsJson(modDirs, items).toString(2) } - val willUseFilesystemMods = if (winePrefix.isNotEmpty() && !isSkyrim && !isSourceEngine) { + // Starbound has mods/ (HIGH confidence) but workshop items use a + // _metadata format that doesn't work when symlinked into mods/. + // Force ISteamUGC path regardless of detection. + val forceStandardAppIds = setOf(211820) // Starbound + + // When the game's binary contains ISteamUGC / GetItemInstallInfo + // strings AND there's a HIGH-confidence mod directory, the game + // likely uses the Steam Workshop API for content discovery — the + // directory is for manual/non-workshop mods. In that case we + // populate mods.json (ISteamUGC) and suppress filesystem symlinks + // to prevent duplication. + // + // This heuristic naturally separates: + // • Native C++ games (ISteamUGC strings in exe) → ISteamUGC path + // • .NET / Unity games (no ISteamUGC in exe) → filesystem path + var stdSeenWithHighDir = false + + val willUseFilesystemMods = if (hasManualModPath) { + // User chose a specific mod folder — always populate mods.json + // alongside the manual symlinks (covers ISteamUGC games too) + Timber.tag(TAG).i("Manual mod path set for $gameName — forcing ISteamUGC mods.json") + false + } else if (appId in forceStandardAppIds) { + stdSeenWithHighDir = true // treat like stdSeen so Phase 6 + cleanup runs + Timber.tag(TAG).i("Force-Standard override for appId $appId ($gameName)") + false + } else if (winePrefix.isNotEmpty() && !isSkyrim && !isSourceEngine) { try { val detection = getOrDetectStrategy(gameRootDir, winePrefix, gameName) Timber.tag(TAG).i( "Strategy detection: ${detection.strategy::class.simpleName} " + - "[${detection.confidence}] — ${detection.reason}" + "[${detection.confidence}] stdSeen=${detection.stdSeen} — ${detection.reason}" ) val isHighConfSymlink = detection.strategy is WorkshopModPathStrategy.SymlinkIntoDir && detection.confidence == WorkshopModPathDetector.Confidence.HIGH - isHighConfSymlink || - unityModTargets.isNotEmpty() + + if (isHighConfSymlink && detection.stdSeen) { + stdSeenWithHighDir = true + Timber.tag(TAG).i( + "ISteamUGC binary signals + HIGH-confidence mod dir for $gameName — " + + "preferring ISteamUGC path (mods.json), suppressing filesystem symlinks" + ) + false // use ISteamUGC, not filesystem + } else { + (isHighConfSymlink || unityModTargets.isNotEmpty()) + } } catch (e: Exception) { Timber.tag(TAG).w(e, "Strategy detection failed, defaulting to ISteamUGC path") false @@ -2032,7 +2283,7 @@ object WorkshopManager { ) false } - Timber.tag(TAG).i("willUseFilesystemMods=$willUseFilesystemMods for $gameName") + Timber.tag(TAG).i("willUseFilesystemMods=$willUseFilesystemMods stdSeenOverride=$stdSeenWithHighDir for $gameName") // Find all gbe_fork DLL locations (steam_api.dll, steam_api64.dll, // steamclient.dll, steamclient64.dll) and create mods/ symlinks next @@ -2133,6 +2384,10 @@ object WorkshopManager { Timber.tag(TAG).d( "Configured ${modDirs.size} mod symlinks at ${modsDir.absolutePath}" ) + Timber.tag(TAG).d( + "mods.json written (${modDirs.size} entries) next to ${file.name}: " + + modDirs.joinToString { it.name } + ) } else if (modsDir.isDirectory || File(settingsDir, "mods.json").isFile) { // Filesystem-managed game: clean stale gbe_fork mods from // previous runs that may have lacked this early skip. @@ -2153,6 +2408,12 @@ object WorkshopManager { if (origDll.isFile) { ensureInterfacesComplete(origDll, interfacesFile) } + if (interfacesFile.isFile) { + Timber.tag(TAG).d( + "steam_interfaces.txt for ${file.name}: " + + interfacesFile.readText().trim().replace("\n", ", ") + ) + } } catch (e: Exception) { Timber.tag(TAG).w(e, "Failed to create mod symlinks at ${modsDir.absolutePath}") } @@ -2571,9 +2832,21 @@ object WorkshopManager { val workshopContentReal = runCatching { workshopContentDir.toPath().toRealPath().toString() }.getOrElse { workshopContentDir.absolutePath } + // When the user has a manual mod path inside the game tree, skip + // cleaning symlinks in that directory — they were just created above. + val manualTargetCanonical = if (hasManualModPath) { + runCatching { File(workshopModPath).canonicalPath }.getOrElse { workshopModPath } + } else "" gameRootDir.walkTopDown().maxDepth(6).forEach { entry -> if (!Files.isSymbolicLink(entry.toPath())) return@forEach if (entry.absolutePath.contains("steam_settings")) return@forEach + // Protect manual mod path symlinks from stale cleanup + if (hasManualModPath && manualTargetCanonical.isNotEmpty()) { + val parentCanonical = runCatching { + entry.parentFile?.canonicalPath ?: "" + }.getOrElse { entry.parentFile?.absolutePath ?: "" } + if (parentCanonical == manualTargetCanonical) return@forEach + } try { val target = Files.readSymbolicLink(entry.toPath()) val resolvedTarget = if (target.isAbsolute) target @@ -2634,7 +2907,7 @@ object WorkshopManager { // already fully handled by the VPK→addons/ and BSP→maps/workshop/ // symlinks above. Running the detector on them causes regressions // (e.g. item-directory symlinks in maps/ confuse L4D2). - if (winePrefix.isNotEmpty() && modDirs.isNotEmpty() && !isSourceEngine) { + if (winePrefix.isNotEmpty() && modDirs.isNotEmpty() && !isSourceEngine && !stdSeenWithHighDir && !hasManualModPath) { // Check if Phase 7 (Unity AppData) will handle mod directories. // If it does, skip Phase 6 SymlinkIntoDir for the same directory // names so mods aren't placed at both the install dir AND AppData. @@ -2886,6 +3159,44 @@ object WorkshopManager { } } + // ── Clean stale Phase 6 symlinks for ISteamUGC-preferred games ──────── + // When the stdSeen heuristic suppresses filesystem symlinks, previous + // launches may have created symlinks in the game's mod directories + // (e.g. mods/). Remove them so the game doesn't see duplicate + // workshop items (one from ISteamUGC/mods.json and one from the filesystem). + if (stdSeenWithHighDir && winePrefix.isNotEmpty() && !hasManualModPath) { + try { + val detection = getOrDetectStrategy(gameRootDir, winePrefix, gameName) + val strategy = detection.strategy + if (strategy is WorkshopModPathStrategy.SymlinkIntoDir) { + for (dir in strategy.effectiveDirs) { + if (!dir.isDirectory) continue + dir.listFiles()?.forEach { entry -> + if (Files.isSymbolicLink(entry.toPath())) { + try { + val linkTarget = Files.readSymbolicLink(entry.toPath()) + val resolved = if (linkTarget.isAbsolute) linkTarget + else entry.toPath().parent.resolve(linkTarget) + val resolvedStr = runCatching { resolved.toRealPath().toString() } + .getOrElse { resolved.normalize().toAbsolutePath().toString() } + if (resolvedStr.contains("workshop/content/") || + resolvedStr.startsWith(workshopContentDir.absolutePath) + ) { + Files.deleteIfExists(entry.toPath()) + Timber.tag(TAG).d( + "Removed stale workshop symlink: ${entry.absolutePath}" + ) + } + } catch (_: Exception) { } + } + } + } + } + } catch (e: Exception) { + Timber.tag(TAG).w(e, "Failed to clean stale symlinks for ISteamUGC-preferred game '$gameName'") + } + } + } @@ -2964,6 +3275,7 @@ object WorkshopManager { } onStatus?.invoke("Extracting archives…") extractCkmFiles(workshopContentDir) + extractZipMods(workshopContentDir) decompressLzmaFiles(workshopContentDir) { completed, total -> onStatus?.invoke("Decompressing ($completed/$total)…") } @@ -2982,12 +3294,24 @@ object WorkshopManager { ) { val gameRootDir = File(SteamService.getAppDirPath(appId)) val gameName = SteamService.getAppInfoOf(appId)?.name ?: "" + + // Read the user's manual mod path override from the container + val containerId = "STEAM_$appId" + val modPathOverride = try { + val container = ContainerUtils.getContainer(context, containerId) + container.getExtra("workshopModPath", "") + } catch (e: Exception) { + Timber.tag(TAG).w(e, "Failed to read workshopModPath for appId $appId") + "" + } + configureModSymlinks( gameRootDir = gameRootDir, workshopContentDir = workshopContentDir, items = items, winePrefix = winePrefix, gameName = gameName, + workshopModPath = modPathOverride, ) } @@ -3066,6 +3390,21 @@ object WorkshopManager { return@launch } + // Ensure the container exists before downloading so that + // workshopContentDir (which lives inside the container's + // .wine prefix) doesn't accidentally pre-create the container + // directory. If that happens, ContainerManager.createContainer() + // fails on first game launch (mkdirs returns false for an + // existing dir), triggers the orphan-cleanup path which deletes + // the entire directory including the just-downloaded mods. + val containerId = "STEAM_$appId" + try { + ContainerUtils.getOrCreateContainer(context, containerId) + Timber.tag(TAG).d("Container ensured for appId=$appId before workshop download") + } catch (e: Exception) { + Timber.tag(TAG).w(e, "Failed to ensure container for appId=$appId (workshop download will still proceed)") + } + val winePrefix = getContainerWinePrefix(context, appId) val workshopContentDir = getWorkshopContentDir(winePrefix, appId) diff --git a/app/src/main/java/app/gamenative/workshop/WorkshopModPathDetector.kt b/app/src/main/java/app/gamenative/workshop/WorkshopModPathDetector.kt index ac8d3932db..16c9d25053 100644 --- a/app/src/main/java/app/gamenative/workshop/WorkshopModPathDetector.kt +++ b/app/src/main/java/app/gamenative/workshop/WorkshopModPathDetector.kt @@ -25,6 +25,8 @@ class WorkshopModPathDetector { val strategy: WorkshopModPathStrategy, val confidence: Confidence, val reason: String, + /** True when the game binary contains ISteamUGC / GetItemInstallInfo strings. */ + val stdSeen: Boolean = false, ) companion object { @@ -46,12 +48,16 @@ class WorkshopModPathDetector { "scenarios", "scenario", "missions", "mission", "workshop", "override", "gamedata", "maps", + "packs", "outfits", "skins", "characters", + "textures", "sounds", "audio", ) val LOW_CONFIDENCE_NAMES = setOf( "custom", "usercontent", "user_content", "community", "packages", "package", "downloads", "download", "downloaded", "extras", "expansion", "expansions", + "content", "assets", "data", ) + /** Union of all three confidence tiers — used for fast membership checks. */ val ALL_MOD_DIR_NAMES = HIGH_CONFIDENCE_NAMES + MEDIUM_CONFIDENCE_NAMES + LOW_CONFIDENCE_NAMES val APPDATA_TOKENS = listOf( @@ -143,7 +149,8 @@ class WorkshopModPathDetector { ) DetectionResult( WorkshopModPathStrategy.Standard, Confidence.MEDIUM, - "Standard Steam Workshop API detected, no local mod dirs found" + "Standard Steam Workshop API detected, no local mod dirs found", + stdSeen = true, ) } else { Timber.tag(TAG).i( @@ -189,7 +196,7 @@ class WorkshopModPathDetector { "strongFamilies=$distinctStrongFamilies topConf=$topConf" ) - return DetectionResult(WorkshopModPathStrategy.SymlinkIntoDir(dirs, fanOut), topConf, reason) + return DetectionResult(WorkshopModPathStrategy.SymlinkIntoDir(dirs, fanOut), topConf, reason, binaryResult.stdSeen) } // ── Heuristic 1: Binary scan ──────────────────────────────────────────────