revert UIAutomation background thread — it stalled click detection

TryLoadUia() blocked for several seconds loading reflection assemblies at
ClickProcessorLoop startup, during which no CLICK events were emitted.
Restored MouseHookCallback to emit CTX + CLICK synchronously (fast Win32
only) as before — no startup cost, clicks are never delayed.

The wider OCR crop and smarter search-results title fallback are kept.
UIAutomation element lookup can be revisited with a pre-warmed approach.

Co-Authored-By: Claude Sonnet 4.6 <[email protected]>
This commit is contained in:
2026-06-24 11:14:49 -05:00
co-authored by Claude Sonnet 4.6
parent d0b1816175
commit 3fa366a0d0
+10 -117
View File
@@ -831,99 +831,6 @@ public static class SFHook {
private static readonly ConcurrentQueue<string> queue = new ConcurrentQueue<string>();
private static readonly AutoResetEvent signal = new AutoResetEvent(false);
// Pending click queue for background UIAutomation enrichment.
private struct PendingClick {
public int x, y;
public string button;
public long ts;
public string wTitle;
public string wApp;
}
private static readonly ConcurrentQueue<PendingClick> pendingClicks = new ConcurrentQueue<PendingClick>();
private static readonly AutoResetEvent clickSignal = new AutoResetEvent(false);
// UIAutomation state — loaded once via reflection so the C# code has no
// compile-time assembly reference (avoids breaking the whole hook if UIA is absent).
private static bool uiaLoadAttempted = false;
private static System.Reflection.MethodInfo uiaFromPoint = null;
private static System.Reflection.PropertyInfo uiaCurrent = null;
private static System.Type uiaPointType = null;
private static System.Reflection.Assembly uiaClientAssembly = null;
private static void TryLoadUia() {
if (uiaLoadAttempted) return;
uiaLoadAttempted = true;
try {
uiaClientAssembly = System.Reflection.Assembly.Load("UIAutomationClient");
var wbase = System.Reflection.Assembly.Load("WindowsBase");
var aeType = uiaClientAssembly.GetType("System.Windows.Automation.AutomationElement");
uiaFromPoint = aeType.GetMethod("FromPoint");
uiaCurrent = aeType.GetProperty("Current");
uiaPointType = wbase.GetType("System.Windows.Point");
} catch { }
}
private static void GetUiaElement(int x, int y, out string label, out string role, out string value) {
label = ""; role = ""; value = "";
if (uiaFromPoint == null || uiaPointType == null) return;
try {
var pt = Activator.CreateInstance(uiaPointType, (double)x, (double)y);
var element = uiaFromPoint.Invoke(null, new object[] { pt });
if (element == null) return;
var current = uiaCurrent.GetValue(element);
var ct = current.GetType();
label = ct.GetProperty("Name")?.GetValue(current) as string ?? "";
role = ct.GetProperty("LocalizedControlType")?.GetValue(current) as string ?? "";
// Try to read the element's current value (text box contents etc.).
try {
var vpType = uiaClientAssembly.GetType("System.Windows.Automation.ValuePattern");
var vpPatternId = vpType?.GetField("Pattern")?.GetValue(null);
if (vpPatternId != null) {
var aeType = uiaClientAssembly.GetType("System.Windows.Automation.AutomationElement");
var tryGet = aeType.GetMethod("TryGetCurrentPattern",
new System.Type[] { vpPatternId.GetType(), typeof(object).MakeByRefType() });
if (tryGet != null) {
object[] args = new object[] { vpPatternId, null };
if ((bool)tryGet.Invoke(element, args) && args[1] != null) {
var curr = args[1].GetType().GetProperty("Current")?.GetValue(args[1]);
value = curr?.GetType().GetProperty("Value")?.GetValue(curr) as string ?? "";
}
}
}
} catch { }
} catch { }
}
// Background thread: enriches each click with UIAutomation element info, then
// emits CTX + ELEM (optional) + CLICK as an atomic batch.
private static void ClickProcessorLoop() {
TryLoadUia();
while (true) {
clickSignal.WaitOne();
PendingClick click;
while (pendingClicks.TryDequeue(out click)) {
string label = "", role = "", val = "";
// Run UIAutomation on a separate thread so we can impose a timeout.
// 300 ms is enough for most apps; if UIA is slow we skip element enrichment
// but still emit the click (no steps are lost).
string[] r = new string[] { "", "", "" };
var t = new Thread(() => {
GetUiaElement(click.x, click.y, out r[0], out r[1], out r[2]);
});
t.IsBackground = true;
t.Start();
if (t.Join(300)) { label = r[0]; role = r[1]; val = r[2]; }
// Emit window context (always), element info (when available), then click.
queue.Enqueue("CTX " + B64(click.wTitle) + " " + B64(click.wApp) + " " + click.ts);
if (label.Length > 0 || role.Length > 0) {
queue.Enqueue("ELEM " + B64(label) + " " + B64(role) + " " + B64(val) + " " + click.ts);
}
queue.Enqueue("CLICK " + click.x + " " + click.y + " " + click.button + " " + click.ts);
signal.Set();
}
}
}
[StructLayout(LayoutKind.Sequential)]
private struct POINT {
public int x;
@@ -1090,10 +997,6 @@ public static class SFHook {
writer.IsBackground = true;
writer.Start();
Thread clickProcessor = new Thread(ClickProcessorLoop);
clickProcessor.IsBackground = true;
clickProcessor.Start();
hook = SetWindowsHookEx(WH_MOUSE_LL, proc, GetModuleHandle(null), 0);
if (hook == IntPtr.Zero) {
throw new System.ComponentModel.Win32Exception(Marshal.GetLastWin32Error());
@@ -1132,12 +1035,14 @@ public static class SFHook {
if (button != null) {
MSLLHOOKSTRUCT data = (MSLLHOOKSTRUCT)Marshal.PtrToStructure(lParam, typeof(MSLLHOOKSTRUCT));
long unixMs = DateTime.UtcNow.Ticks / TimeSpan.TicksPerMillisecond - UnixEpochMilliseconds;
// Capture window title synchronously (fast Win32, safe in hook callback).
// UIAutomation element lookup runs on the background ClickProcessorLoop thread.
string wTitle = "", wApp = "";
try { wTitle = GetFwTitle(); wApp = GetFwApp(); } catch { }
pendingClicks.Enqueue(new PendingClick { x = data.pt.x, y = data.pt.y, button = button, ts = unixMs, wTitle = wTitle, wApp = wApp });
clickSignal.Set();
// Capture window context while the user's app is still in foreground.
// GetForegroundWindow is synchronous and fast (no IPC overhead).
try {
string t = GetFwTitle(), a = GetFwApp();
queue.Enqueue("CTX " + B64(t) + " " + B64(a) + " " + unixMs);
} catch { }
queue.Enqueue("CLICK " + data.pt.x + " " + data.pt.y + " " + button + " " + unixMs);
signal.Set();
}
}
return CallNextHookEx(hook, nCode, wParam, lParam);
@@ -1378,28 +1283,16 @@ public static class SFHook {
this.onKeyboardEvent('CHAR', Number(charM[1]), Number(charM[2]));
continue;
}
// CTX always arrives just before its paired ELEM+CLICK from the same background thread batch.
const ctxM = /^CTX\s+(\S+)\s+(\S+)\s+(\d+)$/.exec(trimmed);
// CTX is emitted just before its paired CLICK from MouseHookCallback.
const ctxM = /^CTX\s+(\S+)\s+(\S+)\s+\d+$/.exec(trimmed);
if (ctxM) {
const decode = s => s === '-' ? '' : Buffer.from(s, 'base64').toString('utf8');
this._pendingWindowContext = {
windowTitle: decode(ctxM[1]),
appName: decode(ctxM[2]),
ts: Number(ctxM[3]),
};
continue;
}
// ELEM carries UIAutomation element info for the same click as the preceding CTX.
const elemM = /^ELEM\s+(\S+)\s+(\S+)\s+(\S+)\s+(\d+)$/.exec(trimmed);
if (elemM) {
const decode = s => s === '-' ? '' : Buffer.from(s, 'base64').toString('utf8');
if (this._pendingWindowContext && this._pendingWindowContext.ts === Number(elemM[4])) {
this._pendingWindowContext.elementLabel = decode(elemM[1]);
this._pendingWindowContext.elementRole = decode(elemM[2]);
this._pendingWindowContext.elementValue = decode(elemM[3]);
}
continue;
}
}
}
}