diff --git a/app/capture.js b/app/capture.js index d7f8243..a3a2350 100644 --- a/app/capture.js +++ b/app/capture.js @@ -139,6 +139,7 @@ class CaptureService { this._keyBuffer = ''; // printable chars typed since last capture this._lastShortcut = ''; // last Ctrl+X / Alt+X combination this._keyLastAt = 0; // timestamp of last key event + this._lastWindowContext = null; // last CTX event from the click watcher this.clickQueue = Promise.resolve(); this.frameLoopInFlight = false; this.frameLoopGrabStartedAt = null; @@ -920,6 +921,58 @@ public static class SFHook { [DllImport("kernel32.dll")] private static extern bool SetPriorityClass(IntPtr hProcess, uint dwPriorityClass); + [DllImport("user32.dll")] + private static extern IntPtr GetForegroundWindow(); + + [DllImport("user32.dll", CharSet = CharSet.Unicode)] + private static extern int GetWindowText(IntPtr hWnd, System.Text.StringBuilder text, int count); + + [DllImport("user32.dll")] + private static extern uint GetWindowThreadProcessId(IntPtr hWnd, out uint processId); + + [DllImport("kernel32.dll", SetLastError = true)] + private static extern IntPtr OpenProcess(uint processAccess, bool bInheritHandle, uint processId); + + [DllImport("kernel32.dll", SetLastError = true)] + private static extern bool CloseHandle(IntPtr hObject); + + [DllImport("kernel32.dll", CharSet = CharSet.Unicode, SetLastError = true)] + private static extern bool QueryFullProcessImageName(IntPtr hProcess, uint dwFlags, System.Text.StringBuilder lpExeName, ref uint lpdwSize); + + private static string GetFwTitle() { + try { + IntPtr hwnd = GetForegroundWindow(); + if (hwnd == IntPtr.Zero) return ""; + var sb = new System.Text.StringBuilder(512); + GetWindowText(hwnd, sb, sb.Capacity); + return sb.ToString(); + } catch { return ""; } + } + + private static string GetFwApp() { + try { + IntPtr hwnd = GetForegroundWindow(); + if (hwnd == IntPtr.Zero) return ""; + uint pid = 0; + GetWindowThreadProcessId(hwnd, out pid); + if (pid == 0) return ""; + IntPtr hProc = OpenProcess(0x1000u, false, pid); + if (hProc == IntPtr.Zero) return ""; + var sb = new System.Text.StringBuilder(260); + uint sz = (uint)sb.Capacity; + QueryFullProcessImageName(hProc, 0u, sb, ref sz); + CloseHandle(hProc); + string path = sb.ToString(); + return string.IsNullOrEmpty(path) ? "" : System.IO.Path.GetFileNameWithoutExtension(path); + } catch { return ""; } + } + + // Base64-encodes a string for safe line-protocol transmission; "-" for empty. + private static string B64(string s) { + if (string.IsNullOrEmpty(s)) return "-"; + try { return Convert.ToBase64String(System.Text.Encoding.UTF8.GetBytes(s)); } catch { return "-"; } + } + // Force this process to run at full CPU speed regardless of the power plan, // so the mouse-hook callback never trips LowLevelHooksTimeout and clicks // keep being delivered while the laptop is in eco / power-saving mode. @@ -982,6 +1035,12 @@ 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 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(); } @@ -1222,6 +1281,15 @@ public static class SFHook { const charM = /^CHAR\s+(\d+)\s+(\d+)\s*$/.exec(trimmed); if (charM) { this.onKeyboardEvent('CHAR', Number(charM[1]), Number(charM[2])); + continue; + } + const ctxM = /^CTX\s+(\S+)\s+(\S+)\s+\d+$/.exec(trimmed); + if (ctxM) { + const decode = s => s === '-' ? '' : Buffer.from(s, 'base64').toString('utf8'); + this._lastWindowContext = { + windowTitle: decode(ctxM[1]), + appName: decode(ctxM[2]), + }; } } } @@ -1401,6 +1469,10 @@ public static class SFHook { } // Snapshot keyboard context accumulated since the last capture. clickMeta.keyContext = this.snapshotKeyContext(); + // Attach the window context emitted by the click watcher alongside the click. + if (this._lastWindowContext) { + clickMeta.windowContext = this._lastWindowContext; + } if (this.session && !this.session.paused && !this.userIsInApp()) { // The guide id pins the click to its recording so it can still be // stored if the session stops while this click waits in the queue. diff --git a/app/text-intel.js b/app/text-intel.js index 1e5ff39..2695bda 100644 --- a/app/text-intel.js +++ b/app/text-intel.js @@ -2,7 +2,7 @@ const fs = require('node:fs'); const path = require('node:path'); -const { execFileSync } = require('node:child_process'); +const { execFileSync, execFile } = require('node:child_process'); const { DEFAULT_CAPTURE_TITLES, @@ -170,7 +170,7 @@ class TextIntelService { return { appName: '', windowTitle: '' }; } - collectWindowsWindowContext(osPoint = null) { + async collectWindowsWindowContext(osPoint = null) { const hasPoint = osPoint && Number.isFinite(osPoint.x) && Number.isFinite(osPoint.y); const clickX = hasPoint ? Number(osPoint.x) : 0; const clickY = hasPoint ? Number(osPoint.y) : 0; @@ -231,12 +231,17 @@ public static class Win32 { }; $out | ConvertTo-Json -Compress; `; - const result = execFileSync('powershell.exe', ['-NoProfile', '-NonInteractive', '-ExecutionPolicy', 'Bypass', '-Command', script], { - encoding: 'utf8', - stdio: ['ignore', 'pipe', 'pipe'], - timeout: 1200, - }).trim(); - return JSON.parse(result || '{}'); + return new Promise(resolve => { + execFile('powershell.exe', ['-NoProfile', '-NonInteractive', '-ExecutionPolicy', 'Bypass', '-Command', script], { + encoding: 'utf8', + timeout: 4000, + windowsHide: true, + }, (err, stdout) => { + if (err) { resolve({}); return; } + try { resolve(JSON.parse(stdout.trim() || '{}')); } + catch { resolve({}); } + }); + }); } collectMacWindowContext() { @@ -295,8 +300,13 @@ public static class Win32 { const keyContext = clickMeta?.keyContext || {}; const recentTyped = keyContext.recentTyped || ''; const recentShortcut = keyContext.recentShortcut || ''; + // Use window context pre-captured by the click watcher when available. + // This avoids a costly PowerShell cold-start (1–3 s) on every capture. + const fastContext = clickMeta?.windowContext || null; const [metadata, ocr] = await Promise.all([ - this.collectForegroundWindowContext(clickMeta?.osPoint || null), + fastContext + ? Promise.resolve(fastContext) + : this.collectForegroundWindowContext(clickMeta?.osPoint || null), this.ocrAroundClick(frame, clickPos), ]); const title = buildCaptureTitle({ mode, metadata, ocrText: ocr.text, recentTyped, recentShortcut });