Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
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
2 changes: 2 additions & 0 deletions .github/workflows/pr-ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -90,6 +90,8 @@ jobs:
KEYSTORE_PASSWORD: ${{ secrets.KEYSTORE_PASSWORD }}
KEY_ALIAS: ${{ secrets.KEY_ALIAS }}
KEY_PASSWORD: ${{ secrets.KEY_PASSWORD }}
COMMUNITY_HMAC_SECRET: ${{ secrets.COMMUNITY_HMAC_SECRET }}
COMMUNITY_API_BASE: ${{ secrets.COMMUNITY_API_BASE }}
GRADLE_OPTS: -Xmx4g -XX:+UseG1GC
run: |
export KEYSTORE_FILE="${{ github.workspace }}/release.jks"
Expand Down
2 changes: 2 additions & 0 deletions .github/workflows/tag-apk-artifacts.yml
Original file line number Diff line number Diff line change
Expand Up @@ -84,6 +84,8 @@ jobs:
KEYSTORE_PASSWORD: ${{ secrets.KEYSTORE_PASSWORD }}
KEY_ALIAS: ${{ secrets.KEY_ALIAS }}
KEY_PASSWORD: ${{ secrets.KEY_PASSWORD }}
COMMUNITY_HMAC_SECRET: ${{ secrets.COMMUNITY_HMAC_SECRET }}
COMMUNITY_API_BASE: ${{ secrets.COMMUNITY_API_BASE }}
GRADLE_OPTS: -Xmx4g -XX:+UseG1GC
VERSION_NAME: ${{ github.ref_name }}
run: |
Expand Down
3 changes: 3 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -53,3 +53,6 @@ signing.properties
References/
*.hprof
android_sysvshm/build64/

# Community config HMAC secret (never commit)
tools/community_hmac.secret
12 changes: 12 additions & 0 deletions app/build.gradle
Original file line number Diff line number Diff line change
Expand Up @@ -77,6 +77,16 @@ def appVersionName = providers.gradleProperty("VERSION_NAME")
def coldClientVersionFile = rootProject.file("tools/gbe_fork.version")
def coldClientVersion = coldClientVersionFile.exists() ? coldClientVersionFile.text.trim() : "unknown"

// Community config-sharing HMAC secret (git-ignored file) + API base URL.
// The secret is NEVER committed; it lives only in tools/community_hmac.secret
// (or the COMMUNITY_HMAC_SECRET env var) and is embedded in the signed APK.
def communityHmacFile = rootProject.file("tools/community_hmac.secret")
def communityHmacSecret = communityHmacFile.exists() ? communityHmacFile.text.trim()
: (System.getenv("COMMUNITY_HMAC_SECRET") ?: "")
def communityApiBase = providers.gradleProperty("COMMUNITY_API_BASE")
.orElse(providers.environmentVariable("COMMUNITY_API_BASE"))
.getOrElse("https://api.winnative.dev/api/v1/")

