Skip to content
Merged
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
Original file line number Diff line number Diff line change
Expand Up @@ -108,6 +108,8 @@ import com.winlator.widget.TouchpadView
import com.winlator.widget.XServerView
import com.winlator.winhandler.WinHandler
import com.winlator.winhandler.WinHandler.PreferredInputApi
import com.winlator.winhandler.OnGetProcessInfoListener
import com.winlator.winhandler.ProcessInfo
import com.winlator.xconnector.UnixSocketConfig
import com.winlator.xenvironment.ImageFs
import com.winlator.xenvironment.XEnvironment
Expand All @@ -128,9 +130,14 @@ import com.winlator.xserver.ScreenInfo
import com.winlator.xserver.Window
import com.winlator.xserver.WindowManager
import com.winlator.xserver.XServer
import kotlinx.coroutines.CompletableDeferred
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.Job
import kotlinx.coroutines.delay
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
import kotlinx.coroutines.withTimeoutOrNull
import org.json.JSONException
import org.json.JSONObject
import timber.log.Timber
Expand All @@ -154,6 +161,50 @@ private const val ALWAYS_REEXTRACT = true
// Guard to prevent duplicate game_exited events when multiple exit triggers fire simultaneously
private val isExiting = AtomicBoolean(false)

private const val EXIT_PROCESS_TIMEOUT_MS = 30_000L
private const val EXIT_PROCESS_POLL_INTERVAL_MS = 1_000L
private const val EXIT_PROCESS_RESPONSE_TIMEOUT_MS = 2_000L
private val CORE_WINE_PROCESSES = setOf(
"wineserver",
"services",
"start",
"winhandler",
"tabtip",
"explorer",
"winedevice",
"svchost",
)

private fun normalizeProcessName(name: String): String {
val trimmed = name.trim().trim('"')
val base = trimmed.substringAfterLast('/').substringAfterLast('\\')
val lower = base.lowercase(Locale.getDefault())
return if (lower.endsWith(".exe")) lower.removeSuffix(".exe") else lower
}
Comment on lines +178 to +183
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 | 🟡 Minor

Use Locale.ROOT for process-name normalization.
Locale-dependent case folding can cause mismatches (e.g., Turkish locale). Use a stable locale for identifier normalization.

♻️ Suggested tweak
-    val lower = base.lowercase(Locale.getDefault())
+    val lower = base.lowercase(Locale.ROOT)
🤖 Prompt for AI Agents
In `@app/src/main/java/app/gamenative/ui/screen/xserver/XServerScreen.kt` around
lines 178 - 183, The process-name normalization uses locale-dependent case
folding; update normalizeProcessName to use a stable locale by replacing
Locale.getDefault() with Locale.ROOT when calling lowercase so identifiers are
normalized consistently (e.g., change the call in normalizeProcessName that
computes lower to use Locale.ROOT).


private fun extractExecutableBasename(path: String): String {
if (path.isBlank()) return ""
return normalizeProcessName(path)
}

private fun windowMatchesExecutable(window: Window, targetExecutable: String): Boolean {
if (targetExecutable.isBlank()) return false
val normalizedTarget = normalizeProcessName(targetExecutable)
val candidates = listOf(window.name, window.className)
return candidates.any { candidate ->
candidate.split('\u0000')
.asSequence()
.map { normalizeProcessName(it) }
.any { it == normalizedTarget }
}
}

private fun buildEssentialProcessAllowlist(): Set<String> {
val essentialServices = WineUtils.getEssentialServiceNames()
.map { normalizeProcessName(it) }
return (essentialServices + CORE_WINE_PROCESSES).toSet()
}

// TODO logs in composables are 'unstable' which can cause recomposition (performance issues)

