Version and better logging added.

This commit is contained in:
Storm Dragon committed 2026-10-15 12:49:33 -04:00
1 parent b16c6968ae
commit 77db0b83ab
10 files changed
+326 -8

No files matched your search

+8 -1
View File
@@ -44,6 +44,13 @@ set(generated_header "${generated_dir}/nvdaController.h")
set(generated_server "${generated_dir}/nvdaController_s.c")
file(MAKE_DIRECTORY "${generated_dir}")
add_custom_target(wine-access-version
COMMAND "${CMAKE_COMMAND}" "-DSOURCE_DIR=${CMAKE_CURRENT_SOURCE_DIR}"
"-DOUTPUT_FILE=${generated_dir}/version.h"
-P "${CMAKE_CURRENT_SOURCE_DIR}/cmake/GenerateVersion.cmake"
BYPRODUCTS "${generated_dir}/version.h"
VERBATIM)
add_custom_command(
OUTPUT "${generated_header}" "${generated_server}"
COMMAND "${WIDL_EXECUTABLE}" --win64 -h -H "${generated_header}"
@@ -52,7 +59,7 @@ add_custom_command(
VERBATIM)
add_executable(wine-access reader_events.cpp msaa_events.cpp main.cpp foreground_focus.cpp list_view.cpp native_items.cpp review_capture.cpp review_x11.cpp review_service.cpp "${generated_server}")
add_dependencies(wine-access wine-access-menu wine-access-menu32)
add_dependencies(wine-access wine-access-menu wine-access-menu32 wine-access-version)
target_compile_features(wine-access PRIVATE cxx_std_20)
target_include_directories(wine-access PRIVATE "${generated_dir}")
target_link_libraries(wine-access PRIVATE
+38
View File
@@ -115,6 +115,44 @@ The helper is started automatically when Cthulhu detects an active Wine
loader for the same user and prefix. It requires the selected reader service
to be running; it exits if that service is unavailable.
## Version and diagnostics
Edit the single line in [`VERSION`](VERSION) to set the release date, using
`YYYY.MM.DD`. Every build embeds that version, the source checkout's Git
revision and a modified-build flag. Incremental builds refresh the identity
after commits or source changes. The flag includes untracked files that Git
does not ignore; a source archive without `.git` reports revision `unavailable`.
It identifies the helper sidecar being executed, rather than a separate
installed version file or the installed package database.
Query an installed executable directly, for example:
```sh
/usr/libexec/wine-access/wine-access.exe --version
```
This exits before reader discovery or singleton acquisition, so it works
without a reader and while another helper is running. Wine itself still needs
to initialize normally. To identify an already running helper, use its startup
record; replacing files on disk does not update the running process.
Ordinary startup writes `wine-access: identity` and `wine-access: ready` JSON
records to stderr. Identity is emitted even when later startup fails. The ready
record means initialization reached the message loop; inspect its individual
booleans for unavailable components. Hook handles and `host32_started` report
installation/launch success, not attachment to a particular application or
confirmation that the companion installed its hooks. Failures retain concise
`wine-access:` diagnostics without accessible event text or password values.
Reader replies that merely filter duplicate/unwanted events are not errors.
Updated Cthulhu versions capture these records in their normal log, including
worker/hook startup failures, reader-call failures, recovery and queue overflow.
Executable, loader and prefix paths appear only in Cthulhu's debug log. For a
bug report, include the helper identity and initialization records, relevant
diagnostics and the exact navigation steps. Review debug output for private
text before sharing. No reader-specific logging service is required by the
helper; other readers can consume the same stderr records.
## Reader selection and Orca integration
The default invocation and `--reader cthulhu` use the existing Cthulhu endpoint.
+1
View File
@@ -0,0 +1 @@
2026.10.15
+28
View File
@@ -0,0 +1,28 @@
# Refresh on every build, including incremental builds after a commit or edit.
# configure_file only changes the header when the embedded identity changes.
file(READ "${SOURCE_DIR}/VERSION" releaseVersion)
string(STRIP "${releaseVersion}" releaseVersion)
if(NOT releaseVersion MATCHES "^[0-9][0-9][0-9][0-9][.][0-9][0-9][.][0-9][0-9]$")
message(FATAL_ERROR "VERSION must contain a date in YYYY.MM.DD format")
endif()
set(gitRevision "unavailable")
set(gitModified "false")
set(buildSuffix "")
find_package(Git QUIET)
# Do not accidentally identify a source archive as its enclosing repository.
if(GIT_FOUND AND EXISTS "${SOURCE_DIR}/.git")
execute_process(COMMAND "${GIT_EXECUTABLE}" rev-parse --short=12 HEAD
WORKING_DIRECTORY "${SOURCE_DIR}" RESULT_VARIABLE revisionResult
OUTPUT_VARIABLE revision OUTPUT_STRIP_TRAILING_WHITESPACE ERROR_QUIET)
execute_process(COMMAND "${GIT_EXECUTABLE}" status --porcelain --untracked-files=normal
WORKING_DIRECTORY "${SOURCE_DIR}" RESULT_VARIABLE statusResult
OUTPUT_VARIABLE status OUTPUT_STRIP_TRAILING_WHITESPACE ERROR_QUIET)
if(revisionResult EQUAL 0 AND revision MATCHES "^[0-9a-f]+$")
set(gitRevision "${revision}")
endif()
if(statusResult EQUAL 0 AND NOT status STREQUAL "")
set(gitModified "true")
set(buildSuffix ", modified")
endif()
endif()
configure_file("${SOURCE_DIR}/cmake/version.h.in" "${OUTPUT_FILE}" @ONLY)
+6
View File
@@ -0,0 +1,6 @@
// Generated from VERSION and the source checkout; do not edit.
#pragma once
#define WINE_ACCESS_VERSION "@releaseVersion@"
#define WINE_ACCESS_REVISION "@gitRevision@"
#define WINE_ACCESS_MODIFIED "@gitModified@"
#define WINE_ACCESS_BUILD "Wine Access @releaseVersion@ (@gitRevision@@buildSuffix@)"
@@ -1,6 +1,6 @@
# Maintainer: Storm Dragon <storm_dragon@stormux.org>
pkgname=wine-access-git
pkgver=r40.g4c47cc5
pkgver=r41.gb16c696
pkgrel=1
pkgdesc='Wine accessibility helper with MSAA, NVDA Controller, and Tolk compatibility for screen readers'
arch=('x86_64')
+54 -6
View File
@@ -12,6 +12,7 @@
#include "reader_endpoint.h"
#include "msaa_events.h"
#include "reader_events.h"
#include "version.h"
#include <commctrl.h>
#include <gio/gio.h>
@@ -1049,6 +1050,17 @@ int WINAPI WinMain(HINSTANCE instance, HINSTANCE, char *, int) {
int argumentCount = 0;
wchar_t **arguments = CommandLineToArgvW(commandLine, &argumentCount);
if (arguments == nullptr) return 1;
// Version queries must work without a reader, even while another helper owns
// the singleton. The identity is embedded in this executable's sidecar.
for (int index = 1; index < argumentCount; ++index) {
if (lstrcmpW(arguments[index], L"--version") == 0) {
std::puts(WINE_ACCESS_BUILD);
LocalFree(arguments);
return 0;
}
}
std::fprintf(stderr, "wine-access: identity {\"version\":\"%s\",\"revision\":\"%s\",\"modified\":%s}\n",
WINE_ACCESS_VERSION, WINE_ACCESS_REVISION, WINE_ACCESS_MODIFIED);
bool validReader = true;
bool validEndpoint = true;
std::string readerPath, readerInterface;
@@ -1097,14 +1109,20 @@ int WINAPI WinMain(HINSTANCE instance, HINSTANCE, char *, int) {
// These strings remain alive until the workers have stopped at shutdown.
if (!readerPath.empty()) readerEndpoint.objectPath = readerPath.c_str();
if (!readerInterface.empty()) readerEndpoint.interfaceName = readerInterface.c_str();
if (!dialogReaderEnabled && !controllerEnabled)
if (!dialogReaderEnabled && !controllerEnabled) {
std::fprintf(stderr, "wine-access: disabled by command-line options\n");
return 0;
}
HANDLE singleton =
CreateMutexW(nullptr, TRUE, L"Local\\cthulhu-wine-access-singleton");
if (singleton == nullptr)
if (singleton == nullptr) {
std::fprintf(stderr, "wine-access: singleton creation failed (error %lu)\n",
static_cast<unsigned long>(GetLastError()));
return 1;
}
if (GetLastError() == ERROR_ALREADY_EXISTS) {
std::fprintf(stderr, "wine-access: another helper already owns this Wine desktop\n");
CloseHandle(singleton);
return 0;
}
@@ -1147,8 +1165,11 @@ int WINAPI WinMain(HINSTANCE instance, HINSTANCE, char *, int) {
CloseHandle(singleton);
return 1;
}
reviewService.start(connection,reviewIdentities,readerEndpoint);
if (dialogReaderEnabled && !msaaEvents.start()) {
const bool reviewActive = reviewService.start(connection,reviewIdentities,readerEndpoint);
if (!reviewActive)
std::fprintf(stderr, "wine-access: review service unavailable\n");
const bool msaaActive = dialogReaderEnabled && msaaEvents.start();
if (dialogReaderEnabled && !msaaActive) {
std::fprintf(stderr, "wine-access: MSAA event worker unavailable\n");
}
bool controllerActive = false;
@@ -1171,6 +1192,8 @@ int WINAPI WinMain(HINSTANCE instance, HINSTANCE, char *, int) {
DestroyWindow(window);
window = nullptr;
}
if (controllerEnabled && !controllerActive)
std::fprintf(stderr, "wine-access: controller endpoint unavailable\n");
HWINEVENTHOOK hook = nullptr;
HHOOK menuHook = nullptr;
@@ -1178,9 +1201,13 @@ int WINAPI WinMain(HINSTANCE instance, HINSTANCE, char *, int) {
HMODULE menuModule = nullptr;
HWND menuBridge = nullptr;
HANDLE menuHost32 = nullptr;
DWORD menuHookError = 0, inputHookError = 0;
if (dialogReaderEnabled) {
hook = SetWinEventHook(EVENT_MIN, EVENT_MAX, nullptr, winevent_callback, 0,
0, WINEVENT_OUTOFCONTEXT | WINEVENT_SKIPOWNPROCESS);
if (hook == nullptr)
std::fprintf(stderr, "wine-access: WinEvent hook unavailable (error %lu)\n",
static_cast<unsigned long>(GetLastError()));
WNDCLASSW bridgeClass{};
bridgeClass.lpfnWndProc = menu_bridge_proc;
bridgeClass.hInstance = instance;
@@ -1189,18 +1216,27 @@ int WINAPI WinMain(HINSTANCE instance, HINSTANCE, char *, int) {
menuBridge = CreateWindowExW(WS_EX_NOACTIVATE | WS_EX_TOOLWINDOW,
WINE_ACCESS_MENU_CLASS, L"", 0, 0, 0, 0, 0,
nullptr, nullptr, instance, nullptr);
if (menuBridge != nullptr && (menuModule = load_menu_hook()) != nullptr) {
if (menuBridge == nullptr) {
menuHookError = inputHookError = GetLastError();
} else if ((menuModule = load_menu_hook()) == nullptr) {
menuHookError = inputHookError = GetLastError();
} else {
const auto callback = std::bit_cast<HOOKPROC>(GetProcAddress(menuModule, "WineAccessMenuHook"));
if (callback != nullptr)
menuHook = SetWindowsHookExW(WH_CALLWNDPROC, callback, menuModule, 0);
if (menuHook == nullptr) menuHookError = GetLastError();
const auto inputCallback = std::bit_cast<HOOKPROC>(GetProcAddress(menuModule, "WineAccessInputHook"));
if (inputCallback != nullptr)
inputHook = SetWindowsHookExW(WH_GETMESSAGE, inputCallback, menuModule, 0);
if (inputHook == nullptr) inputHookError = GetLastError();
editReaderActive = menuHook != nullptr && inputHook != nullptr;
}
if (menuHook == nullptr)
std::fprintf(stderr, "wine-access: native menu hook unavailable (error %lu)\n",
static_cast<unsigned long>(GetLastError()));
static_cast<unsigned long>(menuHookError));
if (inputHook == nullptr)
std::fprintf(stderr, "wine-access: native input hook unavailable (error %lu)\n",
static_cast<unsigned long>(inputHookError));
if (menuBridge != nullptr) {
menuHost32 = start_menu_host32(menuBridge);
if (menuHost32 == nullptr)
@@ -1212,6 +1248,18 @@ int WINAPI WinMain(HINSTANCE instance, HINSTANCE, char *, int) {
// Native lists, tabs, trees and combo boxes can omit change WinEvents in Wine.
// Observe only the foreground control; never read background selections.
const UINT_PTR listTimer = dialogReaderEnabled ? SetTimer(nullptr, 0, 100, nullptr) : 0;
if (dialogReaderEnabled && !listTimer)
std::fprintf(stderr, "wine-access: control polling timer unavailable\n");
// Initialization status is distinct from process creation. Hook handles and
// a started host do not prove attachment to a particular application.
std::fprintf(stderr, "wine-access: ready {\"reader\":\"%s\",\"controls_enabled\":%s,"
"\"controller\":%s,\"review\":%s,\"msaa_worker\":%s,\"win_events\":%s,"
"\"menu_hook\":%s,\"input_hook\":%s,\"host32_started\":%s,\"control_poll\":%s}\n",
readerEndpoint.busName, dialogReaderEnabled ? "true" : "false",
controllerActive ? "true" : "false", reviewActive ? "true" : "false",
msaaActive ? "true" : "false", hook ? "true" : "false",
menuHook ? "true" : "false", inputHook ? "true" : "false",
menuHost32 ? "true" : "false", listTimer ? "true" : "false");
while (GetMessageW(&message, nullptr, 0, 0) > 0) {
if (listTimer && message.message == WM_TIMER && message.hwnd == nullptr &&
message.wParam == listTimer) {
+24
View File
@@ -574,3 +574,27 @@ the new focus event; the Go presenter must suppress that unchanged state
presentation. For live acceptance, change the document, close Notepad and
verify each Tab announces only the newly focused button, including reverse
navigation. Escape must return to the editor without discarding the document.
## Helper identity and startup diagnostics
```sh
python3 tests/version_metadata_test.py
xvfb-run -a python3 tests/diagnostics_test.py --build-dir build
```
The metadata check uses a temporary Git repository and a source archive to
verify clean/modified builds, version edits, commit changes and unchanged
header timestamps. The Wine check uses a private D-Bus service, display and
prefix. It queries `--version` without a reader and while the singleton is
held, verifies identity on failed reader discovery, and checks normal and
missing-hook initialization records. It never contacts the active reader.
After installing both updates and restarting Cthulhu, launch AudioQuake and
check its log for `Wine accessibility helper identity` and
`Wine accessibility helper initialized`, with the expected version/revision.
Enable debug logging to confirm the executable, loader and prefix selected.
Check the launcher list with Down/Down/Up and Tab/Shift+Tab, its map chooser,
and returning to the launcher after game exit. Recheck ordinary Wine Notepad
typing, arrow navigation, menus and shutdown, including password exclusion.
Audible speech and physical Braille require separate live checks. This change
adds diagnostics; it does not establish a fix for a remote machine's silence.
+110
View File
@@ -0,0 +1,110 @@
"""Exercise version/startup diagnostics on a private D-Bus session and Wine prefix.
Run under xvfb-run. No requests go to the active screen reader.
"""
import argparse
import json
import os
from pathlib import Path
import shutil
import subprocess
import sys
import tempfile
import time
from gi.repository import Gio, GLib
parser = argparse.ArgumentParser()
parser.add_argument("--build-dir", type=Path, default=Path("build"))
parser.add_argument("--session", action="store_true")
args = parser.parse_args()
buildDir = args.build_dir.resolve()
if not args.session:
sys.exit(subprocess.call(["dbus-run-session", "--", sys.executable, __file__,
"--session", "--build-dir", str(buildDir)]))
busName = "org.stormux.Cthulhu1.Service"
objectPath = "/org/stormux/Cthulhu1/Service"
xml = '<node><interface name="' + busName + '">'
for method, signature in (("PresentAccessibleEvent", "sssssssiis"),
("SpeakText", "sb"), ("CancelSpeech", "")):
xml += '<method name="' + method + '">'
xml += ''.join('<arg type="' + kind + '" direction="in"/>' for kind in signature)
xml += '<arg type="b" direction="out"/></method>'
xml += '</interface></node>'
def record(connection, sender, path, interface, method, parameters, invocation):
invocation.return_value(GLib.Variant("(b)", (True,)))
with tempfile.TemporaryDirectory(prefix="wine-access-diagnostics-") as temporary:
testDir = Path(temporary)
env = os.environ.copy()
env.update(WINEPREFIX=str(testDir / "prefix"), WINEDEBUG="-all",
WINEDLLOVERRIDES="mscoree,mshtml=", CTHULHU_WINE_ACCESS_MANAGED="1")
helper = None
try:
subprocess.run(["wineboot", "-u"], env=env, check=True, timeout=45)
executable = buildDir / "wine-access.exe"
version = subprocess.run([str(executable), "--version"], env=env,
check=True, capture_output=True, text=True, timeout=5)
assert version.stdout.startswith("Wine Access ") and not version.stderr
# No reader exists yet; ordinary startup must identify itself and fail.
failed = subprocess.run([str(executable), "--no-controller"], env=env,
capture_output=True, text=True, timeout=5)
assert failed.returncode != 0 and "reader service" in failed.stderr
assert "wine-access: identity " in failed.stderr and "wine-access: ready " not in failed.stderr
connection = Gio.bus_get_sync(Gio.BusType.SESSION, None)
connection.register_object(objectPath, Gio.DBusNodeInfo.new_for_xml(xml).interfaces[0], record, None, None)
connection.call_sync("org.freedesktop.DBus", "/org/freedesktop/DBus",
"org.freedesktop.DBus", "RequestName",
GLib.Variant("(su)", (busName, 0)), None,
Gio.DBusCallFlags.NONE, 1000, None)
for missingHooks in (False, True):
selected = executable
if missingHooks:
incompleteDir = testDir / "incomplete"
incompleteDir.mkdir()
for name in ("wine-access.exe", "wine-access.exe.so"):
shutil.copy2(buildDir / name, incompleteDir / name)
selected = incompleteDir / "wine-access.exe"
logPath = testDir / ("missing.log" if missingHooks else "healthy.log")
with logPath.open("w") as diagnostics:
helper = subprocess.Popen([str(selected), "--no-controller"], env=env,
stdout=subprocess.DEVNULL, stderr=diagnostics)
deadline = time.monotonic() + 5
while "wine-access: ready " not in logPath.read_text() and time.monotonic() < deadline:
while GLib.MainContext.default().iteration(False):
pass
assert helper.poll() is None, logPath.read_text()
time.sleep(0.01)
lines = logPath.read_text().splitlines()
identity = next(json.loads(line.removeprefix("wine-access: identity "))
for line in lines if line.startswith("wine-access: identity "))
ready = next(json.loads(line.removeprefix("wine-access: ready "))
for line in lines if line.startswith("wine-access: ready "))
assert identity["version"] in version.stdout and identity["revision"] in version.stdout
for field in ("controls_enabled", "review", "msaa_worker", "win_events", "control_poll"):
assert ready[field], (field, ready)
assert not ready["controller"]
for field in ("menu_hook", "input_hook", "host32_started"):
assert ready[field] == (not missingHooks), (field, ready)
if missingHooks:
assert "native menu hook unavailable" in logPath.read_text()
assert "32-bit menu host unavailable" in logPath.read_text()
assert "unavailable (error 0)" not in logPath.read_text(), logPath.read_text()
# A query must bypass the singleton held by this helper.
queried = subprocess.run([str(selected), "--version"], env=env,
check=True, capture_output=True, text=True, timeout=5)
assert queried.stdout == version.stdout and not queried.stderr
helper.terminate()
helper.wait(timeout=5)
helper = None
finally:
subprocess.run(["wineserver", "-k"], env=env, timeout=10, check=False)
if helper is not None:
helper.wait(timeout=10)
print("PASS: version without reader/with singleton, unavailable reader, healthy and missing-hook startup")
+56
View File
@@ -0,0 +1,56 @@
"""Check build identity refresh and source-archive handling without running Wine."""
from pathlib import Path
import shutil
import subprocess
import tempfile
projectDir = Path(__file__).resolve().parents[1]
with tempfile.TemporaryDirectory(prefix="wine-access-version-") as temporary:
sourceDir = Path(temporary) / "source"
sourceDir.mkdir()
shutil.copytree(projectDir / "cmake", sourceDir / "cmake")
(sourceDir / "VERSION").write_text("2026.10.15\n")
outputFile = Path(temporary) / "version.h"
def generate_version():
subprocess.run(["cmake", f"-DSOURCE_DIR={sourceDir}",
f"-DOUTPUT_FILE={outputFile}", "-P",
str(sourceDir / "cmake/GenerateVersion.cmake")], check=True)
return outputFile.read_text()
def git(*arguments):
return subprocess.check_output(["git", "-C", str(sourceDir), *arguments],
text=True, stderr=subprocess.PIPE).strip()
assert 'WINE_ACCESS_REVISION "unavailable"' in generate_version()
git("init", "--quiet")
git("config", "user.name", "Fixture Author")
git("config", "user.email", "author@example.invalid")
git("add", ".")
git("-c", "commit.gpgsign=false", "commit", "--quiet", "-m", "Initial fixture")
firstRevision = git("rev-parse", "--short=12", "HEAD")
cleanHeader = generate_version()
assert firstRevision in cleanHeader and 'WINE_ACCESS_MODIFIED "false"' in cleanHeader
unchangedTime = outputFile.stat().st_mtime_ns
assert generate_version() == cleanHeader
assert outputFile.stat().st_mtime_ns == unchangedTime, "unchanged build rewrote version header"
(sourceDir / "VERSION").write_text("2026.10.16\n")
dirtyHeader = generate_version()
assert 'WINE_ACCESS_VERSION "2026.10.16"' in dirtyHeader
assert 'WINE_ACCESS_MODIFIED "true"' in dirtyHeader and ", modified)" in dirtyHeader
git("add", "VERSION")
git("-c", "commit.gpgsign=false", "commit", "--quiet", "-m", "Updated fixture")
secondRevision = git("rev-parse", "--short=12", "HEAD")
assert secondRevision != firstRevision
assert secondRevision in generate_version(), "incremental identity retained previous commit"
(sourceDir / "untracked.txt").write_text("fixture\n")
assert 'WINE_ACCESS_MODIFIED "true"' in generate_version()
# An archive nested inside another repository must not borrow its identity.
archiveDir = sourceDir / "archive"
archiveDir.mkdir()
shutil.copytree(projectDir / "cmake", archiveDir / "cmake")
(archiveDir / "VERSION").write_text("2026.10.15\n")
sourceDir = archiveDir
assert 'WINE_ACCESS_REVISION "unavailable"' in generate_version()
print("PASS: clean, modified, incremental, unchanged and archive build identities")