Restore wine access after program loses and regains focus.

This commit is contained in:
Storm Dragon committed 2026-10-12 04:17:31 -04:00
1 parent f40e471938
commit c3e981b4c2
12 files changed
+351 -2

No files matched your search

+16 -1
View File
@@ -49,7 +49,7 @@ add_custom_command(
DEPENDS "${CMAKE_CURRENT_SOURCE_DIR}/nvdaController.idl" DEPENDS "${CMAKE_CURRENT_SOURCE_DIR}/nvdaController.idl"
VERBATIM) VERBATIM)
add_executable(cthulhu-wine-access main.cpp list_view.cpp native_items.cpp review_capture.cpp review_x11.cpp review_service.cpp "${generated_server}") add_executable(cthulhu-wine-access main.cpp foreground_focus.cpp list_view.cpp native_items.cpp review_capture.cpp review_x11.cpp review_service.cpp "${generated_server}")
add_dependencies(cthulhu-wine-access wine-access-menu wine-access-menu32) add_dependencies(cthulhu-wine-access wine-access-menu wine-access-menu32)
target_compile_features(cthulhu-wine-access PRIVATE cxx_std_20) target_compile_features(cthulhu-wine-access PRIVATE cxx_std_20)
target_include_directories(cthulhu-wine-access PRIVATE "${generated_dir}") target_include_directories(cthulhu-wine-access PRIVATE "${generated_dir}")
@@ -61,6 +61,21 @@ target_compile_options(cthulhu-wine-access PRIVATE
include(CTest) include(CTest)
if(BUILD_TESTING) if(BUILD_TESTING)
set(foreground_fixture "${CMAKE_CURRENT_BINARY_DIR}/foreground-fixture.exe")
add_custom_command(OUTPUT "${foreground_fixture}"
COMMAND "${MINGW_C_COMPILER}" -std=c11 -Wall -Wextra -mwindows
-o "${foreground_fixture}" "${CMAKE_CURRENT_SOURCE_DIR}/tests/foreground_fixture.c" -luser32
DEPENDS tests/foreground_fixture.c VERBATIM)
add_custom_target(foreground-fixture ALL DEPENDS "${foreground_fixture}")
add_executable(foreground-focus-test tests/foreground_focus_test.cpp foreground_focus.cpp review_x11.cpp)
add_dependencies(foreground-focus-test foreground-fixture)
target_compile_features(foreground-focus-test PRIVATE cxx_std_20)
target_compile_options(foreground-focus-test PRIVATE -Wall -Wextra)
target_link_libraries(foreground-focus-test PRIVATE user32 PkgConfig::XCB)
add_test(NAME foreground-focus COMMAND python3
"${CMAKE_CURRENT_SOURCE_DIR}/tests/foreground_focus_test.py" "${CMAKE_CURRENT_BINARY_DIR}")
set_tests_properties(foreground-focus PROPERTIES TIMEOUT 90 ENVIRONMENT
"WINEPREFIX=${CMAKE_CURRENT_BINARY_DIR}/focus-prefix;CTHULHU_WINE_ACCESS_MANAGED=1;WINEDEBUG=-all;WINEDLLOVERRIDES=mscoree,mshtml=")
add_executable(msaa-states-test tests/msaa_states_test.cpp) add_executable(msaa-states-test tests/msaa_states_test.cpp)
target_compile_features(msaa-states-test PRIVATE cxx_std_20) target_compile_features(msaa-states-test PRIVATE cxx_std_20)
target_compile_options(msaa-states-test PRIVATE -Wall -Wextra) target_compile_options(msaa-states-test PRIVATE -Wall -Wextra)
+7
View File
@@ -89,6 +89,13 @@ The helper is started automatically when Cthulhu-Go detects an active Wine
loader for the same user and prefix. It requires the Go service to be running; loader for the same user and prefix. It requires the Go service to be running;
it exits if `org.stormux.Cthulhu1.Service` is unavailable. it exits if `org.stormux.Cthulhu1.Service` is unavailable.
When a game closes inside a Wine virtual desktop, the desktop can regain X11
focus while Wine has no foreground application. The helper restores native
focus when exactly one visible, enabled application remains. Both X11 keyboard
focus and the window manager's active window must identify that same virtual
desktop. Existing application focus, Linux windows, minimized/disabled windows,
and ambiguous choices between multiple applications are left alone.
## Protocol and behavior ## Protocol and behavior
`nvdaController.idl` contains the compatible local RPC interfaces. The helper `nvdaController.idl` contains the compatible local RPC interfaces. The helper
@@ -1,6 +1,6 @@
# Maintainer: Storm Dragon <storm_dragon@stormux.org> # Maintainer: Storm Dragon <storm_dragon@stormux.org>
pkgname=wine-access-git pkgname=wine-access-git
pkgver=r18.gdf7879a pkgver=r22.gf40e471
pkgrel=1 pkgrel=1
pkgdesc='Wine accessibility helper with MSAA, NVDA Controller, and Tolk compatibility for Cthulhu' pkgdesc='Wine accessibility helper with MSAA, NVDA Controller, and Tolk compatibility for Cthulhu'
arch=('x86_64') arch=('x86_64')
+30
View File
@@ -0,0 +1,30 @@
// SPDX-License-Identifier: LGPL-2.1-or-later
#include "foreground_focus.h"
#include "review_x11.h"
namespace {
struct Candidate {
HWND window = nullptr;
unsigned count = 0;
};
BOOL CALLBACK find_candidate(HWND window, LPARAM data) {
if (!IsWindowVisible(window) || !IsWindowEnabled(window) || IsIconic(window) ||
(GetWindowLongPtrW(window, GWL_EXSTYLE) & (WS_EX_TOOLWINDOW | WS_EX_NOACTIVATE)) ||
!GetPropW(window, L"__wine_x11_whole_window"))
return TRUE;
auto &candidate = *reinterpret_cast<Candidate *>(data);
candidate.window = window;
return ++candidate.count < 2;
}
}
bool restore_virtual_desktop_focus() {
if (GetForegroundWindow() || !wine_x11_desktop_focused()) return false;
Candidate candidate;
EnumWindows(find_candidate, reinterpret_cast<LPARAM>(&candidate));
// Recheck focus after enumeration so a Linux/application transition cannot
// turn a recovery into activation of a background Wine application.
if (candidate.count != 1 || GetForegroundWindow() || !wine_x11_desktop_focused())
return false;
return wine_x11_restore_focus(candidate.window);
}
+6
View File
@@ -0,0 +1,6 @@
// SPDX-License-Identifier: LGPL-2.1-or-later
#pragma once
// Recover a virtual desktop which regained X11 focus with no Win32 foreground.
// Leave existing focus, other desktops and ambiguous application sets alone.
bool restore_virtual_desktop_focus();
+2
View File
@@ -8,6 +8,7 @@
#include "control_class.h" #include "control_class.h"
#include "native_items.h" #include "native_items.h"
#include "review_service.h" #include "review_service.h"
#include "foreground_focus.h"
#include <commctrl.h> #include <commctrl.h>
#include <gio/gio.h> #include <gio/gio.h>
@@ -1047,6 +1048,7 @@ int WINAPI WinMain(HINSTANCE instance, HINSTANCE, char *, int) {
while (GetMessageW(&message, nullptr, 0, 0) > 0) { while (GetMessageW(&message, nullptr, 0, 0) > 0) {
if (listTimer && message.message == WM_TIMER && message.hwnd == nullptr && if (listTimer && message.message == WM_TIMER && message.hwnd == nullptr &&
message.wParam == listTimer) { message.wParam == listTimer) {
restore_virtual_desktop_focus();
poll_control_focus(); poll_control_focus();
reviewService.focus_changed(reviewIdentities.observe_foreground()); reviewService.focus_changed(reviewIdentities.observe_foreground());
continue; continue;
+60
View File
@@ -9,6 +9,7 @@
#include <poll.h> #include <poll.h>
#include <cstdlib> #include <cstdlib>
#include <memory> #include <memory>
#include <cstring>
namespace { namespace {
struct Connection { struct Connection {
@@ -59,3 +60,62 @@ bool review_x11_geometry(HWND window, RECT &bounds, POINT &origin) {
} }
return true; return true;
} }
bool wine_x11_desktop_focused() {
USEROBJECTFLAGS flags{};
if (!GetUserObjectInformationW(GetThreadDesktop(GetCurrentThreadId()), UOI_FLAGS,
&flags, sizeof(flags), nullptr) || !(flags.dwFlags & 0x80000000))
return false;
const auto xid = reinterpret_cast<uintptr_t>(GetPropW(GetDesktopWindow(), L"__wine_x11_whole_window"));
if (!xid || xid > UINT32_MAX || !connection.value || xcb_connection_has_error(connection.value))
return false;
const uint64_t deadline = GetTickCount64() + 20;
auto focus = reply<xcb_get_input_focus_reply_t>(xcb_get_input_focus(connection.value).sequence, deadline);
if (!focus || focus->focus != xid) return false;
auto size = reply<xcb_get_geometry_reply_t>(xcb_get_geometry(connection.value, xid).sequence, deadline);
if (!size) return false;
constexpr char name[] = "_NET_ACTIVE_WINDOW";
auto atom = reply<xcb_intern_atom_reply_t>(xcb_intern_atom(connection.value, 1, sizeof(name) - 1, name).sequence, deadline);
if (!atom || !atom->atom) return false;
auto active = reply<xcb_get_property_reply_t>(xcb_get_property(connection.value, 0, size->root,
atom->atom, XCB_ATOM_WINDOW, 0, 1).sequence, deadline);
uint32_t window = 0;
if (!active || active->type != XCB_ATOM_WINDOW || active->format != 32 ||
xcb_get_property_value_length(active.get()) != sizeof(window))
return false;
std::memcpy(&window, xcb_get_property_value(active.get()), sizeof(window));
return window == xid;
}
bool wine_x11_restore_focus(HWND window) {
if (!wine_x11_desktop_focused()) return false;
const auto xid = reinterpret_cast<uintptr_t>(GetPropW(window, L"__wine_x11_whole_window"));
const auto desktop = reinterpret_cast<uintptr_t>(GetPropW(GetDesktopWindow(), L"__wine_x11_whole_window"));
if (!xid || xid > UINT32_MAX) return false;
const uint64_t deadline = GetTickCount64() + 20;
auto tree = reply<xcb_query_tree_reply_t>(xcb_query_tree(connection.value, xid).sequence, deadline);
if (!tree || tree->parent != desktop) return false;
// Native focus informs Wine's application thread of activation. A helper's
// SetForegroundWindow can be denied by the Win32 foreground-lock policy.
constexpr char protocolsName[] = "WM_PROTOCOLS";
constexpr char focusName[] = "WM_TAKE_FOCUS";
auto protocols = reply<xcb_intern_atom_reply_t>(xcb_intern_atom(connection.value, 1,
sizeof(protocolsName) - 1, protocolsName).sequence, deadline);
auto takeFocus = reply<xcb_intern_atom_reply_t>(xcb_intern_atom(connection.value, 1,
sizeof(focusName) - 1, focusName).sequence, deadline);
if (!protocols || !protocols->atom || !takeFocus || !takeFocus->atom) return false;
if (GetForegroundWindow() || !IsWindowVisible(window) || !IsWindowEnabled(window) ||
IsIconic(window) || !wine_x11_desktop_focused())
return false;
xcb_client_message_event_t event{};
event.response_type = XCB_CLIENT_MESSAGE;
event.format = 32;
event.window = xid;
event.type = protocols->atom;
event.data.data32[0] = takeFocus->atom;
event.data.data32[1] = XCB_CURRENT_TIME;
xcb_send_event(connection.value, 0, xid, XCB_EVENT_MASK_NO_EVENT, reinterpret_cast<const char *>(&event));
xcb_set_input_focus(connection.value, XCB_INPUT_FOCUS_PARENT, xid, XCB_CURRENT_TIME);
auto focus = reply<xcb_get_input_focus_reply_t>(xcb_get_input_focus(connection.value).sequence, deadline);
return focus && focus->focus == xid;
}
+7
View File
@@ -5,3 +5,10 @@
// Query Wine's actual X11 window, not a title/geometry search. Origin translates // Query Wine's actual X11 window, not a title/geometry search. Origin translates
// desktop-local Win32 positions to physical root coordinates in virtual mode. // desktop-local Win32 positions to physical root coordinates in virtual mode.
bool review_x11_geometry(HWND window, RECT &bounds, POINT &origin); bool review_x11_geometry(HWND window, RECT &bounds, POINT &origin);
// True only when both the window manager and X11 focus select this prefix's
// virtual desktop itself, rather than another application or desktop child.
bool wine_x11_desktop_focused();
// Deliver native focus to the application's actual child of this desktop.
bool wine_x11_restore_focus(HWND window);
+19
View File
@@ -1,5 +1,24 @@
# Wine control integration # Wine control integration
## Virtual desktop focus recovery
`xvfb-run -a ctest --test-dir build -R foreground-focus --output-on-failure`
uses a disposable Wine prefix configured for a virtual desktop and a separate
Windows launcher fixture. It reproduces a visible launcher returned without
activation, then checks recovery of its native foreground and button focus.
It also checks Linux focus, mismatched window-manager/keyboard focus, disabled
windows, multiple visible applications, hidden windows, existing foreground,
and repeated recovery. The fixture acknowledges ICCCM window visibility on
the private display; the runner propagates the fixture's actual exit status.
After installing the updated helper and restarting the reader, open AudioQuake,
play and close a game, then Tab and Shift+Tab through the launcher. Check speech
and braille for each control. Switch to Linux and back to workspace 3 and repeat.
Recheck ordinary Wine menus, edits and lists, including password exclusion.
With two visible applications in one virtual desktop, recovery intentionally
does not choose between them; focus the intended application normally.
Automated focus checks do not prove audible speech or physical braille.
## Wine flat-review capture ## Wine flat-review capture
Build the helper and run `ctest --test-dir build --output-on-failure` on a Build the helper and run `ctest --test-dir build --output-on-failure` on a
+32
View File
@@ -0,0 +1,32 @@
// SPDX-License-Identifier: LGPL-2.1-or-later
#define UNICODE
#define _UNICODE
#include <windows.h>
static HWND button;
static LRESULT CALLBACK window_proc(HWND window, UINT message, WPARAM wp, LPARAM lp) {
if ((message == WM_SETFOCUS || (message == WM_ACTIVATE && LOWORD(wp) != WA_INACTIVE)) && button)
SetFocus(button);
if (message == WM_DESTROY) { PostQuitMessage(0); return 0; }
return DefWindowProcW(window, message, wp, lp);
}
int WINAPI WinMain(HINSTANCE instance, HINSTANCE previous, LPSTR args, int show) {
(void)previous; (void)args; (void)show;
WNDCLASSW cls = {0};
cls.hInstance = instance;
cls.lpfnWndProc = window_proc;
cls.lpszClassName = L"WineAccessForegroundFixture";
RegisterClassW(&cls);
HWND launcher = CreateWindowW(cls.lpszClassName, L"Launcher fixture", WS_OVERLAPPEDWINDOW,
50, 50, 300, 200, NULL, NULL, instance, NULL);
button = CreateWindowW(L"Button", L"Play fixture", WS_CHILD | WS_VISIBLE | WS_TABSTOP,
10, 10, 100, 30, launcher, (HMENU)1, instance, NULL);
ShowWindow(launcher, SW_SHOW);
SetForegroundWindow(launcher);
MSG message;
while (GetMessageW(&message, NULL, 0, 0) > 0) {
TranslateMessage(&message);
DispatchMessageW(&message);
}
return 0;
}
+142
View File
@@ -0,0 +1,142 @@
// SPDX-License-Identifier: LGPL-2.1-or-later
#include "../foreground_focus.h"
#include "../review_x11.h"
#pragma push_macro("_WIN32")
#undef _WIN32
#include <xcb/xcb.h>
#pragma pop_macro("_WIN32")
#include <cstdio>
#include <cstdlib>
#include <string>
#include <vector>
namespace {
void pump() {
const auto deadline = GetTickCount64() + 100;
do {
MSG message{};
while (PeekMessageW(&message, nullptr, 0, 0, PM_REMOVE)) {
TranslateMessage(&message);
DispatchMessageW(&message);
}
Sleep(5);
} while (GetTickCount64() < deadline);
}
bool check(bool passed, const char *message) {
if (!passed) std::fprintf(stderr, "FAIL: %s\n", message);
return passed;
}
}
int main(int argc, char **argv) {
if (argc != 2) return 2;
const std::string command = "\"Z:" + std::string(argv[1]) + "\"";
const int units = MultiByteToWideChar(CP_UTF8, 0, command.c_str(), -1, nullptr, 0);
std::vector<WCHAR> wideCommand(units);
MultiByteToWideChar(CP_UTF8, 0, command.c_str(), -1, wideCommand.data(), units);
STARTUPINFOW startup{};
startup.cb = sizeof(startup);
PROCESS_INFORMATION process{};
if (!CreateProcessW(nullptr, wideCommand.data(), nullptr, nullptr, FALSE, 0,
nullptr, nullptr, &startup, &process)) return 2;
HWND launcher = nullptr;
const auto deadline = GetTickCount64() + 5000;
while (!(launcher = FindWindowW(L"WineAccessForegroundFixture", nullptr)) &&
GetTickCount64() < deadline) Sleep(10);
if (!launcher) return 2;
HWND button = GetDlgItem(launcher, 1);
auto *connection = xcb_connect(nullptr, nullptr);
if (xcb_connection_has_error(connection)) return 2;
auto *screen = xcb_setup_roots_iterator(xcb_get_setup(connection)).data;
const auto desktop = static_cast<xcb_window_t>(reinterpret_cast<uintptr_t>(
GetPropW(GetDesktopWindow(), L"__wine_x11_whole_window")));
if (!desktop) return 2;
constexpr char name[] = "_NET_ACTIVE_WINDOW";
auto *atomReply = xcb_intern_atom_reply(connection,
xcb_intern_atom(connection, 0, sizeof(name) - 1, name), nullptr);
if (!atomReply) return 2;
const auto activeAtom = atomReply->atom;
std::free(atomReply);
constexpr char stateName[] = "WM_STATE";
auto *stateReply = xcb_intern_atom_reply(connection,
xcb_intern_atom(connection, 0, sizeof(stateName) - 1, stateName), nullptr);
if (!stateReply) return 2;
const auto stateAtom = stateReply->atom;
std::free(stateReply);
auto acknowledge = [&](HWND window) {
const auto xid = static_cast<xcb_window_t>(reinterpret_cast<uintptr_t>(
GetPropW(window, L"__wine_x11_whole_window")));
const uint32_t state[] = {1, XCB_WINDOW_NONE}; // ICCCM NormalState.
xcb_change_property(connection, XCB_PROP_MODE_REPLACE, xid,
stateAtom, stateAtom, 32, 2, state);
xcb_flush(connection);
pump();
};
const auto linuxWindow = xcb_generate_id(connection);
xcb_create_window(connection, XCB_COPY_FROM_PARENT, linuxWindow, screen->root,
0, 0, 20, 20, 0, XCB_WINDOW_CLASS_INPUT_OUTPUT, screen->root_visual, 0, nullptr);
xcb_map_window(connection, linuxWindow);
auto focus = [&](xcb_window_t target, xcb_window_t active) {
xcb_change_property(connection, XCB_PROP_MODE_REPLACE, screen->root,
activeAtom, XCB_ATOM_WINDOW, 32, 1, &active);
xcb_set_input_focus(connection, XCB_INPUT_FOCUS_POINTER_ROOT, target, XCB_CURRENT_TIME);
xcb_flush(connection);
pump();
};
WNDCLASSW cls{};
cls.hInstance = GetModuleHandleW(nullptr);
cls.lpfnWndProc = DefWindowProcW;
cls.lpszClassName = L"WineAccessOtherFixture";
RegisterClassW(&cls);
HWND other = CreateWindowW(cls.lpszClassName, L"Second fixture", WS_OVERLAPPEDWINDOW,
380, 50, 300, 200, nullptr, nullptr, cls.hInstance, nullptr);
pump();
bool ok = true;
// Returning a launcher without activation leaves Wine's desktop with no
// remembered foreground, as when an external game window has closed.
ShowWindow(launcher, SW_HIDE);
pump();
ShowWindow(launcher, SW_SHOWNOACTIVATE);
acknowledge(launcher);
focus(linuxWindow, linuxWindow);
ok &= check(!GetForegroundWindow(), "Linux focus clears Wine foreground");
ok &= check(!restore_virtual_desktop_focus() && !GetForegroundWindow(), "Linux focus is never taken");
focus(desktop, linuxWindow);
ok &= check(!restore_virtual_desktop_focus(), "window manager must also select desktop");
focus(linuxWindow, desktop);
ok &= check(!restore_virtual_desktop_focus(), "X11 focus must also select desktop");
focus(desktop, desktop);
ok &= check(wine_x11_desktop_focused() && !GetForegroundWindow(), "reproduce orphaned virtual desktop focus");
EnableWindow(launcher, FALSE);
ok &= check(!restore_virtual_desktop_focus(), "disabled application is not activated");
EnableWindow(launcher, TRUE);
ShowWindow(other, SW_SHOWNOACTIVATE);
acknowledge(other);
focus(linuxWindow, linuxWindow);
focus(desktop, desktop);
ok &= check(!restore_virtual_desktop_focus() && !GetForegroundWindow(), "ambiguous applications are not chosen");
ShowWindow(other, SW_HIDE);
ok &= check(restore_virtual_desktop_focus(), "single application recovers foreground");
pump();
GUITHREADINFO gui{};
gui.cbSize = sizeof(gui);
GetGUIThreadInfo(GetWindowThreadProcessId(launcher, nullptr), &gui);
ok &= check(GetForegroundWindow() == launcher && gui.hwndFocus == button, "launcher restores its control focus");
ok &= check(!restore_virtual_desktop_focus() && GetForegroundWindow() == launcher, "existing Wine focus stays unchanged");
ShowWindow(launcher, SW_HIDE);
pump();
ShowWindow(launcher, SW_SHOWNOACTIVATE);
acknowledge(launcher);
focus(linuxWindow, linuxWindow);
focus(desktop, desktop);
ok &= check(restore_virtual_desktop_focus(), "later workspace return recovers again");
DestroyWindow(other);
PostMessageW(launcher, WM_CLOSE, 0, 0);
WaitForSingleObject(process.hProcess, 5000);
CloseHandle(process.hProcess);
CloseHandle(process.hThread);
xcb_destroy_window(connection, linuxWindow);
xcb_disconnect(connection);
if (ok) std::puts("PASS: virtual desktop foreground recovery and focus guards");
return ok ? 0 : 1;
}
+29
View File
@@ -0,0 +1,29 @@
"""Run the foreground fixture in its own configured Wine virtual desktop."""
import os
from pathlib import Path
import subprocess
import sys
buildDir = Path(sys.argv[1]).resolve()
env = os.environ.copy()
env.update(WINEPREFIX=str(buildDir / "focus-prefix"),
CTHULHU_WINE_ACCESS_MANAGED="1", WINEDEBUG="-all",
WINEDLLOVERRIDES="mscoree,mshtml=")
try:
for key, value, data in (
(r"HKCU\Software\Wine\Explorer", "Desktop", "WineAccessFocusFixture"),
(r"HKCU\Software\Wine\Explorer\Desktops", "WineAccessFocusFixture", "800x600"),
):
subprocess.run(["wine", "reg", "add", key, "/v", value,
"/t", "REG_SZ", "/d", data, "/f"],
env=env, check=True, timeout=40)
# The next loader must create the configured desktop. Wait for this test
# prefix's old server to stop, without touching any user application.
subprocess.run(["wineserver", "-k"], env=env, check=True, timeout=5)
subprocess.run(["wineserver", "-w"], env=env, check=True, timeout=5)
result = subprocess.run([str(buildDir / "foreground-focus-test.exe"),
str(buildDir / "foreground-fixture.exe")],
env=env, timeout=20)
sys.exit(result.returncode)
finally:
subprocess.run(["wineserver", "-k"], env=env, timeout=5)