Skip to content
Merged
Show file tree
Hide file tree
Changes from 11 commits
Commits
Show all changes
25 commits
Select commit Hold shift + click to select a range
4bd9a00
feat: add Steam Workshop mod system
Nightwalker743 Mar 15, 2026
1ff45b0
refactor: use Net.http singleton instead of per-download OkHttpClient
Nightwalker743 Mar 15, 2026
74d4661
Merge remote-tracking branch 'upstream/master' into workshop-implemen…
Nightwalker743 Mar 17, 2026
6c06043
Merge remote-tracking branch 'upstream/master' into workshop-implemen…
Nightwalker743 Mar 21, 2026
da34e96
Workshop implementation, relative mouse movement toggle, and Controls…
Nightwalker743 Mar 22, 2026
63f1916
Address PR review feedback: fix 7 issues
Nightwalker743 Mar 22, 2026
70a6e1a
Fix workshop cleanup, collision disambiguation, and snackbar handling
Nightwalker743 Mar 22, 2026
0110cfd
Fix iteration order, Unity cleanup gap, and locale-dependent formatting
Nightwalker743 Mar 22, 2026
1ac4d72
Fix disk-space check bypass and log flat-file collisions
Nightwalker743 Mar 22, 2026
ba96bd9
Make flat-file collision resolution deterministic
Nightwalker743 Mar 22, 2026
ecf111d
Fix space check bypass, partial cleanup, foreign entry safety, and de…
Nightwalker743 Mar 22, 2026
c5558be
Use extractGameIdFromContainerId for workshop delete game-tree cleanup
Nightwalker743 Mar 22, 2026
7c086dd
Remove relative mouse movement and ControlsProfile binding fix
Nightwalker743 Mar 22, 2026
0d3067e
Use StringUtils.formatBytes for download progress and move KNOWN_EXTE…
Nightwalker743 Mar 24, 2026
bac8a26
Workshop dialog enhancements, OOM fix, filetype broadening, and bug f…
Nightwalker743 Mar 26, 2026
370c4d6
Merge remote-tracking branch 'upstream/master' into New-Workshop-Impl…
Nightwalker743 Mar 26, 2026
d4c7285
Clean up dead workshop strings and fix duplicate Timber import from m…
Nightwalker743 Mar 26, 2026
1bd82ee
Address PR review: move workshop state to SteamApp DB, download-on-sa…
Nightwalker743 Mar 29, 2026
7307010
Merge upstream/master: resolve conflicts, renumber DB v15-v18
Nightwalker743 Mar 29, 2026
f655e3a
Merge remote-tracking branch 'upstream/master' into New-Workshop-Impl…
Nightwalker743 Mar 30, 2026
e8958e3
Add workshop strings to all 13 translation files
Nightwalker743 Mar 30, 2026
7ad1e05
Update migration notes in PluviaDatabase.kt
Nightwalker743 Mar 30, 2026
ad437d4
Fix DB migration: use upstream 17.json, add no-op Migration(16,17)
Nightwalker743 Mar 30, 2026
577f681
Remove unnecessary ROOM_MIGRATION_V16_to_V17
Nightwalker743 Mar 30, 2026
0143850
Address PR review feedback: offline check, fetchResult guard, revert …
Nightwalker743 Mar 31, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
179 changes: 179 additions & 0 deletions app/src/main/java/app/gamenative/ui/PluviaMain.kt
Original file line number Diff line number Diff line change
Expand Up @@ -97,6 +97,7 @@ import app.gamenative.utils.UpdateChecker
import app.gamenative.utils.UpdateInfo
import app.gamenative.utils.UpdateInstaller
import app.gamenative.utils.LaunchDependencies
import app.gamenative.workshop.WorkshopManager
import com.google.android.play.core.splitcompat.SplitCompat
import com.winlator.container.Container
import com.winlator.container.ContainerData
Expand All @@ -106,6 +107,7 @@ import com.winlator.xenvironment.ImageFs
import com.winlator.xenvironment.ImageFsInstaller
import `in`.dragonbra.javasteam.protobufs.steamclient.SteammessagesClientObjects.ECloudPendingRemoteOperation
import java.io.File
import java.util.Locale
import java.util.Date
import java.util.EnumSet
import kotlin.reflect.KFunction2
Expand Down Expand Up @@ -1039,6 +1041,26 @@ fun PluviaMain(
openContainerConfigForAppId = null
}
},
onDeleteWorkshopMods = {
scope.launch {
try {
withContext(Dispatchers.IO) {
val appIdInt = appId.toIntOrNull()
val gameRootDir = appIdInt?.let { SteamService.getAppDirPath(it) }
?.let { File(it) }
val gameName = appIdInt?.let { SteamService.getAppInfoOf(it)?.name } ?: ""
Comment thread
cubic-dev-ai[bot] marked this conversation as resolved.
Outdated
WorkshopManager.deleteWorkshopMods(
context, appId,
gameRootDir, gameName,
)
}
SnackbarManager.show("Workshop mods deleted")
} catch (e: Exception) {
Timber.e(e, "Failed to delete workshop mods")
SnackbarManager.show("Failed to delete workshop mods")
}
}
},
)
}
}
Expand Down Expand Up @@ -1740,6 +1762,163 @@ fun preLaunchApp(
val prefixToPath: (String) -> String = { prefix ->
PathType.from(prefix).toAbsPath(context, gameId, SteamService.userSteamId!!.accountID)
}

// Workshop mod sync: download subscribed mods and configure symlinks
if (container.isWorkshopMods) {
try {
setLoadingMessage("Checking Workshop mods...")
setLoadingProgress(-1f)
val steamClient = SteamService.instance?.steamClient
val steamId = SteamService.userSteamId
if (steamClient != null && steamId != null) {
val imageFs = ImageFs.find(context)
val winePrefix = imageFs.wineprefix
val fetchResult = WorkshopManager.getSubscribedItems(gameId, steamClient, steamId)
val items = fetchResult.items
if (items.isNotEmpty()) {
Timber.tag("Workshop").i("Found ${items.size} subscribed workshop items for appId=$gameId")
val workshopContentDir = WorkshopManager.getWorkshopContentDir(winePrefix, gameId)

if (fetchResult.isComplete) {
WorkshopManager.cleanupUnsubscribedItems(items, workshopContentDir)
} else {
Timber.tag("Workshop").w(
"Subscription fetch incomplete, skipping cleanup to preserve existing mods"
)
}
val itemsToSync = WorkshopManager.getItemsNeedingSync(items, workshopContentDir)

if (itemsToSync.isNotEmpty()) {
// Check available disk space before downloading
// Use 2x safety margin to account for LZMA decompression
// and CKM extraction creating temp files alongside originals
val requiredBytes = itemsToSync.sumOf { it.fileSizeBytes } * 2
// usableSpace returns 0 for non-existent dirs; walk up to an existing ancestor.
// Use -1L sentinel so a truly-full volume (0 bytes) still triggers the guard.
val spaceDir = generateSequence(workshopContentDir) { it.parentFile }
.firstOrNull { it.exists() }
val availableBytes = spaceDir?.usableSpace ?: -1L
if (requiredBytes > 0 && availableBytes >= 0 && requiredBytes > availableBytes) {
val reqMB = String.format(Locale.US, "%.0f", requiredBytes / 1_048_576.0)
val avlMB = String.format(Locale.US, "%.0f", availableBytes / 1_048_576.0)
Timber.tag("Workshop").e(
"Insufficient disk space: need ${reqMB}MB, have ${avlMB}MB"
)
SnackbarManager.show(
"Not enough space for workshop mods (need ${reqMB} MB, have ${avlMB} MB)"
)
} else {
Timber.tag("Workshop").i("Downloading ${itemsToSync.size} workshop items...")
setLoadingMessage("Downloading Workshop Mods (0/${itemsToSync.size})")
val licenses = SteamService.getLicensesFromDb()

// Track item-level progress so byte callbacks can include it
var currentCompleted = 0
var currentTitle = itemsToSync.firstOrNull()?.title ?: ""

val successCount = WorkshopManager.downloadItems(
items = itemsToSync,
steamClient = steamClient,
licenses = licenses,
workshopContentDir = workshopContentDir,
onItemProgress = { completed, total, title ->
currentCompleted = completed
currentTitle = title
Timber.tag("Workshop").d("Progress: $completed/$total - $title")
setLoadingMessage("Downloading Workshop Mods ($completed/$total)\n$title")
},
onBytesProgress = { downloaded, total ->
if (total > 0) {
setLoadingProgress(downloaded.toFloat() / total.toFloat())
val dlMB = String.format(Locale.US, "%.1f", downloaded / 1_048_576.0)
val totalMB = String.format(Locale.US, "%.1f", total / 1_048_576.0)
setLoadingMessage(
"Downloading Workshop Mods ($currentCompleted/${itemsToSync.size})\n" +
"$currentTitle\n" +
"${dlMB} MB / ${totalMB} MB"
)
}
},
onOverallProgress = { progress ->
Timber.tag("Workshop").d("Overall: ${(progress * 100).toInt()}%")
},
)
Comment on lines +1933 to +1943

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I believe we already have stringUtils for doing calculations for download progress. Best check there! (Or network utils, I forget exactly).

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I'll have a look tonight, thanks for the suggestion!


val failedCount = itemsToSync.size - successCount
if (failedCount > 0) {
Timber.tag("Workshop").w("$failedCount workshop mod(s) failed to download")
SnackbarManager.show(
"$failedCount workshop mod(s) failed to download"
)
}

WorkshopManager.fixItemFileNames(itemsToSync, workshopContentDir)
}
}

// Post-processing runs every launch (not just after downloads) so
// that files from a previous broken run get fixed retroactively.
// Order matters: decompress LZMA first so fixFileExtensions can
// read the real magic bytes (e.g. GMAD) instead of LZMA's 0x5D.
WorkshopManager.extractCkmFiles(workshopContentDir)
WorkshopManager.decompressLzmaFiles(workshopContentDir)
WorkshopManager.fixFileExtensions(workshopContentDir)

WorkshopManager.updateMarkerTimestamps(items, workshopContentDir)

setLoadingMessage("Configuring Workshop mods...")
setLoadingProgress(-1f)
val gameRootDir = File(SteamService.getAppDirPath(gameId))
val gameName = SteamService.getAppInfoOf(gameId)?.name ?: ""
WorkshopManager.configureModSymlinks(
gameRootDir = gameRootDir,
workshopContentDir = workshopContentDir,
items = items,
winePrefix = winePrefix,
gameName = gameName,
)
Timber.tag("Workshop").i("Workshop mod sync complete for appId=$gameId")
} else if (!fetchResult.succeeded) {
// Fetch failed and returned no items — don't wipe existing mods.
// Still configure symlinks so previously downloaded mods work offline.
Timber.tag("Workshop").w(
"Workshop fetch failed for appId=$gameId, preserving existing on-disk mods"
)
val workshopContentDir = WorkshopManager.getWorkshopContentDir(winePrefix, gameId)
if (workshopContentDir.exists()) {
setLoadingMessage("Configuring Workshop mods...")
setLoadingProgress(-1f)
val gameRootDir = File(SteamService.getAppDirPath(gameId))
val gameName = SteamService.getAppInfoOf(gameId)?.name ?: ""
WorkshopManager.configureModSymlinks(
gameRootDir = gameRootDir,
workshopContentDir = workshopContentDir,
items = emptyList(),
winePrefix = winePrefix,
gameName = gameName,
)
}
} else {
Timber.tag("Workshop").d("No subscribed workshop items for appId=$gameId")
Comment thread
cubic-dev-ai[bot] marked this conversation as resolved.
Outdated
// Clean up previously downloaded mods and installed symlinks/copies
val gameRootDir = File(SteamService.getAppDirPath(gameId))
val gameName = SteamService.getAppInfoOf(gameId)?.name ?: ""
WorkshopManager.deleteWorkshopMods(
context = context,
containerId = gameId.toString(),
gameRootDir = gameRootDir,
gameName = gameName,
)
Timber.tag("Workshop").i("Cleaned up workshop content for appId=$gameId")
}
} else {
Timber.tag("Workshop").w("Steam client or Steam ID not available, skipping workshop sync")
}
} catch (e: Exception) {
Timber.tag("Workshop").e(e, "Workshop mod sync failed, continuing without mods")
}
}

setLoadingMessage("Syncing cloud saves")
setLoadingProgress(-1f)
val postSyncInfo = SteamService.beginLaunchApp(
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -145,6 +145,7 @@ fun ContainerConfigDialog(
initialConfig: ContainerData = ContainerData(),
onDismissRequest: () -> Unit,
onSave: (ContainerData) -> Unit,
onDeleteWorkshopMods: (() -> Unit)? = null,
) {
if (visible) {
val context = LocalContext.current
Expand Down Expand Up @@ -1127,7 +1128,7 @@ fun ContainerConfigDialog(
.verticalScroll(scrollState)
.weight(1f),
) {
if (selectedTab == 0) GeneralTabContent(state, nonzeroResolutionError, aspectResolutionError)
if (selectedTab == 0) GeneralTabContent(state, nonzeroResolutionError, aspectResolutionError, onDeleteWorkshopMods)
if (selectedTab == 1) GraphicsTabContent(state)
if (selectedTab == 2) EmulationTabContent(state)
if (selectedTab == 3) ControllerTabContent(state, default)
Expand Down

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I'd recommend removing the relative mouse functionality & controlsPorfile bindng changes and put them into a different PR.

It makes this PR more difficult to review and overall, we should have 1 intent per PR.

@Nightwalker743 Nightwalker743 Mar 22, 2026

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Alright, should be good to go now, sorry about that!

@Nightwalker743 Nightwalker743 Mar 22, 2026

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@phobos665

Just FYI I feel like this disclaimer should be mentioned somewhere in the discord in regards to workshop support to hopefully help with "customer support" lol:

*** DISCLAIMER *** If workshop mods don't appear to work for you, you could try going to Open Container then navigating to C:/Program Files x86/Steam/steamapps/workshop/content/game's id/ and copy all of the mod folders there that are just a bunch of numbers then move them to wherever your game's mod folder is if there is one. Look up instructions on how to mod your game, mods might be in the A drive (your game's directory) in a Mods folder. Or it might be in C:/Users/xuser/Appdata then in either Local, LocalLow or Roaming somewhere but you have to find the game folder there yourself since it might be under the studio's name as an example. Workshop support can be very picky sometimes files need to be decompressed, or renamed to a different file type, so don't expect mods to work perfectly for every game. Some game's workshop support will not work without internet connection and/or steam client running, Portal 2 and Killing Floor are two examples.

That being said, these are games that I've confirmed work for me on a Fold 7:

Age of Empires 2 HD

Banished

Broforce

Command and Conquer Generals Zero Hour

Dont Starve Together

Garry's Mod

Going Medieval

Insurgency (mostly, issue with text in the UI being re-named. Singleplayer renamed to #GameUI_Singleplayer for instance. Still works though)

Left 4 Dead 2

Mount and Blade Warband

Rimworld

Star Wars Knights of the Old Republic 2

Starbound

Superliminal

Terraria

Skyrim

Tooth and Tail

Total Tank Simulator

Viscera Cleanup Detail

Will not work:

Portal 2

Killing Floor, it seems like the mods download correctly and are listed in the main menu but you can not click on "Steam Workshop content/mods", seemingly because you don't have an internet connection or steam isn't on.

Half Life 2, seems like it needs to be online too?? Strangely enough...

Men of War Assault Squad 2

Original file line number Diff line number Diff line change
Expand Up @@ -98,6 +98,13 @@ fun ControllerTabContent(state: ContainerConfigState, default: Boolean) {
state = config.shooterMode,
onCheckedChange = { state.config.value = config.copy(shooterMode = it) },
)
SettingsSwitch(
colors = settingsTileColors(),
title = { Text(text = stringResource(R.string.relative_mouse_movement)) },
subtitle = { Text(text = stringResource(R.string.relative_mouse_movement_description)) },
state = config.relativeMouseMovement,
onCheckedChange = { state.config.value = config.copy(relativeMouseMovement = it) },
Comment thread
cubic-dev-ai[bot] marked this conversation as resolved.
Outdated
)
SettingsListDropdown(
colors = settingsTileColors(),
title = { Text(text = stringResource(R.string.external_display_input)) },
Expand Down
41 changes: 41 additions & 0 deletions app/src/main/java/app/gamenative/ui/component/dialog/GeneralTab.kt
Original file line number Diff line number Diff line change
Expand Up @@ -43,6 +43,7 @@ fun GeneralTabContent(
state: ContainerConfigState,
nonzeroResolutionError: String,
aspectResolutionError: String,
onDeleteWorkshopMods: (() -> Unit)? = null,
) {
val config = state.config.value
val graphicsDrivers = state.graphicsDrivers.value
Expand Down Expand Up @@ -335,6 +336,46 @@ fun GeneralTabContent(
onCheckedChange = { state.config.value = config.copy(unpackFiles = it) },
)
}
SettingsSwitch(
colors = settingsTileColorsAlt(),
title = { Text(text = stringResource(R.string.workshop_mods)) },
subtitle = { Text(text = stringResource(R.string.workshop_mods_description)) },
state = config.workshopMods,
onCheckedChange = { state.config.value = config.copy(workshopMods = it) },
)
if (onDeleteWorkshopMods != null) {
var showDeleteConfirmation by rememberSaveable { mutableStateOf(false) }
if (showDeleteConfirmation) {
AlertDialog(
onDismissRequest = { showDeleteConfirmation = false },
title = { Text(text = stringResource(R.string.delete_workshop_mods)) },
text = { Text(text = stringResource(R.string.delete_workshop_mods_confirm)) },
confirmButton = {
TextButton(onClick = {
showDeleteConfirmation = false
onDeleteWorkshopMods()
state.config.value = state.config.value.copy(workshopMods = false)
}) {
Text(text = stringResource(R.string.delete))
}
},
dismissButton = {
TextButton(onClick = { showDeleteConfirmation = false }) {
Text(text = stringResource(R.string.cancel))
}
},
)
}
TextButton(
onClick = { showDeleteConfirmation = true },
modifier = Modifier.fillMaxWidth().padding(horizontal = 16.dp),
) {
Text(
text = stringResource(R.string.delete_workshop_mods),
color = androidx.compose.material3.MaterialTheme.colorScheme.error,
)
}
}

Copy link
Copy Markdown
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

we're adding workshop mods deletion in the general tab of edit container?
should likely go next to the manage dlc button in the settings toggle.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Screenshot_20260325_101921_GameNative

The delete workshop mods goes right under the "Enable Workshop Mods" toggle. Is that okay?

SettingsSwitch(
colors = settingsTileColorsAlt(),
title = { Text(text = stringResource(R.string.steam_offline_mode)) },
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -35,10 +35,12 @@ import app.gamenative.ui.data.GameDisplayInfo
import app.gamenative.ui.enums.AppOptionMenuType
import app.gamenative.utils.ContainerUtils
import app.gamenative.utils.createPinnedShortcut
import app.gamenative.workshop.WorkshopManager
import com.winlator.container.ContainerData
import java.io.File
import kotlin.text.Charsets
import kotlinx.coroutines.CoroutineScope
import timber.log.Timber
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.delay
import kotlinx.coroutines.launch
Expand Down Expand Up @@ -785,6 +787,32 @@ abstract class BaseAppScreen {
saveContainerConfig(context, libraryItem, it)
showConfigDialog = false
},
onDeleteWorkshopMods = if (libraryItem.gameSource == GameSource.STEAM) {
{
uiScope.launch {
try {
withContext(Dispatchers.IO) {
val gameRootDir = File(
app.gamenative.service.SteamService.getAppDirPath(
displayInfo.gameId
)
)
val gameName = displayInfo.name
WorkshopManager.deleteWorkshopMods(
context, libraryItem.appId,
gameRootDir, gameName,
)
}
SnackbarManager.show("Workshop mods deleted")
} catch (e: Exception) {
Timber.e(e, "Failed to delete workshop mods")
SnackbarManager.show("Failed to delete workshop mods")
}
}
}
} else {
null
},
)
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -1657,6 +1657,9 @@ fun XServerScreen(

// Set container-level shooter mode
setContainerShooterMode(container.isShooterMode)

// Set relative mouse movement from container setting
xServerView.getxServer().setRelativeMouseMovement(container.isRelativeMouseMovement)
}
PluviaApp.inputControlsView = icView

Expand Down
Loading
Loading