diff --git a/app/src/main/assets/WN-launcher.exe b/app/src/main/assets/WN-launcher.exe new file mode 100755 index 000000000..7a30c9fe7 Binary files /dev/null and b/app/src/main/assets/WN-launcher.exe differ diff --git a/app/src/main/assets/container_pattern_common.tzst b/app/src/main/assets/container_pattern_common.tzst index 05e4f7c35..b85846a01 100644 Binary files a/app/src/main/assets/container_pattern_common.tzst and b/app/src/main/assets/container_pattern_common.tzst differ diff --git a/app/src/main/assets/wnsteam/bionic/steam.exe b/app/src/main/assets/wnsteam/bionic/steam.exe index 7260a95f5..4893ee1eb 100755 Binary files a/app/src/main/assets/wnsteam/bionic/steam.exe and b/app/src/main/assets/wnsteam/bionic/steam.exe differ diff --git a/app/src/main/cpp/WN-launcher/build.sh b/app/src/main/cpp/WN-launcher/build.sh new file mode 100755 index 000000000..85910a115 --- /dev/null +++ b/app/src/main/cpp/WN-launcher/build.sh @@ -0,0 +1,27 @@ +#!/usr/bin/env bash +# Cross-compile WN-launcher.exe: the wine /desktop=shell program. It launches +# the target and waits on it (so the session ends when the game exits), applies +# per-launch affinity, and with no args (or /idle) just sleeps forever so it can +# also serve as the background input service's keep-alive child. The Gradle build +# does NOT compile this — run this after editing, then rebuild the APK. +# +# Usage: ./build.sh +# Output: ../../assets/WN-launcher.exe +set -euo pipefail +cd "$(dirname "$0")" + +CC="${CC:-x86_64-w64-mingw32-gcc}" +STRIP="${STRIP:-x86_64-w64-mingw32-strip}" +OUT_FILE="../../assets/WN-launcher.exe" + +"$CC" -O2 -Wall -Wextra \ + -static -static-libgcc \ + -Wl,--subsystem,windows \ + -o "$OUT_FILE" \ + src/main.c \ + -lshell32 + +"$STRIP" "$OUT_FILE" + +echo "Built: $OUT_FILE ($(stat -c '%s' "$OUT_FILE") bytes)" +file "$OUT_FILE" diff --git a/app/src/main/cpp/WN-launcher/src/main.c b/app/src/main/cpp/WN-launcher/src/main.c new file mode 100644 index 000000000..96a444b35 --- /dev/null +++ b/app/src/main/cpp/WN-launcher/src/main.c @@ -0,0 +1,282 @@ +#include +#include +#include +#include +#include +#include + +// Counts real (visible, non-trivial) top-level windows on our desktop. +static BOOL CALLBACK countVisibleProc(HWND hwnd, LPARAM lparam) { + if (IsWindowVisible(hwnd)) { + RECT r; + if (GetWindowRect(hwnd, &r) && (r.right - r.left) > 1 && (r.bottom - r.top) > 1) { + (*(int *)lparam)++; + } + } + return TRUE; +} + +static int visibleWindowCount(void) { + int count = 0; + EnumWindows(countVisibleProc, (LPARAM)&count); + return count; +} + +// Waits for the game to exit, force-killing a leftover process once every window is gone. +static void waitForGame(HANDLE hProc) { + int sawWindow = 0; + int emptyTicks = 0; + for (;;) { + if (WaitForSingleObject(hProc, 1000) == WAIT_OBJECT_0) return; + if (visibleWindowCount() > 0) { + sawWindow = 1; + emptyTicks = 0; + } else if (sawWindow && ++emptyTicks >= 4) { + TerminateProcess(hProc, 0); + WaitForSingleObject(hProc, 5000); + return; + } + } +} + +// Continuously pins every later-spawned process to the given affinity mask. +static DWORD WINAPI affinityWatcherThread(LPVOID param) { + DWORD_PTR mask = (DWORD_PTR)param; + for (;;) { + DWORD self = GetCurrentProcessId(); + HANDLE snap = CreateToolhelp32Snapshot(TH32CS_SNAPPROCESS, 0); + if (snap != INVALID_HANDLE_VALUE) { + PROCESSENTRY32 pe; + pe.dwSize = sizeof(pe); + if (Process32First(snap, &pe)) { + do { + if (pe.th32ProcessID > self) { + HANDLE h = OpenProcess(PROCESS_SET_INFORMATION, FALSE, pe.th32ProcessID); + if (h) { + SetProcessAffinityMask(h, mask); + CloseHandle(h); + } + } + } while (Process32Next(snap, &pe)); + } + CloseHandle(snap); + } + Sleep(1000); + } + return 0; +} + +static HANDLE g_serviceJob = NULL; +static DWORD g_servicePid = 0; + +// Starts winhandler in a kill-on-close job so its whole tree dies the instant we exit. +static void startInputService(void) { + char cmd[] = "winhandler.exe WN-launcher.exe /idle"; + STARTUPINFOA si; + ZeroMemory(&si, sizeof(si)); + si.cb = sizeof(si); + PROCESS_INFORMATION pi; + ZeroMemory(&pi, sizeof(pi)); + + HANDLE job = CreateJobObjectA(NULL, NULL); + if (job) { + JOBOBJECT_EXTENDED_LIMIT_INFORMATION jeli; + ZeroMemory(&jeli, sizeof(jeli)); + jeli.BasicLimitInformation.LimitFlags = JOB_OBJECT_LIMIT_KILL_ON_JOB_CLOSE; + SetInformationJobObject(job, JobObjectExtendedLimitInformation, &jeli, sizeof(jeli)); + } + + if (CreateProcessA(NULL, cmd, NULL, NULL, FALSE, CREATE_SUSPENDED, NULL, NULL, &si, &pi)) { + if (job) AssignProcessToJobObject(job, pi.hProcess); + ResumeThread(pi.hThread); + g_serviceJob = job; + g_servicePid = pi.dwProcessId; + CloseHandle(pi.hThread); + CloseHandle(pi.hProcess); + } else if (job) { + CloseHandle(job); + } +} + +// Tears down the input service: closing the job kills winhandler; Toolhelp is the fallback. +static void stopInputService(void) { + if (g_serviceJob) { + CloseHandle(g_serviceJob); + g_serviceJob = NULL; + } + if (!g_servicePid) return; + DWORD pid = g_servicePid; + g_servicePid = 0; + HANDLE snap = CreateToolhelp32Snapshot(TH32CS_SNAPPROCESS, 0); + if (snap != INVALID_HANDLE_VALUE) { + PROCESSENTRY32 pe; + pe.dwSize = sizeof(pe); + if (Process32First(snap, &pe)) { + do { + if (pe.th32ParentProcessID == pid) { + HANDLE c = OpenProcess(PROCESS_TERMINATE, FALSE, pe.th32ProcessID); + if (c) { + TerminateProcess(c, 0); + CloseHandle(c); + } + } + } while (Process32Next(snap, &pe)); + } + CloseHandle(snap); + } + HANDLE h = OpenProcess(PROCESS_TERMINATE, FALSE, pid); + if (h) { + TerminateProcess(h, 0); + CloseHandle(h); + } +} + +int WINAPI WinMain(HINSTANCE hInstance, HINSTANCE hPrevInstance, + LPSTR lpCmdLine, int nShowCmd) { + (void)hInstance; + (void)hPrevInstance; + (void)lpCmdLine; + (void)nShowCmd; + + int argc = __argc; + char **argv = __argv; + + // Idle mode: stay resident as the input service's child; launch nothing. + if (argc <= 1 || _stricmp(argv[1], "/idle") == 0) { + Sleep(INFINITE); + return 0; + } + + // One-shot: pin every process named argv[2] to the mask in argv[3]. + if (argc == 4 && strcmp(argv[1], "/setaffinity") == 0) { + const char *name = argv[2]; + DWORD_PTR mask = (DWORD_PTR)strtoul(argv[3], NULL, 16); + HANDLE snap = CreateToolhelp32Snapshot(TH32CS_SNAPPROCESS, 0); + if (snap != INVALID_HANDLE_VALUE) { + PROCESSENTRY32 pe; + pe.dwSize = sizeof(pe); + if (Process32First(snap, &pe)) { + do { + if (_stricmp(pe.szExeFile, name) == 0) { + HANDLE h = OpenProcess(PROCESS_SET_INFORMATION, FALSE, pe.th32ProcessID); + if (h) { + SetProcessAffinityMask(h, mask); + CloseHandle(h); + } + } + } while (Process32Next(snap, &pe)); + } + CloseHandle(snap); + } + return 0; + } + + // Fire-and-forget: open argv[2] with the remaining args, do not wait. + if (argc >= 3 && strcmp(argv[1], "/exec") == 0) { + const char *file = argv[2]; + char params[2048]; + params[0] = '\0'; + for (int i = 3; i < argc; i++) { + if (i > 3) strncat(params, " ", sizeof(params) - 1 - strlen(params)); + strncat(params, argv[i], sizeof(params) - 1 - strlen(params)); + } + ShellExecuteA(NULL, "open", file, params[0] ? params : NULL, NULL, SW_SHOW); + return 0; + } + + // Default (shell) mode: [/affinity ] [/dir ] [args...]. + const char *dir = NULL; + const char *target = NULL; + long affinity = 0; + int i = 1; + for (; i < argc; i++) { + if (strcmp(argv[i], "/affinity") == 0) { + if (i + 1 < argc) { + affinity = strtol(argv[i + 1], NULL, 16); + i += 1; + } + } else if (strcmp(argv[i], "/dir") == 0) { + if (i + 1 < argc) { + dir = argv[i + 1]; + i += 1; + } + } else { + target = argv[i]; + i += 1; + break; + } + } + if (!target) return 1; + + startInputService(); + + char params[2048]; + params[0] = '\0'; + for (int j = i; j < argc; j++) { + strcat(params, "\""); + strcat(params, argv[j]); + strcat(params, "\" "); + } + + HANDLE gameJob = CreateJobObjectA(NULL, NULL); + if (gameJob) { + JOBOBJECT_EXTENDED_LIMIT_INFORMATION jeli; + ZeroMemory(&jeli, sizeof(jeli)); + jeli.BasicLimitInformation.LimitFlags = JOB_OBJECT_LIMIT_KILL_ON_JOB_CLOSE; + SetInformationJobObject(gameJob, JobObjectExtendedLimitInformation, &jeli, sizeof(jeli)); + } + + HANDLE hProc = NULL; + if (dir && (strchr(dir, '[') || strchr(dir, ']'))) { + char appPath[268]; + char cmdLine[4096]; + snprintf(appPath, sizeof(appPath), "%s\\%s", dir, target); + snprintf(cmdLine, sizeof(cmdLine), "\"%s\"", target); + if (params[0]) { + strncat(cmdLine, " ", sizeof(cmdLine) - 1 - strlen(cmdLine)); + strncat(cmdLine, params, sizeof(cmdLine) - 1 - strlen(cmdLine)); + } + STARTUPINFOA si; + ZeroMemory(&si, sizeof(si)); + si.cb = sizeof(si); + si.dwFlags = STARTF_USESHOWWINDOW; + si.wShowWindow = SW_SHOW; + PROCESS_INFORMATION pi; + ZeroMemory(&pi, sizeof(pi)); + if (CreateProcessA(appPath, cmdLine, NULL, NULL, FALSE, 0, NULL, dir, &si, &pi)) { + CloseHandle(pi.hThread); + hProc = pi.hProcess; + } + } + + if (!hProc) { + SHELLEXECUTEINFOA sei; + ZeroMemory(&sei, sizeof(sei)); + sei.cbSize = sizeof(sei); + sei.fMask = SEE_MASK_NOCLOSEPROCESS; + sei.lpVerb = "open"; + sei.lpFile = target; + sei.lpParameters = params[0] ? params : NULL; + sei.lpDirectory = dir; + sei.nShow = SW_SHOW; + ShellExecuteExA(&sei); + hProc = sei.hProcess; + } + + // Put the game (and everything it spawns) in the job so closing it kills the whole tree. + if (gameJob && hProc) AssignProcessToJobObject(gameJob, hProc); + + if (affinity > 0) { + HANDLE t = CreateThread(NULL, 0, affinityWatcherThread, + (LPVOID)(LONG_PTR)affinity, 0, NULL); + if (t) CloseHandle(t); + } + + if (hProc) { + waitForGame(hProc); + CloseHandle(hProc); + } + if (gameJob) CloseHandle(gameJob); + stopInputService(); + return 0; +} diff --git a/app/src/main/cpp/wn-steam-launcher/src/main.cpp b/app/src/main/cpp/wn-steam-launcher/src/main.cpp index e452cf14a..a5eb5be86 100644 --- a/app/src/main/cpp/wn-steam-launcher/src/main.cpp +++ b/app/src/main/cpp/wn-steam-launcher/src/main.cpp @@ -757,7 +757,8 @@ int main(int argc, char** argv) { const char* user = getenv("WN_STEAM_USERNAME"); const char* token = getenv("WN_STEAM_TOKEN"); uint64_t steamId = env_u64("WN_STEAM_STEAMID"); - const char* gameExe = (argc > 1) ? argv[1] : NULL; + const char* gameExe = getenv("WN_STEAM_GAME_EXE"); + if (!gameExe || !*gameExe) gameExe = (argc > 1) ? argv[1] : NULL; uint32_t appId = appIdStr ? (uint32_t) strtoul(appIdStr, NULL, 10) : 0; log_line("[wn-launcher] env appId=%u steamId=%llu user=%s exe=%s", @@ -775,8 +776,8 @@ int main(int argc, char** argv) { } else { log_line("[wn-launcher] token missing"); } - if (argc <= 1 || !gameExe || !*gameExe) { - log_line("[wn-launcher] no game exe passed on argv[1]"); + if (!gameExe || !*gameExe) { + log_line("[wn-launcher] no game exe passed (env WN_STEAM_GAME_EXE or argv[1])"); return 1; } diff --git a/app/src/main/runtime/display/XServerDisplayActivity.java b/app/src/main/runtime/display/XServerDisplayActivity.java index cf4a7d7f4..37b9d918a 100644 --- a/app/src/main/runtime/display/XServerDisplayActivity.java +++ b/app/src/main/runtime/display/XServerDisplayActivity.java @@ -247,6 +247,32 @@ public class XServerDisplayActivity extends FixedFontScaleAppCompatActivity { "rundll32", "cmd" )); + + // Session plumbing the task manager itself rides on; ending any of these from + // our task manager tears down the session (e.g. start.exe hosts winhandler). + private static final HashSet TASK_MANAGER_PROTECTED_PROCESSES = new HashSet<>(Arrays.asList( + "winhandler", + "wn-launcher", + "start", + "wineserver", + "winedevice", + "services", + "svchost", + "rpcss", + "plugplay", + "wineboot", + "winemenubuilder", + "conhost", + "explorer" + )); + + private static boolean isTaskManagerProtectedProcess(String name) { + if (name == null) return false; + String base = name.trim().toLowerCase(java.util.Locale.ROOT); + if (base.endsWith(".exe")) base = base.substring(0, base.length() - 4); + return TASK_MANAGER_PROTECTED_PROCESSES.contains(base); + } + private XServerSurfaceView xServerView; private InputControlsView inputControlsView; private boolean inputControlsRevealAllowed = false; @@ -292,6 +318,7 @@ public class XServerDisplayActivity extends FixedFontScaleAppCompatActivity { private final EnvVars envVars = new EnvVars(); // True when the chosen launch exe differs from Steam's configured entry: launcher skips Steam LaunchApp and CreateProcess'es the selected exe directly. Recomputed per launch. private boolean wnSteamDirectExeOverride = false; + private String wnSteamPlanWGameExe = null; private boolean firstTimeBoot = false; private SharedPreferences preferences; private boolean isMouseDisabled = false; @@ -1612,6 +1639,7 @@ public void onFramePresented(Window window, WindowManager.FrameSource source, in @Override public void onDestroyWindow(Window window) { changeFrameRatingVisibility(window, null); + scheduleCloseKill(window); } }); @@ -4803,6 +4831,12 @@ public void onTaskManagerCpuExpandedChanged(boolean expanded) { @Override public void onTaskManagerEndProcess(String name) { + if (isTaskManagerProtectedProcess(name)) { + android.widget.Toast.makeText(XServerDisplayActivity.this, + "Can't end " + name + " — required system process", + android.widget.Toast.LENGTH_SHORT).show(); + return; + } if (winHandler != null) winHandler.killProcess(name); } @@ -6736,6 +6770,9 @@ private void setupXEnvironment() throws PackageManager.NameNotFoundException { envVars.put("WN_STEAM_STEAMID", planWSid); envVars.put("WN_STEAM_TOKEN", planWTok); envVars.put("WN_STEAM_APPID", String.valueOf(bsAppId)); + if (wnSteamPlanWGameExe != null && !wnSteamPlanWGameExe.isEmpty()) { + envVars.put("WN_STEAM_GAME_EXE", wnSteamPlanWGameExe); + } if (wnSteamDirectExeOverride) { envVars.put("WN_STEAM_DIRECT_EXE", "1"); Log.i("XServerDisplayActivity", @@ -7161,6 +7198,32 @@ private void ensureWinePrefixReady() { } } + // A closed window whose process has no windows left after a grace period is a + // leftover (e.g. iexplore); terminate it. The delay keeps splash->main transitions safe. + private void scheduleCloseKill(Window window) { + if (window == null || xServer == null) return; + final int pid = window.getProcessId(); + if (pid <= 0) return; + timeoutHandler.postDelayed(() -> { + if (activityDestroyed.get() || xServer == null) return; + if (xServer.processHasApplicationWindow(pid)) return; + ProcessHelper.terminateProcess(pid); + Log.d("XServerDisplayActivity", "close-kill: SIGTERM pid " + pid + " (no windows left)"); + // Some processes trap SIGTERM or hang mid-shutdown; force-kill if it's still alive. + timeoutHandler.postDelayed(() -> { + if (activityDestroyed.get() || xServer == null) return; + if (isProcessAlive(pid) && !xServer.processHasApplicationWindow(pid)) { + ProcessHelper.killProcess(pid); + Log.d("XServerDisplayActivity", "close-kill: SIGKILL pid " + pid + " (survived SIGTERM)"); + } + }, 2000); + }, 2000); + } + + private static boolean isProcessAlive(int pid) { + return pid > 0 && new File("/proc/" + pid).exists(); + } + private void ensureWinePrefixEssentialFiles() { if (container == null) return; File containerWindowsDir = new File(container.getRootDir(), ".wine/drive_c/windows"); @@ -7215,6 +7278,12 @@ private void ensureWinePrefixEssentialFiles() { } } } + + File launcherExe = new File(containerWindowsDir, "WN-launcher.exe"); + if (!launcherExe.exists()) { + FileUtils.copy(this, "WN-launcher.exe", launcherExe); + Log.d("ContainerLaunch", "WN-launcher.exe staged: " + launcherExe.exists()); + } } private boolean ensureRequestedWineVersionInstalled() { @@ -8171,6 +8240,7 @@ private String getWineStartCommand(GuestProgramLauncherComponent launcherCompone int appId = Integer.parseInt(shortcut.getExtra("app_id")); // Reset per launch; set below once the launch exe is resolved. wnSteamDirectExeOverride = false; + wnSteamPlanWGameExe = null; String steamExtraArgs = appendSteamJoinConnect( com.winlator.cmod.feature.stores.steam.utils.SteamLaunchOptions .gameArgs(shortcut.getSettingExtra("execArgs", container.getExecArgs()))); @@ -8254,8 +8324,14 @@ private String getWineStartCommand(GuestProgramLauncherComponent launcherCompone .PrefManager.INSTANCE.getWnPlanW(); String wrapperExe = planW ? "steam.exe" : "wn-steam-helper.exe"; - args = "\"C:\\Program Files (x86)\\Steam\\" + wrapperExe - + "\" \"" + steamGameExe + "\"" + steamExtraArgs; + if (planW) { + wnSteamPlanWGameExe = steamGameExe; + args = "\"C:\\Program Files (x86)\\Steam\\" + wrapperExe + + "\"" + steamExtraArgs; + } else { + args = "\"C:\\Program Files (x86)\\Steam\\" + wrapperExe + + "\" \"" + steamGameExe + "\"" + steamExtraArgs; + } Log.d("XServerDisplayActivity", "Bionic Steam launch via " + wrapperExe + " (planW=" + planW + "): " + steamGameExe); @@ -8398,8 +8474,15 @@ && new File(storeInstallPath).exists()) { } } - if (!args.isEmpty() && !args.startsWith("winhandler.exe") && !args.startsWith("explorer")) { - return "winhandler.exe " + args; + if (!args.isEmpty() + && !args.startsWith("winhandler.exe") + && !args.startsWith("WN-launcher.exe") + && !args.startsWith("explorer")) { + String affinityArg = + taskAffinityMask != 0 + ? "/affinity " + Integer.toHexString(taskAffinityMask & 0xFFFF) + " " + : ""; + return "WN-launcher.exe " + affinityArg + args; } else { return args; } diff --git a/app/src/main/runtime/display/environment/components/GuestProgramLauncherComponent.java b/app/src/main/runtime/display/environment/components/GuestProgramLauncherComponent.java index 58ac5f6ae..8817c183f 100644 --- a/app/src/main/runtime/display/environment/components/GuestProgramLauncherComponent.java +++ b/app/src/main/runtime/display/environment/components/GuestProgramLauncherComponent.java @@ -1160,6 +1160,7 @@ private int execGuestProgram() { + "MESA_VK_WSI_DEBUG=" + envVars.get("MESA_VK_WSI_DEBUG")); + // WN-launcher starts winhandler as a child so it shares the game's desktop. return ProcessHelper.exec( command, envVars.toStringArray(), diff --git a/app/src/main/runtime/display/renderer/VulkanRenderer.java b/app/src/main/runtime/display/renderer/VulkanRenderer.java index 058986a45..fb02a736f 100644 --- a/app/src/main/runtime/display/renderer/VulkanRenderer.java +++ b/app/src/main/runtime/display/renderer/VulkanRenderer.java @@ -475,10 +475,16 @@ private void buildAndSubmitFrame() { if (textureSrc != drawable) { float invW = 1.0f / Math.max(1, textureSrc.width); float invH = 1.0f / Math.max(1, textureSrc.height); + int spanW = drawable.hasPresentedSourceSize() + ? Short.toUnsignedInt(drawable.getPresentedSourceWidth()) + : drawable.width; + int spanH = drawable.hasPresentedSourceSize() + ? Short.toUnsignedInt(drawable.getPresentedSourceHeight()) + : drawable.height; buf.putFloat(uvOff, -scanoutX * invW); buf.putFloat(uvOff + 4, -scanoutY * invH); - buf.putFloat(uvOff + 8, (drawable.width - scanoutX) * invW); - buf.putFloat(uvOff + 12, (drawable.height - scanoutY) * invH); + buf.putFloat(uvOff + 8, (spanW - scanoutX) * invW); + buf.putFloat(uvOff + 12, (spanH - scanoutY) * invH); } else { buf.putFloat(uvOff, 0.0f); buf.putFloat(uvOff + 4, 0.0f); diff --git a/app/src/main/runtime/display/winhandler/WinHandler.java b/app/src/main/runtime/display/winhandler/WinHandler.java index 48711cd73..1a8000472 100644 --- a/app/src/main/runtime/display/winhandler/WinHandler.java +++ b/app/src/main/runtime/display/winhandler/WinHandler.java @@ -462,6 +462,13 @@ public void bringToFront(String processName) { } public void bringToFront(final String processName, final long handle) { + long resolved = handle; + XServer xServer = activity.getXServer(); + if (xServer != null) { + long found = xServer.raiseWindowByProcessName(processName); + if (resolved == 0L) resolved = found; + } + final long targetHandle = resolved; addAction( () -> { try { @@ -470,7 +477,7 @@ public void bringToFront(final String processName, final long handle) { byte[] bytes = processName.getBytes(); this.sendData.putInt(bytes.length); this.sendData.put(bytes); - this.sendData.putLong(handle); + this.sendData.putLong(targetHandle); sendPacket(CLIENT_PORT); } catch (BufferOverflowException e) { e.printStackTrace(); diff --git a/app/src/main/runtime/display/xserver/Window.java b/app/src/main/runtime/display/xserver/Window.java index 87735ab06..0d9e55709 100644 --- a/app/src/main/runtime/display/xserver/Window.java +++ b/app/src/main/runtime/display/xserver/Window.java @@ -174,6 +174,13 @@ public String getClassName() { return property != null ? property.toString() : ""; } + // WM_CLASS holds instance + class as NUL-separated tokens; getClassName only returns the first. + public String[] getClassNames() { + Property property = getProperty(Atom.getId("WM_CLASS")); + if (property == null) return new String[0]; + return new String(property.data.array(), XServer.LATIN1_CHARSET).split("\0"); + } + public int getWMHintsValue(WMHints wmHints) { Property property = getProperty(Atom.getId("WM_HINTS")); return property != null ? property.getInt(wmHints.ordinal()) : 0; diff --git a/app/src/main/runtime/display/xserver/WindowManager.java b/app/src/main/runtime/display/xserver/WindowManager.java index 99e34617a..dcc53f0d7 100644 --- a/app/src/main/runtime/display/xserver/WindowManager.java +++ b/app/src/main/runtime/display/xserver/WindowManager.java @@ -335,6 +335,10 @@ private void resizeFullscreenDescendants(Window parent, short oldWidth, short ol } } + public void raiseWindowToTop(Window window) { + changeWindowZOrder(Window.StackMode.ABOVE, window, null); + } + private void changeWindowZOrder(Window.StackMode stackMode, Window window, Window sibling) { Window parent = window.getParent(); switch (stackMode) { diff --git a/app/src/main/runtime/display/xserver/XServer.java b/app/src/main/runtime/display/xserver/XServer.java index 812bf3468..38b6a0214 100644 --- a/app/src/main/runtime/display/xserver/XServer.java +++ b/app/src/main/runtime/display/xserver/XServer.java @@ -313,6 +313,64 @@ public void injectPointerButtonRelease(Pointer.Button buttonCode) { } } + // Raises the named process's top-level window and returns its HWND (0 if none found). + public long raiseWindowByProcessName(String processName) { + if (processName == null || processName.isEmpty()) return 0; + String wanted = stripExeSuffix(processName); + long handle = 0; + boolean raised = false; + try (XLock lock = lock(Lockable.WINDOW_MANAGER)) { + for (Window window : windowManager.getWindows()) { + if (window == null || window == windowManager.rootWindow) continue; + if (!window.attributes.isMapped()) continue; + if (!matchesProcessName(window, wanted)) continue; + Window toplevel = window; + while (toplevel.getParent() != null + && toplevel.getParent() != windowManager.rootWindow) { + toplevel = toplevel.getParent(); + } + if (toplevel.getParent() == windowManager.rootWindow + && toplevel.attributes.isMapped()) { + windowManager.raiseWindowToTop(toplevel); + // Take focus too (like the taskbar path) or the game stays active and re-raises over it. + windowManager.setFocus(toplevel, WindowManager.FocusRevertTo.POINTER_ROOT); + handle = toplevel.getHandle(); + raised = true; + break; + } + } + } + if (raised && renderer != null) renderer.requestRenderCoalesced(); + return handle; + } + + // True if the given guest process still owns a mapped application window. + public boolean processHasApplicationWindow(int processId) { + if (processId <= 0) return false; + try (XLock lock = lock(Lockable.WINDOW_MANAGER)) { + for (Window window : windowManager.getWindows()) { + if (window == null || window == windowManager.rootWindow) continue; + if (window.isApplicationWindow() && window.getProcessId() == processId) return true; + } + } + return false; + } + + private static boolean matchesProcessName(Window window, String wanted) { + for (String className : window.getClassNames()) { + if (className != null && stripExeSuffix(className).equalsIgnoreCase(wanted)) return true; + } + String name = window.getName(); + return name != null && stripExeSuffix(name).equalsIgnoreCase(wanted); + } + + private static String stripExeSuffix(String value) { + String trimmed = value.trim(); + return trimmed.regionMatches(true, Math.max(0, trimmed.length() - 4), ".exe", 0, 4) + ? trimmed.substring(0, trimmed.length() - 4) + : trimmed; + } + public void injectKeyPress(XKeycode xKeycode) { injectKeyPress(xKeycode, 0); } diff --git a/app/src/main/runtime/display/xserver/extensions/PresentExtension.java b/app/src/main/runtime/display/xserver/extensions/PresentExtension.java index d8b36919a..38cb8d139 100644 --- a/app/src/main/runtime/display/xserver/extensions/PresentExtension.java +++ b/app/src/main/runtime/display/xserver/extensions/PresentExtension.java @@ -294,9 +294,7 @@ private boolean canDirectScanout(Drawable content, Drawable pixmap, short xOff, return pixmap.isDirectScanout() && texture != null && xOff <= 0 - && yOff <= 0 - && pixmap.width + xOff >= content.width - && pixmap.height + yOff >= content.height; + && yOff <= 0; } private static void copyPresentedRegion(