diff --git a/README.md b/README.md index 6db6349..f2f51c5 100644 --- a/README.md +++ b/README.md @@ -20,7 +20,7 @@ V2.0 正式版已经公开发布。它保留 V1.5 的录音、快捷键、Profil > [!WARNING] > 正式版使用已核验的 V2.0.0 出货构建;RC003 长时间回归、VB-CABLE 首次安装与重启恢复、完整 DPI 观感和第三方语音工具组合仍需在具体目标设备上复测。未完成的硬件项目请以“待验证”理解,不要把自动化门禁当作真机证明。 > -> **录音键隔离**:未安装签名设备过滤器时,录音键使用“遥控器在线范围内”的钩子隔离;遥控器在线时 F5 会被拦截,遥控器不在线时普通键盘 F5 原样直通。该机制不能与签名过滤器等同,详情见[已知限制](docs/V2_0_KNOWN_LIMITATIONS_ZH.md)。 +> **录音键隔离**:已发布的 `v2.0.0` 在未安装签名设备过滤器时按“遥控器在线范围”拦截 F5,因此遥控器在线时普通键盘 F5 也会被拦截。当前开发分支已改为 fail-open:来源无法确认的 F5 直通,RC003 语音继续由设备级 Raw Input 驱动;该修复尚未进入 `v2.0.0` 资产,详见[开发进度](docs/V2_0_PROGRESS.md)。 ### V2.0 下载 diff --git a/docs/V2_0_PROGRESS.md b/docs/V2_0_PROGRESS.md index 049a81a..8781c97 100644 --- a/docs/V2_0_PROGRESS.md +++ b/docs/V2_0_PROGRESS.md @@ -2,6 +2,17 @@ > **当前范围(2026-09-08)**:便签本、Notes Deck、AI 便签整理及其用户入口已从当前 Host 产品面移除。下方 2026-09-07 及更早的 Notes Deck、项目现场和便签页记录只用于历史审计,不代表当前可达功能;历史 `notes.json`、备份和旧源码为兼容保留,不由生产 Host 构建或执行。当前可达主导航为:首页、快捷键、语音、自检、设置。 +## 2026-10-18 F5 误拦截改为设备未知时 fail-open + +- **发布边界**:本条记录的是 `play` 开发分支与本地开发构建;已发布 `v2.0.0` 的资产和说明未变,不能把本修复当成 `v2.0.0` 已修复。 +- **现场根因**:`input-bridge-health.json` 中 `last_hook_input_vk=116`(F5)、`voice_f5_isolation=rc003_scoped_hook`、`voice_f5_suppressed_edges=599`;桥日志的对应分支是 `RC003 ISOLATION scoped_suppress=true ... reason=rc003_connected_no_filter`,随后直接 `Key 录音键 DOWN/UP source=rc003_present_hook`。旧逻辑在“遥控器在线但设备过滤器不健康”时由设备盲 Hook 吞掉 F5。 +- **`win32_2` 含义**:`CreateFile("\\\\.\\VibeFlowRc003Filter")` 返回 `ERROR_FILE_NOT_FOUND(2)`;系统没有已发布键盘驱动包,RC003 设备节点也没有加载该过滤器。不是权限拒绝(那会是 `ERROR_ACCESS_DENIED(5)`),也不是驱动已加载但设备接口忙。 +- **修复决策**:低级键盘 Hook 没有来源设备身份,且在目标 Windows 蓝牙栈上 Hook 返回 `1` 会取消后续 Raw Input。因此过滤器不健康时,F5 一律 fail-open,不再调用录音状态机;RC003 的语音边沿继续由带设备实例路径校验的 Raw Input 驱动。 +- **日志与健康状态**:新增 `VOICE F5 HOOK PASSTHROUGH edge=... decision=fail_open source=unknown reason=... filter_state=...`;Raw Input 分支记录 `RC003 RAW KEY ... source_device=rc003`。健康状态新增 `voice_f5_isolation=keyboard_fail_open`、`voice_f5_hook_fail_open_edges`、`voice_f5_last_hook_decision`,旧 `voice_f5_suppressed_edges` 保持字段兼容并固定为 `0`。 +- **未改动边界**:微信输入法 `Ctrl+Win`、录音 hold 状态机、约 60 秒设备边界、Capture 二进制与发送流程均未改;没有新增修改录音键的 UI,也没有安装或修改驱动。 +- **自动验证**:Windows 开发构建、Host/Bridge/Capture 自检、安装配置迁移、`npm test`、V2 聚焦功能套件通过;冻结 Capture SHA-256 仍为 `B62DE035A9CAD0A16B97F6935C6E4DE0BF2B73C61B180595482D852C0582E683`。 +- **待真机验证**:RC003 在线时实体键盘 F5 / Shift+F5 / Ctrl+F5;RC003 按住开始与松开结束;RC003 断开后的 F5;微信输入法 `Ctrl+Win`;以及日志中同一时刻的 `VOICE F5 HOOK PASSTHROUGH` 与 `RC003 RAW KEY ... source_device=rc003`。 + ## 2026-09-10 豆包输入法链路彻查与最终选型(用户真机迭代) - **结论:豆包无法接入遥控器自动链路**(已用全部可行手段实测): diff --git a/scripts/VibeMic.cs b/scripts/VibeMic.cs index 50f8726..ddb0705 100644 --- a/scripts/VibeMic.cs +++ b/scripts/VibeMic.cs @@ -160,6 +160,10 @@ internal sealed partial class VibeMicForm : Form private string voiceSessionSourceProcess = ""; private FocusTargetDescriptor voiceSessionSourceTarget; private uint voiceSessionClipboardSequence; + // Character count of the dictation target captured when the voice key went down. + // The WeType paste fallback compares the same control against it before pasting. + private int voiceSessionTargetTextLength = -1; + private int voiceTargetGrowthProbeToken; private Process keyboardBridgeProcess; private EventWaitHandle showWindowEvent; private EventWaitHandle exitApplicationEvent; @@ -167,7 +171,6 @@ internal sealed partial class VibeMicForm : Form private EventWaitHandle providerHotkeyTapEvent; private EventWaitHandle providerHotkeyDownEvent; private EventWaitHandle providerHotkeyUpEvent; - private EventWaitHandle inputTargetMissingEvent; // Set only after Smart Focus (or a verified manual focus observation) has // confirmed the editable target. This is a passive safety/feedback token; // the Bridge still owns the frozen recording edge and does not wait on UIA. @@ -188,6 +191,39 @@ internal sealed partial class VibeMicForm : Form private System.Windows.Forms.Timer reconnectTimer; private System.Windows.Forms.Timer visualTimer; private System.Windows.Forms.Timer systemRecoveryTimer; + private System.Windows.Forms.Timer uiProgressTimer; + // Windows runs a modal loop while the user moves or resizes the window. The poll is suspended for + // that loop so the drag stays smooth; the timestamp is the guard against a loop that never reports + // its end and would otherwise suspend the poll for the rest of the session. + private bool windowModalMoveLoopActive; + private DateTime windowModalMoveLoopAt = DateTime.MinValue; + private DateTime uiProgressObservedAt = DateTime.MinValue; + private DateTime lastUiStallLogAt = DateTime.MinValue; + private const int UiStallThresholdMs = 400; + // The poll reports the longest tick it saw once per window, and only when one was long enough to be + // felt. See RecordPollDuration. + private const int PollReportWindowSeconds = 30; + // Measured on the machine this was fixed for: an idle poll averaged about 20 ms and the worst tick in + // a 30 second window stayed under 40 ms once the repeated log reads, process scans, repaints and regex + // passes were removed. These thresholds are the "it got slow again" alarm, not a target. + private const int PollSlowTickMs = 60; + private const int PollSlowAverageMs = 40; + private double worstPollMs; + private double totalPollMs; + private long pollTicksInWindow; + private DateTime pollWindowStartedAt = DateTime.Now; + // A tick that costs milliseconds is invisible until it is attributed, so the poll accounts for + // itself stage by stage and reports the average per stage with the window it already reports. + private const int PollStageCount = 22; + private const int PollTopLevelStageCount = 11; + private readonly double[] pollStageMs = new double[PollStageCount]; + private System.Diagnostics.Stopwatch activePollClock; + private double uiHeartbeatMs; + private long uiHeartbeatTicks; + private static readonly string[] PollStageLabels = { "focus", "engine", "capture_btn", "mapping", + "runtime", "input", "bridge", "capture", "events", "session", "snapshot", + "s_build", "s_apply", "s_tail", "s_scan", "s_health", "s_map", "s_focus", "s_create", + "s_ui", "s_ovw", "s_vis" }; private long lastEventLength; private int reconnectAttempt; private int startupRecoveryCount; @@ -206,6 +242,10 @@ internal sealed partial class VibeMicForm : Form private DateTime lastFocusObservationUtc = DateTime.MinValue; private bool lastFocusObservationReady; private string lastFocusObservationCode = ""; + // The observation retries about once a second, so the same failure reports the same disarm over + // and over: that repeat says nothing new and only buried the log. The reason last written is kept + // here, and arming clears it so the next genuine change is reported again. + private string lastFocusLockDisarmReason = ""; private string activeVoiceFocusTargetId = ""; private FocusTargetDescriptor activeVoiceFocusTarget; private DateTime lastCaptureRecoveryAt = DateTime.MinValue; @@ -240,6 +280,31 @@ internal sealed partial class VibeMicForm : Form private MemoryStream dictationStopSound; private long runtimeFeedbackPosition; private long inputFeedbackPosition; + // The 500 ms poll re-read and re-parsed up to ~750 KB of the runtime log on every tick, which cost + // tens of milliseconds of the UI thread on this class of machine and showed up as stutter while + // dragging the window. The tail and its parsed session are cached together and rebuilt only when + // the log really changes; the key carries the configuration the parse depends on. + private RuntimeLogTail runtimeLogTail; + private const int RuntimeLogTailBytes = 512 * 1024; + // The parsed bridge health document, kept until the file changes. + private Dictionary bridgeHealthDocument; + private string bridgeHealthDocumentRevision; + // The capture heartbeat document, kept until the file changes for the same reason as the bridge one: + // the poll asks for it twice a second and only ever needs the newest document written. + private Dictionary captureHealthDocument; + private string captureHealthDocumentRevision; + // Both caches below exist because enumerating the process table costs milliseconds and the 500 ms poll + // asks about several processes on every tick. processLookupCache holds the per-name topology the + // self-check inspects, and only while the process is running: a restart must be noticed immediately. + private static readonly Dictionary processLookupCache = + new Dictionary(StringComparer.OrdinalIgnoreCase); + // A running process's topology does not change second to second, and every caller that needs the + // current answer (the self-check, the duplicate-process tests) forces a refresh anyway. + private const int ProcessTopologyCacheMs = 3000; + private const int ProcessSnapshotMs = 2000; + private static DateTime processSnapshotAt = DateTime.MinValue; + private static HashSet processSnapshotNames; + private static int processSnapshotRefreshing; private int lastFeedbackGeneration; private int lastPasteFallbackGeneration; private int updateOperationActive; @@ -274,6 +339,9 @@ internal sealed partial class VibeMicForm : Form private int panelSubmitStimulusCount; private int lastPanelStimulusNoticeTick; private bool triggerOnlyModeForced; + private bool triggerOnlyModeCached; + private DateTime triggerOnlyModeResolvedAt = DateTime.MinValue; + private const int TriggerOnlyModeCacheMs = 2000; private bool triggerOnlyModeForcedResolved; private ActiveInputEngine activeInputEngine; private DateTime activeInputEngineReadAt = DateTime.MinValue; @@ -290,6 +358,13 @@ internal sealed partial class VibeMicForm : Form private string transientFeedbackText = ""; private Color currentVisualAccent = Color.FromArgb(15, 158, 100); private string currentVisualState = "connecting"; + // The surfaces ApplyVisualState paints, remembered by identity so that a rebuilt page (which + // creates new controls) is always painted once, while an unchanged state on the same controls is + // not painted again. See ApplyVisualState. + private object lastPaintedHeroPanel; + private object lastPaintedRemoteVisual; + private object lastPaintedConnectionBadge; + private object lastPaintedVoiceBridgeStateLabel; private DateTime windowsHardwareProbeAt = DateTime.MinValue; private WindowsHardwareProbe windowsHardwareProbe; private int windowsHardwareProbeRunning; @@ -609,6 +684,11 @@ private VibeMicForm(bool launchInBackground, bool smokeMode, bool resourceTestMo focusAutomationBackend = new WindowsUiaFocusAutomationBackend(recordingPriorityCommitGate); focusTargetService = new FocusTargetService(focusAutomationBackend, IsVoiceKeyHeld, HostLog, recordingPriorityCommitGate); + // The workflow status caption asks whether the target application is running on every interface + // refresh. The Host already keeps one process-name snapshot for exactly that question and refreshes + // it in the background, so the shared service is given that answer instead of enumerating the + // process table on the UI thread for a caption. + FocusTargetService.processRunningProbe = IsProcessRunning; focusTargetDocument = LoadFocusTargetDocument(); string migratedConfigSource = uiSmokeMode ? "" : MigrateLegacyUserConfig(root, userStateRoot); sessionDir = Path.Combine(userStateRoot, "remote-voice-session"); @@ -672,7 +752,7 @@ private VibeMicForm(bool launchInBackground, bool smokeMode, bool resourceTestMo if (!uiSmokeMode) ReleaseVoiceHotkey(); RotateLogFile(Path.Combine(sessionDir, "vibe-mic-runtime.log"), 4 * 1024 * 1024); RotateLogFile(hostLogPath, 2 * 1024 * 1024); - RotateLogFile(Path.Combine(root, "input-bridge-log.txt"), 4 * 1024 * 1024); + RotateLogFile(BridgeStateFile("input-bridge-log.txt"), 4 * 1024 * 1024); if (!uiSmokeMode) { audioDuckingLease = new WindowsAudioDuckingLease( @@ -681,7 +761,7 @@ private VibeMicForm(bool launchInBackground, bool smokeMode, bool resourceTestMo } string existingRuntimeLog = Path.Combine(sessionDir, "vibe-mic-runtime.log"); runtimeFeedbackPosition = File.Exists(existingRuntimeLog) ? new FileInfo(existingRuntimeLog).Length : 0; - string existingInputLog = Path.Combine(root, "input-bridge-log.txt"); + string existingInputLog = BridgeStateFile("input-bridge-log.txt"); inputFeedbackPosition = File.Exists(existingInputLog) ? new FileInfo(existingInputLog).Length : 0; AutoScaleDimensions = new SizeF(96f, 96f); @@ -730,7 +810,6 @@ private VibeMicForm(bool launchInBackground, bool smokeMode, bool resourceTestMo providerHotkeyTapEvent = new EventWaitHandle(false, EventResetMode.AutoReset, "Local\\VibeMicProviderHotkeyTapRequested"); providerHotkeyDownEvent = new EventWaitHandle(false, EventResetMode.AutoReset, "Local\\VibeMicProviderHotkeyDownRequested"); providerHotkeyUpEvent = new EventWaitHandle(false, EventResetMode.AutoReset, "Local\\VibeMicProviderHotkeyUpRequested"); - inputTargetMissingEvent = new EventWaitHandle(false, EventResetMode.AutoReset, "Local\\VibeMicInputTargetMissing"); focusTargetLockedEvent = new EventWaitHandle(false, EventResetMode.ManualReset, "Local\\VibeMicFocusTargetLocked"); focusTargetLockedEvent.Reset(); recordingStartCueEvent = new EventWaitHandle(false, EventResetMode.AutoReset, "Local\\VibeMicRecordingStartCue"); @@ -738,7 +817,7 @@ private VibeMicForm(bool launchInBackground, bool smokeMode, bool resourceTestMo ThreadPool.QueueUserWorkItem(delegate { WaitHandle[] handles = { showWindowEvent, exitApplicationEvent, voiceWakeRequestEvent, - providerHotkeyTapEvent, providerHotkeyDownEvent, providerHotkeyUpEvent, inputTargetMissingEvent }; + providerHotkeyTapEvent, providerHotkeyDownEvent, providerHotkeyUpEvent }; while (!applicationExiting && !IsDisposed) { try @@ -754,15 +833,15 @@ private VibeMicForm(bool launchInBackground, bool smokeMode, bool resourceTestMo // editable target synchronously before the UI // thread paints any feedback, so provider startup // cannot race a stale or lost focus. - bool focusPrepared = PrepareVoiceFocusForWake(); + bool wakeTextDestinationSeen; + bool focusPrepared = PrepareVoiceFocusForWake(out wakeTextDestinationSeen); BeginInvoke(new Action(delegate { - HandleVoiceWakeRequest(focusPrepared); + HandleVoiceWakeRequest(focusPrepared, wakeTextDestinationSeen); })); } else if (signal == 3) HandleProviderHotkeyTapRequest(); else if (signal == 4 || signal == 5) HandleProviderHotkeyHoldRequest(signal == 4); - else if (signal == 6) BeginInvoke(new Action(HandleMissingInputTarget)); } catch (Exception ex) { @@ -786,6 +865,13 @@ private VibeMicForm(bool launchInBackground, bool smokeMode, bool resourceTestMo activityTimer.Interval = 500; activityTimer.Tick += delegate { PollActivity(); }; activityTimer.Start(); + + // A timer this cheap can not be starved by anything the poll does: if it runs late, the UI + // thread really was blocked, and that is what the user feels as a stutter. + uiProgressTimer = new System.Windows.Forms.Timer(); + uiProgressTimer.Interval = 200; + uiProgressTimer.Tick += delegate { ObserveUiThreadProgress(); }; + uiProgressTimer.Start(); } visualTimer = new System.Windows.Forms.Timer(); @@ -1545,6 +1631,19 @@ private string GetUserStateRoot() return Path.Combine(localAppData, "Vibe Flow Remote", "UserData"); } + // The mapping document the keyboard bridge loads, its log, its heartbeat and the custom-button + // handshake files used to be written beside the executable. In a Program Files installation that + // directory is read-only for the ordinary user, so the write threw, the sync returned no revision + // and the host aborted the bridge start on every launch - "KEYBOARD BRIDGE start_aborted=true + // reason=config_sync_failed" - leaving the remote's mapped keys dead while the voice path, which + // runs in its own process, kept working. This state belongs next to the configuration the host + // already keeps in the user's own directory, and that is the one location a portable copy and an + // installed copy agree on. + private string BridgeStateFile(string fileName) + { + return Path.Combine(userStateRoot, fileName); + } + private static string MigrateLegacyUserConfig(string legacyRoot, string stateRoot) { return MigrateLegacyUserConfig(legacyRoot, stateRoot, true); @@ -2003,6 +2102,41 @@ private void RunPageResourceTest() Application.DoEvents(); } } + // The floating panel may only pop up on its own while 设置 ▸ 交互反馈 allows it. A + // receipt raised with the main window hidden is the case that used to put the panel in + // the bottom-right corner unasked, so that is the case pinned here — off must stay + // hidden, on must reach the user, and the preference must survive the check. + if (failure.Length == 0) + { + bool previousAutoShow = config.liveHudAutoShow; + liveHud.HideInactive(); + liveHudPresentationKey = ""; + liveHudExplicitlyRequested = false; + liveHudSessionSuppressed = false; + latestActionResult = ActionResult.Create("截图提问", "已验证目标", ActionState.Warning, + "悬浮窗自动弹出偏好的回归检查", "", "", "LIVE-HUD-PREFERENCE-PROBE"); + Hide(); + Application.DoEvents(); + config.liveHudAutoShow = false; + PublishFeedbackSnapshot(); + Application.DoEvents(); + if (liveHud.IsPresented) + failure = "The floating panel popped up while its automatic pop-up was turned off"; + if (failure.Length == 0) + { + liveHudPresentationKey = ""; + config.liveHudAutoShow = true; + PublishFeedbackSnapshot(); + Application.DoEvents(); + if (!liveHud.IsPresented) + failure = "The floating panel stayed hidden while its automatic pop-up was turned on"; + } + config.liveHudAutoShow = previousAutoShow; + liveHud.HideInactive(); + liveHudPresentationKey = ""; + Show(); + Application.DoEvents(); + } if (failure.Length == 0) { ShowContextDeck(); @@ -2090,7 +2224,7 @@ private void RunPageResourceTest() ") duration_ms=" + testTimer.ElapsedMilliseconds + (failure.Length == 0 ? "" : " FAILURE " + failure); Console.WriteLine(result); - string reportDirectory = Path.Combine(root, "tmp"); + string reportDirectory = Path.Combine(userStateRoot, "tmp"); Directory.CreateDirectory(reportDirectory); File.WriteAllText(Path.Combine(reportDirectory, "ui-resource-test.txt"), result, Encoding.UTF8); Environment.ExitCode = failure.Length == 0 && userDelta <= 120 && gdiDelta <= 50 ? 0 : 1; @@ -2261,6 +2395,12 @@ private static int RunHostSelfTests() ShouldScheduleProviderPasteFallback("wechat", weTypeSubmitDelivered, false, "recording", 10, 0)) throw new InvalidOperationException("WeChat early submit receipt paste policy failed"); + if (ShouldSkipPasteForChangedTarget(0, 0) || + !ShouldSkipPasteForChangedTarget(0, 12) || + !ShouldSkipPasteForChangedTarget(40, 52) || + ShouldSkipPasteForChangedTarget(-1, 12) || + ShouldSkipPasteForChangedTarget(12, -1)) + throw new InvalidOperationException("WeChat paste fallback did not honour the target text change guard"); // No shipped tool defaults to a held shortcut any more, but the hold path stays: a custom // slot whose stored trigger is 按住触发 still gets KeyDown / KeyUp from the capture. if (ShouldHoldProviderHotkeyForSession("wechat", "hold") || @@ -2738,20 +2878,76 @@ private static int RunHostSelfTests() WizardCaptureRestoreResult(false, true).State != ActionState.Error) throw new InvalidOperationException("Reopened setup can leave the voice bridge in the wrong running state after cancel"); if (!VoiceProviderEvidenceIsCurrent(providerEvidenceA, providerEvidenceA, providerEvidenceA, - 10, 11, true, true) || + true, true) || VoiceProviderEvidenceIsCurrent(providerEvidenceB, providerEvidenceA, providerEvidenceB, - 10, 11, true, true) || + true, true) || VoiceProviderEvidenceIsCurrent(providerEvidenceB, providerEvidenceB, providerEvidenceA, - 10, 11, true, true) || + true, true) || VoiceProviderEvidenceIsCurrent(providerEvidenceA, "", providerEvidenceA, - 10, 11, true, true) || + true, true) || VoiceProviderEvidenceIsCurrent(providerEvidenceA, providerEvidenceA, providerEvidenceA, - 11, 11, true, true) || + false, true) || VoiceProviderEvidenceIsCurrent(providerEvidenceA, providerEvidenceA, providerEvidenceA, - 10, 11, false, true) || - VoiceProviderEvidenceIsCurrent(providerEvidenceA, providerEvidenceA, providerEvidenceA, - 10, 11, true, false)) + true, false)) throw new InvalidOperationException("Voice provider evidence survived a configuration change or incomplete test"); + var dictationBaseline = new DateTime(2026, 9, 4, 2, 0, 0, DateTimeKind.Utc); + var laterDictation = new SessionHealth { LastCompletedAt = dictationBaseline.AddSeconds(1) }; + var sameMomentDictation = new SessionHealth { LastCompletedAt = dictationBaseline }; + var earlierDictation = new SessionHealth { LastCompletedAt = dictationBaseline.AddSeconds(-1) }; + var noDictation = new SessionHealth(); + if (!DictationAudioIsFresh(laterDictation, dictationBaseline) || + DictationAudioIsFresh(sameMomentDictation, dictationBaseline) || + DictationAudioIsFresh(earlierDictation, dictationBaseline) || + DictationAudioIsFresh(noDictation, dictationBaseline) || + DictationAudioIsFresh(null, dictationBaseline)) + throw new InvalidOperationException("Dictation freshness accepted a completion that did not happen after the test started"); + var completedDictationLines = new[] + { + "2026-09-04 10:00:00.000 VOICE KEY detected generation=1; waiting_for_natural_stream_ms=120", + "2026-09-04 10:00:03.250 WETYPE SESSION END generation=1 audio_delivered=True submitted=True panel_wait_ms=100", + "2026-09-04 10:00:07.500 TRANSCRIPTION SESSION END provider=bage generation=1 audio_delivered=True mode=toggle sent=True", + "2026-09-04 10:00:09.750 WETYPE SESSION END generation=2 audio_delivered=False submitted=False panel_wait_ms=0", + "no timestamp here WETYPE SESSION END audio_delivered=True submitted=True" + }; + if (LatestCompletedDictationAt(completedDictationLines) != new DateTime(2026, 9, 4, 10, 0, 7, 500) || + LatestCompletedDictationAt(new[] { + "2026-09-04 10:00:09.750 WETYPE SESSION END generation=2 audio_delivered=False submitted=False panel_wait_ms=0" }) + != DateTime.MinValue || + LatestCompletedDictationAt(null) != DateTime.MinValue) + throw new InvalidOperationException("Latest completed dictation did not come from the newest delivered and submitted receipt"); + var cachedHealth = new SessionHealth { Started = true, AudioMs = 4321, NextAction = "original" }; + SessionHealth cachedHealthCopy = cachedHealth.Copy(); + cachedHealthCopy.AudioMs = 1; + cachedHealthCopy.NextAction = "changed"; + cachedHealthCopy.Started = false; + if (cachedHealth.AudioMs != 4321 || cachedHealth.NextAction != "original" || !cachedHealth.Started) + throw new InvalidOperationException("Copying a cached session health let a caller write into the cache"); + var topologySource = new ProcessTopologySnapshot + { + TotalCount = 3, CurrentRootCount = 1, ForeignCount = 1, InaccessibleCount = 1 + }; + ProcessTopologySnapshot topologyCopy = CopyProcessTopology(topologySource); + topologyCopy.CurrentRootCount = 9; + if (topologySource.CurrentRootCount != 1 || topologyCopy.ForeignCount != 1) + throw new InvalidOperationException("Copying a cached process topology let a caller write into the cache"); + if (ReadProcessLookupCache("self-test-absent") != null) + throw new InvalidOperationException("Process lookup cache answered for an entry that was never stored"); + var storedLookup = new ProcessLookupEntry(); + storedLookup.Running = true; + WriteProcessLookupCache("self-test-fresh", storedLookup); + ProcessLookupEntry freshLookup = ReadProcessLookupCache("self-test-fresh"); + if (freshLookup == null || !freshLookup.Running || ReadProcessLookupCache("self-test-FRESH") == null) + throw new InvalidOperationException("Process lookup cache did not answer a fresh entry"); + storedLookup.At = DateTime.UtcNow.AddSeconds(-5); + if (ReadProcessLookupCache("self-test-fresh") != null) + throw new InvalidOperationException("Process lookup cache served an answer past its lifetime"); + HashSet runningNow = ProcessNamesSnapshot(true); + if (!runningNow.Contains(Process.GetCurrentProcess().ProcessName)) + throw new InvalidOperationException("The process snapshot did not contain the host process itself"); + if (!object.ReferenceEquals(runningNow, ProcessNamesSnapshot(false))) + throw new InvalidOperationException("The process snapshot was rebuilt inside its lifetime"); + if (ProcessNamesSnapshot(false).Contains("vibe-flow-self-test-absent-process")) + throw new InvalidOperationException("The process snapshot reported a process that does not exist"); if (!BridgeConfigurationMatchesExpected("revision-a", "revision-a") || BridgeConfigurationMatchesExpected("revision-a", "") || BridgeConfigurationMatchesExpected("", "revision-a") || @@ -4630,6 +4826,22 @@ private static int RunHostSelfTests() VibeMicConfig legacyMappingFixture = VibeMicConfig.Default(); legacyMappingFixture.schemaVersion = 8; + // The floating panel's automatic pop-up arrived after schema 32 shipped: a file + // written before it existed must load with the pop-up off, and a file that carries + // it must keep the user's choice through a save. + VibeMicConfig liveHudPreferenceFixture = VibeMicConfig.Default(); + liveHudPreferenceFixture.liveHudAutoShow = true; + var liveHudPreferenceDocument = new JavaScriptSerializer().DeserializeObject( + SerializeConfigPreservingUnknown(liveHudPreferenceFixture)) as Dictionary; + object liveHudPreferenceValue; + if (schemaEightLoaded.liveHudAutoShow || VibeMicConfig.Default().liveHudAutoShow || + liveHudPreferenceDocument == null || + !liveHudPreferenceDocument.TryGetValue("liveHudAutoShow", out liveHudPreferenceValue) || + !(liveHudPreferenceValue is bool) || !(bool)liveHudPreferenceValue || + !DeserializeConfigPreservingUnknown( + SerializeConfigPreservingUnknown(liveHudPreferenceFixture)).liveHudAutoShow) + throw new InvalidOperationException( + "The floating-panel pop-up preference does not default to off or does not survive a save"); legacyMappingFixture.mappings = new Dictionary { { "Home", "open-url:https://example.com/legacy-home" }, @@ -5456,30 +5668,16 @@ private void BuildShell() content.BackColor = pageBackground; content.AutoScroll = true; content.AutoScrollMinSize = new Size(UiDesignTokens.ContentMinimumWidth, UiDesignTokens.ContentMinimumHeight); - content.Paint -= PaintWorkspaceTexture; - content.Paint += PaintWorkspaceTexture; + // The canvas is deliberately left as a plain page-coloured surface. It used to carry a decorative + // dot grid and two accent lines drawn from a Paint handler on this panel, and because Windows Forms + // asks the parent to paint the background behind every transparent child, that texture was redrawn + // once per label on the page: one scroll step measured 230 ms before the walk was bounded by the + // clip and 2.4 ms after it. The decoration itself was then removed by request instead of kept. Controls.Add(content); Controls.Add(sidebar); BuildToastOverlay(); } - private void PaintWorkspaceTexture(object sender, PaintEventArgs e) - { - e.Graphics.SmoothingMode = SmoothingMode.AntiAlias; - using (var dot = new SolidBrush(Color.FromArgb(22, 109, 121, 155))) - { - for (int y = 18; y < content.Height; y += 32) - for (int x = 18; x < content.Width; x += 32) - e.Graphics.FillEllipse(dot, x, y, 1.4f, 1.4f); - } - using (var cyanLine = new Pen(Color.FromArgb(25, cyan), 1f)) - using (var violetLine = new Pen(Color.FromArgb(18, violet), 1f)) - { - e.Graphics.DrawLine(cyanLine, Math.Max(0, content.Width - 260), 0, content.Width, 0); - e.Graphics.DrawLine(violetLine, Math.Max(0, content.Width - 160), 4, content.Width, 4); - } - } - private void ApplyRoundedRegion(Control control, int radius) { if (control == null || control.Width <= 0 || control.Height <= 0) return; @@ -6608,6 +6806,7 @@ private void ArmVoiceFocusLock(FocusTargetDescriptor target, string source) StringComparison.OrdinalIgnoreCase); activeVoiceFocusTargetId = targetId; activeVoiceFocusTarget = target.Copy(); + lastFocusLockDisarmReason = ""; if (targetChanged) UpdateVoiceFocusIndicator(target); if (!alreadyArmed) HostLog("VOICE FOCUS LOCK armed=true target_id=" + SafeLogValue(target.Id) + @@ -6633,11 +6832,14 @@ private void DisarmVoiceFocusLock(string reason) } try { + bool wasArmed = IsFocusLockArmed(); focusTargetLockedEvent.Reset(); activeVoiceFocusTargetId = ""; activeVoiceFocusTarget = null; ResetVoiceFocusIndicator(); - HostLog("VOICE FOCUS LOCK armed=false reason=" + SafeLogValue(reason)); + if (wasArmed || !string.Equals(lastFocusLockDisarmReason, reason, StringComparison.Ordinal)) + HostLog("VOICE FOCUS LOCK armed=false reason=" + SafeLogValue(reason)); + lastFocusLockDisarmReason = reason ?? ""; } catch { } } @@ -6809,10 +7011,23 @@ internal static bool ShouldUseTransientVoiceTarget(string focusedProcessName, string.Equals(candidate.ControlType, "Edit", StringComparison.OrdinalIgnoreCase); } + // The idle observer only needs the target, so it keeps the short form. private FocusTargetDescriptor TryCaptureTransientVoiceTarget( FocusTargetDescriptor configuredTarget, out string errorCode) + { + bool editableControlSeen; + return TryCaptureTransientVoiceTarget(configuredTarget, out errorCode, out editableControlSeen); + } + + // The extra flag answers one question on its own: does the foreground process + // hold a focused, writable control right now. The provider types at the caret, + // so that is exactly when the words have somewhere to land even if the app is + // not an approved voice target — and therefore not worth a warning. + private FocusTargetDescriptor TryCaptureTransientVoiceTarget( + FocusTargetDescriptor configuredTarget, out string errorCode, out bool editableControlSeen) { errorCode = "FOCUS-NO-FOCUS"; + editableControlSeen = false; string focusedProcess = GetForegroundProcessName(GetForegroundWindow()); if (focusedProcess.Length == 0) return null; if (string.Equals(focusedProcess, "vibemic", StringComparison.OrdinalIgnoreCase)) @@ -6854,6 +7069,7 @@ private FocusTargetDescriptor TryCaptureTransientVoiceTarget( errorCode = capture == null ? "FOCUS-TARGET-NOT-EDITABLE" : capture.ErrorCode; return null; } + editableControlSeen = true; if (!ShouldUseTransientVoiceTarget(focusedProcess, configuredTarget == null ? "" : configuredTarget.ProcessName, capture.Descriptor)) { @@ -6996,6 +7212,94 @@ private void ExecuteDefaultFocusTarget() }); } + // Owner-approved deviation from the upstream "never read the input box" promise: this build reads only the + // character count of the dictation target so the WeType clipboard fallback can tell "the provider never wrote + // into the field" from "the provider wrote into the field and also parked the same text on the clipboard" + // (the duplicate-sentence case). The text itself is never stored, logged, uploaded, or reused; only the count + // is held in memory for the duration of one dictation. Returns -1 whenever the answer would be unsafe to use. + private static int ReadFocusedEditableTextLength(string expectedProcessName) + { + try + { + IntPtr foreground = GetForegroundWindow(); + if (foreground == IntPtr.Zero) return -1; + uint foregroundProcessId; + if (GetWindowThreadProcessId(foreground, out foregroundProcessId) == 0 || + foregroundProcessId == 0) return -1; + System.Windows.Automation.AutomationElement focused = + System.Windows.Automation.AutomationElement.FocusedElement; + if (focused == null) return -1; + System.Windows.Automation.AutomationElement.AutomationElementInformation info = focused.Current; + if (info.ProcessId != (int)foregroundProcessId) return -1; + string expected = FocusTargetDescriptor.NormalizeProcessName(expectedProcessName); + if (expected.Length > 0 && + !string.Equals(GetForegroundProcessName(foreground), expected, + StringComparison.OrdinalIgnoreCase)) return -1; + object pattern; + if (focused.TryGetCurrentPattern(System.Windows.Automation.ValuePattern.Pattern, out pattern)) + { + System.Windows.Automation.ValuePattern value = + pattern as System.Windows.Automation.ValuePattern; + if (value != null) + { + string current = value.Current.Value; + return current == null ? 0 : current.Length; + } + } + if (focused.TryGetCurrentPattern(System.Windows.Automation.TextPattern.Pattern, out pattern)) + { + System.Windows.Automation.TextPattern text = + pattern as System.Windows.Automation.TextPattern; + if (text != null) + { + string current = text.DocumentRange.GetText(-1); + return current == null ? 0 : current.Length; + } + } + return -1; + } + catch (System.Windows.Automation.ElementNotAvailableException) { return -1; } + catch (ArgumentException) { return -1; } + catch (InvalidOperationException) { return -1; } + catch (COMException) { return -1; } + catch { return -1; } + } + + + // Diagnostic probe (same owner-approved read as above: the character count only). While a dictation + // runs it records when the verified target stops growing, so a provider that stops writing into the + // field mid-utterance can be placed against the audio timeline. It logs one line per change and goes + // quiet once the field has stopped changing. Remove once the stall is understood. + private void StartVoiceTargetGrowthProbe(string expectedProcessName) + { + if (voiceSessionTargetTextLength < 0) return; + int token = Interlocked.Increment(ref voiceTargetGrowthProbeToken); + int baseline = voiceSessionTargetTextLength; + DateTime started = DateTime.UtcNow; + string expected = expectedProcessName; + ThreadPool.QueueUserWorkItem(delegate + { + int lastChars = baseline; + string lastForeground = ""; + while (Volatile.Read(ref voiceTargetGrowthProbeToken) == token && IsVoiceKeyHeld()) + { + Thread.Sleep(1000); + if (Volatile.Read(ref voiceTargetGrowthProbeToken) != token) return; + int chars = ReadFocusedEditableTextLength(expected); + string foreground = GetForegroundProcessName(GetForegroundWindow()); + if (chars != lastChars || + !string.Equals(foreground, lastForeground, StringComparison.OrdinalIgnoreCase)) + { + HostLog("VOICE TARGET GROWTH t=" + + (DateTime.UtcNow - started).TotalSeconds.ToString("0.0", CultureInfo.InvariantCulture) + + "s chars=" + chars + " delta=" + (chars - baseline) + + " foreground=" + SafeLogValue(foreground)); + lastChars = chars; + lastForeground = foreground; + } + } + }); + } private static string GetForegroundProcessName(IntPtr window) { if (window == IntPtr.Zero) return ""; @@ -7133,9 +7437,9 @@ private void BuildOverview() // Wrapped, not clipped: a fixed 610 px box was tuned for 100%, and at 125% the sentence needs // more width than it has, so it was cut off mid-word (measured with // scripts/check-ui-geometry.ps1). The wording is short enough to fit one line at 125% while - // keeping the distinction the honesty gate requires: audio still works AND the foreground - // application may receive the record key. - filterWarning = NewLabel("! 未安装签名通道:音频不受影响;连接遥控器时会拦截录音键(键盘 F5 同时被拦截),断开后恢复。详见「自检」。", + // keeping the distinction the honesty gate requires: the physical keyboard always passes + // through, and the remote's native key effect may also reach the foreground application. + filterWarning = NewLabel("! 未安装签名通道:实体键盘始终直通;遥控器录音键的原始按键效果也可能同时发生。详见「自检」。", 8.4f, FontStyle.Bold, amber); filterWarning.Name = "rc003FilterWarning"; filterWarning.Location = new Point(52, 190); @@ -7352,6 +7656,7 @@ private void BuildOverview() private void UpdateOverviewStatus() { if (overviewStatusValues[0] == null || overviewStatusValues[0].IsDisposed) return; + double overviewStartedAt = PollClockMs(); SessionHealth latestHealth = GetLatestSessionHealth(); LinkQualityVerdict liveQuality = CurrentLinkQuality(); bool triggerOnlyOverview = IsTriggerOnlyVoiceMode(); @@ -7368,13 +7673,11 @@ private void UpdateOverviewStatus() bool[] ready = { bridgeReady, bridgeReady, liveQuality.IsGood, IsProviderRunning(config.inputMethod), true }; for (int i = 0; i < values.Length; i++) { - if (overviewStatusValues[i] != null && !overviewStatusValues[i].IsDisposed) overviewStatusValues[i].Text = values[i]; - if (overviewStatusGlyphs[i] != null && !overviewStatusGlyphs[i].IsDisposed) - { - overviewStatusGlyphs[i].ForeColor = ready[i] ? (i == 4 ? green : i < 2 ? violet : cyan) : amber; - } + SetLabelText(overviewStatusValues[i], values[i]); + SetLabelColor(overviewStatusGlyphs[i], ready[i] ? (i == 4 ? green : i < 2 ? violet : cyan) : amber); } UpdateActionReceipt(ReadKeyboardBridgeHealth()); + MarkPollStage(20, overviewStartedAt); } private string GestureMappingSummary(string shortKey, string longKey) @@ -7404,13 +7707,10 @@ private void UpdateActionReceipt(BridgeHealthSnapshot receipt) ? receipt.SmartEffectiveProfileName : active == null ? "当前 Profile" : active.name; if (receipt == null || receipt.LastExecutionSequence <= 0) { - actionReceiptTitle.Text = "等待按键操作"; - actionReceiptDetail.Text = activeName + " · 按下已配置按键后,这里显示真实结果"; - if (actionReceiptGlyph != null && !actionReceiptGlyph.IsDisposed) - { - actionReceiptGlyph.Text = "\uE946"; - actionReceiptGlyph.ForeColor = muted; - } + SetLabelText(actionReceiptTitle, "等待按键操作"); + SetLabelText(actionReceiptDetail, activeName + " · 按下已配置按键后,这里显示真实结果"); + SetLabelText(actionReceiptGlyph, "\uE946"); + SetLabelColor(actionReceiptGlyph, muted); return; } string label = string.IsNullOrWhiteSpace(receipt.LastExecutionLabel) @@ -7420,19 +7720,18 @@ private void UpdateActionReceipt(BridgeHealthSnapshot receipt) string action = CustomActionText(receipt.LastExecutionAction); string normalizedAction = (receipt.LastExecutionAction ?? "").Trim().ToLowerInvariant(); bool disabledAction = normalizedAction.Length == 0 || normalizedAction == "none" || normalizedAction == "passthrough"; - actionReceiptTitle.Text = label + " · " + trigger + " · " + action; + SetLabelText(actionReceiptTitle, label + " · " + trigger + " · " + action); string profileName = string.IsNullOrWhiteSpace(receipt.LastExecutionProfileName) ? activeName : receipt.LastExecutionProfileName; string age = receipt.LastExecutionAgeSeconds < 5 ? "刚刚" : receipt.LastExecutionAgeSeconds < 60 ? ((int)receipt.LastExecutionAgeSeconds) + " 秒前" : receipt.LastExecutionAtUtc.ToLocalTime().ToString("HH:mm:ss"); - actionReceiptDetail.Text = profileName + " · " + (disabledAction ? "未配置动作,不是连接故障" : - receipt.LastExecutionSuccess ? "执行成功" : "执行失败,请打开自检查看") + " · " + age; - if (actionReceiptGlyph != null && !actionReceiptGlyph.IsDisposed) - { - actionReceiptGlyph.Text = disabledAction ? "\uE946" : receipt.LastExecutionSuccess ? "\uE73E" : "\uEA39"; - actionReceiptGlyph.ForeColor = disabledAction ? amber : receipt.LastExecutionSuccess ? green : coral; - } + SetLabelText(actionReceiptDetail, profileName + " · " + (disabledAction ? "未配置动作,不是连接故障" : + receipt.LastExecutionSuccess ? "执行成功" : "执行失败,请打开自检查看") + " · " + age); + SetLabelText(actionReceiptGlyph, + disabledAction ? "\uE946" : receipt.LastExecutionSuccess ? "\uE73E" : "\uEA39"); + SetLabelColor(actionReceiptGlyph, + disabledAction ? amber : receipt.LastExecutionSuccess ? green : coral); } // The dedicated 工作流 page: everything about the favourite applications lives here — @@ -9385,8 +9684,20 @@ private void EditGestureLayerAction(string gestureKey, string label, GestureKind ShowToast(label + "保存失败,请打开自检", "error"); return; } + // Writing the store is only half of the binding. The Bridge runs what the generated mapping document + // says, and that document is built from this store, so saving without re-syncing left the key running + // its previous action - measured: the 双击 row already read shortcut:rightshift+enter while the remote + // still sent the old ctrl+a, and it kept doing so until an unrelated settings change happened to + // rewrite the document. The per-Profile editors confirm the revision on every save; this one now does + // the same, so a gesture-layer edit is live as soon as it is saved. + string layerBridgeRevision = uiSmokeMode ? "" : SyncKeyboardBridgeConfig(); + bool layerActive = uiSmokeMode || StartKeyboardBridgeForRevision(layerBridgeRevision); + HostLog("GESTURE LAYER SAVE applied=true key=" + SafeLogValue(gestureKey) + + " layer=" + kind + " bridge_revision=" + SafeLogValue(layerBridgeRevision) + + " acknowledged=" + layerActive); ShowPage(PageShortcuts); - ShowToast(label + "已保存", "success"); + ShowToast(layerActive ? label + "已保存并生效" : label + "已保存,桥接仍在确认", + layerActive ? "success" : "warning"); } // Testing a layer runs exactly the action the key will run, through the same bridge test channel the @@ -10111,7 +10422,7 @@ private void BuildMappingsPageLegacy() ShowActionToast(result); }; var openBridge = SecondaryButton("打开高级配置", new Point(290, 780), new Size(150, 42)); - openBridge.Click += delegate { Process.Start(Path.Combine(root, "voxdeck-shortcuts.json")); }; + openBridge.Click += delegate { Process.Start(BridgeStateFile("voxdeck-shortcuts.json")); }; mappings.Controls.Add(save); mappings.Controls.Add(openBridge); content.Controls.Add(mappings); @@ -11025,10 +11336,35 @@ private void BuildSettingsPage() var feedbackState = NewLabel("● 视觉反馈始终开启", 9f, FontStyle.Bold, violet); feedbackState.Location = new Point(28, 258); feedbackState.Size = new Size(260, 24); + // The floating panel is a preference now, and it ships off: it used to appear in the + // bottom-right corner on its own, over whatever the user was doing. The tray entry + // 显示 Live HUD stays as the way to open it on purpose, at any time. + var liveHudPopUp = StyledCheck("录音时自动弹出右下角悬浮状态窗", config.liveHudAutoShow, new Point(28, 288)); + liveHudPopUp.Size = new Size(308, 40); + bool liveHudPopUpChanging = false; + liveHudPopUp.CheckedChanged += delegate + { + if (liveHudPopUpChanging) return; + bool previous = config.liveHudAutoShow; + config.liveHudAutoShow = liveHudPopUp.Checked; + ActionResult result = ApplySettingsChangeCore("更改悬浮窗弹出", + liveHudPopUp.Checked ? "录音时会自动弹出悬浮状态窗" : "悬浮状态窗不再自动弹出", false, + SaveConfig, null, delegate { config.liveHudAutoShow = previous; }); + if (!result.IsSuccess) + { + liveHudPopUpChanging = true; + liveHudPopUp.Checked = previous; + liveHudPopUpChanging = false; + } + if (result.IsSuccess && !config.liveHudAutoShow && liveHud != null && !liveHud.IsDisposed) + liveHud.HideInactive(); + ShowActionToast(result); + }; feedbackCard.Controls.Add(feedbackSound); feedbackCard.Controls.Add(previewStopSound); feedbackCard.Controls.Add(feedbackNote); feedbackCard.Controls.Add(feedbackState); + feedbackCard.Controls.Add(liveHudPopUp); BridgeHealthSnapshot routingHealth = ReadKeyboardBridgeHealth(); bool exactDeviceIsolation = routingHealth.FilterHealthy; @@ -11042,7 +11378,7 @@ private void BuildSettingsPage() // is worse than no statement at all. var sourceProtection = StyledCheck(exactDeviceIsolation ? "设备级隔离已启用:只有带 RC003 身份的事件会执行遥控器动作" - : "未安装签名通道:遥控器连接时会拦截录音键(实体键盘的 F5 同时被拦截),断开后恢复", + : "未安装签名通道:实体键盘始终直通;遥控器录音键的原始效果可能同时发生", exactDeviceIsolation, new Point(32, 62)); sourceProtection.Size = new Size(620, 40); sourceProtection.AutoCheck = false; @@ -11055,7 +11391,7 @@ private void BuildSettingsPage() var sourceProtectionNote = NewLabel( exactDeviceIsolation ? "RC003 专属签名通道已就绪:遥控器原按键被设备级拦截,实体键盘保持原行为。" : - "未安装签名通道时,Vibe Link按「遥控器是否在场」判断:在场时拦截录音键(实体键盘的 F5 也同时被拦截),非语音键的原生效果仍会透传。签名通道装上后改为按设备精确拦截。", + "未安装签名通道时,Vibe Link不拦截来源未知的 F5:实体键盘始终直通;RC003 录音仍由设备级 Raw Input 驱动,但原始 F5 也可能到达前台。签名通道装上后可精确拦截。", 8.8f, FontStyle.Regular, muted); sourceProtectionNote.Location = new Point(34, 112); sourceProtectionNote.Size = new Size(890, 34); @@ -11153,7 +11489,7 @@ private void BuildSettingsPage() { "Smart Profiles", "按当前应用自动切换键位方案;不修改录音与转写链路。" }, { "Browser Remote Lite", "用遥控器按键操作浏览器的轻量配置:先看差异,再应用或一键撤销。" }, { "Raw Input", "Windows 直接投递的原始键盘事件。「安全直通」表示尚未做逐设备隔离。" }, - { "Live HUD", "不抢焦点的悬浮状态窗;主窗口不在前台时仍能看到录音状态。" }, + { "Live HUD", "不抢焦点的悬浮状态窗;默认不自动弹出,可在托盘菜单随时打开,或在设置里改为自动弹出。" }, { "Context Deck", "遥控器、Profile、目标与最近一次动作的详细面板,从托盘菜单打开。" } }; for (int row = 0; row < glossary.GetLength(0); row++) @@ -11853,6 +12189,7 @@ protected override void OnFormClosing(FormClosingEventArgs e) ClearPendingCustomButtonCapture("application_exit"); DisposeFeedbackSurfaces(); if (activityTimer != null) { activityTimer.Stop(); activityTimer.Dispose(); activityTimer = null; } + if (uiProgressTimer != null) { uiProgressTimer.Stop(); uiProgressTimer.Dispose(); uiProgressTimer = null; } if (visualTimer != null) { visualTimer.Stop(); visualTimer.Dispose(); visualTimer = null; } if (toastTimer != null) { toastTimer.Stop(); toastTimer.Dispose(); toastTimer = null; } if (systemRecoveryTimer != null) { systemRecoveryTimer.Stop(); systemRecoveryTimer.Dispose(); systemRecoveryTimer = null; } @@ -11878,7 +12215,6 @@ protected override void OnFormClosing(FormClosingEventArgs e) try { if (providerHotkeyTapEvent != null) { providerHotkeyTapEvent.Set(); providerHotkeyTapEvent.Dispose(); } } catch { } try { if (providerHotkeyDownEvent != null) { providerHotkeyDownEvent.Set(); providerHotkeyDownEvent.Dispose(); } } catch { } try { if (providerHotkeyUpEvent != null) { providerHotkeyUpEvent.Set(); providerHotkeyUpEvent.Dispose(); } } catch { } - try { if (inputTargetMissingEvent != null) { inputTargetMissingEvent.Set(); inputTargetMissingEvent.Dispose(); } } catch { } try { if (focusTargetLockedEvent != null) { focusTargetLockedEvent.Reset(); focusTargetLockedEvent.Dispose(); focusTargetLockedEvent = null; } } catch { } try { if (recordingStartCueEvent != null) { recordingStartCueEvent.Dispose(); recordingStartCueEvent = null; } } catch { } try { if (recordingStopCueEvent != null) { recordingStopCueEvent.Dispose(); recordingStopCueEvent = null; } } catch { } @@ -12307,8 +12643,15 @@ private static void InvokeCancellation(Action cancel) catch { } } - private bool PrepareVoiceFocusForWake() + // textDestinationSeen reports that the wake found somewhere the words can + // land: either a focused writable control in the foreground process, or the + // configured provider's own panel (a normal wake — the panel owns the focus and + // the provider writes the transcript into the caret it captured), or the + // configured application still in front under its previous verified lease. + // It is evidence about the destination only — never a recording gate. + private bool PrepareVoiceFocusForWake(out bool textDestinationSeen) { + textDestinationSeen = false; if (applicationExiting) return false; // A previous lock is not proof that the user is still in that control. // Re-observe the foreground on every physical wake. The idle observer @@ -12320,10 +12663,13 @@ private bool PrepareVoiceFocusForWake() // a wake over the provider panel or this Host keeps the previous source. string wakeForeground = GetForegroundProcessName(GetForegroundWindow()); string wakeProvider = config == null ? "wechat" : config.inputMethod; + bool wakeOverProvider = IsProviderProcess(wakeProvider, wakeForeground); + if (wakeOverProvider) textDestinationSeen = true; try { voiceSessionClipboardSequence = GetClipboardSequenceNumber(); } catch { voiceSessionClipboardSequence = 0; } + voiceSessionTargetTextLength = -1; if (!string.Equals(wakeForeground, "vibemic", StringComparison.OrdinalIgnoreCase) && - !IsProviderProcess(wakeProvider, wakeForeground)) + !wakeOverProvider) voiceSessionSourceProcess = wakeForeground; else if (voiceSessionSourceProcess.Length == 0) voiceSessionSourceProcess = wakeForeground; @@ -12352,10 +12698,15 @@ private bool PrepareVoiceFocusForWake() FocusTargetDescriptor configuredTarget = DefaultFocusTarget(); string errorCode; - FocusTargetDescriptor transient = TryCaptureTransientVoiceTarget(configuredTarget, out errorCode); + bool editableControlSeen; + FocusTargetDescriptor transient = TryCaptureTransientVoiceTarget(configuredTarget, out errorCode, + out editableControlSeen); + if (editableControlSeen) textDestinationSeen = true; if (transient != null) { ArmVoiceFocusLock(transient, "voice_wake_preflight"); + voiceSessionTargetTextLength = ReadFocusedEditableTextLength(transient.ProcessName); + StartVoiceTargetGrowthProbe(transient.ProcessName); HostLog("VOICE INPUT TARGET ready=true target_id=" + SafeLogValue(transient.Id) + " code=OK action=voice_wake_preflight"); return true; @@ -12364,6 +12715,8 @@ private bool PrepareVoiceFocusForWake() if (configuredTarget != null && focusTargetService.ObserveFocusedTarget(configuredTarget, out errorCode)) { ArmVoiceFocusLock(configuredTarget, "voice_wake_preflight_saved"); + voiceSessionTargetTextLength = ReadFocusedEditableTextLength(configuredTarget.ProcessName); + StartVoiceTargetGrowthProbe(configuredTarget.ProcessName); HostLog("VOICE INPUT TARGET ready=true target_id=" + SafeLogValue(configuredTarget.Id) + " code=OK action=voice_wake_preflight_saved"); return true; @@ -12381,6 +12734,7 @@ private bool PrepareVoiceFocusForWake() if (ShouldRetainVoiceFocusRecoveryLease(IsFocusLockArmed(), targetForeground, false) && activeVoiceFocusTarget != null) { + textDestinationSeen = true; HostLog("VOICE INPUT TARGET ready=pending target_id=" + SafeLogValue(activeVoiceFocusTarget.Id) + " code=FOCUS-TARGET-TRANSIENT action=voice_wake_preflight_recovery"); @@ -12390,11 +12744,13 @@ private bool PrepareVoiceFocusForWake() HostLog("VOICE INPUT TARGET ready=false target_id=" + SafeLogValue(configuredTarget == null ? "" : configuredTarget.Id) + " code=" + SafeLogValue(string.IsNullOrWhiteSpace(errorCode) ? "FOCUS-TARGET-MISSING" : errorCode) + + " foreground=" + SafeLogValue(GetForegroundProcessName(GetForegroundWindow())) + + " editable_control_seen=" + editableControlSeen + " action=voice_wake_preflight"); return false; } - private void HandleVoiceWakeRequest(bool focusPrepared) + private void HandleVoiceWakeRequest(bool focusPrepared, bool textDestinationSeen) { if (applicationExiting) return; CancelV2ExternalActionsForRecording(); @@ -12421,7 +12777,11 @@ private void HandleVoiceWakeRequest(bool focusPrepared) bool providerReady = IsProviderReadyForStartup(config.inputMethod); bool focusLockArmed = ShouldCommitVoiceFocusPreflight(focusPrepared, held, IsFocusLockArmed()); - if (!focusLockArmed) DispatchUi(HandleMissingInputTarget); + if (ShouldAnnounceUnverifiedVoiceTarget(held, focusLockArmed, textDestinationSeen)) + DispatchUi(HandleUnverifiedVoiceTarget); + else if (!focusLockArmed) + HostLog("INPUT TARGET UNVERIFIED announced=false reason=" + + (held ? "wake_destination_seen" : "voice_key_released")); if (ShouldLaunchProviderNow(providerReady, focusLockArmed, held)) WarmConfiguredProviderAsync(true); else if (!providerReady) @@ -12502,14 +12862,25 @@ internal static bool ShouldCommitVoiceFocusPreflight(bool focusPrepared, return focusPrepared && voiceKeyHeld && currentLease; } - private void HandleMissingInputTarget() + // A failing wake preflight is not a failing dictation. The frozen Capture edge + // has already been delivered and the provider types at the caret, so the user + // must not be interrupted while the words have somewhere to land — dictating + // into an app that is not the configured target is ordinary use. Only a press + // that is still held with no destination at all is worth saying out loud. + internal static bool ShouldAnnounceUnverifiedVoiceTarget(bool voiceKeyHeld, bool focusLockArmed, + bool textDestinationSeen) + { + return voiceKeyHeld && !focusLockArmed && !textDestinationSeen; + } + + private void HandleUnverifiedVoiceTarget() { - const string message = "未确认工作流;请先在“工作流”页添加并测试落字目标,或手动点击目标输入框,再按住录音键"; + const string message = "当前窗口没有可落字的输入框;请先把光标放进输入框,再按住录音键"; HostLog("INPUT TARGET MISSING user_action=focus_editable_text_box"); if (Visible && WindowState != FormWindowState.Minimized) ShowToast(message, "warning"); else { - tray.BalloonTipTitle = "Vibe Link尚未开始听写"; + tray.BalloonTipTitle = "Vibe Link 没找到落字位置"; tray.BalloonTipText = message; tray.ShowBalloonTip(3500); } @@ -12999,23 +13370,20 @@ private bool TryReadCaptureHeartbeat(Process process, out Dictionary>(reader.ReadToEnd()); - } + string parseError; + health = ReadCaptureHealthDocumentRaw(path, out parseError); if (health == null) { - error = "heartbeat_empty"; + error = parseError ?? "heartbeat_empty"; } - else if ((DateTime.UtcNow - File.GetLastWriteTimeUtc(path)).TotalSeconds > 7) + else if (ageSeconds > 7) { error = "heartbeat_stale"; } @@ -13284,7 +13652,7 @@ private bool WaitForBridgeConfigRevision(string expectedRevision, int timeoutMil int expectedProcessId, Func cancellationRequested) { if (string.IsNullOrWhiteSpace(expectedRevision)) return false; - string path = Path.Combine(root, "input-bridge-health.json"); + string path = BridgeStateFile("input-bridge-health.json"); DateTime deadline = DateTime.UtcNow.AddMilliseconds(Math.Max(100, timeoutMilliseconds)); do { @@ -13405,7 +13773,7 @@ private static void PrepareOnboardingCompletionPending(VibeMicConfig config, private void PollKeyboardBridgeHealth() { if (applicationExiting || !config.setupCompleted) return; - string healthPath = Path.Combine(root, "input-bridge-health.json"); + string healthPath = BridgeStateFile("input-bridge-health.json"); ProcessTopologySnapshot topology = InspectProcessTopology("VoxDeckInputBridge"); if (topology.CurrentRootCount == 0) { @@ -13498,7 +13866,12 @@ private void ObserveSmartProfileHealth(Dictionary health) string profileName = health.TryGetValue("smart_profile_effective_name", out nameValue) ? Convert.ToString(nameValue) : profileId; if (overviewProfileLabel != null && !overviewProfileLabel.IsDisposed && !string.IsNullOrWhiteSpace(profileName)) - overviewProfileLabel.Text = "● Profile · " + profileName; + { + // Written through the same "only when it really changed" helper as every other label: a plain + // assignment here re-laid out and repainted this label on every 500 ms refresh, for a name that + // almost never changes. + SetLabelText(overviewProfileLabel, "● Profile · " + profileName); + } if (!enabled || string.IsNullOrWhiteSpace(profileId)) { lastObservedSmartProfileId = profileId; @@ -13516,7 +13889,7 @@ private void ObserveSmartProfileHealth(Dictionary health) ShowToast("已自动切换到“" + profileName + "”", "success"); } - private static bool TryReadBridgeHealth(string path, out Dictionary health, out string error) + private bool TryReadBridgeHealth(string path, out Dictionary health, out string error) { health = null; error = null; @@ -13524,25 +13897,18 @@ private static bool TryReadBridgeHealth(string path, out Dictionary>(reader.ReadToEnd()); - } - if (health != null) - { - DateTime modified = File.GetLastWriteTimeUtc(path); - if ((DateTime.UtcNow - modified).TotalSeconds <= 7) return true; - error = "heartbeat_stale"; - } - else error = "heartbeat_empty"; + string parseError; + health = ReadBridgeHealthDocumentRaw(path, out parseError); + if (health == null) error = parseError ?? "heartbeat_empty"; + else if (ageSeconds < 0 || ageSeconds <= 7) return true; + else error = "heartbeat_stale"; } } catch (Exception ex) { error = ex.Message; } @@ -13866,7 +14232,16 @@ internal static bool TriggerOnlyModeSupportsProvider(string provider) private bool IsTriggerOnlyVoiceMode() { - return ShouldUseTriggerOnlyVoiceMode(HasCableInput(), IsTriggerOnlyVoiceModeForced()); + // Probing the wave-out device list costs milliseconds, and this decision is asked for several times + // per poll and several times per page render while it only changes when VB-CABLE is installed or + // removed. The answer is reused for a moment. HasCableInput() itself stays uncached, so the checks + // that exist to re-probe the device list (自检, the wizard's re-check) still see it as it is. + if (triggerOnlyModeResolvedAt != DateTime.MinValue && + (DateTime.Now - triggerOnlyModeResolvedAt).TotalMilliseconds < TriggerOnlyModeCacheMs) + return triggerOnlyModeCached; + triggerOnlyModeCached = ShouldUseTriggerOnlyVoiceMode(HasCableInput(), IsTriggerOnlyVoiceModeForced()); + triggerOnlyModeResolvedAt = DateTime.Now; + return triggerOnlyModeCached; } private bool IsTriggerOnlyVoiceModeForced() @@ -13958,16 +14333,30 @@ private static string OnboardingNextButtonText(bool smokeMode, int currentStep) private static bool HasFreshOnboardingDirectionEvidence(BridgeHealthSnapshot snapshot, DateTime baselineUtc) { - if (snapshot == null || snapshot.LastRawActionAtUtc <= baselineUtc) return false; - if (!string.Equals(snapshot.LastActionSource, "raw_input", StringComparison.OrdinalIgnoreCase) && - !string.Equals(snapshot.LastActionSource, "device_filter", StringComparison.OrdinalIgnoreCase)) + if (snapshot == null) return false; + // Two records are accepted, and the second one is what makes this task passable at all. The bridge + // publishes the last routed edge, which after a key is released reads "...:up"; a heartbeat read + // therefore almost never shows the press itself, and judging only that field left a correctly + // working remote failing this check forever. The bridge also publishes the last press. + return IsOnboardingDirectionEvidence(snapshot.LastRawAction, snapshot.LastActionSource, + snapshot.LastRawActionAtUtc, baselineUtc) || + IsOnboardingDirectionEvidence(snapshot.LastRawPress, snapshot.LastRawPressSource, + snapshot.LastRawPressAtUtc, baselineUtc); + } + + private static bool IsOnboardingDirectionEvidence(string action, string source, + DateTime actionAtUtc, DateTime baselineUtc) + { + if (actionAtUtc <= baselineUtc) return false; + if (!string.Equals(source, "raw_input", StringComparison.OrdinalIgnoreCase) && + !string.Equals(source, "device_filter", StringComparison.OrdinalIgnoreCase)) return false; - string action = (snapshot.LastRawAction ?? "").Trim(); - int separator = action.LastIndexOf(':'); + string text = (action ?? "").Trim(); + int separator = text.LastIndexOf(':'); if (separator <= 0 || - !string.Equals(action.Substring(separator + 1).Trim(), "down", + !string.Equals(text.Substring(separator + 1).Trim(), "down", StringComparison.OrdinalIgnoreCase)) return false; - string button = action.Substring(0, separator).Trim(); + string button = text.Substring(0, separator).Trim(); string[] directions = { "up", "down", "left", "right", "上键", "下键", "左键", "右键" }; for (int index = 0; index < directions.Length; index++) if (string.Equals(button, directions[index], StringComparison.OrdinalIgnoreCase)) return true; @@ -14160,7 +14549,7 @@ private void ShowSetupWizard() bool smartProfilesChoice = onboardingChoices[3]; var onboardingStepVerified = new bool[OnboardingStepCount]; DateTime keyBaselineUtc = currentStep == 1 ? DateTime.UtcNow : DateTime.MinValue; - int dictationBaselineGeneration = currentStep == 3 ? GetLatestSessionHealth().Generation : 0; + DateTime dictationBaselineAt = currentStep == 3 ? DateTime.Now : DateTime.MinValue; bool textInsertionConfirmed = false; string dictationEvidenceConfigurationKey = ""; ComboBox providerChoice = null; @@ -14170,7 +14559,7 @@ private void ShowSetupWizard() Action renderStep = null; Action resetDictationEvidence = delegate { - dictationBaselineGeneration = GetLatestSessionHealth().Generation; + dictationBaselineAt = DateTime.Now; textInsertionConfirmed = false; dictationEvidenceConfigurationKey = ""; }; @@ -14425,14 +14814,13 @@ private void ShowSetupWizard() else if (currentStep == 3) { SessionHealth health = GetLatestSessionHealth(); - bool audioSubmissionSucceeded = health.Generation > dictationBaselineGeneration && health.Success; + bool freshAudio = DictationAudioIsFresh(health, dictationBaselineAt); string currentProviderConfigurationKey = VoiceProviderConfigurationKey( selectedProvider, selectedHotkey, selectedTrigger); bool dictationSucceeded = VoiceProviderEvidenceIsCurrent( currentProviderConfigurationKey, captureProviderConfigurationKey, dictationEvidenceConfigurationKey, - dictationBaselineGeneration, health.Generation, health.Success, - textInsertionConfirmed); + freshAudio, textInsertionConfirmed); var providerLabel = NewLabel("默认语音工具", 8.9f, FontStyle.Bold, ink); providerLabel.Location = new Point(8, 104); providerLabel.Size = new Size(140, 24); @@ -14448,7 +14836,7 @@ private void ShowSetupWizard() hotkeyTrigger.SelectedIndex = NormalizeProviderKey(selectedProvider) == "wechat" ? 0 : selectedTrigger == "hold" ? 1 : 0; string testState = dictationSucceeded ? "● 已由用户目视确认测试框中的文字" : - audioSubmissionSucceeded ? "● 音频与工具唤起已通过,请确认文字" : "● 等待一次真实听写"; + freshAudio ? "● 音频与工具唤起已通过,请确认文字" : "● 等待一次真实听写"; var status = NewLabel(testState, 11.5f, FontStyle.Bold, dictationSucceeded ? green : cyan); status.Location = new Point(8, 188); status.Size = new Size(620, 30); @@ -14459,10 +14847,22 @@ private void ShowSetupWizard() confirmText.Click += delegate { SessionHealth currentHealth = GetLatestSessionHealth(); - bool currentAudioSubmissionSucceeded = currentHealth.Generation > dictationBaselineGeneration && - currentHealth.Success; + bool currentAudioSubmissionSucceeded = + DictationAudioIsFresh(currentHealth, dictationBaselineAt); if (!currentAudioSubmissionSucceeded) { + // This task refuses in three different places and none of them left a trace, so a + // user who is stuck here could not report anything and a support answer had nothing + // to read. Measured on a remote whose dictation was working: the wizard said only + // that the step was not done. + HostLog("ONBOARDING DICTATION CONFIRM refused=true reason=no_fresh_audio" + + " baseline_at=" + SafeLogValue(dictationBaselineAt.ToString("O", CultureInfo.InvariantCulture)) + + " last_completed_at=" + SafeLogValue(currentHealth.LastCompletedAt.ToString("O", CultureInfo.InvariantCulture)) + + " last_session_started_at=" + SafeLogValue(currentHealth.StartedAt.ToString("O", CultureInfo.InvariantCulture)) + + " last_session_generation=" + currentHealth.Generation + + " audio_success=" + currentHealth.Success + + " completed=" + currentHealth.Completed + + " audio_ms=" + currentHealth.AudioMs); showFeedback("还没有收到本次的真实音频与工具响应,测试结果未确认", false); return; } @@ -14471,11 +14871,17 @@ private void ShowSetupWizard() if (!string.Equals(selectedConfigurationKey, captureProviderConfigurationKey, StringComparison.Ordinal)) { + HostLog("ONBOARDING DICTATION CONFIRM refused=true reason=configuration_changed" + + " selected_key=" + SafeLogValue(selectedConfigurationKey) + + " runtime_key=" + SafeLogValue(captureProviderConfigurationKey)); showFeedback("当前设置已变更,请先用当前设置重新完成真实听写", false); return; } textInsertionConfirmed = true; dictationEvidenceConfigurationKey = captureProviderConfigurationKey; + HostLog("ONBOARDING DICTATION CONFIRM confirmed=true generation=" + + currentHealth.Generation + + " configuration_key=" + SafeLogValue(selectedConfigurationKey)); status.Text = "● 已由用户目视确认测试框中的文字"; status.ForeColor = green; }; @@ -14522,6 +14928,9 @@ private void ShowSetupWizard() return; } resetDictationEvidence(); + HostLog("ONBOARDING DICTATION TEST started=true baseline_at=" + + SafeLogValue(dictationBaselineAt.ToString("O", CultureInfo.InvariantCulture)) + + " configuration_key=" + SafeLogValue(currentConfigurationKey)); testInput.Focus(); showFeedback("现在按住录音键说话,松开后等待文字", true); }; @@ -14728,17 +15137,29 @@ private void ShowSetupWizard() return; } SessionHealth health = GetLatestSessionHealth(); + bool freshAudio = DictationAudioIsFresh(health, dictationBaselineAt); string currentProviderConfigurationKey = VoiceProviderConfigurationKey( selectedProvider, selectedHotkey, selectedTrigger); if (!VoiceProviderEvidenceIsCurrent(currentProviderConfigurationKey, captureProviderConfigurationKey, dictationEvidenceConfigurationKey, - dictationBaselineGeneration, - health.Generation, health.Success, textInsertionConfirmed)) + freshAudio, textInsertionConfirmed)) { + // The same reason the confirmation button records: without it a refusal here + // is invisible, and this task is where a correctly working remote was most + // likely to be reported as broken. + HostLog("ONBOARDING DICTATION EVIDENCE ready=false selected_key=" + + SafeLogValue(currentProviderConfigurationKey) + + " runtime_key=" + SafeLogValue(captureProviderConfigurationKey) + + " evidence_key=" + SafeLogValue(dictationEvidenceConfigurationKey) + + " baseline_at=" + SafeLogValue(dictationBaselineAt.ToString("O", CultureInfo.InvariantCulture)) + + " last_completed_at=" + SafeLogValue(health.LastCompletedAt.ToString("O", CultureInfo.InvariantCulture)) + + " fresh_audio=" + freshAudio + + " audio_success=" + health.Success + + " text_confirmed=" + textInsertionConfirmed); showFeedback(!string.Equals(currentProviderConfigurationKey, dictationEvidenceConfigurationKey, StringComparison.Ordinal) ? "语音工具设置已变更,请用当前设置重新完成真实听写" - : health.Generation <= dictationBaselineGeneration || !health.Success + : !freshAudio ? "音频与语音工具唤起尚未通过,请重新测试" : "请目视确认测试框中的文字,再点击「我已看到文字」", false); return; @@ -16302,12 +16723,29 @@ private RoundPanel AddSetupStep(Control parent, int number, string title, string return step; } + // The poll rewrites the same strings onto the hero card and the status strip every 500 ms. On this + // class of machine a write that repeats the current value still costs about a millisecond of + // re-measure and repaint per label, which was most of what made the interface feel slow. Only real + // changes are written, so a repeating poll stays off the paint path. + private static void SetLabelText(Control control, string text) + { + if (control == null || control.IsDisposed) return; + if (string.Equals(control.Text, text, StringComparison.Ordinal)) return; + control.Text = text; + } + + private static void SetLabelColor(Control control, Color color) + { + if (control == null || control.IsDisposed) return; + if (control.ForeColor == color) return; + control.ForeColor = color; + } + private void UpdateCaptureUi() { if (InvokeRequired) { BeginInvoke(new Action(UpdateCaptureUi)); return; } bool triggerOnlyUi = IsTriggerOnlyVoiceMode(); - if (bridgeButton != null && !bridgeButton.IsDisposed) - bridgeButton.Text = triggerOnlyUi ? "查看语音设置" : IsCapturing ? "管理语音桥接" : "启动语音桥接"; + SetLabelText(bridgeButton, triggerOnlyUi ? "查看语音设置" : IsCapturing ? "管理语音桥接" : "启动语音桥接"); if (DateTime.Now < transientFeedbackUntil && !string.IsNullOrWhiteSpace(transientFeedbackState)) { ApplyVisualState(transientFeedbackState); @@ -16330,20 +16768,22 @@ private void UpdateCaptureUi() ApplyVisualState("trigger_only"); return; } - if (heroTitle != null && !heroTitle.IsDisposed) - heroTitle.Text = !IsCapturing ? "语音桥接已暂停" : bridgeReady ? "已准备好" : "正在连接"; - if (heroSubtitle != null && !heroSubtitle.IsDisposed) - heroSubtitle.Text = !IsCapturing ? "启动后按住录音键说话,松开结束" : bridgeReady ? "按住录音键说话,松开结束;请目视确认文字" : "正在建立遥控器语音通道,请稍候"; - if (heroStateLabel != null && !heroStateLabel.IsDisposed) - heroStateLabel.Text = !IsCapturing ? "语音未启动" : bridgeReady ? "已就绪" : "连接中"; - connectionBadge.Text = !IsCapturing ? "● 语音已暂停" : bridgeReady ? "● 语音链路就绪" : "● 正在连接"; - connectionBadge.ForeColor = !IsCapturing ? muted : bridgeReady ? green : amber; + SetLabelText(heroTitle, + !IsCapturing ? "语音桥接已暂停" : bridgeReady ? "已准备好" : "正在连接"); + SetLabelText(heroSubtitle, + !IsCapturing ? "启动后按住录音键说话,松开结束" : + bridgeReady ? "按住录音键说话,松开结束;请目视确认文字" : "正在建立遥控器语音通道,请稍候"); + SetLabelText(heroStateLabel, !IsCapturing ? "语音未启动" : bridgeReady ? "已就绪" : "连接中"); + SetLabelText(connectionBadge, + !IsCapturing ? "● 语音已暂停" : bridgeReady ? "● 语音链路就绪" : "● 正在连接"); + SetLabelColor(connectionBadge, !IsCapturing ? muted : bridgeReady ? green : amber); UpdateOverviewStatus(); ApplyVisualState(!IsCapturing ? "stopped" : bridgeReady ? "ready" : "connecting"); } private void ApplyVisualState(string state) { + double visualStartedAt = PollClockMs(); Color accent; Color surface; string voiceText; @@ -16401,6 +16841,12 @@ private void ApplyVisualState(string state) surface = StatusSurface("stopped"); voiceText = "● 语音桥接已暂停"; } + bool samePaintedSurface = string.Equals(state, currentVisualState, StringComparison.Ordinal) && + accent == currentVisualAccent && + ReferenceEquals(heroPanel, lastPaintedHeroPanel) && + ReferenceEquals(remoteVisual, lastPaintedRemoteVisual) && + ReferenceEquals(connectionBadge, lastPaintedConnectionBadge) && + ReferenceEquals(voiceBridgeStateLabel, lastPaintedVoiceBridgeStateLabel); currentVisualAccent = accent; currentVisualState = state; if (state == "recording") @@ -16409,6 +16855,18 @@ private void ApplyVisualState(string state) remoteHighlightUntil = DateTime.Now.AddSeconds(1); } if (visualTimer != null && visualTimer.Interval != 500) visualTimer.Interval = 500; + // This method is re-applied by the 500 ms poll. Repainting a state that is already on screen is + // what made the interface stutter while the window was dragged, so an unchanged state on the + // same controls stops here. + if (samePaintedSurface) + { + MarkPollStage(21, visualStartedAt); + return; + } + lastPaintedHeroPanel = heroPanel; + lastPaintedRemoteVisual = remoteVisual; + lastPaintedConnectionBadge = connectionBadge; + lastPaintedVoiceBridgeStateLabel = voiceBridgeStateLabel; if (heroStateLabel != null && !heroStateLabel.IsDisposed) heroStateLabel.ForeColor = accent; if (heroPanel != null && !heroPanel.IsDisposed) { @@ -16440,23 +16898,77 @@ private void ApplyVisualState(string state) if (voiceBridgeStateLabel.Parent != null) voiceBridgeStateLabel.Parent.BackColor = surface; } PublishFeedbackSnapshot(); + MarkPollStage(21, visualStartedAt); + } + + // WM_ENTERSIZEMOVE / WM_EXITSIZEMOVE bracket the modal loop Windows runs while the user moves or + // resizes the window. That loop is the one place where a busy UI thread is directly visible as a + // window that will not follow the mouse, so the poll steps aside for it. + private const int WmEnterSizeMove = 0x0231; + private const int WmExitSizeMove = 0x0232; + + protected override void WndProc(ref Message m) + { + if (m.Msg == WmEnterSizeMove) + { + windowModalMoveLoopActive = true; + windowModalMoveLoopAt = DateTime.Now; + } + else if (m.Msg == WmExitSizeMove) + { + windowModalMoveLoopActive = false; + } + base.WndProc(ref m); + } + + // Measures how late the UI thread runs against a cheap timer. A gap here is real interference, and + // it is the thing a user feels as a stutter, so it is recorded instead of being guessed at. + private void ObserveUiThreadProgress() + { + DateTime now = DateTime.Now; + var heartbeatClock = System.Diagnostics.Stopwatch.StartNew(); + if (uiProgressObservedAt != DateTime.MinValue) + { + double gapMs = (now - uiProgressObservedAt).TotalMilliseconds; + if (gapMs >= UiStallThresholdMs && (now - lastUiStallLogAt).TotalSeconds >= 5) + { + lastUiStallLogAt = now; + HostLog("UI STALL gap_ms=" + ((int)gapMs).ToString(CultureInfo.InvariantCulture) + + " threshold_ms=" + UiStallThresholdMs + + " modal_loop=" + windowModalMoveLoopActive); + } + } + uiProgressObservedAt = now; + // This timer fires five times per tick, so its own cost is accounted for as well. + uiHeartbeatMs += heartbeatClock.Elapsed.TotalMilliseconds; + uiHeartbeatTicks++; } private void PollActivity() { try { + if (windowModalMoveLoopActive) + { + // Every step below touches the disk or the process table. They are all incremental, so + // deferring them costs nothing but keeps the window following the mouse. + if ((DateTime.Now - windowModalMoveLoopAt).TotalSeconds < 30) return; + windowModalMoveLoopActive = false; + } + var pollClock = System.Diagnostics.Stopwatch.StartNew(); + activePollClock = pollClock; // Passive UIA observation arms the existing focus lease while the // app is idle. It never activates a window and is skipped during // recording/provider processing by PollFocusTargetForVoiceLock. - PollFocusTargetForVoiceLock(); - RefreshActiveInputEngineIfStale(); - ApplyCustomButtonCaptureResult(); - ApplyMappingActionTestResult(); - PollRuntimeFeedback(); - PollInputFeedback(); - PollKeyboardBridgeHealth(); - PollCaptureHealth(); + AccumulatePollStage(0, pollClock, delegate { PollFocusTargetForVoiceLock(); }); + AccumulatePollStage(1, pollClock, RefreshActiveInputEngineIfStale); + AccumulatePollStage(2, pollClock, ApplyCustomButtonCaptureResult); + AccumulatePollStage(3, pollClock, ApplyMappingActionTestResult); + AccumulatePollStage(4, pollClock, PollRuntimeFeedback); + AccumulatePollStage(5, pollClock, PollInputFeedback); + AccumulatePollStage(6, pollClock, PollKeyboardBridgeHealth); + AccumulatePollStage(7, pollClock, PollCaptureHealth); + double eventsStartedAt = pollClock.Elapsed.TotalMilliseconds; if (File.Exists(eventsPath)) { long len = new FileInfo(eventsPath).Length; @@ -16475,12 +16987,85 @@ private void PollActivity() } } } - UpdateSessionConfidence(); - PublishFeedbackSnapshot(); + pollStageMs[8] += pollClock.Elapsed.TotalMilliseconds - eventsStartedAt; + AccumulatePollStage(9, pollClock, UpdateSessionConfidence); + AccumulatePollStage(10, pollClock, PublishFeedbackSnapshot); + RecordPollDuration(pollClock.Elapsed.TotalMilliseconds); + activePollClock = null; } catch { } } + // The longest poll the user could have felt, reported once per window rather than per tick. This is + // the measurement the "the interface stutters" report was closed out with: a tick at or above the + // threshold really did hold the UI thread, and one that is not logged was not felt. + private void RecordPollDuration(double milliseconds) + { + if (milliseconds > worstPollMs) worstPollMs = milliseconds; + totalPollMs += milliseconds; + pollTicksInWindow++; + if ((DateTime.Now - pollWindowStartedAt).TotalSeconds < PollReportWindowSeconds) return; + double worst = worstPollMs; + long ticks = pollTicksInWindow; + double windowTotal = totalPollMs; + double average = ticks == 0 ? 0 : windowTotal / ticks; + string breakdown = PollStageBreakdown(windowTotal, ticks); + double heartbeatAverage = uiHeartbeatTicks == 0 ? 0 : uiHeartbeatMs / uiHeartbeatTicks; + pollWindowStartedAt = DateTime.Now; + worstPollMs = 0; + totalPollMs = 0; + pollTicksInWindow = 0; + Array.Clear(pollStageMs, 0, pollStageMs.Length); + uiHeartbeatMs = 0; + uiHeartbeatTicks = 0; + if (worst >= PollSlowTickMs || average >= PollSlowAverageMs) + HostLog("POLL SLOW worst_ms=" + ((int)worst).ToString(CultureInfo.InvariantCulture) + + " avg_ms=" + average.ToString("0.0", CultureInfo.InvariantCulture) + + " heartbeat_avg_ms=" + heartbeatAverage.ToString("0.00", CultureInfo.InvariantCulture) + + " ticks=" + ticks.ToString(CultureInfo.InvariantCulture) + + " stages=" + breakdown + + " window_s=" + PollReportWindowSeconds); + } + + private void AccumulatePollStage(int index, System.Diagnostics.Stopwatch clock, Action action) + { + double before = clock.Elapsed.TotalMilliseconds; + action(); + if (index >= 0 && index < pollStageMs.Length) + pollStageMs[index] += clock.Elapsed.TotalMilliseconds - before; + } + + private double PollClockMs() + { + return activePollClock == null ? 0 : activePollClock.Elapsed.TotalMilliseconds; + } + + private void MarkPollStage(int index, double startedAt) + { + if (activePollClock == null || index < 0 || index >= pollStageMs.Length) return; + pollStageMs[index] += activePollClock.Elapsed.TotalMilliseconds - startedAt; + } + + // Every stage the poll runs, plus what is left over, so the next "it got slow again" report + // already names the stage instead of being a single unexplained average. + private string PollStageBreakdown(double windowTotalMs, long ticks) + { + var text = new StringBuilder(); + double accounted = 0; + for (int i = 0; i < PollStageLabels.Length; i++) + { + double stage = i < pollStageMs.Length ? pollStageMs[i] : 0; + if (i < PollTopLevelStageCount) accounted += stage; + if (i > 0) text.Append(','); + text.Append(PollStageLabels[i]).Append('=').Append( + (ticks == 0 ? 0 : stage / ticks).ToString("0.00", CultureInfo.InvariantCulture)); + } + text.Append(",other=").Append( + (ticks == 0 ? 0 : Math.Max(0, windowTotalMs - accounted) / ticks) + .ToString("0.00", CultureInfo.InvariantCulture)); + return text.ToString(); + } + private void ScanDevice() { Toast("正在检查蓝牙和遥控器语音通道"); @@ -16595,17 +17180,82 @@ private static string FormatUsageGap(long milliseconds) return Math.Max(1, (int)span.TotalSeconds) + " 秒"; } + // Freshness for the wizard's real-dictation step can not be measured from the session framing the + // capture writes ("REMOTE STREAM START session=..."): when the remote starts streaming audio before + // the capture finished connecting, the capture starts the stream implicitly and writes no such line, + // and the session counters restart with every bridge restart. The completion receipt is written for + // every provider and carries a real timestamp, so "a dictation finished after the test started" is + // measured from it instead. + private static DateTime LatestCompletedDictationAt(string[] lines) + { + DateTime latest = DateTime.MinValue; + if (lines == null) return latest; + for (int i = 0; i < lines.Length; i++) + { + string line = lines[i]; + bool completion = line.IndexOf("WETYPE SESSION END", StringComparison.OrdinalIgnoreCase) >= 0 || + line.IndexOf("TRANSCRIPTION SESSION END", StringComparison.OrdinalIgnoreCase) >= 0; + if (!completion) continue; + if (line.IndexOf("audio_delivered=True", StringComparison.OrdinalIgnoreCase) < 0) continue; + if (line.IndexOf("submitted=True", StringComparison.OrdinalIgnoreCase) < 0 && + line.IndexOf("sent=True", StringComparison.OrdinalIgnoreCase) < 0) continue; + DateTime stamp; + if (!TryParseRuntimeTimestamp(line, out stamp)) continue; + if (stamp > latest) latest = stamp; + } + return latest; + } + private SessionHealth GetLatestSessionHealth() + { + RuntimeLogTail tail = ReadRuntimeLogTail(); + if (tail.Health != null) return tail.Health.Copy(); + SessionHealth parsed = ComputeSessionHealth(tail.Lines); + tail.Health = parsed; + return parsed.Copy(); + } + + // The cached runtime-log tail. A missing log is a valid cache entry (an empty tail), so an idle + // machine never opens the file at all. + private RuntimeLogTail ReadRuntimeLogTail() + { + string path = Path.Combine(sessionDir, "vibe-mic-runtime.log"); + string key = RuntimeLogTailKey(path); + RuntimeLogTail cached = runtimeLogTail; + if (cached != null && string.Equals(cached.Key, key, StringComparison.Ordinal)) return cached; + string[] lines; + try { lines = ReadLogTailLines(path, RuntimeLogTailBytes); } + catch { lines = new string[0]; } + var fresh = new RuntimeLogTail(); + fresh.Key = key; + fresh.Lines = lines; + runtimeLogTail = fresh; + return fresh; + } + + // Length and write time identify one revision of an append-only log. The configuration is part of + // the key because the parse switches on the voice mode (long dictation has a different start + // marker) and reports the configured provider and route. + private string RuntimeLogTailKey(string path) + { + try + { + var info = new FileInfo(path); + if (!info.Exists) return "missing"; + return info.Length.ToString(CultureInfo.InvariantCulture) + "|" + + info.LastWriteTimeUtc.Ticks.ToString(CultureInfo.InvariantCulture) + "|" + + NormalizeProviderKey(config.inputMethod) + "|" + (config.voiceMode ?? "") + "|" + + (config.autoRouteVirtualMicrophone ? "route" : "manual"); + } + catch { return "unreadable"; } + } + + private SessionHealth ComputeSessionHealth(string[] lines) { var health = new SessionHealth(); health.Provider = NormalizeProviderKey(config.inputMethod); health.NextAction = "按住遥控器录音键完成一次测试,松开后等待转译"; - string path = Path.Combine(sessionDir, "vibe-mic-runtime.log"); - if (!File.Exists(path)) return health; - - string[] lines; - try { lines = ReadLogTailLines(path, 512 * 1024); } - catch { return health; } + health.LastCompletedAt = LatestCompletedDictationAt(lines); int searchStart = Math.Max(0, lines.Length - 2400); int sessionStart = -1; bool longHealth = UsesLongDictation(config.voiceMode); @@ -16840,7 +17490,7 @@ private string BuildProblemSummary() BridgeHealthSnapshot bridgeHealth = ReadKeyboardBridgeHealth(); result.AppendLine("按键路由:" + (bridgeHealth.FilterHealthy ? "设备级精确隔离" : string.Equals(bridgeHealth.RoutingAuthority, "raw_input", StringComparison.OrdinalIgnoreCase) ? - "Raw Input 安全直通" : "未确认") + " · 设备事件 " + bridgeHealth.RawRemoteEdges + + "Raw Input 安全直通(F5 fail-open)" : "未确认") + " · 设备事件 " + bridgeHealth.RawRemoteEdges + " · 动作事件 " + (bridgeHealth.RawActionEdges + bridgeHealth.FilterActionEdges) + (string.IsNullOrWhiteSpace(bridgeHealth.LastRawAction) ? "" : " · 最近 " + bridgeHealth.LastRawAction)); result.AppendLine("VB-CABLE:Input " + (HasCableInput() ? "已检测" : "未检测") + " / Output " + (HasCableOutput() ? "已检测" : "未检测")); @@ -17047,8 +17697,10 @@ private SelfCheckReport BuildSelfCheckReport() bool filesReady = File.Exists(Path.Combine(root, "VibeMicAtvvCapture.exe")) && File.Exists(Path.Combine(root, "VoxDeckInputBridge.exe")) && File.Exists(Path.Combine(root, "NAudio.Core.dll")) && File.Exists(Path.Combine(root, "NAudio.Wasapi.dll")); - ProcessTopologySnapshot captureTopology = InspectProcessTopology("VibeMicAtvvCapture"); - ProcessTopologySnapshot bridgeTopology = InspectProcessTopology("VoxDeckInputBridge"); + // Forced past the cache: this page is rendered because the user asked for it, so it must not + // repeat an answer the poll measured up to a second ago. + ProcessTopologySnapshot captureTopology = InspectProcessTopology("VibeMicAtvvCapture", true); + ProcessTopologySnapshot bridgeTopology = InspectProcessTopology("VoxDeckInputBridge", true); bool processTopologyReady = captureTopology.ForeignCount == 0 && captureTopology.InaccessibleCount == 0 && captureTopology.CurrentRootCount <= 1 && bridgeTopology.ForeignCount == 0 && bridgeTopology.InaccessibleCount == 0 && bridgeTopology.CurrentRootCount <= 1; @@ -17158,7 +17810,7 @@ private SelfCheckReport BuildSelfCheckReport() bool routingReady = string.Equals(bridge.InputRoutingMode, configuredRoutingMode, StringComparison.OrdinalIgnoreCase); bool authorityReady = string.Equals(bridge.RoutingAuthority, "raw_input", StringComparison.OrdinalIgnoreCase) || string.Equals(bridge.RoutingAuthority, "device_filter", StringComparison.OrdinalIgnoreCase); - string isolationLabel = bridge.FilterHealthy ? "设备级精确隔离" : "Raw Input 安全直通"; + string isolationLabel = bridge.FilterHealthy ? "设备级精确隔离" : "Raw Input 安全直通(F5 fail-open)"; string keyState = !keyboardRunning || !bridge.Healthy || !mappingReady || !routingReady || !authorityReady ? "fail" : recentAction ? "pass" : "checking"; report.Items.Add(new SelfCheckItem("keys", "遥控器按键监听", @@ -17865,10 +18517,97 @@ private void RunSelfCheckAndRefresh() private static bool IsProcessRunning(string processName) { - Process[] processes = Process.GetProcessesByName(processName); - bool running = processes.Length > 0; - foreach (Process process in processes) process.Dispose(); - return running; + return IsProcessRunning(processName, false); + } + + // forceRefresh bypasses the snapshot so a user-visible answer is measured at the moment it is asked for. + private static bool IsProcessRunning(string processName, bool forceRefresh) + { + if (string.IsNullOrEmpty(processName)) return false; + return ProcessNamesSnapshot(forceRefresh).Contains(processName); + } + + // One enumeration of the process table answers every "is X running" question until the snapshot ages + // out. Process.GetProcessesByName enumerates the whole table on every call and was measured at about + // five milliseconds; the poll asks it about four provider names for the link strip on every tick, + // which made that the largest single cost of the poll. A process that starts is seen within the + // snapshot's lifetime, and forceRefresh measures on demand. + private static HashSet ProcessNamesSnapshot(bool forceRefresh) + { + HashSet current; + bool stale; + lock (processLookupCache) + { + current = processSnapshotNames; + stale = current == null || + (DateTime.UtcNow - processSnapshotAt).TotalMilliseconds >= ProcessSnapshotMs; + } + if (forceRefresh || current == null) return RefreshProcessNamesSnapshot(); + // The stale list is served once more while a background refresh runs. Enumerating the process + // table costs about twelve milliseconds on this class of machine, which is a stall the user feels + // when it lands on the UI thread; a name list a couple of seconds old is not. + if (stale) BeginProcessNamesRefresh(); + return current; + } + + // Builds and publishes the snapshot. Only the publish is done under the lock, so a reader is never + // held behind the enumeration. + private static HashSet RefreshProcessNamesSnapshot() + { + var names = new HashSet(StringComparer.OrdinalIgnoreCase); + try + { + Process[] running = Process.GetProcesses(); + foreach (Process item in running) + { + try { names.Add(item.ProcessName); } + catch { } + finally { item.Dispose(); } + } + } + catch { return processSnapshotNames ?? names; } + lock (processLookupCache) + { + processSnapshotNames = names; + processSnapshotAt = DateTime.UtcNow; + } + return names; + } + + private static void BeginProcessNamesRefresh() + { + if (Interlocked.CompareExchange(ref processSnapshotRefreshing, 1, 0) != 0) return; + ThreadPool.QueueUserWorkItem(delegate + { + try { RefreshProcessNamesSnapshot(); } + catch { } + finally { Interlocked.Exchange(ref processSnapshotRefreshing, 0); } + }); + } + + // A cached answer is trusted for one second. Only "it is running" is ever stored: caching "it is not + // running" would hide the restart the recovery paths are waiting for. + private static ProcessLookupEntry ReadProcessLookupCache(string key) + { + if (string.IsNullOrEmpty(key)) return null; + lock (processLookupCache) + { + ProcessLookupEntry entry; + if (!processLookupCache.TryGetValue(key, out entry)) return null; + if (entry == null || (DateTime.UtcNow - entry.At).TotalMilliseconds > ProcessTopologyCacheMs) + { + processLookupCache.Remove(key); + return null; + } + return entry; + } + } + + private static void WriteProcessLookupCache(string key, ProcessLookupEntry entry) + { + if (string.IsNullOrEmpty(key) || entry == null) return; + entry.At = DateTime.UtcNow; + lock (processLookupCache) { processLookupCache[key] = entry; } } private bool IsCurrentProcessRunningFromRoot(string processName) @@ -17906,6 +18645,19 @@ private bool KeyboardBridgeProcessMatchesExpectedRoot(int processId) private ProcessTopologySnapshot InspectProcessTopology(string processName) { + return InspectProcessTopology(processName, false); + } + + private ProcessTopologySnapshot InspectProcessTopology(string processName, bool forceRefresh) + { + if (!forceRefresh) + { + ProcessLookupEntry cached = ReadProcessLookupCache(processName); + if (cached != null && cached.Topology != null) return CopyProcessTopology(cached.Topology); + // A name the shared snapshot says is not running needs no enumeration at all: the foreign-root + // and duplicate tests are the only reason to look at module paths, and there is nothing to look at. + if (!ProcessNamesSnapshot(false).Contains(processName)) return new ProcessTopologySnapshot(); + } var snapshot = new ProcessTopologySnapshot(); string expected = Path.GetFullPath(Path.Combine(root, processName + ".exe")); foreach (Process process in Process.GetProcessesByName(processName)) @@ -17920,20 +18672,28 @@ private ProcessTopologySnapshot InspectProcessTopology(string processName) catch { snapshot.InaccessibleCount++; } finally { process.Dispose(); } } - return snapshot; + if (snapshot.TotalCount > 0) + { + var entry = new ProcessLookupEntry(); + entry.Running = true; + entry.Topology = snapshot; + WriteProcessLookupCache(processName, entry); + } + return CopyProcessTopology(snapshot); } private BridgeHealthSnapshot ReadKeyboardBridgeHealth() { var snapshot = new BridgeHealthSnapshot(); - string path = Path.Combine(root, "input-bridge-health.json"); + string path = BridgeStateFile("input-bridge-health.json"); try { - if (!File.Exists(path)) return snapshot; - snapshot.FileAgeSeconds = Math.Max(0, (DateTime.UtcNow - File.GetLastWriteTimeUtc(path)).TotalSeconds); - Dictionary data; - string readError; - TryReadBridgeHealth(path, out data, out readError); + // One stat answers both "is the heartbeat there" and "how old is it", instead of a File.Exists + // and a File.GetLastWriteTimeUtc either side of the cached parse below. + double ageSeconds; + if (FileRevisionKey(path, out ageSeconds) == "missing") return snapshot; + snapshot.FileAgeSeconds = ageSeconds < 0 ? 0 : ageSeconds; + Dictionary data = ReadBridgeHealthDocument(path); if (data == null) return snapshot; object value; snapshot.ProcessId = ReadBridgeProcessId(data); @@ -17950,6 +18710,8 @@ private BridgeHealthSnapshot ReadKeyboardBridgeHealth() if (data.TryGetValue("hook_candidate_passthroughs", out value)) snapshot.HookCandidatePassthroughs = Convert.ToInt64(value); if (data.TryGetValue("last_raw_action", out value)) snapshot.LastRawAction = Convert.ToString(value); if (data.TryGetValue("last_action_source", out value)) snapshot.LastActionSource = Convert.ToString(value); + if (data.TryGetValue("last_raw_press", out value)) snapshot.LastRawPress = Convert.ToString(value); + if (data.TryGetValue("last_raw_press_source", out value)) snapshot.LastRawPressSource = Convert.ToString(value); if (data.TryGetValue("last_execution_sequence", out value)) snapshot.LastExecutionSequence = Convert.ToInt64(value); if (data.TryGetValue("last_execution_button", out value)) snapshot.LastExecutionButton = Convert.ToString(value); if (data.TryGetValue("last_execution_label", out value)) snapshot.LastExecutionLabel = Convert.ToString(value); @@ -18017,6 +18779,13 @@ private BridgeHealthSnapshot ReadKeyboardBridgeHealth() (DateTime.UtcNow - snapshot.LastRawActionAtUtc).TotalSeconds); } } + if (data.TryGetValue("last_raw_press_at", out value)) + { + DateTime parsed; + if (DateTime.TryParse(Convert.ToString(value), CultureInfo.InvariantCulture, + DateTimeStyles.RoundtripKind, out parsed)) + snapshot.LastRawPressAtUtc = parsed.ToUniversalTime(); + } snapshot.Healthy = snapshot.FileAgeSeconds <= 7 && string.Equals(snapshot.State, "running", StringComparison.OrdinalIgnoreCase) && snapshot.HookInstalled && snapshot.RawInputRegistered && @@ -18030,6 +18799,99 @@ private BridgeHealthSnapshot ReadKeyboardBridgeHealth() return snapshot; } + // The bridge rewrites its health file every few seconds, and several callers inside one poll re-read + // and re-parse the same bytes. The parse is kept until the file changes, so one poll costs at most one + // parse; freshness is still judged against the real file age by the caller that needs it. + private Dictionary ReadBridgeHealthDocument(string path) + { + Dictionary data; + string readError; + return TryReadBridgeHealth(path, out data, out readError) ? data : null; + } + + // The bytes are only re-parsed when the file itself changes; a heartbeat that stopped being written + // still fails the freshness test above, so a dead bridge is reported exactly as before. + private Dictionary ReadBridgeHealthDocumentRaw(string path, out string error) + { + error = null; + string revision = FileRevisionKey(path); + Dictionary cached = bridgeHealthDocument; + if (cached != null && string.Equals(bridgeHealthDocumentRevision, revision, StringComparison.Ordinal)) + return cached; + Dictionary data; + try + { + using (var stream = new FileStream(path, FileMode.Open, FileAccess.Read, + FileShare.ReadWrite | FileShare.Delete)) + using (var reader = new StreamReader(stream, Encoding.UTF8, true)) + { + data = new JavaScriptSerializer().Deserialize>(reader.ReadToEnd()); + } + } + catch (Exception ex) + { + error = ex.Message; + return null; + } + if (data == null) return null; + bridgeHealthDocument = data; + bridgeHealthDocumentRevision = revision; + return data; + } + + private static string FileRevisionKey(string path) + { + double ageSeconds; + return FileRevisionKey(path, out ageSeconds); + } + + // Length and write time identify one revision of a file the keyboard bridge or the capture worker + // rewrites. Returning the age from the same stat means a caller no longer needs File.Exists and + // File.GetLastWriteTimeUtc as two extra trips to the file system on every poll. + private static string FileRevisionKey(string path, out double ageSeconds) + { + ageSeconds = -1; + try + { + var info = new FileInfo(path); + if (!info.Exists) return "missing"; + ageSeconds = Math.Max(0, (DateTime.UtcNow - info.LastWriteTimeUtc).TotalSeconds); + return info.Length.ToString(CultureInfo.InvariantCulture) + "|" + + info.LastWriteTimeUtc.Ticks.ToString(CultureInfo.InvariantCulture); + } + catch { return "unreadable"; } + } + + // Same policy as the bridge heartbeat: the bytes are only re-parsed when the file changes, while the + // freshness, pid and process-start checks below still run against the live process every time. + private Dictionary ReadCaptureHealthDocumentRaw(string path, out string error) + { + error = null; + string revision = FileRevisionKey(path); + Dictionary cached = captureHealthDocument; + if (cached != null && string.Equals(captureHealthDocumentRevision, revision, StringComparison.Ordinal)) + return cached; + Dictionary data; + try + { + using (var stream = new FileStream(path, FileMode.Open, FileAccess.Read, + FileShare.ReadWrite | FileShare.Delete)) + using (var reader = new StreamReader(stream, Encoding.UTF8, true)) + { + data = new JavaScriptSerializer().Deserialize>(reader.ReadToEnd()); + } + } + catch (Exception ex) + { + error = ex.GetType().Name; + return null; + } + if (data == null) return null; + captureHealthDocument = data; + captureHealthDocumentRevision = revision; + return data; + } + private static int ReadBridgeProcessId(Dictionary data) { if (data == null) return 0; @@ -18059,41 +18921,63 @@ private static string[] ReadLogTailLines(string path, int maximumBytes) return lines.ToArray(); } - private void UpdateSessionConfidence() + // Backwards first: the newest capture start is the one that bounds this session, and stopping at the + // first hit avoids walking the whole tail (whose lines are long enough for the scan to cost several + // milliseconds on this class of machine). + private static SessionMarkerScan ScanSessionMarkers(string[] lines) { - if (activityLabel == null || activityLabel.IsDisposed) return; - string path = Path.Combine(sessionDir, "vibe-mic-runtime.log"); - if (!File.Exists(path)) return; - string[] lines = ReadLogTailLines(path, 256 * 1024); - int startIndex = -1; - int stopIndex = -1; - int longStartIndex = -1; - int longEndIndex = -1; - int segmentStopIndex = -1; - int audioLiveIndex = -1; - int transportHealthIndex = -1; - bool transportHealthLive = false; + var scan = new SessionMarkerScan(); int captureBoundaryIndex = 0; - for (int i = 0; i < lines.Length; i++) + for (int i = lines.Length - 1; i >= 0; i--) { if (lines[i].IndexOf(" START endpoint=", StringComparison.OrdinalIgnoreCase) >= 0) + { captureBoundaryIndex = i; + break; + } } for (int i = Math.Max(captureBoundaryIndex, Math.Max(0, lines.Length - 500)); i < lines.Length; i++) { - if (lines[i].IndexOf("REMOTE STREAM START session=", StringComparison.OrdinalIgnoreCase) >= 0) startIndex = i; - if (lines[i].IndexOf("REMOTE STREAM STOP session=", StringComparison.OrdinalIgnoreCase) >= 0) stopIndex = i; - if (lines[i].IndexOf("LONG DICTATION START generation=", StringComparison.OrdinalIgnoreCase) >= 0) longStartIndex = i; - if (lines[i].IndexOf("LONG DICTATION END generation=", StringComparison.OrdinalIgnoreCase) >= 0) longEndIndex = i; - if (lines[i].IndexOf("REMOTE STREAM SEGMENT STOP session=", StringComparison.OrdinalIgnoreCase) >= 0) segmentStopIndex = i; - if (lines[i].IndexOf("AUDIO LIVE START session=", StringComparison.OrdinalIgnoreCase) >= 0) audioLiveIndex = i; + if (lines[i].IndexOf("REMOTE STREAM START session=", StringComparison.OrdinalIgnoreCase) >= 0) scan.StartIndex = i; + if (lines[i].IndexOf("REMOTE STREAM STOP session=", StringComparison.OrdinalIgnoreCase) >= 0) scan.StopIndex = i; + if (lines[i].IndexOf("LONG DICTATION START generation=", StringComparison.OrdinalIgnoreCase) >= 0) scan.LongStartIndex = i; + if (lines[i].IndexOf("LONG DICTATION END generation=", StringComparison.OrdinalIgnoreCase) >= 0) scan.LongEndIndex = i; + if (lines[i].IndexOf("REMOTE STREAM SEGMENT STOP session=", StringComparison.OrdinalIgnoreCase) >= 0) scan.SegmentStopIndex = i; + if (lines[i].IndexOf("AUDIO LIVE START session=", StringComparison.OrdinalIgnoreCase) >= 0) scan.AudioLiveIndex = i; if (lines[i].IndexOf("AUDIO TRANSPORT HEALTH", StringComparison.OrdinalIgnoreCase) >= 0) { - transportHealthIndex = i; - transportHealthLive = string.Equals(ExtractMetric(lines[i], "audio_live"), "True", + scan.TransportHealthIndex = i; + scan.TransportHealthLive = string.Equals(ExtractMetric(lines[i], "audio_live"), "True", StringComparison.OrdinalIgnoreCase); } } + return scan; + } + + private void UpdateSessionConfidence() + { + if (activityLabel == null || activityLabel.IsDisposed) return; + // The same cached tail GetLatestSessionHealth parses, so one log revision is read and split once + // per poll no matter how many readers want it. Only the newest lines are examined below. + double tailStartedAt = PollClockMs(); + RuntimeLogTail tail = ReadRuntimeLogTail(); + MarkPollStage(13, tailStartedAt); + string[] lines = tail.Lines; + if (lines.Length == 0) return; + SessionMarkerScan markers = tail.Markers; + if (markers == null) + { + markers = ScanSessionMarkers(lines); + tail.Markers = markers; + } + int startIndex = markers.StartIndex; + int stopIndex = markers.StopIndex; + int longStartIndex = markers.LongStartIndex; + int longEndIndex = markers.LongEndIndex; + int segmentStopIndex = markers.SegmentStopIndex; + int audioLiveIndex = markers.AudioLiveIndex; + int transportHealthIndex = markers.TransportHealthIndex; + bool transportHealthLive = markers.TransportHealthLive; bool longSessionActive = UsesLongDictation(config.voiceMode) && longStartIndex > longEndIndex; bool longAudioLive = longSessionActive && @@ -18146,7 +19030,9 @@ private void UpdateSessionConfidence() } transientFeedbackState = ""; transientFeedbackText = ""; + double uiStartedAt = PollClockMs(); UpdateCaptureUi(); + MarkPollStage(19, uiStartedAt); if (stopIndex >= 0) { SessionHealth latest = UsesLongDictation(config.voiceMode) ? GetLatestSessionHealth() : null; @@ -18259,7 +19145,7 @@ private string LoadRecentDiagnostics() int start = Math.Max(0, lines.Length - 30); for (int i = start; i < lines.Length; i++) sb.AppendLine(lines[i]); } - string inputLog = Path.Combine(root, "input-bridge-log.txt"); + string inputLog = BridgeStateFile("input-bridge-log.txt"); if (File.Exists(inputLog)) { sb.AppendLine(); @@ -18326,6 +19212,16 @@ internal static bool ShouldPresentInlineToast(bool explicitlyRequested, bool win return windowInFront; } + // The floating panel used to appear on its own whenever the main window was not in front, + // which put a dark rectangle in the bottom-right corner over whatever the user was doing — + // most visibly on the first recording after a start. Whether it may do that is now a + // preference (设置 ▸ 交互反馈) that ships off; an explicit request from the tray is + // separate and keeps working either way. + internal static bool ShouldPresentLiveHudOnItsOwn(bool explicitlyRequested, bool autoShowEnabled) + { + return explicitlyRequested || autoShowEnabled; + } + internal static bool WindowCountsAsInFront(bool visible, bool minimized, bool isForeground) { return visible && !minimized && isForeground; @@ -18774,6 +19670,15 @@ internal static bool ShouldScheduleProviderPasteFallback(string provider, return false; } + + // The provider wrote the sentence into the field itself when the verified target gained text between the + // key-down snapshot and this paste. The clipboard payload is then a second copy, so pasting it would leave + // two identical sentences. An unreadable surface (-1 on either side) keeps the historical paste behaviour. + internal static bool ShouldSkipPasteForChangedTarget(int baselineLength, int currentLength) + { + if (baselineLength < 0 || currentLength < 0) return false; + return currentLength != baselineLength; + } private void ScheduleProviderPasteFallback(int generation) { ThreadPool.QueueUserWorkItem(delegate @@ -18894,6 +19799,21 @@ private void ScheduleProviderPasteFallback(int generation) }); return; } + int targetLengthNow = ReadFocusedEditableTextLength(verified.ProcessName); + if (ShouldSkipPasteForChangedTarget(voiceSessionTargetTextLength, targetLengthNow)) + { + HostLog("WETYPE PASTE FALLBACK skipped=true reason=target_already_updated before=" + + voiceSessionTargetTextLength + " after=" + targetLengthNow + + " generation=" + generation); + DispatchUi(delegate + { + ShowToast("输入框已经收到识别文字,未重复粘贴", "info"); + }); + return; + } + HostLog("WETYPE PASTE FALLBACK target_length before=" + + voiceSessionTargetTextLength + " after=" + targetLengthNow + + " generation=" + generation); bool down = SendConfiguredHotkey("ctrl+v", false); Thread.Sleep(60); bool up = SendConfiguredHotkey("ctrl+v", true); @@ -20042,7 +20962,7 @@ private void PlayRecordingCue(bool starting) private void PollInputFeedback() { - string path = Path.Combine(root, "input-bridge-log.txt"); + string path = BridgeStateFile("input-bridge-log.txt"); if (!File.Exists(path)) return; using (var stream = new FileStream(path, FileMode.Open, FileAccess.Read, FileShare.ReadWrite | FileShare.Delete)) { @@ -20528,14 +21448,23 @@ private static ActionResult WizardCaptureRestoreResult(bool expectedRunning, boo "打开自检后重新启动或停止语音桥接", "ONBOARDING-VOICE-RUNTIME-RESTORE-FAILED"); } + // A dictation counts as fresh when its completion receipt is newer than the moment the test started. + // The generation counter can not answer that: it restarts with the bridge, and the remote starting to + // stream before the capture finished connecting leaves the capture with no session frame of its own. + private static bool DictationAudioIsFresh(SessionHealth health, DateTime baselineAt) + { + if (health == null) return false; + return health.LastCompletedAt > baselineAt; + } + private static bool VoiceProviderEvidenceIsCurrent(string currentConfigurationKey, - string runtimeConfigurationKey, string evidenceConfigurationKey, int baselineGeneration, - int observedGeneration, bool audioSucceeded, bool textConfirmed) + string runtimeConfigurationKey, string evidenceConfigurationKey, bool freshAudio, + bool textConfirmed) { return !string.IsNullOrWhiteSpace(currentConfigurationKey) && string.Equals(currentConfigurationKey, runtimeConfigurationKey, StringComparison.Ordinal) && string.Equals(currentConfigurationKey, evidenceConfigurationKey, StringComparison.Ordinal) && - observedGeneration > baselineGeneration && audioSucceeded && textConfirmed; + freshAudio && textConfirmed; } private static ActionResult ProviderConfigurationSaveResult(bool saved, bool captureWasRunning) @@ -21442,7 +22371,7 @@ private string SyncKeyboardBridgeConfig() Dictionary document = BuildKeyboardBridgeDocument(config, BuildGestureOverrides(), snippetStore.Load()); string revision = Convert.ToString(document["revision"]); - string bridgeConfigPath = Path.Combine(root, "voxdeck-shortcuts.json"); + string bridgeConfigPath = BridgeStateFile("voxdeck-shortcuts.json"); WriteTextAtomically(bridgeConfigPath, new JavaScriptSerializer().Serialize(document), bridgeConfigPath + ".bak"); expectedKeyboardConfigRevision = revision; if (IsCurrentProcessRunningFromRoot("VoxDeckInputBridge")) @@ -21452,6 +22381,9 @@ private string SyncKeyboardBridgeConfig() catch (Exception ex) { Log("Keyboard config sync failed: " + ex.Message); + // The interface log box was the only place this failure ever appeared, so a fault that + // stopped the entire keyboard bridge left nothing in the host log to explain it. + HostLog("KEYBOARD BRIDGE config_sync_error=" + SafeLogValue(ex.GetType().Name + ": " + ex.Message)); return ""; } } @@ -21996,7 +22928,7 @@ private long InputBridgeLogLength() { try { - string path = Path.Combine(root, "input-bridge-log.txt"); + string path = BridgeStateFile("input-bridge-log.txt"); return File.Exists(path) ? new FileInfo(path).Length : 0; } catch { return 0; } @@ -22009,7 +22941,7 @@ private bool HasObservedPhysicalButton(string key) private bool HasObservedPhysicalButtonSince(string key, long startPosition) { - string path = Path.Combine(root, "input-bridge-log.txt"); + string path = BridgeStateFile("input-bridge-log.txt"); if (!File.Exists(path)) return false; try { @@ -23916,9 +24848,9 @@ private void BeginCustomButtonCapture(int slot, Label status) request["token"] = token; request["slot"] = slot; request["created_at"] = DateTime.UtcNow.ToString("o"); - File.WriteAllText(Path.Combine(root, "custom-button-capture-request.json"), + File.WriteAllText(BridgeStateFile("custom-button-capture-request.json"), new JavaScriptSerializer().Serialize(request), Encoding.UTF8); - File.Delete(Path.Combine(root, "custom-button-capture-result.json")); + File.Delete(BridgeStateFile("custom-button-capture-result.json")); StartKeyboardBridge(); if (status != null) status.Text = "请在 10 秒内按一下遥控器按键…"; ShowToast("正在等待第 " + (slot + 1) + " 个自定义按键", "info"); @@ -23935,8 +24867,8 @@ private void ClearPendingCustomButtonCapture(string reason) { try { - string requestPath = Path.Combine(root, "custom-button-capture-request.json"); - string resultPath = Path.Combine(root, "custom-button-capture-result.json"); + string requestPath = BridgeStateFile("custom-button-capture-request.json"); + string resultPath = BridgeStateFile("custom-button-capture-result.json"); bool cleared = false; if (File.Exists(requestPath)) { File.Delete(requestPath); cleared = true; } if (File.Exists(resultPath)) { File.Delete(resultPath); cleared = true; } @@ -23956,7 +24888,7 @@ private void TestCustomButtonAction(int slot) request["token"] = token; request["created_at"] = DateTime.UtcNow.ToString("o"); PrepareMappingActionTest(token, "自定义按键 " + (slot + 1)); - File.WriteAllText(Path.Combine(root, "custom-button-test.json"), + File.WriteAllText(BridgeStateFile("custom-button-test.json"), new JavaScriptSerializer().Serialize(request), Encoding.UTF8); StartKeyboardBridge(); ShowToast("正在验证自定义按键动作…", "info"); @@ -23983,7 +24915,7 @@ private void TestMappingAction(string key, string action) request["token"] = token; request["created_at"] = DateTime.UtcNow.ToString("o"); PrepareMappingActionTest(token, key); - File.WriteAllText(Path.Combine(root, "custom-button-test.json"), + File.WriteAllText(BridgeStateFile("custom-button-test.json"), new JavaScriptSerializer().Serialize(request), Encoding.UTF8); StartKeyboardBridge(); ShowToast("正在验证“" + key + "”的当前功能…", "info"); @@ -24005,7 +24937,7 @@ private void PrepareMappingActionTest(string token, string label, pendingMappingTestResultHandler = resultHandler; try { - string resultPath = Path.Combine(root, "custom-button-test-result.json"); + string resultPath = BridgeStateFile("custom-button-test-result.json"); if (File.Exists(resultPath)) File.Delete(resultPath); } catch { } @@ -24014,7 +24946,7 @@ private void PrepareMappingActionTest(string token, string label, private void ApplyMappingActionTestResult() { if (string.IsNullOrWhiteSpace(pendingMappingTestToken)) return; - string path = Path.Combine(root, "custom-button-test-result.json"); + string path = BridgeStateFile("custom-button-test-result.json"); if (!File.Exists(path)) { if (pendingMappingTestStartedAt != DateTime.MinValue && @@ -24066,7 +24998,7 @@ private bool ClearPendingMappingActionTest(string expectedToken) !string.Equals(pendingMappingTestToken, expectedToken, StringComparison.OrdinalIgnoreCase)) return false; BrowserRemoteRequestFile.TryCancel( - Path.Combine(root, "custom-button-test.json"), expectedToken); + BridgeStateFile("custom-button-test.json"), expectedToken); pendingMappingTestToken = ""; if (string.Equals(pendingBrowserRemoteTestToken, expectedToken, StringComparison.OrdinalIgnoreCase)) pendingBrowserRemoteTestToken = ""; @@ -24079,7 +25011,7 @@ private bool ClearPendingMappingActionTest(string expectedToken) private void ApplyCustomButtonCaptureResult() { if (string.IsNullOrWhiteSpace(pendingCustomCaptureToken)) return; - string path = Path.Combine(root, "custom-button-capture-result.json"); + string path = BridgeStateFile("custom-button-capture-result.json"); if (!File.Exists(path)) return; try { @@ -24354,7 +25286,7 @@ private string BuildDiagnosticsReport() report.AppendLine("Mappings: " + BuildDiagnosticMappingSummary(config.mappings)); report.AppendLine(); AppendLogTail(report, Path.Combine(sessionDir, "vibe-mic-runtime.log"), "Runtime log", 200); - AppendLogTail(report, Path.Combine(root, "input-bridge-log.txt"), "Input bridge log", 200); + AppendLogTail(report, BridgeStateFile("input-bridge-log.txt"), "Input bridge log", 200); string captureReport = Path.Combine(sessionDir, "remote-voice-report.json"); if (File.Exists(captureReport)) { @@ -24747,6 +25679,12 @@ private sealed class BridgeHealthSnapshot public string LastActionSource = ""; public DateTime LastRawActionAtUtc = DateTime.MinValue; public double LastRawActionAgeSeconds = double.MaxValue; + // The last press the bridge saw, published separately from the last routed edge. A key that was + // pressed and released reads "...:up" in the routed field, so a heartbeat sample almost never shows + // the press itself, and the wizard's direction task could not see the press it was waiting for. + public string LastRawPress = ""; + public string LastRawPressSource = ""; + public DateTime LastRawPressAtUtc = DateTime.MinValue; public long LastExecutionSequence; public string LastExecutionButton = ""; public string LastExecutionLabel = ""; @@ -24796,6 +25734,27 @@ private sealed class ProcessTopologySnapshot public int InaccessibleCount; } + // One cached answer about one process name: whether it is running at all, and for the two names the + // self-check inspects, the fuller topology. + private sealed class ProcessLookupEntry + { + public DateTime At; + public bool Running; + public ProcessTopologySnapshot Topology; + } + + // The topology lives in the cache, so callers get a copy instead of the cached instance. + private static ProcessTopologySnapshot CopyProcessTopology(ProcessTopologySnapshot source) + { + var copy = new ProcessTopologySnapshot(); + if (source == null) return copy; + copy.TotalCount = source.TotalCount; + copy.CurrentRootCount = source.CurrentRootCount; + copy.ForeignCount = source.ForeignCount; + copy.InaccessibleCount = source.InaccessibleCount; + return copy; + } + private sealed class SelfCheckItem { public string Id; @@ -26094,6 +27053,10 @@ private static void RunFeedbackOutletSelfTests() // An explicit tray request is additive, never a replacement for the card. if (!ShouldPresentLiveHud(true, true) || !ShouldPresentInlineToast(true, true)) throw new InvalidOperationException("An explicitly requested Live HUD suppressed the in-window card"); + // The automatic pop-up is a preference, and the tray entry works no matter how it is set. + if (ShouldPresentLiveHudOnItsOwn(false, false) || !ShouldPresentLiveHudOnItsOwn(false, true) || + !ShouldPresentLiveHudOnItsOwn(true, false)) + throw new InvalidOperationException("The floating panel ignored its automatic pop-up preference"); // Minimised and hidden windows both count as "not in front". if (WindowCountsAsInFront(true, true, true) || WindowCountsAsInFront(false, false, true) || !WindowCountsAsInFront(true, false, true)) @@ -26458,8 +27421,41 @@ private sealed class SessionHealth public string DefaultRouteState = ""; public DateTime StartedAt; public DateTime EndedAt; + // When the newest dictation really finished, taken from the completion receipt. This is what the + // wizard measures freshness with: the session generation restarts with every bridge restart and so + // reads zero for the very dictation the test just produced. + public DateTime LastCompletedAt; public string Error = ""; public string NextAction = ""; + + // The parsed session is cached between log revisions, so every caller is handed its own instance: + // a caller that wrote into the cached object would change what the next reader sees. + public SessionHealth Copy() { return (SessionHealth)MemberwiseClone(); } + } + + // The cached runtime-log tail together with the session parsed from it. A missing log is a valid and + // cheap cache entry, so an idle machine never opens the file at all. + private sealed class RuntimeLogTail + { + public string Key; + public string[] Lines; + public SessionHealth Health; + public SessionMarkerScan Markers; + } + + // The newest session markers inside the cached tail only move when the log does, and the interface + // asks for several of them on every refresh. Keeping the answer next to the lines it was derived from + // stops the poll from re-walking hundreds of long lines twice a second. + private sealed class SessionMarkerScan + { + public int StartIndex = -1; + public int StopIndex = -1; + public int LongStartIndex = -1; + public int LongEndIndex = -1; + public int SegmentStopIndex = -1; + public int AudioLiveIndex = -1; + public int TransportHealthIndex = -1; + public bool TransportHealthLive; } private sealed class VibeMicConfig @@ -26487,6 +27483,10 @@ private sealed class VibeMicConfig public bool launchAtStartup { get; set; } public bool startBridgeOnLaunch { get; set; } public bool minimizeToTray { get; set; } + // Whether the floating status panel (Live HUD) may pop up on its own when the main window + // is not in front. The tray entry always opens it; this only governs the automatic + // appearance. Files written before it existed load it as off, which is the shipped default. + public bool liveHudAutoShow { get; set; } public string audioEndpointName { get; set; } public string inputMethod { get; set; } public string inputMethodHotkey { get; set; } @@ -26524,6 +27524,7 @@ public static VibeMicConfig Default() c.launchAtStartup = false; c.startBridgeOnLaunch = false; c.minimizeToTray = true; + c.liveHudAutoShow = false; c.audioEndpointName = "CABLE Input"; c.inputMethod = "wechat"; c.inputMethodHotkey = WeChatStableHotkey; diff --git a/scripts/VoxDeckInputBridge.cs b/scripts/VoxDeckInputBridge.cs index 09a06d7..9eccb6f 100644 --- a/scripts/VoxDeckInputBridge.cs +++ b/scripts/VoxDeckInputBridge.cs @@ -62,13 +62,11 @@ internal static class VoxDeckInputBridge private const int VOICE_RESTART_GUARD_MS = 500; private const int SMART_PROFILE_POLL_MS = 250; private const int SMART_PROFILE_DEBOUNCE_MS = 350; - // RC003 voice F5 isolation (no signed device filter). Measured on this - // machine: the hook edge precedes Raw Input and hook suppression cancels - // the Raw Input packet, so per-event device attribution is impossible in - // user mode. V1.5 treated F5 as the voice key and suppressed it at the - // hook. V2 keeps that suppression but only while the RC003 HID device is - // physically connected; ordinary-keyboard F5 passes through otherwise. - private const int RC003_PRESENT_GRACE_MS = 5000; + // RC003 voice F5 isolation. Measured on this machine: the hook edge + // precedes Raw Input and hook suppression cancels the Raw Input packet, + // so per-event device attribution is impossible in user mode. Without a + // healthy device filter the hook therefore fails open; Raw Input remains + // the device-scoped voice source. private const int RC003_VOICE_STUCK_RELEASE_MS = 900; // The remote/ATVV chain ends a session at its own device-controlled boundary (about // 60 s), so a genuine hold cannot be reported for much longer than that. The stuck @@ -132,20 +130,21 @@ internal static class VoxDeckInputBridge private static readonly TimeSpan SharedFormVoiceWindow = TimeSpan.FromMinutes(10); private static DateTime lastDuplicateDownLogUtc = DateTime.MinValue; private static DateTime rc003DevicePresentUtc = DateTime.MinValue; - // How long a sign of the RC003 keeps it "present" for the hook's scoped record-key suppression. The Raw - // Input health probe refreshes that sign and must run inside this window, or the first key press of a - // session is not suppressed; Rc003PresenceWindowMs is what ties the two together. + // How long a Raw Input sign keeps the RC003 classified as present for + // diagnostics and the safe fail-open decision. The Raw Input health probe + // refreshes that sign inside the same window. private const int Rc003PresenceWindowMs = 5000; - private static long suppressedHookEdgeCount; + private static long hookVoiceFailOpenEdgeCount; // Stuck-hold bookkeeping. voiceHoldStartedTicks is the start of the CURRENT hold, // which is what tells a genuinely held key from one whose release edge was lost: // "time since last activity" cannot, because a repeating key always looks active. private static long voiceHoldStartedTicks; private static int voiceHoldStaleLatched; private static int voiceHoldStaleReleaseCount; - private static int voiceHoldRepeatsSuppressed; - private static DateTime lastIsolationLogUtc = DateTime.MinValue; - private static int isolationRepeatCount; + private static DateTime lastHookVoiceFailOpenLogUtc = DateTime.MinValue; + private static int hookVoiceFailOpenRepeatCount; + private static int hookUnknownSourceF5Held; + private static string lastVoiceF5HookDecision = ""; private static readonly BlockingCollection mappingQueue = new BlockingCollection(); private static Thread mappingWorker; private static BridgeConfig config = BridgeConfig.Default(); @@ -188,6 +187,13 @@ internal static class VoxDeckInputBridge private static DateTime lastRawActionUtc = DateTime.MinValue; private static string lastRawAction = ""; private static string lastActionSource = ""; + // The last routed edge is not evidence that a key was pressed: a person presses and releases, so by + // the time the heartbeat is read this reads "...:up". First-run setup asks for exactly one direction + // press, and judging that from the last edge alone made the task impossible to pass with a normal + // press. The press is therefore recorded on its own, next to the edge that keeps the status line honest. + private static DateTime lastRawPressUtc = DateTime.MinValue; + private static string lastRawPress = ""; + private static string lastRawPressSource = ""; private static readonly object actionReceiptLock = new object(); private static long actionReceiptSequence; private static ActionExecutionReceipt lastExecutionReceipt; @@ -200,13 +206,36 @@ internal static class VoxDeckInputBridge private static int smartProfileEvaluationRunning; private static readonly string Root = AppDomain.CurrentDomain.BaseDirectory; - private static readonly string ConfigPath = Path.Combine(Root, "voxdeck-shortcuts.json"); - private static readonly string LogPath = Path.Combine(Root, "input-bridge-log.txt"); - private static readonly string HealthPath = Path.Combine(Root, "input-bridge-health.json"); - private static readonly string CustomCaptureRequestPath = Path.Combine(Root, "custom-button-capture-request.json"); - private static readonly string CustomCaptureResultPath = Path.Combine(Root, "custom-button-capture-result.json"); - private static readonly string CustomTestPath = Path.Combine(Root, "custom-button-test.json"); - private static readonly string CustomTestResultPath = Path.Combine(Root, "custom-button-test-result.json"); + // Everything this process writes used to live beside its own executable. In a Program Files + // installation that directory is read-only for the ordinary user, so the mapping document could + // not be written, the log and the heartbeat could not be updated, and the host refused to start + // the bridge at all - "KEYBOARD BRIDGE start_aborted=true reason=config_sync_failed" - which left + // the remote's mapped keys dead while voice, handled by another process, still worked. The + // writable state lives where the host already keeps its own: the user's local application data. + private static readonly string StateRoot = ResolveStateRoot(); + private static readonly string ConfigPath = Path.Combine(StateRoot, "voxdeck-shortcuts.json"); + private static readonly string LogPath = Path.Combine(StateRoot, "input-bridge-log.txt"); + private static readonly string HealthPath = Path.Combine(StateRoot, "input-bridge-health.json"); + private static readonly string CustomCaptureRequestPath = Path.Combine(StateRoot, "custom-button-capture-request.json"); + private static readonly string CustomCaptureResultPath = Path.Combine(StateRoot, "custom-button-capture-result.json"); + private static readonly string CustomTestPath = Path.Combine(StateRoot, "custom-button-test.json"); + private static readonly string CustomTestResultPath = Path.Combine(StateRoot, "custom-button-test-result.json"); + + private static string ResolveStateRoot() + { + try + { + string localAppData = Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData); + if (!string.IsNullOrWhiteSpace(localAppData)) + { + string directory = Path.Combine(localAppData, "Vibe Flow Remote", "UserData"); + Directory.CreateDirectory(directory); + return directory; + } + } + catch { } + return Root; + } [STAThread] private static void Main(string[] args) @@ -422,7 +451,8 @@ private static IntPtr HookCallback(int nCode, IntPtr wParam, IntPtr lParam) bool isVoiceMapping = mapping != null && (mapping.name ?? "").Equals("voice", StringComparison.OrdinalIgnoreCase); bool taskSwitcherNavigation = IsTaskSwitcherNavigationCandidate(data.vkCode); - if (ShouldBypassHookForRc003Filter(IsRc003FilterHealthy(), + bool filterHealthy = IsRc003FilterHealthy(); + if (ShouldBypassHookForRc003Filter(filterHealthy, mapping != null, taskSwitcherNavigation)) { // A healthy per-device filter has already removed every @@ -447,37 +477,24 @@ private static IntPtr HookCallback(int nCode, IntPtr wParam, IntPtr lParam) { if (isVoiceMapping && mapping.suppress) { - // No signed per-device filter is installed, and this - // machine's input stack delivers the low-level hook - // edge before Raw Input and cancels the Raw Input - // packet when the hook suppresses the event. Precise - // per-event device attribution is therefore impossible - // in user mode. V1.5 handled this by treating F5 as - // the voice key and suppressing it at the hook. - // - // V2 keeps the same suppression but scopes it to the - // moments the RC003 is physically connected: only - // then is F5 captured and driven through the voice - // state machine. Without the RC003, an ordinary - // keyboard F5 always passes through untouched. The - // edge is dispatched here because suppressing it - // would otherwise cancel the Raw Input packet that - // used to drive the state machine. - if (ShouldUseScopedHookVoice(IsRc003FilterHealthy(), - mapping.enabled, mapping.suppress, Rc003PresentRecently())) - { - Interlocked.Increment(ref suppressedHookEdgeCount); - // Read the held state BEFORE the transition, so this edge - // can be recognised as a repeat of the current hold. - bool repeatEdge = isDown && Volatile.Read(ref voiceKeyHeldState) == 1; - HandleVoicePhysicalTransition(isDown, - "rc003_present_hook", data.vkCode, data.scanCode); - LogIsolationEdge(isDown, repeatEdge, data.vkCode, data.scanCode); - return (IntPtr)1; - } - // The RC003 is not connected (or the healthy device - // filter owns its edges): this F5 belongs to an - // ordinary keyboard and must pass through untouched. + // The low-level hook has no source-device identity, + // and returning 1 necessarily cancels Raw Input on + // this Windows Bluetooth stack. Suppressing an + // unconfirmed F5 would break the physical keyboard, + // so fail open and let device-scoped Raw Input drive + // the RC003 voice transition. + bool repeatEdge = isDown && + Volatile.Read(ref hookUnknownSourceF5Held) == 1; + Volatile.Write(ref hookUnknownSourceF5Held, isDown ? 1 : 0); + string reason = VoiceF5HookFailOpenReason( + filterHealthy, mapping.enabled, + mapping.suppress, Rc003PresentRecently()); + Interlocked.Increment(ref hookVoiceFailOpenEdgeCount); + lastVoiceF5HookDecision = "fail_open:" + reason; + Rc003FilterClient filterClient = rc003FilterClient; + LogVoiceF5HookFailOpenEdge(isDown, repeatEdge, data.vkCode, + data.scanCode, reason, + filterClient == null ? "not_started" : filterClient.State); Interlocked.Increment(ref hookCandidatePassthroughCount); return CallNextHookEx(hookHandle, nCode, wParam, lParam); } @@ -656,14 +673,25 @@ private static void HandleRc003FilterEvent(Rc003FilterKeyEvent input) private static void RecordFilterAction(string actionName, bool keyUp) { Interlocked.Increment(ref filterActionEdgeCount); - lastRawActionUtc = DateTime.UtcNow; - lastRawAction = (actionName ?? "unknown") + (keyUp ? ":up" : ":down"); - lastActionSource = "device_filter"; + RecordRawAction(actionName, keyUp, "device_filter"); Log("RC003 action routed source=device_filter button=" + (actionName ?? "unknown") + " edge=" + (keyUp ? "up" : "down") + " revision=" + (config == null ? "" : config.revision ?? "")); } + private static void RecordRawAction(string actionName, bool keyUp, string source) + { + string name = actionName ?? "unknown"; + DateTime nowUtc = DateTime.UtcNow; + lastRawActionUtc = nowUtc; + lastRawAction = name + (keyUp ? ":up" : ":down"); + lastActionSource = source; + if (keyUp) return; + lastRawPressUtc = nowUtc; + lastRawPress = name + ":down"; + lastRawPressSource = source; + } + private static int VirtualKeyFromRc003FilterEvent(int makeCode, int flags) { uint encodedScan = (uint)(makeCode & 0xFF); @@ -709,9 +737,7 @@ private static bool RouteAuthoritativeRawKeyboard(ShortcutMapping mapping, string actionName = taskSwitcherNavigation ? "task-switcher-navigation" : mapping == null ? "unmapped" : mapping.name ?? mapping.labelOrName(); - lastRawActionUtc = DateTime.UtcNow; - lastRawAction = actionName + (keyUp ? ":up" : ":down"); - lastActionSource = "raw_input"; + RecordRawAction(actionName, keyUp, "raw_input"); if (taskSwitcherNavigation && ObserveNativeTaskSwitcherNavigation(virtualKey, !keyUp, keyUp)) { @@ -879,6 +905,18 @@ private static int RunSelfTests() if (GestureBindingStore.ResolveSteps( ToGestureEntry(new ShortcutMapping { name = "up" }), GestureKind.Short).Count != 0) throw new InvalidOperationException("A gesture layer with no action resolved to something instead of nothing"); + // Raw Input must pass the physical key through so the packet can be read. When the + // selected layer resolves to that same key, injecting it again makes one RC003 press + // move twice. The native event already delivered the action. + var nativeArrowMapping = new ShortcutMapping { + name = "left", label = "左键", vk = "Left", scan = "0x4B", sourceType = "keyboard" + }; + if (!ShouldUseNativeRawPassthrough(nativeArrowMapping, "left", "raw_input") || + ShouldUseNativeRawPassthrough(nativeArrowMapping, "right", "raw_input") || + ShouldUseNativeRawPassthrough(nativeArrowMapping, "left", "device_filter") || + ShouldUseNativeRawPassthrough(nativeArrowMapping, "ctrl+left", "raw_input")) + throw new InvalidOperationException( + "A same-key Raw Input action would be injected after Windows already received it"); // One action per layer: a mapping whose double layer carries a single action must resolve to // exactly that, and the removal of macros must leave no way for a mapping document to smuggle a // multi-step sequence back in. @@ -1015,18 +1053,12 @@ private static int RunSelfTests() ShouldRecoverVoiceHostAfterSignal(true, true) || ShouldRecoverVoiceHostAfterSignal(false, true)) throw new InvalidOperationException("Voice host recovery still treats a named-event Set as a listener acknowledgement"); - // RC003 voice F5 isolation policy: without a healthy device filter, - // hook capture of F5 is allowed only while the RC003 device is - // physically connected AND the voice mapping is enabled with - // suppression. In every other combination an ordinary keyboard F5 - // must pass through untouched. - if (ShouldUseScopedHookVoice(true, true, true, true) || - ShouldUseScopedHookVoice(false, false, true, true) || - ShouldUseScopedHookVoice(false, true, false, true) || - ShouldUseScopedHookVoice(false, true, true, false)) - throw new InvalidOperationException("RC003 scoped hook voice admitted an ordinary-keyboard edge"); - if (!ShouldUseScopedHookVoice(false, true, true, true)) - throw new InvalidOperationException("RC003 scoped hook voice rejected the connected-remote case"); + if (VoiceF5HookFailOpenReason(true, true, true, true) != "device_filter" || + VoiceF5HookFailOpenReason(false, false, true, true) != "voice_mapping_inactive" || + VoiceF5HookFailOpenReason(false, true, false, true) != "voice_mapping_inactive" || + VoiceF5HookFailOpenReason(false, true, true, false) != "rc003_absent_source_unknown" || + VoiceF5HookFailOpenReason(false, true, true, true) != "filter_unavailable_source_unknown") + throw new InvalidOperationException("Unknown-source F5 hook logging policy is inconsistent"); string[] executableShortcuts = { "up", "down", "left", "right", "ctrl+c", "ctrl+x", "ctrl+v", "ctrl+z", "ctrl+shift+z", "ctrl+s", "ctrl+a", "ctrl+f", "enter", "escape", "tab", @@ -1483,8 +1515,8 @@ private static int RunSelfTests() DateTime savedRelease = lastVoiceReleaseUtc; int savedLatched = Volatile.Read(ref voiceHoldStaleLatched); int savedStaleReleases = voiceHoldStaleReleaseCount; - int savedRepeats = voiceHoldRepeatsSuppressed; - int savedIsolationRepeats = isolationRepeatCount; + int savedFailOpenRepeats = hookVoiceFailOpenRepeatCount; + DateTime savedFailOpenLogUtc = lastHookVoiceFailOpenLogUtc; try { // The latch can only be cleared by a quiet gap, so by the time it lifts the @@ -1520,13 +1552,14 @@ private static int RunSelfTests() if (Volatile.Read(ref voiceKeyHeldState) != 1) throw new InvalidOperationException("A genuine press after a stuck release was rejected"); HandleVoicePhysicalTransition(false, "self_test_after_release", 0x74, 0x3F); - // Repeats are aggregated: the counter grows but the log stays quiet. - int repeatsBefore = voiceHoldRepeatsSuppressed; - for (int repeat = 0; repeat < 50; repeat++) LogIsolationEdge(true, true, 0x74, 0x3F); - if (voiceHoldRepeatsSuppressed != repeatsBefore + 50) - throw new InvalidOperationException("Held-key repeats are not being aggregated"); - if (isolationRepeatCount != savedIsolationRepeats + 50) - throw new InvalidOperationException("The repeat counter did not follow the held-key repeats"); + // Fail-open repeats are aggregated even though every edge is + // passed through to Windows. + int repeatsBefore = hookVoiceFailOpenRepeatCount; + for (int repeat = 0; repeat < 50; repeat++) + LogVoiceF5HookFailOpenEdge(true, true, 0x74, 0x3F, + "filter_unavailable_source_unknown", "fallback:open_failed_win32_2"); + if (hookVoiceFailOpenRepeatCount != repeatsBefore + 50) + throw new InvalidOperationException("Fail-open F5 repeats are not being aggregated"); } finally { @@ -1536,8 +1569,8 @@ private static int RunSelfTests() lastVoiceReleaseUtc = savedRelease; Interlocked.Exchange(ref voiceHoldStaleLatched, savedLatched); voiceHoldStaleReleaseCount = savedStaleReleases; - voiceHoldRepeatsSuppressed = savedRepeats; - isolationRepeatCount = savedIsolationRepeats; + hookVoiceFailOpenRepeatCount = savedFailOpenRepeats; + lastHookVoiceFailOpenLogUtc = savedFailOpenLogUtc; } Console.WriteLine("Vibe Link input bridge self-test passed."); return 0; @@ -1797,7 +1830,13 @@ private static void HandleMapping(ShortcutMapping mapping, bool keyUp, string so } else { - bool success = TapShortcut(mapping); + bool nativePassthrough = ShouldUseNativeRawPassthrough( + mapping, mapping.shortcut, source); + if (nativePassthrough) + Log("Shortcut action skipped label=" + mapping.labelOrName() + + " action=" + mapping.shortcut + + " reason=raw_input_native_passthrough"); + bool success = nativePassthrough || TapShortcut(mapping); RecordActionExecution(mapping, "单击", mapping.shortcut, success, source); BridgeForm.SetStatusText(mapping.labelOrName() + " 轻触 -> " + mapping.shortcut); } @@ -2036,6 +2075,21 @@ private static void RepeatHoldAction(object stateValue) } } + private static bool ShouldUseNativeRawPassthrough(ShortcutMapping mapping, string action, string source) + { + if (mapping == null || + !string.Equals(source, "raw_input", StringComparison.OrdinalIgnoreCase) || + !(mapping.sourceType ?? "keyboard").Equals("keyboard", StringComparison.OrdinalIgnoreCase)) + return false; + string normalized = (action ?? "").Trim(); + if (normalized.StartsWith("shortcut:", StringComparison.OrdinalIgnoreCase)) + normalized = normalized.Substring("shortcut:".Length).Trim(); + List keys = ParseShortcut(normalized); + if (keys.Count != 1) return false; + int physicalKey = VkFromName(mapping.vk); + return physicalKey > 0 && keys[0] == physicalKey; + } + private static bool ExecuteMappingAction(ShortcutMapping source, string action, string phase, string actionSource) { string normalized = (action ?? "").Trim(); @@ -2047,6 +2101,14 @@ private static bool ExecuteMappingAction(ShortcutMapping source, string action, RecordActionExecution(source, phase, normalized, false, actionSource); return false; } + if (ShouldUseNativeRawPassthrough(source, normalized, actionSource)) + { + Log("Gesture action skipped label=" + source.labelOrName() + " phase=" + phase + + " action=" + normalized + " reason=raw_input_native_passthrough"); + BridgeForm.SetStatusText(source.labelOrName() + " " + phase + " -> " + normalized); + RecordActionExecution(source, phase, normalized, true, actionSource); + return true; + } var mapping = new ShortcutMapping { @@ -2132,13 +2194,9 @@ private static bool SetVoiceKeyHeld(bool held) // Measured on this machine: the low-level hook edge arrives before the Raw // Input packet, and returning 1 from the hook cancels the Raw Input packet // for that event. Per-event device attribution in user mode is therefore - // impossible. The V1.5 stable contract treated F5 as the voice key and - // suppressed it at the hook. V2 keeps that suppression but scopes it - // strictly to moments the RC003 HID device is physically connected: only - // then is F5 captured and driven through the voice state machine from the - // hook. An ordinary keyboard F5 always passes through untouched while the - // RC003 is absent, and a healthy signed filter keeps every RC003 edge out - // of this hook entirely. + // impossible. Without a healthy device filter the hook fails open so an + // ordinary keyboard F5 always reaches the foreground application. A + // healthy signed filter keeps every RC003 edge out of this hook entirely. private static void TouchRc003Present() { rc003DevicePresentUtc = DateTime.UtcNow; @@ -2150,11 +2208,14 @@ private static bool Rc003PresentRecently() (DateTime.UtcNow - rc003DevicePresentUtc).TotalMilliseconds <= Rc003PresenceWindowMs; } - internal static bool ShouldUseScopedHookVoice(bool filterHealthy, + internal static string VoiceF5HookFailOpenReason(bool filterHealthy, bool voiceMappingEnabled, bool mappingSuppress, bool rc003PresentRecently) { - return !filterHealthy && voiceMappingEnabled && mappingSuppress && - rc003PresentRecently; + if (filterHealthy) return "device_filter"; + if (!voiceMappingEnabled || !mappingSuppress) return "voice_mapping_inactive"; + return rc003PresentRecently + ? "filter_unavailable_source_unknown" + : "rc003_absent_source_unknown"; } private static void HandleVoicePhysicalTransition(bool isDown, string source, int vk, int scan) @@ -2181,9 +2242,9 @@ private static void HandleVoicePhysicalTransition(bool isDown, string source, in } if (alreadyHeld) { - // Auto-repeat DOWN edges are expected while the remote - // is held; the scoped hook path produces one per repeat. - // Log them once per hold instead of once per repeat. + // Auto-repeat DOWN edges are expected while a voice + // source is held. Log them once per hold instead of + // once per repeat. if (lastDuplicateDownLogUtc == DateTime.MinValue || (DateTime.UtcNow - lastDuplicateDownLogUtc).TotalMilliseconds >= 2000) { @@ -2241,32 +2302,28 @@ private static void HandleVoicePhysicalTransition(bool isDown, string source, in } } - // One line per hold instead of one line per repeat. A held key repeats at the - // keyboard rate (~30/s), and the previous unconditional log therefore wrote about - // thirty lines a second: that consumed the whole 2 MB log in roughly ten minutes and - // rotated away the history that makes the log worth keeping (13862 isolation lines in - // one log plus 17486 in its predecessor, about four fifths of all recorded lines). - // The start of a hold, a periodic repeat count, and a closing summary keep the same - // diagnostic value at a fraction of the volume. - private static void LogIsolationEdge(bool isDown, bool repeatEdge, int vk, int scan) + private static void LogVoiceF5HookFailOpenEdge(bool isDown, bool repeatEdge, + int vk, int scan, string reason, string filterState) { string edge = " vk=0x" + vk.ToString("X2") + " scan=0x" + scan.ToString("X2") + - " reason=rc003_connected_no_filter"; + " source=unknown reason=" + reason + + " filter_state=" + (filterState ?? "unknown"); if (repeatEdge) { - int repeats = Interlocked.Increment(ref isolationRepeatCount); - Interlocked.Increment(ref voiceHoldRepeatsSuppressed); - if (lastIsolationLogUtc != DateTime.MinValue && - (DateTime.UtcNow - lastIsolationLogUtc).TotalMilliseconds < ISOLATION_REPEAT_LOG_INTERVAL_MS) + int repeats = Interlocked.Increment(ref hookVoiceFailOpenRepeatCount); + if (lastHookVoiceFailOpenLogUtc != DateTime.MinValue && + (DateTime.UtcNow - lastHookVoiceFailOpenLogUtc).TotalMilliseconds < + ISOLATION_REPEAT_LOG_INTERVAL_MS) return; - lastIsolationLogUtc = DateTime.UtcNow; - Log("RC003 ISOLATION scoped_suppress=true repeat_held=true repeats=" + repeats + edge); + lastHookVoiceFailOpenLogUtc = DateTime.UtcNow; + Log("VOICE F5 HOOK PASSTHROUGH repeat_held=true repeats=" + repeats + edge); return; } - int held = Interlocked.Exchange(ref isolationRepeatCount, 0); + int held = Interlocked.Exchange(ref hookVoiceFailOpenRepeatCount, 0); if (held > 0) - Log("RC003 ISOLATION hold_summary repeats=" + held + edge); - Log("RC003 ISOLATION scoped_suppress=true " + (isDown ? "down" : "up") + edge); + Log("VOICE F5 HOOK PASSTHROUGH hold_summary repeats=" + held + edge); + Log("VOICE F5 HOOK PASSTHROUGH edge=" + (isDown ? "down" : "up") + + " decision=fail_open" + edge); } private static string VoiceKeyLogLabel() @@ -2275,12 +2332,12 @@ private static string VoiceKeyLogLabel() } // A held voice key repeats DOWN edges while physically held and normally - // receives its UP edge from the hook. If that UP edge is lost (Bluetooth - // blip, or a suppressed event that never reaches this state machine), - // release the stale hold shortly after the last observed activity so the - // next press is not rejected as a duplicate. The frozen Capture ends its - // session on the natural ATVV stop regardless; this only restores the - // bridge/host voice state. + // receives its UP edge. If that UP edge is lost (Bluetooth blip, or an + // event that never reaches this state machine), release the stale hold + // shortly after the last observed activity so the next press is not + // rejected as a duplicate. The frozen Capture ends its session on the + // natural ATVV stop regardless; this only restores the bridge/host voice + // state. // // The idle test alone can never fire while the key is genuinely stuck: a repeating // key refreshes lastVoiceActivityUtc every ~31 ms, so "time since last activity" never @@ -3233,7 +3290,7 @@ private static void HandleRawInput(IntPtr hRawInput) RAWKEYBOARD keyboard = (RAWKEYBOARD)Marshal.PtrToStructure(data, typeof(RAWKEYBOARD)); bool keyUp = IsRawKeyUp(keyboard.Message); // Any RC003 keyboard packet proves the device is connected and - // keeps the scoped hook voice isolation armed. + // refreshes the presence timestamp used by diagnostics. TouchRc003Present(); if (IsRc003FilterHealthy()) { @@ -3243,9 +3300,9 @@ private static void HandleRawInput(IntPtr hRawInput) return; } // Raw Input is the only user-mode API in this process that carries - // the originating device handle. Execute RC003 actions here. The - // low-level hook only suppresses the matching legacy event or - // replays an unconfirmed physical-keyboard event unchanged. + // the originating device handle. Execute RC003 actions here; the + // source-blind low-level hook fails open so a physical F5 still + // reaches the foreground application. ShortcutMapping mapping = FindRc003FilterMapping(keyboard.VKey, keyboard.MakeCode); // Same rule as the filter path: an unassigned (disabled) power key hands the shared raw form 0xFF/0x5E // back to the record fallback, so a microphone that reports that way keeps working. @@ -3273,7 +3330,7 @@ private static void HandleRawInput(IntPtr hRawInput) keyboard.VKey, keyboard.MakeCode, keyUp); Log("RC003 RAW KEY " + (keyUp ? "UP" : "DOWN") + " vk=0x" + keyboard.VKey.ToString("X2") + " scan=0x" + keyboard.MakeCode.ToString("X2") + - " flags=0x" + keyboard.Flags.ToString("X2") + + " flags=0x" + keyboard.Flags.ToString("X2") + " source_device=rc003" + " action_routed=" + actionRouted); return; @@ -4574,11 +4631,14 @@ private static void WriteHealth(string state) health["config_revision"] = snapshot == null ? "" : snapshot.revision ?? ""; health["config_loaded_at"] = configLoadedAtUtc == DateTime.MinValue ? "" : configLoadedAtUtc.ToString("o"); health["config_mapping_count"] = snapshot == null || snapshot.mappings == null ? 0 : snapshot.mappings.Length; - health["voice_f5_isolation"] = Rc003PresentRecently() ? "rc003_scoped_hook" : "keyboard_passthrough"; - health["voice_f5_suppressed_edges"] = suppressedHookEdgeCount; + bool filterHealthy = IsRc003FilterHealthy(); + health["voice_f5_isolation"] = filterHealthy ? "device_filter" : "keyboard_fail_open"; + health["voice_f5_hook_fail_open_edges"] = Interlocked.Read(ref hookVoiceFailOpenEdgeCount); + health["voice_f5_last_hook_decision"] = lastVoiceF5HookDecision ?? ""; + health["voice_f5_suppressed_edges"] = 0L; // Published so the self-check can explain a stuck record key instead of // leaving the user with a remote that looks like it stopped responding. - health["voice_hold_repeats_suppressed"] = voiceHoldRepeatsSuppressed; + health["voice_hold_repeats_suppressed"] = 0; health["voice_hold_stale_releases"] = voiceHoldStaleReleaseCount; health["voice_hold_stale_latched"] = Volatile.Read(ref voiceHoldStaleLatched) == 1; health["smart_profiles_enabled"] = snapshot != null && snapshot.smartProfilesEnabled; @@ -4590,7 +4650,6 @@ private static void WriteHealth(string state) health["smart_profile_match_state"] = smartProfileMatchState ?? ""; health["input_routing_mode"] = snapshot == null ? "strict" : NormalizeInputRoutingMode(snapshot.inputRoutingMode); - bool filterHealthy = IsRc003FilterHealthy(); health["routing_authority"] = filterHealthy ? "device_filter" : "raw_input"; health["routing_isolation"] = filterHealthy ? "exact_device" : "native_passthrough"; health["raw_remote_edges"] = Interlocked.Read(ref rawRemoteEdgeCount); @@ -4600,6 +4659,9 @@ private static void WriteHealth(string state) health["last_raw_action_at"] = lastRawActionUtc == DateTime.MinValue ? "" : lastRawActionUtc.ToString("o"); health["last_raw_action"] = lastRawAction ?? ""; health["last_action_source"] = lastActionSource ?? ""; + health["last_raw_press_at"] = lastRawPressUtc == DateTime.MinValue ? "" : lastRawPressUtc.ToString("o"); + health["last_raw_press"] = lastRawPress ?? ""; + health["last_raw_press_source"] = lastRawPressSource ?? ""; ActionExecutionReceipt receipt; lock (actionReceiptLock) receipt = lastExecutionReceipt; health["last_execution_sequence"] = receipt == null ? 0L : receipt.Sequence; @@ -4782,13 +4844,9 @@ protected override void OnHandleCreated(EventArgs e) // delivering a usable WM_INPUT_DEVICE_CHANGE notification. Rebind // at a low frequency so the bridge heals itself while idle. rawInputHealthTimer = new System.Windows.Forms.Timer(); - // 5 s, not 30 s. The hook's scoped record-key suppression treats the RC003 as present only for - // Rc003PresenceWindowMs after the last sign of it, and this probe is the only thing that refreshes that - // sign while the remote is idle. At a 30 s interval the presence had usually expired again by the time a - // user pressed the record key, so the *first* press of a session — the one that starts dictation — went - // unsuppressed and the foreground application received F5 as well. The probe now runs inside the window it - // feeds. Measured on this machine before the change: the ISOLATION lines only ever showed - // scoped_suppress=true for auto-repeat edges, never for the first press. + // Match the presence window so reconnect healing and diagnostic + // presence state agree. The probe itself remains independent of the + // fail-open hook decision. int healthIntervalMs = Rc003PresenceWindowMs; rawInputHealthTimer.Interval = healthIntervalMs; DateTime lastHealthLogUtc = DateTime.MinValue; @@ -4820,7 +4878,7 @@ protected override void OnHandleCreated(EventArgs e) Log("Raw Input health check ok device_present=true"); } }; - // One line at startup, so the interval that feeds the suppression window is visible in any log. + // One line at startup, so reconnect healing and diagnostic presence timing are visible in any log. Log("Raw Input health timer interval_ms=" + healthIntervalMs + " presence_window_ms=" + Rc003PresenceWindowMs); rawInputHealthTimer.Start(); @@ -4857,8 +4915,7 @@ protected override void WndProc(ref Message m) HandleVoicePhysicalTransition(false, "raw_device_removed", 0, 0); if (changeType == 2) { - // Expire the scoped hook-voice lease: with the RC003 gone, - // an ordinary keyboard F5 must pass through untouched. + // Expire the diagnostic presence marker when the RC003 goes. rc003DevicePresentUtc = DateTime.MinValue; } DateTime now = DateTime.UtcNow; @@ -5239,6 +5296,9 @@ private void OpenAndValidate() IntPtr opened = CreateFile(DevicePath, GenericRead | GenericWrite, 0, IntPtr.Zero, OpenExisting, 0, IntPtr.Zero); if (opened == InvalidHandleValue) + // ERROR_FILE_NOT_FOUND (2) on this device path means the + // control-device interface is absent (driver not installed or + // not loaded). Access denied would be ERROR_ACCESS_DENIED (5). throw new IOException("open_failed_win32_" + Marshal.GetLastWin32Error()); deviceHandle = opened; diff --git a/scripts/features/ActionResult.cs b/scripts/features/ActionResult.cs index 671526f..38e7b65 100644 --- a/scripts/features/ActionResult.cs +++ b/scripts/features/ActionResult.cs @@ -303,11 +303,33 @@ internal static string SanitizeOverlayText(string value) { if (string.IsNullOrWhiteSpace(value)) return ""; string safe = value.Replace('\r', ' ').Replace('\n', ' ').Trim(); - safe = UrlPattern.Replace(safe, "[网页地址]"); - safe = WindowsPathPattern.Replace(safe, "[本机路径]"); + // Each overlay snapshot sanitizes every field and every mapping, and running both patterns was + // measured at about six milliseconds per snapshot — most of what one poll cost. The patterns can + // only match text that really carries a scheme or a drive path, so they are skipped otherwise and + // the result is unchanged. + if (ContainsUrlScheme(safe)) safe = UrlPattern.Replace(safe, "[网页地址]"); + if (ContainsDrivePath(safe)) safe = WindowsPathPattern.Replace(safe, "[本机路径]"); return safe.Length <= 180 ? safe : safe.Substring(0, 177) + "..."; } + private static bool ContainsUrlScheme(string text) + { + return text.IndexOf("http", StringComparison.OrdinalIgnoreCase) >= 0; + } + + // Over-approximates "a letter, a colon and a backslash" on purpose: a false positive only runs the + // pattern, which then finds nothing. + private static bool ContainsDrivePath(string text) + { + for (int i = 0; i + 2 < text.Length; i++) + { + if (text[i + 1] != ':' || text[i + 2] != '\\') continue; + char letter = text[i]; + if ((letter >= 'a' && letter <= 'z') || (letter >= 'A' && letter <= 'Z')) return true; + } + return false; + } + internal static string SanitizeMappingAction(string action) { string normalized = (action ?? "").Trim(); diff --git a/scripts/features/FocusTargetService.cs b/scripts/features/FocusTargetService.cs index b938008..229f389 100644 --- a/scripts/features/FocusTargetService.cs +++ b/scripts/features/FocusTargetService.cs @@ -454,10 +454,22 @@ internal static string VerificationStatusText(FocusTargetDescriptor target) return IsProcessRunning(target.ProcessName) ? "已验证" : "已验证 · 应用未运行"; } + // The Host keeps one process-name snapshot that answers every "is X running" question and refreshes it + // in the background, so it installs that answer here. Process.GetProcessesByName enumerates the whole + // process table, and the status caption is rebuilt on every interface refresh; measured, that was the + // single largest cost of an idle refresh on this class of machine. + internal static Func processRunningProbe; + private static bool IsProcessRunning(string processName) { string normalized = FocusTargetDescriptor.NormalizeProcessName(processName); if (normalized.Length == 0) return false; + Func probe = processRunningProbe; + if (probe != null) + { + try { return probe(normalized); } + catch { } + } try { Process[] processes = Process.GetProcessesByName(normalized); diff --git a/scripts/tests/FeatureSurfaceTests.cs b/scripts/tests/FeatureSurfaceTests.cs index e443e01..991fa47 100644 --- a/scripts/tests/FeatureSurfaceTests.cs +++ b/scripts/tests/FeatureSurfaceTests.cs @@ -11,6 +11,7 @@ private static int Main() TestVoiceTargetObservationFailsClosed(); TestTransientFocusedVoiceTargetPolicy(); TestVoiceFocusPreflightRequiresCurrentLease(); + TestUnverifiedWakeTargetIsNotAnnouncedOverUsableFocus(); TestVoiceWakeRecoveryRequiresFocusWhenCold(); TestRecordingKeepsVerifiedVoiceFocusLock(); TestVoiceFocusRestoreOnlyRepairsProviderTakeover(); @@ -94,6 +95,26 @@ private static void TestVoiceFocusPreflightRequiresCurrentLease() "Voice focus preflight accepted a stale, rejected, or released lease"); } + private static void TestUnverifiedWakeTargetIsNotAnnouncedOverUsableFocus() + { + MethodInfo method = typeof(VibeMicForm).GetMethod( + "ShouldAnnounceUnverifiedVoiceTarget", BindingFlags.Static | BindingFlags.NonPublic); + if (method == null) throw new InvalidOperationException( + "Wake-time unverified voice target notice policy is unavailable"); + + bool noDestination = Convert.ToBoolean(method.Invoke(null, + new object[] { true, false, false })); + bool destinationFocused = Convert.ToBoolean(method.Invoke(null, + new object[] { true, false, true })); + bool leaseArmed = Convert.ToBoolean(method.Invoke(null, + new object[] { true, true, false })); + bool keyAlreadyReleased = Convert.ToBoolean(method.Invoke(null, + new object[] { false, false, false })); + if (!noDestination || destinationFocused || leaseArmed || keyAlreadyReleased) + throw new InvalidOperationException( + "An unverified wake target was announced without a held press and an empty destination"); + } + private static void TestVoiceWakeRecoveryRequiresFocusWhenCold() { MethodInfo method = typeof(VibeMicForm).GetMethod( diff --git a/scripts/ui/LiveHudForm.cs b/scripts/ui/LiveHudForm.cs index a4e89fd..d7b4efe 100644 --- a/scripts/ui/LiveHudForm.cs +++ b/scripts/ui/LiveHudForm.cs @@ -594,6 +594,14 @@ private void OnLiveHudHideTimerTick(object sender, EventArgs e) if (liveHud != null && !liveHud.IsDisposed) liveHud.HideInactive(); } + // The floating panel's automatic pop-up is a user choice (设置 ▸ 交互反馈). An explicit + // tray request always opens it, and turning the choice off never takes that entry away. + private bool LiveHudAutoPresentationEnabled() + { + return ShouldPresentLiveHudOnItsOwn(liveHudExplicitlyRequested, + config != null && config.liveHudAutoShow); + } + private void PresentLiveHud(VibeUiStatusSnapshot snapshot, bool force) { PresentLiveHud(snapshot, force, false); @@ -604,6 +612,14 @@ private void PresentLiveHud(VibeUiStatusSnapshot snapshot, bool force) private void PresentLiveHud(VibeUiStatusSnapshot snapshot, bool force, bool carryMessage) { if (snapshot == null || applicationExiting || IsDisposed) return; + // The panel is a preference, not a fixture. It may only appear on its own while + // 设置 ▸ 交互反馈 allows it, or when the user opened it from the tray on purpose; its + // content stays current either way, so switching the choice back on shows state at once. + if (!force && !LiveHudAutoPresentationEnabled()) + { + if (liveHud != null && !liveHud.IsDisposed) liveHud.ApplySnapshot(snapshot); + return; + } string key = LiveHudKey(snapshot); bool firstSnapshot = string.IsNullOrWhiteSpace(liveHudPresentationKey); bool changed = !string.Equals(key, liveHudPresentationKey, StringComparison.Ordinal); @@ -742,7 +758,10 @@ private void PublishFeedbackSnapshotInternal(bool presentHud, bool carryMessage) DispatchUi(delegate { PublishFeedbackSnapshotInternal(presentHud, carryMessage); }); return; } + double buildStartedAt = PollClockMs(); latestUiSnapshot = BuildFeedbackSnapshot(); + MarkPollStage(11, buildStartedAt); + double applyStartedAt = PollClockMs(); if (contextDeck != null && !contextDeck.IsDisposed) { contextDeck.ApplySnapshot(latestUiSnapshot); @@ -750,12 +769,15 @@ private void PublishFeedbackSnapshotInternal(bool presentHud, bool carryMessage) } if (presentHud) PresentLiveHud(latestUiSnapshot, false, carryMessage); else if (liveHud != null && !liveHud.IsDisposed) liveHud.ApplySnapshot(latestUiSnapshot); + MarkPollStage(12, applyStartedAt); } private VibeUiStatusSnapshot BuildFeedbackSnapshot() { + double healthStartedAt = PollClockMs(); BridgeHealthSnapshot bridge = ReadKeyboardBridgeHealth(); UpdateLatestBridgeAction(bridge); + MarkPollStage(15, healthStartedAt); ShortcutProfileConfig activeProfile = ActiveShortcutProfile(config); string profileName = bridge != null && !string.IsNullOrWhiteSpace(bridge.SmartEffectiveProfileName) ? bridge.SmartEffectiveProfileName : activeProfile == null ? "通用导航" : activeProfile.name; @@ -765,6 +787,7 @@ private VibeUiStatusSnapshot BuildFeedbackSnapshot() currentVisualState == "error" ? "语音链路需要检查" : bridgeReady ? "语音桥接就绪" : IsCapturing ? "语音桥接正在连接" : "语音桥接已暂停"; string highlighted = DateTime.Now < remoteHighlightUntil ? latestHighlightedControl : ""; + double mapStartedAt = PollClockMs(); var mappings = new Dictionary(StringComparer.OrdinalIgnoreCase); mappings["录音键"] = "按住开始 · 松开结束"; mappings["上键"] = MappingCardActionText(GetMapping("上键", "up")); @@ -777,11 +800,18 @@ private VibeUiStatusSnapshot BuildFeedbackSnapshot() mappings["TV"] = MappingCardActionText(GetMapping("TV", "task-switcher")); mappings["功能键短按"] = MappingCardActionText(GetMapping("功能键:short", "ctrl+c")); mappings["功能键长按"] = MappingCardActionText(GetMapping("功能键:long", "ctrl+v")); - return VibeUiStatusSnapshot.Create(deviceStatus, voiceStatus, profileName, - bridge == null ? "" : bridge.SmartForegroundProcess, FocusTargetSummary(), + MarkPollStage(16, mapStartedAt); + double focusStartedAt = PollClockMs(); + string focusSummary = FocusTargetSummary(); + MarkPollStage(17, focusStartedAt); + double createStartedAt = PollClockMs(); + VibeUiStatusSnapshot snapshot = VibeUiStatusSnapshot.Create(deviceStatus, voiceStatus, profileName, + bridge == null ? "" : bridge.SmartForegroundProcess, focusSummary, string.IsNullOrWhiteSpace(currentProjectSpaceName) ? "未配置项目" : currentProjectSpaceName, highlighted, currentVisualState == "recording", latestAudioOutputRmsPercent, latestActionResult, mappings, ProviderDisplayName(config.inputMethod)); + MarkPollStage(18, createStartedAt); + return snapshot; } private void UpdateLatestBridgeAction(BridgeHealthSnapshot bridge) diff --git a/scripts/validate.js b/scripts/validate.js index 50d4bb8..643bdb6 100644 --- a/scripts/validate.js +++ b/scripts/validate.js @@ -420,15 +420,16 @@ assert(includesAll(read("scripts/ui/BrowserRemoteLiteForm.cs"), [ "Colouring it needs custom draw.", ]), "The dark-mode table fix is gone, so the rows would render light on a dark page again");// The caveat also has to be where a downloader reads it, not only inside the application: the release body that // BUILD_RELEASE.ps1 generates from docs/GITHUB_RELEASE_BODY_ZH.md, the README, and the quick-start. All three -// described using the record key without ever saying that without the signed filter the key uses hook isolation -// scoped to the remote being online, and that an ordinary keyboard's F5 passes through when it is not. +// described using the record key without distinguishing the immutable published release from the development +// branch fix: v2.0.0 still suppresses by online scope, while the current branch fails open. assert(includesAll(read("docs/GITHUB_RELEASE_BODY_ZH.md"), [ "录音键隔离现状(安装前请先读)", "遥控器**不在线**时,普通键盘的 F5 **原样直通**", "不能与签名过滤器等同", ]) && includesAll(read("README.md"), [ "**录音键隔离**", - "普通键盘 F5 原样直通", + "当前开发分支已改为 fail-open", + "该修复尚未进入 `v2.0.0` 资产", ]) && includesAll(read("QUICK_START_ZH.md"), [ "录音键隔离(安装前请先读)", "遥控器**不在线**时普通键盘的 F5 **原样直通**", @@ -830,10 +831,9 @@ assert(includesAll(app, [ "FavoriteAppSummaryText", "voiceFocusTargetButton", "添加应用", "常用应用", "尚未配置工作流", "重新聚焦输入框后重试", ]), "Voice UI does not expose the favourite-application state or submit recovery path"); -// The warning has to keep the two facts apart: the audio chain is unaffected, and the record key is intercepted for a -// stated span. The wording changed with plan B — the interception now covers the first press too and ends when the -// remote goes — but the distinction is what this pins. -assert(app.includes("音频不受影响;连接遥控器时会拦截录音键(键盘 F5 同时被拦截),断开后恢复"), +// The warning has to keep the two facts apart: the audio chain is unaffected, and the remote's native key effect +// can still reach the foreground application because an unconfirmed F5 fails open. +assert(app.includes("实体键盘始终直通;遥控器录音键的原始按键效果也可能同时发生"), "The RC003 filter warning does not distinguish audio availability from key-isolation risk"); assert(includesAll(bridge, [ "HandleVoicePhysicalTransition", "voiceTransitionLock", @@ -853,25 +853,30 @@ assert(voiceSignalSection.indexOf("voiceKeyPressedEvent != null && voiceKeyPress "Bridge no longer preserves the V1.5 immediate voice-edge ordering"); // Measured constraint of this Windows Bluetooth stack: the low-level hook edge // precedes Raw Input and hook suppression cancels the Raw Input packet, so the -// hook can never attribute a single F5 event to the RC003 device. The voice F5 -// isolation must therefore be scoped to RC003-connected moments: while the RC003 -// is physically present and the voice mapping is enabled with suppression, F5 -// is captured at the hook and driven through the voice state machine from there -// (rc003_present_hook). Without the RC003, or with a healthy per-device filter, -// an ordinary keyboard F5 must pass through untouched. -const scopedHookVoiceIndex = bridge.indexOf("ShouldUseScopedHookVoice("); +// hook can never attribute a single F5 event to the RC003 device. When the +// device filter is unavailable the hook must fail open, including while the +// remote is connected; Raw Input remains the device-scoped voice source. +const failOpenHookVoiceIndex = bridge.indexOf("VoiceF5HookFailOpenReason("); assert(!bridge.includes("ShouldSuppressVoiceHookForRc003Fallback") && !bridge.includes("keyboard_hook_rc003_fallback") && !bridge.includes('HandleVoicePhysicalTransition(isDown, "keyboard_hook"') && !bridge.includes('HandleVoicePhysicalTransition(!keyUp, "keyboard_hook"') && - bridge.includes('"rc003_present_hook"') && - bridge.includes("ShouldUseScopedHookVoice") && + !bridge.includes('"rc003_present_hook"') && + bridge.includes("VoiceF5HookFailOpenReason") && + bridge.includes("VOICE F5 HOOK PASSTHROUGH") && + bridge.includes("decision=fail_open") && bridge.includes("Rc003PresentRecently()") && bridge.indexOf('if (isVoiceMapping && mapping.suppress)') > 0 && - scopedHookVoiceIndex > 0 && - bridge.indexOf("RC003_PRESENT_GRACE_MS") > 0 && + failOpenHookVoiceIndex > 0 && bridge.indexOf("rc003DevicePresentUtc") > 0, - "Bridge expands device-blind low-level Hook interception for the recording key"); + "Bridge can suppress an F5 whose source device is unknown"); +const unknownSourceVoiceHookBranch = section(bridge, + "if (isVoiceMapping && mapping.suppress)", + "// Enabled non-voice mappings and disabled voice mappings"); +assert(unknownSourceVoiceHookBranch.includes("return CallNextHookEx") && + !unknownSourceVoiceHookBranch.includes("HandleVoicePhysicalTransition") && + !unknownSourceVoiceHookBranch.includes("return (IntPtr)1"), + "The source-unknown voice-hook branch can suppress F5 or start recording without device identity"); // A record key that keeps reporting "held" with no release edge used to wedge the voice // state: the held event stayed set, so the frozen Capture re-armed a session on every ATVV // reconnect (RecoverHeldVoiceRequestAtReady) and each one failed for want of audio. One @@ -886,18 +891,19 @@ assert(includesAll(stuckWatchdog, [ ]) && /heldMs\s*>=\s*VOICE_HOLD_STUCK_BOUND_MS/.test(stuckWatchdog), "The stuck-hold watchdog measures idle time only, which a repeating record key never reaches"); assert(includesAll(bridge, [ - "VOICE_HOLD_LATCH_CLEAR_MS", "voiceHoldRepeatsSuppressed", "voiceHoldStaleReleaseCount", + "VOICE_HOLD_LATCH_CLEAR_MS", 'health["voice_hold_repeats_suppressed"]', "voiceHoldStaleReleaseCount", "The stuck-hold latch can clear before the restart guard expires", ]) && /VOICE_HOLD_LATCH_CLEAR_MS\s*<=\s*VOICE_RESTART_GUARD_MS/.test(bridge), "A stuck record key can be re-armed by its own repeats, or the next real press is lost"); -// Logging every suppressed edge wrote about thirty lines a second and consumed a 2 MB log -// in ten minutes (13862 isolation lines plus 17486 in the rotated file, about four fifths -// of everything recorded). Repeats must be aggregated instead. +// A held F5 repeats at the keyboard rate. The fail-open logger must aggregate +// those pass-through edges and the self-test must exercise that logger. assert(includesAll(bridge, [ - "LogIsolationEdge", "repeat_held=true repeats=", "hold_summary repeats=", - "ISOLATION_REPEAT_LOG_INTERVAL_MS", -]) && !bridge.includes('" rc003_connected_no_filter");\n return (IntPtr)1;'), - "Held-key repeats are logged one line each again, which rotates away the useful history"); + "LogVoiceF5HookFailOpenEdge", "VOICE F5 HOOK PASSTHROUGH repeat_held=true repeats=", + "VOICE F5 HOOK PASSTHROUGH hold_summary repeats=", "ISOLATION_REPEAT_LOG_INTERVAL_MS", + "Fail-open F5 repeats are not being aggregated", +]) && !bridge.includes("LogIsolationEdge") && + !bridge.includes('" rc003_connected_no_filter");\n return (IntPtr)1;'), + "Fail-open F5 repeats are not aggregated or the retired suppression logger is still present"); assert(includesAll(bridge, [ 'health["voice_hold_repeats_suppressed"]', 'health["voice_hold_stale_releases"]', 'health["voice_hold_stale_latched"]', @@ -1219,19 +1225,14 @@ assert(includesAll(app, [ 'result.Message;', '(string.IsNullOrWhiteSpace(result.ErrorCode) ? "" : "\\r\\n" + result.ErrorCode)', ]) && !app.includes('result.Message + (string.IsNullOrWhiteSpace(result.ErrorCode) ? "" : " · " + result.ErrorCode)'), - "The wizard's status line is showing its error code again");// Plan B for the record-key isolation: close the window the first press fell through, and say what the fallback does. + "The wizard's status line is showing its error code again"); +// The Raw Input health probe also refreshes the RC003 presence marker used by diagnostics and reconnect healing. +// It runs on the same named window so the reported presence state is current. The F5 hook no longer depends on this +// marker for suppression: a source-unknown F5 always fails open, while Raw Input owns the device-scoped voice edge. // -// The scoped suppression treats the RC003 as present only for a short window after the last sign of it, and the Raw -// Input health probe is the only thing that refreshes that sign while the remote is idle. The probe ran every 30 s -// against a 5 s window, so for most of every 30 s the presence had expired — and the *first* press of a session, the -// one that starts dictation, was not suppressed. The ISOLATION lines in this machine's own log only ever showed -// scoped_suppress=true for auto-repeat edges, which is what that looks like from outside. The probe now runs inside -// the window it feeds, the window is a named constant, and a startup line records both so a log says which timer is -// feeding the suppression. -// -// The copy no longer says the app "does not intercept keys of unknown origin", which was no longer true: it says the -// app judges by whether the remote is present, that the record key is then intercepted for both the remote and the -// keyboard's F5, that this ends when the remote goes, and that non-voice keys keep passing their native effect. +// The copy must follow the fail-open hook policy: an F5 without confirmed +// device identity reaches the foreground application, while Raw Input still +// drives the RC003 voice transition after the device identity check. assert(includesAll(read("scripts/VoxDeckInputBridge.cs"), [ "private const int Rc003PresenceWindowMs = 5000;", "TotalMilliseconds <= Rc003PresenceWindowMs;", @@ -1239,10 +1240,11 @@ assert(includesAll(read("scripts/VoxDeckInputBridge.cs"), [ "rawInputHealthTimer.Interval = healthIntervalMs;", 'Log("Raw Input health timer interval_ms=" + healthIntervalMs +', ]) && includesAll(app, [ - '未安装签名通道:音频不受影响;连接遥控器时会拦截录音键(键盘 F5 同时被拦截),断开后恢复。详见「自检」。', - '未安装签名通道时,Vibe Link按「遥控器是否在场」判断', -]) && !app.includes('RC003 按键隔离未就绪') && !app.includes('Vibe Link不拦截来源未知的键'), - "The presence probe fell back outside its window, or the copy went back to saying keys of unknown origin pass through");// The voice page reports the last session's measured capture level. + '未安装签名通道:实体键盘始终直通;遥控器录音键的原始按键效果也可能同时发生。详见「自检」。', + '未安装签名通道时,Vibe Link不拦截来源未知的 F5', +]) && !app.includes('RC003 按键隔离未就绪') && + !app.includes('键盘 F5 同时被拦截'), + "The presence probe fell back outside its window, or the copy disagrees with the fail-open F5 policy");// The voice page reports the last session's measured capture level. // // This is the one place where the software can help with the microphone problem: measured over 920 sessions, the // median output level was 5% against the 10% speech recognition needs, 92% of sessions sat below it, and the @@ -1333,9 +1335,8 @@ assert(bridge.split("MappingHasNoAction(mapping)) && IsVoiceRawCandidate(").leng // // Three facts have to stay true or the recorded bug comes back: the bridge's built-in power mapping stays disabled // (unassigned means the scan code belongs to Windows and to the record fallback), an actionless mapping never takes the -// key away from the record fallback, and the record-key suppression window stays the same size as the probe that keeps -// it fresh. The last one matters because the probe is the only thing refreshing presence while the remote is idle: with -// a 30 s probe against a 5 s window the first press of a session was not suppressed and the page refreshed. +// key away from the record fallback, and the diagnostic presence window stays aligned with its refresh probe. The F5 +// hook itself fails open regardless of presence, so this timing no longer gates physical-keyboard passthrough. assert(includesAll(bridge, [ 'name = "power", label = "电源键", vk = "0xFF", scan = "0x5E", enabled = false, suppress = false', "if ((mapping == null || !mapping.enabled || MappingHasNoAction(mapping)) && IsVoiceRawCandidate(", @@ -1457,7 +1458,7 @@ assert(includesAll(app, [ // native effect can still fire, and the channel is optional. The log note and the statistics note lost their padding. // "配置 schema" became 参数版本: schema is the word the code uses, not the word a user reads. assert(includesAll(app, [ - '未安装签名通道时,Vibe Link按「遥控器是否在场」判断:在场时拦截录音键(实体键盘的 F5 也同时被拦截),非语音键的原生效果仍会透传。签名通道装上后改为按设备精确拦截。', + '未安装签名通道时,Vibe Link不拦截来源未知的 F5:实体键盘始终直通;RC003 录音仍由设备级 Raw Input 驱动,但原始 F5 也可能到达前台。签名通道装上后可精确拦截。', '每个日志上限 4 MB。诊断音频每次都要你明确确认。', '参数版本 " + ConfigSchemaVersion', '只统计当前日志窗口内有结束回执的会话(日志限长,旧记录随滚动丢弃);不含录音、转写文字、窗口标题或设备地址。', @@ -1928,8 +1929,12 @@ assert(includesAll(bridge, [ "RawKeyboardEdgeTracker", "RouteAuthoritativeRawKeyboard", "returning 1 here prevents Windows from delivering", "RC003 action routed source=raw_input", "delivery=native_passthrough", + "source_device=rc003", 'health["routing_authority"] = filterHealthy ? "device_filter" : "raw_input"', 'health["routing_isolation"] = filterHealthy ? "exact_device" : "native_passthrough"', + 'health["voice_f5_isolation"] = filterHealthy ? "device_filter" : "keyboard_fail_open"', + 'health["voice_f5_hook_fail_open_edges"]', + 'health["voice_f5_last_hook_decision"]', 'health["raw_remote_edges"]', 'health["raw_action_edges"]', "Vibe Link device-aware keyboard hook", "WM_APP_REINSTALL_HOOK", ]), "RC003 source isolation or its deterministic regression tests are incomplete"); @@ -1937,8 +1942,9 @@ assert(includesAll(app, [ "Retired compatibility routing was not normalized to strict", // The card's statement follows the actual state now: it used to assert device-level isolation unconditionally, // right next to a badge and a note saying that isolation was not there. - // Plan B: the state line says what the fallback does and when it ends, instead of naming the missing subsystem. - "未安装签名通道:遥控器连接时会拦截录音键(实体键盘的 F5 同时被拦截),断开后恢复", + // The fallback state says that the physical keyboard always passes through and that the remote's native F5 + // effect can still reach the foreground application. + "未安装签名通道:实体键盘始终直通;遥控器录音键的原始效果可能同时发生", "设备级隔离已启用:只有带 RC003 身份的事件会执行遥控器动作", "Raw Input 安全直通", "设备级精确隔离", ]) && !app.includes("compatibility.CheckedChanged"), @@ -1956,9 +1962,11 @@ assert(includesAll(bridge, [ "IoctlHeartbeat = 0x8000A408U", "IoctlReadEvents = 0x8000640CU", "IoctlDisarm = 0x8000A410U", ]), "The optional RC003 filter client lacks protocol checks, health, or strict fallback"); -const hookFilterGuard = bridge.indexOf("ShouldBypassHookForRc003Filter(IsRc003FilterHealthy()"); +const hookFilterGuard = bridge.indexOf("bool filterHealthy = IsRc003FilterHealthy();"); const hookNativePassthrough = bridge.indexOf("bool nonVoiceCandidate", hookFilterGuard); -assert(hookFilterGuard >= 0 && hookNativePassthrough > hookFilterGuard, +assert(hookFilterGuard >= 0 && + bridge.indexOf("ShouldBypassHookForRc003Filter(filterHealthy", hookFilterGuard) > hookFilterGuard && + hookNativePassthrough > hookFilterGuard, "A healthy RC003 filter does not release matching physical-keyboard input before hook routing"); assert(includesAll(rc003FilterPublic, [ @@ -2074,8 +2082,9 @@ assert(bridge.includes("IsDeviceScopedVoiceSource") && !bridge.includes('HandleVoicePhysicalTransition(isDown, "keyboard_hook"') && !bridge.includes('HandleVoicePhysicalTransition(!keyUp, "keyboard_hook"') && !bridge.includes('MarkRemoteInput("keyboard_hook")') && - bridge.includes('"rc003_present_hook"'), - "The device-blind keyboard hook can still enter the voice recording state machine outside its RC003-connected scope"); + bridge.includes("VoiceF5HookFailOpenReason") && + bridge.includes("VOICE F5 HOOK PASSTHROUGH"), + "The device-blind keyboard hook can still suppress F5 or enter recording without a source identity"); assert(!bridge.includes("RouteDeviceScopedHookEvent(") && !bridge.includes("deviceInputGate."), "The retired Hook-to-Raw pairing route is still active"); @@ -2737,7 +2746,7 @@ assert(includesAll(app, [ // opposite ("Vibe Link不会拦截来源未知的键"). The bold line and the checkbox state now follow the actual state, so the // four parts of that card agree. A checked box promising what the same card denies is worse than saying nothing. assert(includesAll(app, [ - '"未安装签名通道:遥控器连接时会拦截录音键(实体键盘的 F5 同时被拦截),断开后恢复"', + '"未安装签名通道:实体键盘始终直通;遥控器录音键的原始效果可能同时发生"', '"设备级隔离已启用:只有带 RC003 身份的事件会执行遥控器动作"', "exactDeviceIsolation, new Point(32, 62));", ]) && !app.includes('"设备识别:只有带 RC003 身份的事件可以执行遥控器动作"'),