Skip to content
Merged
Show file tree
Hide file tree
Changes from 5 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
151 changes: 151 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 Down Expand Up @@ -1039,6 +1040,13 @@ fun PluviaMain(
openContainerConfigForAppId = null
}
},
onDeleteWorkshopMods = {
scope.launch {
withContext(Dispatchers.IO) {
WorkshopManager.deleteWorkshopMods(context, appId)
}
}
},
)
}
}
Expand Down Expand Up @@ -1740,6 +1748,149 @@ 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.succeeded) {
WorkshopManager.cleanupUnsubscribedItems(items, workshopContentDir)
Comment thread
coderabbitai[bot] marked this conversation as resolved.
Outdated
} 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
val availableBytes = workshopContentDir.usableSpace
if (requiredBytes > 0 && availableBytes > 0 && requiredBytes > availableBytes) {
Comment thread
cubic-dev-ai[bot] marked this conversation as resolved.
Outdated
val reqMB = String.format("%.0f", requiredBytes / 1_048_576.0)
val avlMB = String.format("%.0f", availableBytes / 1_048_576.0)
Comment thread
coderabbitai[bot] marked this conversation as resolved.
Outdated
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("%.1f", downloaded / 1_048_576.0)
val totalMB = String.format("%.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
}
} 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
40 changes: 40 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,45 @@ 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()
}) {
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,
)
}
}
Comment thread
coderabbitai[bot] marked this conversation as resolved.
Outdated
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,6 +35,7 @@ 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
Expand Down Expand Up @@ -785,6 +786,13 @@ abstract class BaseAppScreen {
saveContainerConfig(context, libraryItem, it)
showConfigDialog = false
},
onDeleteWorkshopMods = {
uiScope.launch {
withContext(Dispatchers.IO) {
WorkshopManager.deleteWorkshopMods(context, libraryItem.appId)
}
}
},
Comment thread
cubic-dev-ai[bot] marked this conversation as resolved.
Outdated
Comment thread
coderabbitai[bot] marked this conversation as resolved.
Outdated
)
}

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
5 changes: 5 additions & 0 deletions app/src/main/java/app/gamenative/utils/ContainerUtils.kt
Original file line number Diff line number Diff line change
Expand Up @@ -293,6 +293,7 @@ object ContainerUtils {
steamOfflineMode = container.isSteamOfflineMode(),
Comment thread
cubic-dev-ai[bot] marked this conversation as resolved.
useLegacyDRM = container.isUseLegacyDRM(),
unpackFiles = container.isUnpackFiles(),
workshopMods = container.isWorkshopMods,
suspendPolicy = container.suspendPolicy,
portraitMode = container.isPortraitMode,
enableXInput = enableX,
Expand All @@ -301,6 +302,7 @@ object ContainerUtils {
disableMouseInput = disableMouse,
touchscreenMode = touchscreenMode,
shooterMode = shooterMode,
relativeMouseMovement = container.isRelativeMouseMovement,
gestureConfig = gestureConfig,
externalDisplayMode = externalDisplayMode,
externalDisplaySwap = externalDisplaySwap,
Expand Down Expand Up @@ -378,6 +380,7 @@ object ContainerUtils {
"useLegacyDRM" -> value?.let { updatedData.copy(useLegacyDRM = it as? Boolean ?: updatedData.useLegacyDRM) } ?: updatedData
"steamOfflineMode" -> value?.let { updatedData.copy(steamOfflineMode = it as? Boolean ?: updatedData.steamOfflineMode) } ?: updatedData
"unpackFiles" -> value?.let { updatedData.copy(unpackFiles = it as? Boolean ?: updatedData.unpackFiles) } ?: updatedData
"workshopMods" -> value?.let { updatedData.copy(workshopMods = it as? Boolean ?: updatedData.workshopMods) } ?: updatedData
"suspendPolicy" -> value?.let { updatedData.copy(suspendPolicy = it as? String ?: updatedData.suspendPolicy) } ?: updatedData
"envVars" -> value?.let { updatedData.copy(envVars = it as? String ?: updatedData.envVars) } ?: updatedData
"cpuList" -> value?.let { updatedData.copy(cpuList = it as? String ?: updatedData.cpuList) } ?: updatedData
Expand Down Expand Up @@ -464,13 +467,15 @@ object ContainerUtils {
container.setDisableMouseInput(containerData.disableMouseInput)
container.setTouchscreenMode(containerData.touchscreenMode)
container.setShooterMode(containerData.shooterMode)
container.setRelativeMouseMovement(containerData.relativeMouseMovement)
container.setGestureConfig(containerData.gestureConfig)
container.setExternalDisplayMode(containerData.externalDisplayMode)
container.setExternalDisplaySwap(containerData.externalDisplaySwap)
container.setForceDlc(containerData.forceDlc)
container.setSteamOfflineMode(containerData.steamOfflineMode)
container.setUseLegacyDRM(containerData.useLegacyDRM)
container.setUnpackFiles(containerData.unpackFiles)
container.setWorkshopMods(containerData.workshopMods)

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.

⚠️ Potential issue | 🟠 Major

New containers still won’t inherit these two defaults.

This completes the per-container apply path, but the PrefManager-backed default path in this same file still omits relativeMouseMovement and workshopMods (getDefaultContainerData(), setDefaultContainerData(), and the default ContainerData(...) branch in createNewContainer()). Newly created containers will therefore still initialize both flags to false, even after the user changes the app’s default container config.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@app/src/main/java/app/gamenative/utils/ContainerUtils.kt` around lines 470 -
478, The PrefManager-backed default path is missing relativeMouseMovement and
workshopMods, so update getDefaultContainerData(), setDefaultContainerData(),
and the default ContainerData(...) branch inside createNewContainer() to
read/store/apply those two fields from the PrefManager defaults; ensure the
default ContainerData constructor call used when creating a new container
includes relativeMouseMovement and workshopMods so newly created containers
inherit the user-changed defaults set via setDefaultContainerData().

container.setSuspendPolicy(containerData.suspendPolicy)
container.setPortraitMode(containerData.portraitMode)
if (previousUnpackFiles != containerData.unpackFiles && containerData.unpackFiles) {
Expand Down
27 changes: 27 additions & 0 deletions app/src/main/java/app/gamenative/workshop/WorkshopItem.kt
Original file line number Diff line number Diff line change
@@ -0,0 +1,27 @@
package app.gamenative.workshop

/**
* Represents a subscribed Steam Workshop item with its metadata.
*/
data class WorkshopItem(
val publishedFileId: Long,
val appId: Int,
val title: String,
val fileSizeBytes: Long,
val manifestId: Long,
val timeUpdated: Long,
val fileUrl: String = "",
val fileName: String = "",
val previewUrl: String = "",
)

/**
* Wraps a subscription fetch result so callers can distinguish
* "user has no subscriptions" from "the fetch failed (network error, timeout)".
* When [succeeded] is false, callers should preserve existing on-disk mods
* instead of cleaning up based on an unreliable empty list.
*/
data class WorkshopFetchResult(
val items: List<WorkshopItem>,
val succeeded: Boolean,
)
Loading
Loading