android {
namespace 'com.winlator.cmod'
compileSdk 35
Expand Down Expand Up @@ -104,6 +114,8 @@ android {
resConfigs "en", "da", "de", "es", "fr", "hi", "it", "ko", "pl", "pt-rBR", "ro", "ru", "uk", "zh-rCN", "zh-rTW"

buildConfigField("String", "COLD_CLIENT_VERSION", "\"${coldClientVersion}\"")
buildConfigField("String", "COMMUNITY_HMAC_SECRET", "\"${communityHmacSecret}\"")
buildConfigField("String", "COMMUNITY_API_BASE", "\"${communityApiBase}\"")
}

androidResources {
Expand Down
7 changes: 7 additions & 0 deletions app/proguard-rules.pro
Original file line number Diff line number Diff line change
Expand Up @@ -103,3 +103,10 @@
-dontwarn androidx.window.sidecar.SidecarInterface
-dontwarn androidx.window.sidecar.SidecarProvider
-dontwarn androidx.window.sidecar.SidecarWindowLayoutInfo

# Community config sharing: keep kotlinx.serialization DTOs + generated
# serializers so JSON (de)serialization survives R8 in release builds.
-keepclassmembers class com.winlator.cmod.feature.community.net.** {
*** Companion;
}
-keep,includedescriptorclasses class com.winlator.cmod.feature.community.net.** { *; }
122 changes: 122 additions & 0 deletions app/src/main/feature/community/ComponentChecker.kt
Original file line number Diff line number Diff line change
@@ -0,0 +1,122 @@
package com.winlator.cmod.feature.community

import android.content.Context
import com.winlator.cmod.R
import com.winlator.cmod.feature.settings.DXVKConfigUtils
import com.winlator.cmod.feature.settings.GraphicsDriverConfigUtils
import com.winlator.cmod.runtime.content.AdrenotoolsManager
import com.winlator.cmod.runtime.content.ContentProfile
import com.winlator.cmod.runtime.content.ContentsManager
import com.winlator.cmod.runtime.system.GPUInformation
import com.winlator.cmod.runtime.wine.WineInfo
import org.json.JSONObject

/**
* Compares the components a downloaded config needs (Wine, DXVK, VKD3D, Box64,
* FEXCore) against what is actually installed via [ContentsManager], so the UI
* can show a "⚠ MISSING COMPONENT" warning before applying.
*
* Matching is deliberately lenient (treats anything it can't positively prove
* missing as present) to avoid false positives that would block a valid apply.
*/
object ComponentChecker {

data class Missing(val label: String)

private val SKIP = setOf("", "none", "system", "builtin", "auto", "wined3d")

fun findMissing(context: Context, contentsManager: ContentsManager, settings: JSONObject): List<Missing> {
contentsManager.syncContents()
val missing = mutableListOf<Missing>()

// Wine / Proton
val wineVer = settings.optString("wineVersion", "")
if (wineVer.lowercase() !in SKIP) {
val installed = installedNames(contentsManager, ContentProfile.ContentType.CONTENT_TYPE_WINE) +
installedNames(contentsManager, ContentProfile.ContentType.CONTENT_TYPE_PROTON)
// WineInfo resolves bundled/main versions; only flag if clearly a
// managed profile name that isn't installed.
val resolved = runCatching {
WineInfo.fromIdentifier(context, contentsManager, wineVer)

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P2 Badge Check Wine profile presence without fallback

When a config requests a Wine/Proton identifier that is not installed, this check treats it as present because WineInfo.fromIdentifier() is not an existence check: it falls back to the bundled MAIN_WINE_VERSION instead of returning null for unrecognized or missing identifiers. As a result, the missing-component dialog is skipped and the config is applied with the wrong Wine runtime for games that require a custom Wine/Proton build; please check the installed profile names directly before considering it resolved.

Useful? React with 👍 / 👎.

}.getOrNull()
if (resolved == null && !matches(wineVer, installed) && installed.isNotEmpty()) {
missing += Missing("Wine $wineVer")
}
}

// DXVK / VKD3D from dxwrapperConfig
val dxwrapper = settings.optString("dxwrapper", "")
if (dxwrapper.lowercase() !in SKIP) {
val cfg = DXVKConfigUtils.parseConfig(settings.optString("dxwrapperConfig", ""))
checkVersion(cfg.get("version"), "DXVK",
installedNames(contentsManager, ContentProfile.ContentType.CONTENT_TYPE_DXVK), missing)
checkVersion(cfg.get("vkd3dVersion"), "VKD3D",
installedNames(contentsManager, ContentProfile.ContentType.CONTENT_TYPE_VKD3D), missing)
}

// Box64 / WOWBox64
checkVersion(settings.optString("box64Version", ""), "Box64",
installedNames(contentsManager, ContentProfile.ContentType.CONTENT_TYPE_BOX64) +
installedNames(contentsManager, ContentProfile.ContentType.CONTENT_TYPE_WOWBOX64), missing)

// FEXCore
checkVersion(settings.optString("fexcoreVersion", ""), "FEXCore",
installedNames(contentsManager, ContentProfile.ContentType.CONTENT_TYPE_FEXCORE), missing)

// Graphics driver version (built-in wrapper version or installed custom
// adrenotools driver). The `version=` token in graphicsDriverConfig is
// the selected driver; if it's not available here, the config can't be
// applied faithfully -> report it.
val gdVersion = runCatching {
(GraphicsDriverConfigUtils.parseGraphicsDriverConfig(
settings.optString("graphicsDriverConfig", ""))["version"] ?: "").trim()
}.getOrDefault("")
if (gdVersion.isNotEmpty() && gdVersion.lowercase() !in SKIP &&
!graphicsDriverAvailable(context, gdVersion)) {
missing += Missing("Graphics driver \"$gdVersion\"")
}

return missing
}

private fun graphicsDriverAvailable(context: Context, version: String): Boolean {
// Built-in / system wrapper versions (filtered by GPU support).
runCatching {
val sys = context.resources.getStringArray(R.array.wrapper_graphics_driver_version_entries)
if (sys.any { it.equals(version, ignoreCase = true) }) {
return GPUInformation.isDriverSupported(version, context)
}
}
// Installed custom adrenotools drivers (the folder id == the version token).
return runCatching {
AdrenotoolsManager(context).enumarateInstalledDrivers()
?.any { it.equals(version, ignoreCase = true) } ?: false
}.getOrDefault(false)
}

private fun checkVersion(version: String?, label: String, installed: List<String>, out: MutableList<Missing>) {
val v = version?.trim() ?: ""
if (v.lowercase() in SKIP) return
if (installed.isEmpty()) {
// Nothing of this type installed at all -> it's needed but absent.
out += Missing("$label $v")
} else if (!matches(v, installed)) {
out += Missing("$label $v")
}
}

private fun installedNames(cm: ContentsManager, type: ContentProfile.ContentType): List<String> {
val list = cm.getProfiles(type) ?: return emptyList()
return list.filter { it.isInstalled }.flatMap {
listOf(it.verName ?: "", ContentsManager.getEntryName(it))
}.filter { it.isNotBlank() }
}

private fun matches(version: String, installed: List<String>): Boolean {
val v = version.lowercase()
return installed.any { name ->
val n = name.lowercase()
n == v || n.endsWith(v) || n.contains(v) || v.contains(n)
}
}
}
42 changes: 42 additions & 0 deletions app/src/main/feature/community/ConfigApplier.kt
Original file line number Diff line number Diff line change
@@ -0,0 +1,42 @@
package com.winlator.cmod.feature.community

import com.winlator.cmod.runtime.container.Shortcut
import org.json.JSONObject

/**
* Applies a downloaded settings document to the CURRENT shortcut only, as
* shortcut-level overrides. It never mutates the Container, other shortcuts, the
* Exec line, icon, artwork, or container_id -- so containers can't be broken.
* EVERY known setting is applied verbatim (including the graphics-driver version
* and dxvk/vkd3d/gpuName tokens); availability is gated beforehand by
* ComponentChecker, which blocks apply + shows MISSING COMPONENT if anything the
* config needs isn't installed.
*/
object ConfigApplier {

/** The only keys an applied config may write. Mirrors the serializer. */
private val ALLOWED = setOf(
"screenSize", "audioDriver", "midiSoundFont", "graphicsDriver",
"graphicsDriverConfig", "dxwrapper", "dxwrapperConfig", "swapRB",
"refreshRate", "fpsLimit", "sgsrEnabled", "sgsrUpscaleMode", "sgsrSharpness",
"wineVersion", "emulator", "emulator64", "lc_all", "desktopTheme",
"wincomponents", "envVars", "box64Version", "box64Preset",
"fexcoreVersion", "fexcorePreset", "startupSelection", "execArgs",
"fullscreenStretched", "cpuList", "cpuListWoW64", "inputType",
"exclusiveXInput", "numControllers", "disableXinput", "simTouchScreen",
"useColdClient", "unpackFiles", "useSteamInput", "steamOfflineMode",
"runtimePatcher",
)

fun apply(shortcut: Shortcut, settings: JSONObject) {
val keys = settings.keys()
while (keys.hasNext()) {
val key = keys.next()
if (key !in ALLOWED) continue
shortcut.putExtra(key, settings.optString(key, ""))
}
// Force shortcut-level resolution so the applied overrides take effect.
shortcut.putExtra("use_container_defaults", "0")
shortcut.saveData()
}
}
106 changes: 106 additions & 0 deletions app/src/main/feature/community/ConfigSerializer.kt
Original file line number Diff line number Diff line change
@@ -0,0 +1,106 @@
package com.winlator.cmod.feature.community

import com.winlator.cmod.runtime.container.Container
import com.winlator.cmod.runtime.container.Shortcut
import org.json.JSONObject

/**
* Serializes the *effective* value of every portable shortcut setting (resolving
* shortcut override -> container default via [Shortcut.getSettingExtra]) into a
* settings map for sharing. Device-local driver tokens (version=/gpuName=) are
* blanked so a downloader keeps their own GPU driver. PII / structural / local
* fields (paths, uuid, container_id, controlsProfile, artwork) are excluded.
*/
object ConfigSerializer {

fun serialize(shortcut: Shortcut): JSONObject {
val c = shortcut.container
val s = JSONObject()

fun put(key: String, value: String?) {
if (!value.isNullOrBlank()) s.put(key, value)

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P2 Badge Serialize disabled shortcut-only settings

This drops settings whose disabled state is represented by an absent or blank extra, such as sgsrEnabled, fpsLimit, and refreshRate. Since ConfigApplier.apply() only writes keys that are present and never clears existing allowed extras, applying a config where those options are off to a shortcut that currently has them on leaves the old values enabled, so the applied result is not the shared config; please encode explicit disabled values or clear absent allowed keys during apply.

Useful? React with 👍 / 👎.

}
fun eff(key: String, containerValue: String): String =
shortcut.getSettingExtra(key, containerValue)
fun extra(key: String, def: String): String = shortcut.getExtra(key, def)

// General / Display (explicit Java getter calls to avoid Kotlin
// property-name ambiguity for ALL-CAPS getters like getDXWrapper).
put("screenSize", eff("screenSize", c.getScreenSize()))
put("audioDriver", eff("audioDriver", c.getAudioDriver()))
put("midiSoundFont", eff("midiSoundFont", c.getMIDISoundFont()))
put("graphicsDriver", eff("graphicsDriver", c.getGraphicsDriver()))
put("graphicsDriverConfig", eff("graphicsDriverConfig", c.getGraphicsDriverConfig()))
put("dxwrapper", eff("dxwrapper", c.getDXWrapper()))
put("dxwrapperConfig", eff("dxwrapperConfig", c.getDXWrapperConfig()))
put("swapRB", extra("swapRB", ""))
put("refreshRate", extra("refreshRate", ""))
put("fpsLimit", extra("fpsLimit", ""))
put("sgsrEnabled", extra("sgsrEnabled", ""))
put("sgsrUpscaleMode", extra("sgsrUpscaleMode", ""))
put("sgsrSharpness", extra("sgsrSharpness", ""))

// Wine
put("wineVersion", eff("wineVersion", c.getWineVersion()))
put("emulator", eff("emulator", c.getEmulator()))
put("emulator64", eff("emulator64", c.getEmulator64()))
put("lc_all", eff("lc_all", c.getLC_ALL()))
put("desktopTheme", eff("desktopTheme", c.getDesktopTheme()))

// Components / Variables
put("wincomponents", eff("wincomponents", c.getWinComponents()))
put("envVars", eff("envVars", c.getEnvVars()))

// Advanced
put("box64Version", eff("box64Version", c.getBox64Version()))
put("box64Preset", eff("box64Preset", c.getBox64Preset()))
put("fexcoreVersion", eff("fexcoreVersion", c.getFEXCoreVersion()))
put("fexcorePreset", eff("fexcorePreset", c.getFEXCorePreset()))
put("startupSelection", eff("startupSelection", c.getStartupSelection().toString()))
put("execArgs", eff("execArgs", c.getExecArgs()))
put("fullscreenStretched", eff("fullscreenStretched", if (c.isFullscreenStretched()) "1" else "0"))
put("cpuList", eff("cpuList", c.getCPUList(true)))
put("cpuListWoW64", eff("cpuListWoW64", c.getCPUListWoW64(true)))

// Input
put("inputType", eff("inputType", c.getInputType().toString()))
put("exclusiveXInput", eff("exclusiveXInput", if (c.isExclusiveXInput()) "1" else "0"))
put("numControllers", extra("numControllers", ""))
put("disableXinput", extra("disableXinput", ""))
put("simTouchScreen", extra("simTouchScreen", ""))

// Steam (only for Steam games)
if (isSteam(shortcut)) {
put("useColdClient", eff("useColdClient", if (c.isUseColdClient()) "1" else "0"))
put("unpackFiles", eff("unpackFiles", if (c.isUnpackFiles()) "1" else "0"))
put("useSteamInput", extra("useSteamInput", ""))
put("steamOfflineMode", eff("steamOfflineMode", if (c.isSteamOfflineMode()) "1" else "0"))
put("runtimePatcher", eff("runtimePatcher", if (c.isRuntimePatcher()) "1" else "0"))
}
return s
}

fun gameKey(shortcut: Shortcut): String {
return when (storeOf(shortcut)) {
"STEAM" -> "steam:" + shortcut.getExtra("app_id", "").ifBlank { slug(shortcut.name) }
"GOG" -> "gog:" + shortcut.getExtra("gog_id", "").ifBlank { slug(shortcut.name) }
"EPIC" -> "epic:" + slug(shortcut.name)
else -> "name:" + slug(shortcut.name)
}.take(128)
}

fun storeOf(shortcut: Shortcut): String {
val raw = shortcut.getExtra("game_source", "").uppercase()
val allowed = setOf("STEAM", "EPIC", "GOG", "AMAZON", "UBISOFT", "EA", "BATTLENET")
return if (raw in allowed) raw else "CUSTOM"
}

private fun isSteam(shortcut: Shortcut): Boolean =
shortcut.getExtra("game_source", "").equals("steam", ignoreCase = true)

private fun slug(name: String): String {
val s = name.lowercase().map { if (it.isLetterOrDigit() || it == '-' || it == '.') it else '-' }
.joinToString("").trim('-')
return s.ifBlank { "game" }.take(100)
}
}
Loading