Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
17 commits
Select commit Hold shift + click to select a range
1515c2c
Add community config sharing (download/upload, voting, reporting, pre…
maxjivi05 Jun 18, 2026
e4bee40
CI: pass community HMAC secret and API base to release build
maxjivi05 Jun 18, 2026
b8d768b
Merge branch 'WinNative-Emu:main' into community-configs
maxjivi05 Jun 19, 2026
8590efb
Merge branch 'WinNative-Emu:main' into community-configs
maxjivi05 Jun 19, 2026
176d38a
Merge branch 'WinNative-Emu:main' into community-configs
maxjivi05 Jun 21, 2026
cf5f5f8
Merge branch 'WinNative-Emu:main' into community-configs
maxjivi05 Jun 21, 2026
d223fcc
Merge branch 'WinNative-Emu:main' into community-configs
maxjivi05 Jun 22, 2026
54b1362
Merge branch 'WinNative-Emu:main' into community-configs
maxjivi05 Jun 23, 2026
11600a4
Merge branch 'main' into community-configs
Xnick417x Jun 23, 2026
f43b792
Merge branch 'main' into community-configs
maxjivi05 Jun 29, 2026
90d58ff
Merge branch 'main' into community-configs
maxjivi05 Aug 2, 2026
0297e50
Community configs: settings registry, controller nav and hardening
maxjivi05 Aug 2, 2026
7515221
Community configs: report setup failures, scroll the header buttons
maxjivi05 Aug 2, 2026
03f0c3b
Community configs: negotiate the schema version down on upload
maxjivi05 Aug 2, 2026
c05d100
Center dialogs on the display instead of beside the camera cutout
maxjivi05 Aug 2, 2026
fc01aa3
Do not let an unset community secret silently blank the API base
maxjivi05 Aug 2, 2026
5dff6af
Fix chipset identity, spurious sign-in prompt, add delete confirmation
maxjivi05 Aug 3, 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
2 changes: 2 additions & 0 deletions .github/workflows/pr-ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -106,6 +106,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 @@ -97,6 +97,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 @@ -54,6 +54,9 @@ References/
*.hprof
android_sysvshm/build64/

# Community config HMAC secret (never commit)
tools/community_hmac.secret

# local build artifacts (FEX/Proton wcp)
dist/

Expand Down
20 changes: 20 additions & 0 deletions app/build.gradle
Original file line number Diff line number Diff line change
Expand Up @@ -131,6 +131,22 @@ def appVersionName = providers.gradleProperty("VERSION_NAME")
def coldClientVersionFile = rootProject.file("tools/gbe_fork.version")
def coldClientVersion = coldClientVersionFile.exists() ? coldClientVersionFile.text.trim() : "unknown"

def communityHmacFile = rootProject.file("tools/community_hmac.secret")
def communityHmacFromFile = communityHmacFile.exists() ? communityHmacFile.text.trim() : ""
def communityHmacFromEnv = providers.environmentVariable("COMMUNITY_HMAC_SECRET")
.getOrElse("").trim()
def communityHmacSecret = communityHmacFromFile ?: communityHmacFromEnv
def communityApiBaseOverride = providers.gradleProperty("COMMUNITY_API_BASE")
.orElse(providers.environmentVariable("COMMUNITY_API_BASE"))
.getOrElse("").trim()
def communityApiBase = communityApiBaseOverride ?: "https://api.winnative.dev/api/v1/"
if (!communityHmacSecret) {
logger.warn("WinNative: COMMUNITY_HMAC_SECRET is empty - community config sharing will be " +
"DISABLED in this build. Set tools/community_hmac.secret or export " +
"COMMUNITY_HMAC_SECRET. Note: GitHub does not pass secrets to pull_request runs " +
"from a forked repository.")
}

android {
namespace 'com.winlator.cmod'
compileSdk 35
Expand Down Expand Up @@ -158,6 +174,8 @@ android {
resConfigs "en", "da", "de", "es", "b+es+419", "fi", "fr", "hi", "it", "ja", "ko", "no", "pl", "pt", "pt-rBR", "ro", "ru", "sv", "th", "tr", "uk", "zh-rCN", "zh-rTW"

buildConfigField("String", "COLD_CLIENT_VERSION", "\"${coldClientVersion}\"")
buildConfigField("String", "COMMUNITY_HMAC_SECRET", "\"${communityHmacSecret}\"")
buildConfigField("String", "COMMUNITY_API_BASE", "\"${communityApiBase}\"")
buildConfigField "String", "RESHADE_CATALOG_URL", "\"https://raw.githubusercontent.com/nicholasx417/WinNative-Components/main/Reshade.json\""
}

Expand Down Expand Up @@ -389,3 +407,5 @@ tasks.register("checkKotlinFormatOnly") {
description = "Checks Kotlin formatting only."
dependsOn("spotlessKotlinCheck")
}

apply from: rootProject.file("gradle/community-settings-check.gradle")
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.** { *; }
258 changes: 258 additions & 0 deletions app/src/main/feature/community/CommunitySettings.kt
Original file line number Diff line number Diff line change
@@ -0,0 +1,258 @@
package com.winlator.cmod.feature.community

import com.winlator.cmod.runtime.container.Container
import com.winlator.cmod.runtime.container.Shortcut
import com.winlator.cmod.runtime.reshade.ReshadeConfigWriter
import com.winlator.cmod.runtime.reshade.ReshadeLoadout
import org.json.JSONArray
import org.json.JSONObject

object CommunitySettings {

const val SCHEMA_VERSION = 2
const val MIN_SCHEMA_VERSION = 1

enum class Source { CONTAINER, FALLBACK, SHORTCUT }

class Entry(
val key: String,
val source: Source,
val maxLength: Int,
val steamOnly: Boolean,
val validate: (String) -> Boolean,
val containerDefault: (Container) -> String,
)

private val FORBIDDEN = listOf(
"..", "\$(", "\${", "`", "&&", "||", "|", "<", ">", "\\x", "/*", "*/",
)

private val BOOL01 = Regex("^[01]$")
private val INT_SMALL = Regex("^\\d{1,5}$")
private val CONTROLLER_COUNT = Regex("^[1-4]$")
private val RES = Regex("^\\d{2,5}x\\d{2,5}$")
private val IDENT = Regex("^[A-Za-z0-9._+\\- ]{0,64}$")
private val LOCALE = Regex("^[A-Za-z0-9._@\\-]{0,32}$")
private val CPULIST = Regex("^(\\d{1,3})(,\\d{1,3})*$")
private val WINCOMPONENTS = Regex("^([a-z0-9]+=-?\\d{1,2})(,[a-z0-9]+=-?\\d{1,2})*$")
private val THEME = Regex("^[A-Za-z0-9_,#\\- ]{0,48}$")
private val FONT = Regex("^[A-Za-z0-9._\\- ]{0,128}$")
private val KVBLOB = Regex("^[A-Za-z0-9_.,;=+\\-()\\[\\]:/ ]{0,2048}$")
private val ENV_TOKEN = Regex("^[A-Za-z_][A-Za-z0-9_]*=[A-Za-z0-9_.:,+/=\\-]{0,256}$")
private val EXECARGS = Regex("^[A-Za-z0-9 _.,:=+/\"'\\-]{0,512}$")

private val AUDIO = setOf("alsa", "jack", "pulse", "pulseaudio")
private val PRESETS = setOf(
"COMPATIBILITY", "INTERMEDIATE", "PERFORMANCE", "STABILITY", "CUSTOM",
"MONOTHREAD", "PRIMUS", "",
)
private val STARTUP = setOf("0", "1", "2")
private val TOUCH_MODE = setOf("0", "1", "2")
private val ZINK_MODE = setOf("", "unix", "windows")
private val RESHADE_MODE = setOf("", ReshadeLoadout.MODE_SOLO, ReshadeLoadout.MODE_STACK)

private const val MAX_ENV_TOKENS = 64
private const val MAX_RESHADE_PARAMS = 128

fun isSafeText(value: String): Boolean {
if (value.any { it.code < 0x20 || it.code == 0x7f }) return false
val low = value.lowercase()
return FORBIDDEN.none { low.contains(it) }
}

private fun validEnvVars(value: String): Boolean {
if (value.isEmpty()) return true
val tokens = value.split(" ")
if (tokens.size > MAX_ENV_TOKENS) return false
return tokens.all { it.isEmpty() || ENV_TOKEN.matches(it) }
}

private fun validReshadeLoadout(value: String): Boolean {
if (value.isEmpty()) return true
return try {
val arr = JSONArray(value)
if (arr.length() > ReshadeLoadout.MAX_EFFECTS) return false
var ok = true
for (i in 0 until arr.length()) {
val item = arr.optJSONObject(i)
val name = item?.optString("name", "") ?: ""
if (item == null || name.isBlank() || !IDENT.matches(name) || !item.has("enabled")) {
ok = false
}
}
ok
} catch (e: Exception) {
false
}
}

private fun validReshadeParams(value: String): Boolean {
if (value.isEmpty()) return true
return try {
val root = JSONObject(value)
if (root.length() > ReshadeLoadout.MAX_EFFECTS) return false
var ok = true
for (name in root.keys()) {
val effect = root.optJSONObject(name)
if (!IDENT.matches(name) || effect == null || effect.length() > MAX_RESHADE_PARAMS) {
ok = false
continue
}
for (param in effect.keys()) {
if (!IDENT.matches(param) || effect.opt(param) !is Number) ok = false
}
}
ok
} catch (e: Exception) {
false
}
}

private fun entry(
key: String,
source: Source,
maxLength: Int,
validate: (String) -> Boolean,
steamOnly: Boolean = false,
containerDefault: (Container) -> String = { it.getExtra(key, "") ?: "" },
) = Entry(key, source, maxLength, steamOnly, validate, containerDefault)

private fun flag(value: Boolean) = if (value) "1" else "0"

val ENTRIES: List<Entry> = listOf(
entry("screenSize", Source.CONTAINER, 16, { RES.matches(it) }) { it.getScreenSize() ?: "" },
entry("refreshRate", Source.FALLBACK, 5, { INT_SMALL.matches(it) }),
entry("fpsLimit", Source.SHORTCUT, 5, { INT_SMALL.matches(it) }),
entry("audioDriver", Source.CONTAINER, 16, { it in AUDIO }) { it.getAudioDriver() ?: "" },
entry("midiSoundFont", Source.CONTAINER, 128, { FONT.matches(it) }) {
it.getMIDISoundFont() ?: ""
},
entry("graphicsDriver", Source.CONTAINER, 32, { IDENT.matches(it) }) {
it.getGraphicsDriver() ?: ""
},
entry("graphicsDriverConfig", Source.CONTAINER, 2048, { KVBLOB.matches(it) }) {
it.getGraphicsDriverConfig() ?: ""
},
entry("zinkMode", Source.CONTAINER, 16, { it in ZINK_MODE }) { it.getZinkMode() ?: "" },
entry("dxwrapper", Source.CONTAINER, 32, { IDENT.matches(it) }) { it.getDXWrapper() ?: "" },
entry("dxwrapperConfig", Source.CONTAINER, 2048, { KVBLOB.matches(it) }) {
it.getDXWrapperConfig() ?: ""
},
entry("swapRB", Source.CONTAINER, 1, { BOOL01.matches(it) }),
entry("sgsrEnabled", Source.SHORTCUT, 1, { BOOL01.matches(it) }),
entry("sgsrUpscaleMode", Source.SHORTCUT, 16, { IDENT.matches(it) }),
entry("sgsrSharpness", Source.SHORTCUT, 8, { IDENT.matches(it) }),
entry("wineVersion", Source.CONTAINER, 64, { IDENT.matches(it) }) { it.getWineVersion() ?: "" },
entry("emulator", Source.CONTAINER, 32, { IDENT.matches(it) }) { it.getEmulator() ?: "" },
entry("emulator64", Source.CONTAINER, 32, { IDENT.matches(it) }) { it.getEmulator64() ?: "" },
entry("useUnixLibs", Source.CONTAINER, 1, { BOOL01.matches(it) }) { flag(it.isUseUnixLibs) },
entry("lc_all", Source.CONTAINER, 32, { LOCALE.matches(it) }) { it.getLC_ALL() ?: "" },
entry("desktopTheme", Source.CONTAINER, 48, { THEME.matches(it) }) {
it.getDesktopTheme() ?: ""
},
entry("wincomponents", Source.CONTAINER, 512, { WINCOMPONENTS.matches(it) }) {
it.getWinComponents() ?: ""
},
entry("envVars", Source.CONTAINER, 4096, { validEnvVars(it) }) { it.getEnvVars() ?: "" },
entry("box64Version", Source.CONTAINER, 64, { IDENT.matches(it) }) {
it.getBox64Version() ?: ""
},
entry("box64Preset", Source.CONTAINER, 24, { it in PRESETS }) { it.getBox64Preset() ?: "" },
entry("fexcoreVersion", Source.CONTAINER, 64, { IDENT.matches(it) }) {
it.getFEXCoreVersion() ?: ""
},
entry("fexcorePreset", Source.CONTAINER, 24, { it in PRESETS }) {
it.getFEXCorePreset() ?: ""
},
entry("startupSelection", Source.CONTAINER, 1, { it in STARTUP }) {
it.getStartupSelection().toInt().toString()
},
entry("execArgs", Source.CONTAINER, 512, { EXECARGS.matches(it) }) { it.getExecArgs() ?: "" },
entry("fullscreenStretched", Source.CONTAINER, 1, { BOOL01.matches(it) }) {
flag(it.isFullscreenStretched)
},
entry("cpuList", Source.CONTAINER, 96, { CPULIST.matches(it) }) { it.getCPUList(true) ?: "" },
entry("cpuListWoW64", Source.CONTAINER, 96, { CPULIST.matches(it) }) {
it.getCPUListWoW64(true) ?: ""
},
entry("inputType", Source.CONTAINER, 5, { INT_SMALL.matches(it) }) {
it.getInputType().toString()
},
entry("exclusiveXInput", Source.CONTAINER, 1, { BOOL01.matches(it) }) {
flag(it.isExclusiveXInput)
},
entry("numControllers", Source.SHORTCUT, 1, { CONTROLLER_COUNT.matches(it) }),
entry("disableXinput", Source.SHORTCUT, 1, { BOOL01.matches(it) }),
entry("simTouchScreen", Source.SHORTCUT, 1, { BOOL01.matches(it) }),
entry("screenTouchMode", Source.SHORTCUT, 1, { it in TOUCH_MODE }),
entry(ReshadeConfigWriter.EXTRA_LOADOUT, Source.CONTAINER, 512, { validReshadeLoadout(it) }),
entry(ReshadeConfigWriter.EXTRA_MODE, Source.CONTAINER, 8, { it in RESHADE_MODE }),
entry(ReshadeConfigWriter.EXTRA_PARAMS, Source.CONTAINER, 4096, { validReshadeParams(it) }),
entry(ReshadeConfigWriter.EXTRA_EFFECT, Source.CONTAINER, 64, { IDENT.matches(it) }),
entry("useColdClient", Source.CONTAINER, 1, { BOOL01.matches(it) }, steamOnly = true) {
flag(it.isUseColdClient)
},
entry("unpackFiles", Source.CONTAINER, 1, { BOOL01.matches(it) }, steamOnly = true) {
flag(it.isUnpackFiles)
},
entry("useSteamInput", Source.CONTAINER, 1, { BOOL01.matches(it) }, steamOnly = true),
entry("steamOfflineMode", Source.CONTAINER, 1, { BOOL01.matches(it) }, steamOnly = true) {
flag(it.isSteamOfflineMode)
},
entry("runtimePatcher", Source.CONTAINER, 1, { BOOL01.matches(it) }, steamOnly = true) {
flag(it.isRuntimePatcher)
},
)

val BY_KEY: Map<String, Entry> = ENTRIES.associateBy { it.key }

val KEYS: Set<String> = BY_KEY.keys

private val ADDED_IN_V2: Set<String> = setOf(
"zinkMode",
"useUnixLibs",
"screenTouchMode",
ReshadeConfigWriter.EXTRA_LOADOUT,
ReshadeConfigWriter.EXTRA_MODE,
ReshadeConfigWriter.EXTRA_PARAMS,
ReshadeConfigWriter.EXTRA_EFFECT,
)

fun keysForSchema(version: Int): Set<String> =
if (version >= 2) KEYS else KEYS - ADDED_IN_V2

val NON_PORTABLE: Set<String> = setOf(
"custom_name",
"container_id",
"use_container_defaults",
"controlsProfile",
"gestureProfileId",
"launch_exe_path",
"custom_exe",
"custom_game_folder",
"cloud_force_download",
"launchRealSteam",
"steamType",
)

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

fun effective(shortcut: Shortcut, entry: Entry): String {
val container: Container? = shortcut.container
val fromContainer = if (container != null) entry.containerDefault(container) else ""
return when (entry.source) {
Source.CONTAINER -> shortcut.getSettingExtra(entry.key, fromContainer) ?: ""
Source.FALLBACK -> shortcut.getExtra(entry.key, "").ifEmpty { fromContainer }
Source.SHORTCUT -> shortcut.getExtra(entry.key, "")
}
}

fun accepts(entry: Entry, value: String): Boolean =
value.length <= entry.maxLength && isSafeText(value) && entry.validate(value)

fun accepts(key: String, value: String): Boolean {
val entry = BY_KEY[key] ?: return false
return accepts(entry, value)
}
}
Loading
Loading