@Composable
Expand Down Expand Up @@ -244,11 +295,14 @@ fun XServerScreen(

var win32AppWorkarounds: Win32AppWorkarounds? by remember { mutableStateOf(null) }
var physicalControllerHandler: PhysicalControllerHandler? by remember { mutableStateOf(null) }
var exitWatchJob: Job? by remember { mutableStateOf(null) }

DisposableEffect(Unit) {
onDispose {
physicalControllerHandler?.cleanup()
physicalControllerHandler = null
exitWatchJob?.cancel()
exitWatchJob = null
}
}
var isKeyboardVisible = false
Expand All @@ -260,6 +314,83 @@ fun XServerScreen(
var elementToEdit by remember { mutableStateOf<com.winlator.inputcontrols.ControlElement?>(null) }
var showPhysicalControllerDialog by remember { mutableStateOf(false) }

fun startExitWatchForUnmappedGameWindow(window: Window) {
val winHandler = xServerView?.getxServer()?.winHandler ?: return
if (exitWatchJob?.isActive == true) return
val targetExecutable = extractExecutableBasename(container.executablePath)
if (!windowMatchesExecutable(window, targetExecutable)) return

exitWatchJob = CoroutineScope(Dispatchers.IO).launch {
val allowlist = buildEssentialProcessAllowlist()
val previousListener = winHandler.getOnGetProcessInfoListener()
val lock = Any()
var pendingSnapshot: CompletableDeferred<List<ProcessInfo>?>? = null
var currentList = mutableListOf<ProcessInfo>()
var expectedCount = 0

val listener = OnGetProcessInfoListener { index, count, processInfo ->
previousListener?.onGetProcessInfo(index, count, processInfo)
synchronized(lock) {
val deferred = pendingSnapshot ?: return@synchronized
if (count == 0 && processInfo == null) {
if (!deferred.isCompleted) deferred.complete(null)
return@synchronized
}
if (index == 0) {
currentList = mutableListOf()
expectedCount = count
}
if (processInfo != null) {
currentList.add(processInfo)
}
if (currentList.size >= expectedCount && !deferred.isCompleted) {
deferred.complete(currentList.toList())
}
}
}

winHandler.setOnGetProcessInfoListener(listener)
try {
val startTime = System.currentTimeMillis()
while (System.currentTimeMillis() - startTime < EXIT_PROCESS_TIMEOUT_MS) {
val deferred = CompletableDeferred<List<ProcessInfo>?>()
synchronized(lock) {
pendingSnapshot = deferred
}
winHandler.listProcesses()
val snapshot = withTimeoutOrNull(EXIT_PROCESS_RESPONSE_TIMEOUT_MS) {
deferred.await()
}
if (snapshot != null) {
val hasNonEssential = snapshot.any {
!allowlist.contains(normalizeProcessName(it.name))
}
if (!hasNonEssential) {
withContext(Dispatchers.Main) {
exit(
winHandler,
PluviaApp.xEnvironment,
frameRating,
currentAppInfo,
container,
onExit,
navigateBack,
)
}
break
}
}
Comment thread
coderabbitai[bot] marked this conversation as resolved.
delay(EXIT_PROCESS_POLL_INTERVAL_MS)
}
} finally {
winHandler.setOnGetProcessInfoListener(previousListener)
synchronized(lock) {
pendingSnapshot = null
}
}
}
}

val gameBack: () -> Unit = gameBack@{
val imeVisible = ViewCompat.getRootWindowInsets(view)
?.isVisible(WindowInsetsCompat.Type.ime()) == true
Expand Down Expand Up @@ -550,7 +681,9 @@ fun XServerScreen(
if (!bootToContainer) {
renderer.setUnviewableWMClasses("explorer.exe")
// TODO: make 'force fullscreen' be an option of the app being launched
appLaunchInfo?.let { renderer.forceFullscreenWMClass = Paths.get(it.executable).name }
if (container.executablePath.isNotBlank()) {
renderer.forceFullscreenWMClass = Paths.get(container.executablePath).name
}
}
getxServer().windowManager.addOnWindowModificationListener(
object : WindowManager.OnWindowModificationListener {
Expand Down Expand Up @@ -613,6 +746,7 @@ fun XServerScreen(
"\n\tchildrenSize: ${window.children.size}",
)
changeFrameRatingVisibility(window, null)
startExitWatchForUnmappedGameWindow(window)
onWindowUnmapped?.invoke(window)
}
},
Expand Down
35 changes: 34 additions & 1 deletion app/src/main/java/com/winlator/core/WineUtils.java
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,10 @@
import org.json.JSONObject;

import java.io.File;
import java.util.ArrayList;
import java.util.Collections;
import java.util.Iterator;
import java.util.List;
import java.util.Locale;

import timber.log.Timber;
Expand Down Expand Up @@ -355,8 +358,38 @@ else if (identifier.equals("wmdecoder")) {
}
}

private static final String[] SERVICE_DEFAULTS = {
"BITS:3",
"Eventlog:2",
"HTTP:3",
"LanmanServer:3",
"NDIS:2",
"PlugPlay:2",
"RpcSs:3",
"scardsvr:3",
"Schedule:3",
"Spooler:3",
"StiSvc:3",
"TermService:3",
"winebus:3",
"winehid:3",
"Winmgmt:3",
"wuauserv:3",
};

public static List<String> getEssentialServiceNames() {
ArrayList<String> names = new ArrayList<>();
for (String service : SERVICE_DEFAULTS) {
int separator = service.indexOf(":");
if (separator > 0) {
names.add(service.substring(0, separator));
}
}
return Collections.unmodifiableList(names);
}

public static void changeServicesStatus(Container container, boolean onlyEssential) {
final String[] services = {"BITS:3", "Eventlog:2", "HTTP:3", "LanmanServer:3", "NDIS:2", "PlugPlay:2", "RpcSs:3", "scardsvr:3", "Schedule:3", "Spooler:3", "StiSvc:3", "TermService:3", "winebus:3", "winehid:3", "Winmgmt:3", "wuauserv:3"};
final String[] services = SERVICE_DEFAULTS;
File systemRegFile = new File(container.getRootDir(), ".wine/system.reg");

try (WineRegistryEditor registryEditor = new WineRegistryEditor(systemRegFile)) {
Expand Down