; PressF8 Bridge — puente F8 entre el RIS y pressf8.cl ; AutoHotkey v2. Ver README.md para compilar con Ahk2Exe y para instalación. ; ; F8 en el RIS -> copia el campo enfocado, lo envía a PressF8 y trae la web al frente. ; F8 en PressF8 -> lo maneja la web (este script NO captura F8 si la ventana activa ; contiene "PressF8" en el título); el texto vuelve por polling y ; se pega en la ventana original del RIS (Ctrl+A, Delete, Ctrl+V). #Requires AutoHotkey v2.0 #SingleInstance Force Persistent #Include JSON.ahk #Include bridge_response.ahk #Include protocol.ahk #Include preflight.ahk ; --- Estado global --- global ConfigDir := EnvGet("LOCALAPPDATA") "\PressF8" global ConfigFile := ConfigDir "\config.ini" global LogFile := ConfigDir "\diagnostico.log" global BackendUrl := "https://pressf8.cl" global InstanceId := "" global InstanceToken := "" global Username := "" global BridgeEnabled := true global SoundEnabled := true global AutofocusEnabled := true global LivePreprocessEnabled := false global RisHwnd := 0 global RisHwndTime := 0 global Offline := false global BridgeVersion := "0.5.1" global ProtocolVersion := 3 global ReturnRisProcessName := "" global ReturnRisAutomationId := "" global ReturnRisControlType := 0 global ReturnRisControlHwnd := 0 global ReturnRisControlClassNN := "" global ReturnRisOriginalText := "" global RisProcessName := "" global RisAutomationId := "" global RisControlType := 0 global RisControlHwnd := 0 global RisControlClassNN := "" global LiveDraftId := "" global LiveRevision := 0 global LastLiveText := "" global LastLiveChange := 0 global LiveStableSent := false global LastCommittedText := "" ; Guardas de fluidez: NetBusy evita que los timers apilen requests HTTP ; (el pump de WaitForResponse permite que un timer dispare durante otra espera); ; RoundBusy congela timers y F8 repetido durante una ronda de captura/pegado. global NetBusy := false global RoundBusy := false ; Backoff tras un fallo de live-snapshot: sin esto se reintenta cada 500 ms. global LiveSendFailUntil := 0 ; UIA cacheado: crear CUIAutomation en cada tick de 500 ms cuesta un objeto COM ; por lectura; se crea una vez y se recrea solo si una llamada falla. global UIAObj := "" ; Pestillo: en un Windows sin el ProgID de UI Automation registrado, reintentar ; crear el objeto en cada tick de 500 ms es puro desperdicio. global UIAUnavailable := false ; Aviso "este RIS no expone el campo": una vez por sesión, no en cada F8. global WarnedLivePreprocess := false ; Ventana del RIS guardada hace más de esto se considera vencida (contexto pudo cambiar) global RIS_HWND_TTL_MS := 15 * 60 * 1000 global POLL_INTERVAL_MS := 1500 global LIVE_PREFLIGHT_IDLE_MS := 750 ; El umbral de material suficiente vive en preflight.ahk. El envío sigue siendo ; uno solo por informe: el servidor reutiliza el prefijo común al llegar el F8, ; así que no hace falta re-enviar tras cada pausa (eso costaba ~10,6 revisiones ; GPT por informe — medido el 2026-07-28). SetTitleMatchMode(2) ; "PressF8" en cualquier parte del título InitConfig() RebuildTray() HandleStartupArguments() SetTimer(SafePoll, POLL_INTERVAL_MS) SetTimer(SafeLiveCapture, 500) ; --- Hotkey contextual: F8 solo actúa fuera de PressF8 --- ; Con el foco en la pestaña de PressF8, F8 pasa al navegador y lo maneja la web (Flujo 2). #HotIf BridgeHotkeyActive() F8:: SafeCaptureFromRis() #HotIf BridgeHotkeyActive() { global BridgeEnabled return BridgeEnabled && !WinActive("PressF8") } ; --------------------------------------------------------------------------- ; Configuración (config.ini en %LOCALAPPDATA%\PressF8) ; --------------------------------------------------------------------------- InitConfig() { global if !DirExist(ConfigDir) DirCreate(ConfigDir) BackendUrl := RTrim(SafeIniRead("server", "backend_url", BackendUrl), "/") if (BackendUrl = "") BackendUrl := "https://pressf8.cl" InstanceId := SafeIniRead("instance", "instance_id", "") InstanceToken := SafeIniRead("instance", "instance_token", "") Username := SafeIniRead("instance", "username", "") BridgeEnabled := SafeIniBool("prefs", "bridge_enabled", true) SoundEnabled := SafeIniBool("prefs", "sound_enabled", true) AutofocusEnabled := SafeIniBool("prefs", "autofocus_enabled", true) LivePreprocessEnabled := SafeIniBool("prefs", "live_preprocess_enabled", false) RisProcessName := SafeIniRead("ris", "process_name", "") RisAutomationId := SafeIniRead("ris", "automation_id", "") RisControlType := SafeIniInteger("ris", "control_type", 0) RisControlHwnd := SafeIniInteger("ris", "native_hwnd", 0) RisControlClassNN := SafeIniRead("ris", "class_nn", "") if (InstanceId = "") { InstanceId := NewInstanceId() IniWrite(InstanceId, ConfigFile, "instance", "instance_id") } IniWrite(BackendUrl, ConfigFile, "server", "backend_url") } ; --------------------------------------------------------------------------- ; Registro de diagnóstico ; ; SÓLO motivos, estados y contadores. NUNCA texto del informe ni nada del ; paciente (CLAUDE.md: PHI jamás en logs). Los largos en caracteres sí se ; registran: dicen si llegó algo o llegó vacío, y no revelan contenido. ; ; Existe porque hasta 2026-07-28 el puente no dejaba rastro de nada: cada ; diagnóstico había que hacerlo con sondas improvisadas sobre la máquina del ; usuario, una ronda por pregunta. ; --------------------------------------------------------------------------- LogDiag(msg) { global LogFile, BridgeVersion try { ; Rotación simple: al pasar de ~200 KB se descarta y se empieza de nuevo. if (FileExist(LogFile) && FileGetSize(LogFile) > 200000) { FileDelete(LogFile) FileAppend(FormatTime(, "yyyy-MM-dd HH:mm:ss") " --- registro rotado ---`r`n", LogFile, "UTF-8") } FileAppend(FormatTime(, "yyyy-MM-dd HH:mm:ss") " [" BridgeVersion "] " msg "`r`n", LogFile, "UTF-8") } } SafeIniRead(section, key, fallback := "") { global ConfigFile try return IniRead(ConfigFile, section, key, fallback) catch return fallback } SafeIniInteger(section, key, fallback := 0) { raw := SafeIniRead(section, key, String(fallback)) if !RegExMatch(raw, "^-?\d+$") return fallback try return Integer(raw) catch return fallback } SafeIniBool(section, key, fallback := false) { raw := SafeIniRead(section, key, fallback ? "1" : "0") if (raw = "1") return true if (raw = "0") return false return fallback } NewInstanceId() { id := "" Loop 32 id .= SubStr("0123456789abcdef", Random(1, 16), 1) return id } SaveSession() { global IniWrite(InstanceToken, ConfigFile, "instance", "instance_token") IniWrite(Username, ConfigFile, "instance", "username") } SavePrefs() { global IniWrite(BridgeEnabled ? "1" : "0", ConfigFile, "prefs", "bridge_enabled") IniWrite(SoundEnabled ? "1" : "0", ConfigFile, "prefs", "sound_enabled") IniWrite(AutofocusEnabled ? "1" : "0", ConfigFile, "prefs", "autofocus_enabled") IniWrite(LivePreprocessEnabled ? "1" : "0", ConfigFile, "prefs", "live_preprocess_enabled") } ; --------------------------------------------------------------------------- ; Tray ; --------------------------------------------------------------------------- RebuildTray() { global status := !BridgeEnabled ? "Pausado" : ((InstanceToken != "") ? "Conectado como: " Username : "Sin sesión") tm := A_TrayMenu tm.Delete() statusLabel := "Bridge " BridgeVersion " — " status tm.Add(statusLabel, (*) => 0) tm.Disable(statusLabel) tm.Add() tm.Add("Atajo F8 activo", (*) => ToggleBridge()) tm.Add() tm.Add("Iniciar sesión en la web…", (*) => StartBrowserLink()) tm.Add("Ingresar código manual…", (*) => DoLink()) tm.Add("Cerrar sesión", (*) => DoUnlink()) tm.Add() tm.Add("Sonido", (*) => ToggleSound()) tm.Add("Traer PressF8 al frente (F8 en RIS)", (*) => ToggleAutofocus()) tm.Add("Procesamiento anticipado", (*) => ToggleLivePreprocess()) tm.Add() tm.Add("Ver registro de diagnóstico", (*) => AbrirLog()) tm.Add() tm.Add("Salir", (*) => ExitApp()) if BridgeEnabled tm.Check("Atajo F8 activo") if SoundEnabled tm.Check("Sonido") if AutofocusEnabled tm.Check("Traer PressF8 al frente (F8 en RIS)") if LivePreprocessEnabled tm.Check("Procesamiento anticipado") A_IconTip := "PressF8 Bridge " BridgeVersion " — " status } ; Abrir SIEMPRE con el Bloc de notas. Delegar en la asociación de Windows no ; sirve: .log no tiene aplicación asociada por defecto y Run() no lanza ; excepción en ese caso —Windows se traga el intento o abre el diálogo «¿cómo ; quieres abrir esto?»—, así que el respaldo del catch nunca llegaba a correr y ; el menú parecía muerto. AbrirLog() { global LogFile if !FileExist(LogFile) LogDiag("registro abierto sin actividad previa") try Run('notepad.exe "' LogFile '"') catch try Run('explorer.exe /select,"' LogFile '"') } ToggleBridge() { global BridgeEnabled BridgeEnabled := !BridgeEnabled SavePrefs() if !BridgeEnabled { ResetLiveDraft() ClearRisReturnTarget() } RebuildTray() if BridgeEnabled PushConfig() } ToggleSound() { global SoundEnabled SoundEnabled := !SoundEnabled SavePrefs() RebuildTray() PushConfig() } ToggleAutofocus() { global AutofocusEnabled AutofocusEnabled := !AutofocusEnabled SavePrefs() RebuildTray() PushConfig() } PushConfig() { ; Sincroniza el toggle del tray con la preferencia del usuario en el backend ; (best-effort: si falla, el próximo poll del ciclo lo reconcilia). global if !BridgeEnabled || (InstanceToken = "") return body := Map("sound_enabled", SoundEnabled ? 1 : 0, "autofocus_enabled", AutofocusEnabled ? 1 : 0, "live_preprocess_enabled", LivePreprocessEnabled ? 1 : 0) try Http("POST", "/bridge/script-config", JSON.Stringify(body)) } ; --------------------------------------------------------------------------- ; Vinculación de sesión (código corto de un solo uso) ; --------------------------------------------------------------------------- DoLink() { result := InputBox("Ingresa el código de 6 dígitos generado en pressf8.cl → Puente F8 → Vincular este equipo", "PressF8 Bridge — Iniciar sesión", "w420 h130") if (result.Result != "OK") return LinkWithCode(Trim(result.Value)) } ToggleLivePreprocess() { global LivePreprocessEnabled LivePreprocessEnabled := !LivePreprocessEnabled SavePrefs() RebuildTray() PushConfig() if LivePreprocessEnabled NoteLivePreprocessNecesitaF8() else ResetLiveDraft() } HandleStartupArguments() { global if !A_Args.Length return callbackCode := CallbackCodeFromUri(A_Args[1]) if (callbackCode != "") { CompleteBrowserLink(callbackCode) return } code := LinkCodeFromUri(A_Args[1]) if (code = "") { MsgBox("El enlace de vinculación no es válido. Genera uno nuevo desde pressf8.cl.", "PressF8 Bridge", "Iconx") return } LinkWithCode(code) } RandomUrlSafe(length := 64) { chars := "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789-._~" value := "" Loop length value .= SubStr(chars, Random(1, StrLen(chars)), 1) return value } PkceChallenge(verifier) { hAlg := 0, hHash := 0 DllCall("bcrypt\BCryptOpenAlgorithmProvider", "ptr*", &hAlg, "wstr", "SHA256", "ptr", 0, "uint", 0) objLen := 0, cb := 0, hashLen := 0 DllCall("bcrypt\BCryptGetProperty", "ptr", hAlg, "wstr", "ObjectLength", "uint*", &objLen, "uint", 4, "uint*", &cb, "uint", 0) DllCall("bcrypt\BCryptGetProperty", "ptr", hAlg, "wstr", "HashDigestLength", "uint*", &hashLen, "uint", 4, "uint*", &cb, "uint", 0) obj := Buffer(objLen), digest := Buffer(hashLen), input := Buffer(StrPut(verifier, "UTF-8") - 1) StrPut(verifier, input, "UTF-8") DllCall("bcrypt\BCryptCreateHash", "ptr", hAlg, "ptr*", &hHash, "ptr", obj, "uint", objLen, "ptr", 0, "uint", 0, "uint", 0) DllCall("bcrypt\BCryptHashData", "ptr", hHash, "ptr", input, "uint", input.Size, "uint", 0) DllCall("bcrypt\BCryptFinishHash", "ptr", hHash, "ptr", digest, "uint", hashLen, "uint", 0) chars := 0 DllCall("Crypt32\CryptBinaryToString", "ptr", digest, "uint", hashLen, "uint", 0x40000001, "ptr", 0, "uint*", &chars) out := Buffer(chars * 2) DllCall("Crypt32\CryptBinaryToString", "ptr", digest, "uint", hashLen, "uint", 0x40000001, "ptr", out, "uint*", &chars) DllCall("bcrypt\BCryptDestroyHash", "ptr", hHash), DllCall("bcrypt\BCryptCloseAlgorithmProvider", "ptr", hAlg, "uint", 0) return RTrim(StrReplace(StrReplace(StrGet(out), "+", "-"), "/", "_"), "=") } StartBrowserLink() { global verifier := RandomUrlSafe(64), state := RandomUrlSafe(48) IniWrite(verifier, ConfigFile, "oauth", "pkce_verifier") body := Map("state", state, "code_challenge", PkceChallenge(verifier), "instance_id", InstanceId, "hostname", A_ComputerName) try resp := Http("POST", "/bridge/authorize/start", JSON.Stringify(body)) catch { TrayTip("No se pudo iniciar la vinculación web.", "Atajo tecla F8", "Iconx") return } if (resp.status != 200) { TrayTip("No se pudo iniciar la vinculación web. Inténtalo nuevamente.", "Atajo tecla F8", "Iconx") return } if !TryParseJsonMap(resp.text, &data) return authorizeUrl := MapString(data, "authorize_url") if (authorizeUrl != "") Run(BackendUrl authorizeUrl) } CompleteBrowserLink(code) { global verifier := IniRead(ConfigFile, "oauth", "pkce_verifier", "") if (verifier = "") return try resp := Http("POST", "/bridge/token", JSON.Stringify(Map("code", code, "code_verifier", verifier))) catch { TrayTip("No se pudo completar la vinculación. Inténtalo nuevamente.", "Atajo tecla F8", "Iconx") return } if (resp.status != 200) { TrayTip("La vinculación expiró o fue rechazada. Inténtalo nuevamente.", "Atajo tecla F8", "Iconx") return } if !TryParseJsonMap(resp.text, &data) { TrayTip("El servidor devolvió una respuesta inválida. Inténtalo nuevamente.", "Atajo tecla F8", "Iconx") return } token := MapString(data, "instance_token") username := MapString(data, "username") if (token = "" || username = "") return InstanceToken := token, Username := username IniDelete(ConfigFile, "oauth", "pkce_verifier") SaveSession(), RebuildTray(), TrayTip("Conectado como " Username, "Atajo tecla F8") } LinkWithCode(code) { global if !RegExMatch(code, "^\d{6}$") { MsgBox("El código debe tener exactamente 6 dígitos.", "PressF8 Bridge", "Iconx") return } body := Map("code", code, "instance_id", InstanceId, "hostname", A_ComputerName) try { resp := Http("POST", "/bridge/link", JSON.Stringify(body)) } catch { MsgBox("No se pudo conectar con " BackendUrl ". Revisa la conexión.", "PressF8 Bridge", "Iconx") return } if (resp.status != 200) { detail := "Código inválido o expirado." if TryParseJsonMap(resp.text, &errorData) { serverDetail := MapString(errorData, "detail") if (serverDetail != "") detail := serverDetail } MsgBox(detail, "PressF8 Bridge", "Iconx") return } if !TryParseJsonMap(resp.text, &data) { MsgBox("El servidor devolvió una respuesta inválida. Inténtalo nuevamente.", "PressF8 Bridge", "Iconx") return } token := MapString(data, "instance_token") username := MapString(data, "username") if (token = "" || username = "") { MsgBox("La respuesta de vinculación está incompleta. Inténtalo nuevamente.", "PressF8 Bridge", "Iconx") return } InstanceToken := token Username := username SaveSession() RebuildTray() TrayTip("Conectado como " Username, "PressF8 Bridge") SoundCapture() } DoUnlink() { global if (InstanceToken != "") try Http("POST", "/bridge/unlink") ClearLocalSessionState() SaveSession() SavePrefs() RebuildTray() TrayTip("Sesión cerrada", "PressF8 Bridge") } ClearLocalSessionState() { global InstanceToken := "" Username := "" LivePreprocessEnabled := false LastCommittedText := "" ResetLiveDraft() ClearRisReturnTarget() ClearLearnedRisControl() } ClearLearnedRisControl() { global RisProcessName := "" RisAutomationId := "" RisControlType := 0 RisControlHwnd := 0 RisControlClassNN := "" try IniDelete(ConfigFile, "ris", "process_name") try IniDelete(ConfigFile, "ris", "automation_id") try IniDelete(ConfigFile, "ris", "control_type") try IniDelete(ConfigFile, "ris", "native_hwnd") try IniDelete(ConfigFile, "ris", "class_nn") } ; --------------------------------------------------------------------------- ; Polling (texto corregido pendiente + configuración vigente) ; --------------------------------------------------------------------------- SafePoll() { global NetBusy, RoundBusy ; No apilar requests ni interferir con una ronda de captura/pegado en curso ; (el pegado tiene Sleeps interrumpibles: un poll intercalado podía colarse). if NetBusy || RoundBusy return try Poll() } Poll() { global if !BridgeEnabled || (InstanceToken = "") return try { resp := Http("GET", "/bridge/poll", "", 4000) } catch { if !Offline { Offline := true TrayTip("Sin conexión con " BackendUrl, "PressF8 Bridge", "Iconx Mute") } return } if (resp.status = 401) { ClearLocalSessionState() SaveSession() SavePrefs() RebuildTray() TrayTip("Sesión expirada. Vuelve a vincular este equipo desde pressf8.cl.", "PressF8 Bridge", "Iconx") return } if (resp.status != 200) return if Offline { Offline := false TrayTip("Conexión restablecida", "PressF8 Bridge", "Mute") } if !TryParseJsonMap(resp.text, &data) { ; Una respuesta incompleta o inválida se reintenta silenciosamente. ; El polling frecuente no debe generar notificaciones repetitivas. return } changed := false polledUsername := MapString(data, "username") if (polledUsername != "" && polledUsername != Username) { Username := polledUsername SaveSession() changed := true } ; Si falta config se conservan las preferencias locales. if data.Has("config") && data["config"] is Map { cfg := data["config"] prefsChanged := false if TryMapBool(cfg, "sound_enabled", &newSound) && newSound != SoundEnabled { SoundEnabled := newSound prefsChanged := true } if TryMapBool(cfg, "autofocus_enabled", &newAutofocus) && newAutofocus != AutofocusEnabled { AutofocusEnabled := newAutofocus prefsChanged := true } if TryMapBool(cfg, "live_preprocess_enabled", &newLive) && newLive != LivePreprocessEnabled { LivePreprocessEnabled := newLive prefsChanged := true if newLive NoteLivePreprocessNecesitaF8() ; encendido desde la web else ResetLiveDraft() } if prefsChanged { SavePrefs() changed := true } } if changed RebuildTray() pendingText := MapString(data, "text") if (pendingText != "") PasteToRis(pendingText) } ; --------------------------------------------------------------------------- ; Flujo 1: F8 en el RIS -> capturar y enviar a PressF8 ; --------------------------------------------------------------------------- SafeCaptureFromRis() { global RoundBusy ; Un segundo F8 durante una ronda en curso se ignora: repetir la captura a ; mitad de un pegado o de otra captura mezcla destinos y portapapeles. if RoundBusy { LogDiag("F8 ignorado: ya hay una ronda de captura/pegado en curso") return } RoundBusy := true try CaptureFromRis() catch as e { LogDiag("captura ABORTADA por excepción: " e.Message) ClearRisReturnTarget() try TrayTip("No se pudo capturar el informe. Inténtalo nuevamente.", "PressF8 Bridge", "Iconx Mute") try SoundError() } finally RoundBusy := false } CaptureFromRis() { global if !BridgeEnabled { LogDiag("F8 ignorado: el atajo está pausado desde el menú de la bandeja") return } if (InstanceToken = "") { LogDiag("captura ABORTADA: este equipo no está vinculado a ninguna cuenta") TrayTip("Abriendo PressF8 para vincular este Windows…", "Atajo tecla F8") StartBrowserLink() SoundError() return } ; Una ronda nueva invalida el destino anterior. El destino nuevo se guarda ; globalmente sólo después de que el backend acepte la captura. ClearRisReturnTarget() LogDiag("F8 en el RIS: iniciando captura") try activeHwnd := WinGetID("A") catch { LogDiag("captura ABORTADA: no se pudo determinar la ventana activa") SoundError() return } target := SnapshotCurrentRisTarget(activeHwnd) ; Aprender SIEMPRE el campo del informe, no sólo con el preprocesamiento ya ; activo. Al condicionarlo, activar el toggle dejaba la lectura anticipada ; inerte hasta el siguiente F8 —RisProcessName vacío corta LiveCapture() en ; su primera línea— y ni la web ni el tray lo delataban. if !LearnRisControl(activeHwnd) && LivePreprocessEnabled WarnLivePreprocessSinCampo() text := "" copyOk := false clipboardSaved := false try { saved := ClipboardAll() clipboardSaved := true A_Clipboard := "" Send("^a") Sleep(60) Send("^c") if ClipWait(0.6) { text := A_Clipboard copyOk := true } } catch { copyOk := false } finally { if clipboardSaved try A_Clipboard := saved } CollapseRisSelection(activeHwnd) if !copyOk { LogDiag("captura ABORTADA: no se pudo copiar el campo (¿campo vacío o sin foco?)") TrayTip("No se pudo copiar el campo completo del informe.", "PressF8 Bridge", "Iconx Mute") SoundError() return } body := Map("text", text) ; El draft_id viaja SIEMPRE que hubo un preflight enviado (LiveRevision>=1): ; ya no se exige que ambas lecturas coincidan — antes 1 carácter de ; diferencia descartaba la revisión adelantada entera. El servidor decide ; cuánto del preflight sirve reutilizando el prefijo común de líneas. if (LiveDraftId = "" || LiveRevision = 0) LogDiag("preflight: no había borrador adelantado para este informe") else { body["draft_id"] := LiveDraftId body["revision"] := LiveRevision if (NormalizeBridgeText(LastLiveText) = NormalizeBridgeText(text)) LogDiag("preflight: adjuntado (rev " LiveRevision ") — lecturas idénticas") else LogDiag("preflight: adjuntado (rev " LiveRevision ") — lecturas difieren (" . StrLen(NormalizeBridgeText(LastLiveText)) " vs " StrLen(NormalizeBridgeText(text)) . " caracteres); el servidor reutilizará el prefijo común") } try { resp := Http("POST", "/bridge/paste-ris", JSON.Stringify(body)) } catch { LogDiag("captura ABORTADA: sin conexión con el servidor (" StrLen(text) " caracteres perdidos)") TrayTip("Sin conexión con " BackendUrl, "PressF8 Bridge", "Iconx Mute") SoundError() return } if (resp.status != 200) { detail := "Error " resp.status if TryParseJsonMap(resp.text, &errorData) { serverDetail := MapString(errorData, "detail") if (serverDetail != "") detail := serverDetail } LogDiag("captura ABORTADA: el servidor respondió " resp.status " — " detail) TrayTip(detail, "PressF8 Bridge", "Iconx Mute") SoundError() return } CommitRisReturnTarget(activeHwnd, target, text) LogDiag("captura OK desde el RIS: " StrLen(text) " caracteres, destino guardado") LastCommittedText := NormalizeBridgeText(text) SoundCapture() ResetLiveDraft() if AutofocusEnabled FocusPressF8() } SnapshotCurrentRisTarget(hwnd) { target := Map("process", "", "automation_id", "", "control_type", 0, "control_hwnd", 0, "class_nn", "") try target["process"] := WinGetProcessName("ahk_id " hwnd) try target["class_nn"] := ControlGetFocus("ahk_id " hwnd) try { element := UIAFocusedElement() target["automation_id"] := element.CurrentAutomationId target["control_type"] := element.CurrentControlType target["control_hwnd"] := element.CurrentNativeWindowHandle } return target } CommitRisReturnTarget(hwnd, target, originalText) { global RisHwnd := hwnd RisHwndTime := A_TickCount ReturnRisProcessName := target["process"] ReturnRisAutomationId := target["automation_id"] ReturnRisControlType := target["control_type"] ReturnRisControlHwnd := target["control_hwnd"] ReturnRisControlClassNN := target["class_nn"] ReturnRisOriginalText := NormalizeBridgeText(originalText) } CollapseRisSelection(hwnd) { try { if WinActive("ahk_id " hwnd) Send("{Right}") } } FocusPressF8() { global if WinExist("PressF8") WinActivate("PressF8") else Run(BackendUrl) ; el texto queda retenido en el backend hasta que haya sesión } ; --------------------------------------------------------------------------- ; Flujo 2: texto corregido -> ventana original del RIS ; --------------------------------------------------------------------------- PasteToRis(text) { global RoundBusy LogDiag("llegó texto corregido para el RIS: " StrLen(text) " caracteres") RoundBusy := true try PasteToRisImpl(text) catch KeepCorrectedForManualPaste(text, "No se pudo completar el retorno automático.") finally RoundBusy := false } PasteToRisImpl(text) { global ; Normalizar saltos de línea a CRLF: el texto llega del backend con LF y ; algunas apps Windows (incluido el RIS) pegan LF solo como "punto seguido". text := StrReplace(text, "`r`n", "`n") text := StrReplace(text, "`n", "`r`n") ; Protección contra borrado sin reemplazo: nunca tocar el campo del RIS ; sin tener el texto nuevo asegurado y la ventana original validada. ; Distinguir los tres motivos: "ventana no disponible" para los tres casos ; hacía imposible saber cuál ocurrió, y el vencimiento por tiempo es el más ; frecuente y el menos evidente para el usuario. if (RisHwnd = 0) { KeepCorrectedForManualPaste(text, "No hay un informe capturado al que volver. Presiona F8 en el RIS primero.", true) return } if !WinExist("ahk_id " RisHwnd) { KeepCorrectedForManualPaste(text, "La ventana original del RIS ya no está abierta.", true) return } if (A_TickCount - RisHwndTime > RIS_HWND_TTL_MS) { KeepCorrectedForManualPaste(text, "Pasaron más de 15 minutos desde que capturaste el informe. Vuelve a presionar " . "F8 en el RIS para no arriesgar pegarlo en otro paciente.", true) return } if !ActivateRisWindow(RisHwnd) { KeepCorrectedForManualPaste(text, "No se pudo activar la ventana del RIS.") return } if !WaitRisReturnTarget(¤tText, &textReadable) { KeepCorrectedForManualPaste(text, "Cambió el campo activo del RIS.") return } saved := ClipboardAll() if !textReadable { ; Fallback seguro: la identidad del control ya fue validada. Seleccionar/copiar ; permite confirmar que sigue siendo el mismo informe antes de borrar nada. A_Clipboard := "" Send("^a") Sleep(60) Send("^c") if !ClipWait(0.6) { KeepCorrectedForManualPaste(text, "No se pudo comprobar el contenido actual del RIS.") return } currentText := A_Clipboard } if (NormalizeBridgeText(currentText) != ReturnRisOriginalText) { KeepCorrectedForManualPaste(text, "El informe del RIS cambió desde que se envió.") return } A_Clipboard := text Send("^a") Sleep(80) Send("^v") Sleep(400) ; Verificar el resultado antes de restaurar el portapapeles o anunciar éxito. if !WaitRisReturnTarget(&pastedText, &pastedReadable) { KeepCorrectedForManualPaste(text, "No se pudo comprobar el pegado en el RIS.") return } if !pastedReadable { A_Clipboard := "" Send("^a") Sleep(60) Send("^c") if !ClipWait(0.6) { KeepCorrectedForManualPaste(text, "No se pudo comprobar el pegado en el RIS.") return } pastedText := A_Clipboard } CollapseRisSelection(RisHwnd) if (NormalizeBridgeText(pastedText) != NormalizeBridgeText(text)) { KeepCorrectedForManualPaste(text, "El RIS no confirmó el texto pegado.") return } try A_Clipboard := saved LastCommittedText := NormalizeBridgeText(text) ResetLiveDraft() ClearRisReturnTarget() LogDiag("pegado en el RIS OK y verificado") SoundPaste() } ; Traer la ventana del RIS al frente desde el timer de polling. ; Windows sólo permite poner una ventana en primer plano al proceso que recibió ; el último evento de entrada. Con la tecla F8 física el hook de teclado de este ; script recibe la pulsación (aunque el hotkey esté inactivo por estar el foco en ; PressF8) y un WinActivate simple basta; cuando el usuario aprieta el botón F8 ; de la web con el mouse, este proceso no ve nada, WinActivate falla en silencio ; y el retorno al RIS se caía a "pegado manual". Compartir la cola de entrada con ; el hilo dueño de la ventana en primer plano devuelve ese permiso sin tocar ; configuración global del sistema (nada de ForegroundLockTimeout). ActivateRisWindow(hwnd) { Loop 2 { WinActivate("ahk_id " hwnd) if WinWaitActive("ahk_id " hwnd, , 0.7) return true ForceForeground(hwnd) if WinWaitActive("ahk_id " hwnd, , 0.7) return true } return false } ForceForeground(hwnd) { fgThread := 0, myThread := 0, attached := false try { fg := DllCall("GetForegroundWindow", "Ptr") if !fg return fgThread := DllCall("GetWindowThreadProcessId", "Ptr", fg, "Ptr", 0, "UInt") myThread := DllCall("GetCurrentThreadId", "UInt") if (fgThread && fgThread != myThread) attached := DllCall("AttachThreadInput", "UInt", myThread, "UInt", fgThread, "Int", 1) DllCall("SetForegroundWindow", "Ptr", hwnd) DllCall("BringWindowToTop", "Ptr", hwnd) } finally { ; Soltar SIEMPRE: una cola de entrada compartida que queda colgada acopla ; este script al proceso ajeno más allá de esta llamada. if attached try DllCall("AttachThreadInput", "UInt", myThread, "UInt", fgThread, "Int", 0) } } ; Tras activar la ventana, el foco del control interno puede tardar unos ms en ; restablecerse; validar una sola vez producía "Cambió el campo activo del RIS" ; con un RIS lento. Sólo reintenta una comprobación de lectura: no escribe nada ; ni relaja ninguna de las guardas de identidad del control. WaitRisReturnTarget(¤tText, &textReadable) { Loop 4 { if ValidateRisReturnTarget(¤tText, &textReadable) return true Sleep(120) } return false } ValidateRisReturnTarget(¤tText, &textReadable) { global currentText := "" textReadable := false if (ReturnRisProcessName = "" || (ReturnRisAutomationId = "" && !ReturnRisControlHwnd && ReturnRisControlClassNN = "")) return false try { if WinGetProcessName("ahk_id " RisHwnd) != ReturnRisProcessName return false if (ReturnRisControlClassNN != "" && ControlGetFocus("ahk_id " RisHwnd) != ReturnRisControlClassNN) return false elementAvailable := false try { element := UIAFocusedElement() elementAvailable := true } if (ReturnRisAutomationId != "" || ReturnRisControlHwnd) { if !elementAvailable return false if (ReturnRisAutomationId != "" && element.CurrentAutomationId != ReturnRisAutomationId) return false if (ReturnRisControlHwnd && element.CurrentNativeWindowHandle != ReturnRisControlHwnd) return false if (ReturnRisControlType && element.CurrentControlType != ReturnRisControlType) return false } if elementAvailable { try { currentText := element.GetCurrentPattern(10002).CurrentValue textReadable := true } catch { try { currentText := element.GetCurrentPattern(10014).DocumentRange.GetText(-1) textReadable := true } } } return true } catch { return false } } ; `discardTarget` sólo para los casos en que el destino ya no sirve (no hay ; captura, la ventana se cerró, venció el plazo). Los demás fallos son ; recuperables —el foco se movió, la ventana no subió al frente, la ; comprobación no cerró— y borrar el destino en esos convertía un tropiezo ; puntual en una avería permanente: el siguiente F8 moría en el primer if y ; el usuario no tenía forma de saber por qué ni cómo salir. ; ; Conservarlo es seguro porque CADA intento revalida todo de nuevo: ventana, ; proceso, control enfocado y que el informe siga siendo el mismo. Reintentar ; no relaja ninguna guarda. KeepCorrectedForManualPaste(text, reason, discardTarget := false) { global RisHwnd LogDiag("pegado ABORTADO: " reason (discardTarget ? " [destino descartado]" : " [destino conservado]")) try A_Clipboard := text CollapseRisSelection(RisHwnd) try TrayTip(reason "`nEl texto corregido quedó copiado para pegarlo manualmente.", "PressF8 Bridge", "Iconx") try SoundError() if discardTarget ClearRisReturnTarget() } ClearRisReturnTarget() { global RisHwnd := 0 RisHwndTime := 0 ReturnRisProcessName := "" ReturnRisAutomationId := "" ReturnRisControlType := 0 ReturnRisControlHwnd := 0 ReturnRisControlClassNN := "" ReturnRisOriginalText := "" } ; --------------------------------------------------------------------------- ; Captura anticipada no invasiva (nunca envía teclas) ; ; Dos vías para identificar y leer el campo del informe. UI Automation es la ; preferida —funciona con aplicaciones web y modernas—, pero su ProgID no está ; registrado en todos los Windows: donde falta, ComObject lanza 0x800401F3 y ; toda la lectura anticipada quedaba muerta en silencio. La vía Win32 cubre las ; aplicaciones de escritorio clásicas (un RIS WinForms, por ejemplo) sin ; depender de ningún componente COM. ; --------------------------------------------------------------------------- ; CUIAutomation global, creado una vez. Si una llamada COM falla, se descarta ; para recrearlo en el siguiente uso (el objeto puede quedar inválido tras ; cambios de sesión/escritorio). UIAFocusedElement() { global UIAObj, UIAUnavailable ; Sin este pestillo se reintentaba crear el objeto COM en cada tick de 500 ms ; durante toda la sesión, en una máquina donde nunca va a existir. if UIAUnavailable throw Error("UI Automation no disponible en este equipo") if !IsObject(UIAObj) { ; Fallar al CREAR el objeto significa que el ProgID no está registrado en ; este Windows: no va a aparecer más tarde, así que se cierra el pestillo. ; (Distinguir por dónde falla evita mirar el texto del error, que cambia ; con el idioma del sistema.) try UIAObj := ComObject("UIAutomationClient.CUIAutomation") catch as e { UIAObj := "" UIAUnavailable := true throw e } } ; Fallar al USARLO es otra cosa: el objeto pudo quedar inválido tras un ; cambio de sesión o escritorio. Se descarta para recrearlo en el próximo uso. try return UIAObj.GetFocusedElement() catch as e { UIAObj := "" throw e } } ; Identidad Win32 del control enfocado: el handle sirve dentro de la sesión y ; el ClassNN permite reconocerlo aunque la ventana cambie de handle. Se exige ; que AMBOS coincidan antes de leer: leer el control equivocado significaría ; mandar al motor el texto de otro campo. ReadFocusedRisTextWin32(hwnd) { global RisControlHwnd, RisControlClassNN if (!RisControlHwnd || RisControlClassNN = "") return "" try { if (ControlGetFocus("ahk_id " hwnd) != RisControlHwnd) return "" if (ControlGetClassNN(RisControlHwnd) != RisControlClassNN) return "" return ControlGetText(RisControlHwnd) } return "" } ; Devuelve true si el campo del informe quedó identificado y persistido. ; Sin AutomationId ni handle nativo no hay forma de reconocer el mismo control ; después, así que se descarta entero en vez de guardar una identidad a medias. LearnRisControl(hwnd) { global ; Si esta captura no logra identificar el campo, se conserva lo aprendido ; antes: ReadFocusedRisText() compara la identidad completa antes de leer, ; así que una entrada vieja nunca hace leer el control equivocado — sólo deja ; de coincidir. Borrarla ante un tropiezo puntual de UIA apagaría la lectura ; anticipada hasta el siguiente F8, sin motivo. prevProcess := RisProcessName, prevId := RisAutomationId prevType := RisControlType, prevHwnd := RisControlHwnd prevClassNN := RisControlClassNN learned := false ; Una identidad equivocada se persiste en config.ini y envenena la lectura ; hasta el siguiente F8 correcto, así que las ventanas que no pueden ser un ; RIS se descartan antes de tocar nada. try { if IsNonRisProcess(WinGetProcessName("ahk_id " hwnd)) return false } try { element := UIAFocusedElement() RisProcessName := WinGetProcessName("ahk_id " hwnd) RisAutomationId := element.CurrentAutomationId RisControlType := element.CurrentControlType RisControlHwnd := element.CurrentNativeWindowHandle RisControlClassNN := "" if (RisAutomationId != "" || RisControlHwnd) learned := true } if !learned { ; Sin UI Automation, la vía Win32: el control enfocado de una ventana ; de escritorio se identifica por su handle y su ClassNN. try { ctrl := ControlGetFocus("ahk_id " hwnd) if ctrl { RisProcessName := WinGetProcessName("ahk_id " hwnd) RisAutomationId := "" RisControlType := 0 RisControlHwnd := ctrl RisControlClassNN := ControlGetClassNN(ctrl) learned := (RisControlClassNN != "") } } } ; Identificar el control no basta: hay que poder LEERLO. El SunAwtFrame de ; Agfa se identifica sin problemas y nunca devuelve texto, así que hasta ; ahora el puente daba por aprendido un campo mudo, el aviso no se disparaba ; y la lectura anticipada quedaba inerte sin que nada lo delatara. if (learned && Trim(ReadFocusedRisText()) = "") learned := false if learned { IniWrite(RisProcessName, ConfigFile, "ris", "process_name") IniWrite(RisAutomationId, ConfigFile, "ris", "automation_id") IniWrite(RisControlType, ConfigFile, "ris", "control_type") IniWrite(RisControlHwnd, ConfigFile, "ris", "native_hwnd") IniWrite(RisControlClassNN, ConfigFile, "ris", "class_nn") } else { RisProcessName := prevProcess, RisAutomationId := prevId RisControlType := prevType, RisControlHwnd := prevHwnd RisControlClassNN := prevClassNN } return learned } ; Un aviso por sesión: repetirlo en cada F8 sería ruido en medio del trabajo. WarnLivePreprocessSinCampo() { global WarnedLivePreprocess if WarnedLivePreprocess return WarnedLivePreprocess := true msg := "El preprocesamiento anticipado está activo, pero este RIS no expone el campo " . "del informe de forma identificable. La revisión seguirá ocurriendo al presionar F8." try TrayTip(msg, "Atajo tecla F8", "Iconi Mute") } ; Al encender el preprocesamiento no hay nada aprendido todavía si el usuario aún ; no capturó desde el RIS. Sin este aviso la función queda activada en la web y ; muda en la práctica, que es justo el fallo que se está corrigiendo. NoteLivePreprocessNecesitaF8() { global RisProcessName if (RisProcessName != "") return msg := "Presiona F8 una vez en el RIS para que el puente aprenda cuál es el campo del " . "informe. Desde el informe siguiente, la revisión empieza mientras dictas." try TrayTip(msg, "Atajo tecla F8", "Iconi Mute") } ReadFocusedRisText() { global try { hwnd := WinGetID("A") if WinGetProcessName("ahk_id " hwnd) != RisProcessName return "" } catch { return "" } ; Vía Win32 cuando el campo se aprendió por ahí (sin AutomationId). if (RisAutomationId = "" && RisControlClassNN != "") return ReadFocusedRisTextWin32(hwnd) try { element := UIAFocusedElement() if (RisAutomationId != "" && element.CurrentAutomationId != RisAutomationId) return "" if (RisAutomationId = "" && RisControlHwnd && element.CurrentNativeWindowHandle != RisControlHwnd) return "" if (RisControlType && element.CurrentControlType != RisControlType) return "" try { return element.GetCurrentPattern(10002).CurrentValue } catch { return element.GetCurrentPattern(10014).DocumentRange.GetText(-1) } } catch { return "" } } LiveCapture() { global if !BridgeEnabled || !LivePreprocessEnabled || InstanceToken = "" || RisProcessName = "" return if (A_TickCount < LiveSendFailUntil) return text := NormalizeBridgeText(ReadFocusedRisText()) if StrLen(text) < 20 || text = LastCommittedText return if (text != LastLiveText) { ; Un texto que se encoge a menos de la mitad tras el envío estable es ; un informe nuevo en el mismo campo (el anterior se abandonó sin F8): ; reiniciar el borrador para que el nuevo reciba su propio preflight. if (LiveStableSent && StrLen(text) * 2 < StrLen(LastLiveText)) ResetLiveDraft() LastLiveText := text LastLiveChange := A_TickCount if (LiveDraftId = "") LiveDraftId := SubStr(InstanceId, 1, 12) "_" A_TickCount return } ; Un solo envío por informe: LiveStableSent NO se resetea cuando el texto ; sigue creciendo — el servidor reutiliza el prefijo común al llegar F8. if LiveStableSent return idle := A_TickCount - LastLiveChange if (idle >= LIVE_PREFLIGHT_IDLE_MS && HasEnoughDraft(text)) SendLiveSnapshot(true) } SafeLiveCapture() { global NetBusy, RoundBusy ; La lectura continua nunca debe apilarse sobre una request en vuelo ni ; snapshotear el campo a mitad de una captura/pegado (leería texto a medias). if NetBusy || RoundBusy return try LiveCapture() } NormalizeBridgeText(text) { text := StrReplace(text, "`r`n", "`n") text := StrReplace(text, "`r", "`n") return Trim(text) } SendLiveSnapshot(stable) { global if (LiveDraftId = "" || LastLiveText = "") return LiveRevision += 1 body := Map("text", LastLiveText, "draft_id", LiveDraftId, "revision", LiveRevision, "stable", stable ? 1 : 0) ok := false try { resp := Http("POST", "/bridge/live-snapshot", JSON.Stringify(body), 4000) if (resp.status = 200) { ok := true if stable LiveStableSent := true } LogDiag("lectura anticipada: enviado borrador rev " LiveRevision ", " . StrLen(LastLiveText) " caracteres, " . (stable ? "estable (dispara revisión)" : "provisional") . " — servidor respondió " resp.status) } ; Backoff tras fallo: sin esto la lectura continua reintenta cada 500 ms ; y con red lenta convierte el bridge en una ametralladora de requests. if !ok { LogDiag("lectura anticipada: FALLÓ el envío del borrador — pausa de 5 s") LiveSendFailUntil := A_TickCount + 5000 } } ResetLiveDraft() { global LiveDraftId := "" LiveRevision := 0 LastLiveText := "" LastLiveChange := 0 LiveStableSent := false } ; --------------------------------------------------------------------------- ; Sonidos (cortos, tono bajo, diferenciables; silenciables desde tray o web) ; --------------------------------------------------------------------------- SoundCapture() { global if SoundEnabled SoundBeep(700, 90) } SoundPaste() { global if SoundEnabled { SoundBeep(500, 70) SoundBeep(700, 70) } } SoundError() { global if SoundEnabled SoundBeep(220, 250) } ; --------------------------------------------------------------------------- ; HTTP (WinHttp ASÍNCRONO + WaitForResponse, timeouts cortos, UTF-8 explícito) ; El modo síncrono bloqueaba el script completo (F8, tray y timers incluidos) ; hasta 7 s con red lenta; WaitForResponse bombea mensajes mientras espera. ; --------------------------------------------------------------------------- Http(method, path, body := "", timeoutMs := 6000) { global NetBusy prevBusy := NetBusy NetBusy := true try { return HttpImpl(method, path, body, timeoutMs) } finally { NetBusy := prevBusy } } HttpImpl(method, path, body, timeoutMs) { global whr := ComObject("WinHttp.WinHttpRequest.5.1") whr.SetTimeouts(3000, 3000, 4000, 4000) whr.Open(method, BackendUrl path, true) whr.SetRequestHeader("X-Instance-Id", InstanceId) whr.SetRequestHeader("X-Bridge-Version", BridgeVersion) whr.SetRequestHeader("X-Protocol-Version", ProtocolVersion) if (InstanceToken != "") whr.SetRequestHeader("X-Instance-Token", InstanceToken) if (body != "") { whr.SetRequestHeader("Content-Type", "application/json; charset=utf-8") whr.Send(Utf8Encode(body)) } else { whr.Send() } timeoutSec := Max(1, Round(timeoutMs / 1000)) completed := false try completed := (whr.WaitForResponse(timeoutSec) != 0) catch { completed := false } if !completed { try whr.Abort() throw Error("HTTP timeout tras " timeoutSec "s: " method " " path) } return {status: whr.Status, text: Utf8Decode(whr.ResponseBody)} } ; Los informes traen tildes/ñ: enviar y leer siempre como UTF-8 explícito ; (ResponseText/Send con strings nativos corrompen el encoding). Utf8Encode(str) { stream := ComObject("ADODB.Stream") stream.Type := 2 ; texto stream.Charset := "UTF-8" stream.Open() stream.WriteText(str) stream.Position := 0 stream.Type := 1 ; binario stream.Position := 3 ; saltar el BOM que agrega ADODB bytes := stream.Read() stream.Close() return bytes } Utf8Decode(bytes) { try { stream := ComObject("ADODB.Stream") stream.Type := 1 stream.Open() stream.Write(bytes) stream.Position := 0 stream.Type := 2 stream.Charset := "UTF-8" text := stream.ReadText() stream.Close() return text } catch { return "" ; respuesta sin body } }