跳转至

恢复

恢复意味着下一次全新的采集能够成功,包括其预期输出。仅凭一个已清除的错误或一个仍在运行的进程,并不能证明恢复成功。

本页负责中断和失败状态转换。听写 负责正常的采集行为;索引 涵盖可选处理、平台和依赖。

子功能

如何进入

从一个前台应用中的正常听写开始。听写没有取消功能(#786);中断发生后,尝试重新按住快捷键。在 Linux 上,设置界面还提供 Start/Stop 和 Retry/Dismiss。

interruption / dependency failure
  -> affected operation stops, falls back, or reports failure
  -> automatic recovery OR explicit retry              // depends on the failure
  -> fresh hold -> new audio -> release -> expected output // recovery endpoint

这些是在失败发生时需要识别的路径,不是要你去打断正在运行的应用程序或其设备的操作指示。下面的检查并不能证明这整条路径都有效。

现有检查

这里是现有检查的地图,而不是经过验证的端到端恢复驱动器。除非下方有注明执行日期,否则这些检查只是定位器,而不是已通过的测试运行。除非另有说明,采集证据都是 macOS 特有的;每个示意都将其来源和证明边界列在旁边。

加载含无效字段的设置

recover.settings:由另一个构建写入、或手工编辑的设置文件,不能阻止应用打开。

Launch -> read settings.json
├── Unknown keys -> ignored
├── Invalid field -> log its key and reason -> that field's default
│   └── Valid siblings and unrelated sections stay
├── Invalid JSON / unreadable file -> log why -> defaults in memory
└── Save over invalid settings -> preserve exact original first
    ├── settings.json.unreadable-<sha256>, owner-only; same bytes backed up once
    └── Backup fails / original cannot be read -> save fails, original stays

加载已修复的设置本身不会重写文件。之后的设置保存(包括迁移保存)会在替换文件前先进行备份。备份永远不会被自动移除。在 Linux 上,正在运行的 Hex 每两秒检查一次 settings.json,并采用在窗口之外发生的编辑(手工或由代理进行的编辑),因此其模式会到达监听器,并且下一次保存会保留它(#970);macOS 和 Windows 仍然只在启动时或通过 hex settings set 读取它。未选择的 meetings.retention 会以缺失键的形式写入,因此使用非可选 retention 的旧构建仍然可以读取该文件;现有的 null 值也能够在当前构建中加载。

来源与检查:settings persistence 及其 tests:旧结构的 retention、无效枚举、嵌套类型、列表条目、未知键、无效 JSON、恰好一次的私有备份、被中断的保存,以及备份失败。这些检查使用临时文件;它们不执行真正的应用启动。

不丢失其他工作的前提下完成

recover.finish:没有任何操作会取消一次听写(#786)。Escape 会传递给应用,每个被接受的任务都会按提交顺序粘贴。

Escape -> nothing: the capture and every accepted job go on
older job + fresh capture -> both complete, in order

检查位于 parakeet.rs:cancellation_replaces_waiting_output_and_ignores_late_completion(有序输出)。证明边界:受控的有序输出;不包括目标应用对剪贴板的消费。

重新武装而不产生幽灵采集

recover.shortcut:一个普通的组合键或漏掉的释放可能留下过期的快捷键状态记录。修复该状态记录时绝不能凭空制造出一次录音。

stale shortcut state
  -> no hold/pending gesture + no pending input       // required gates
  -> full key scan + >= 100 ms sampled neutrality
  -> rearm bookkeeping only                          // emits NO capture actions
  -> fresh hold -> release -> expected output

LOCKED + key release missed while hands-free          // would block the finish press
  -> same gates, scan, and neutrality
  -> clear key tracking, stay LOCKED                  // emits NO capture actions
  -> fresh shortcut press -> Finish

delayed pre-recovery input -> fenced                  // cannot corrupt the fresh hold

检查位于 suppression/tests.rs:neutral_keyboard_rearms_after_a_missing_modifier_release、delayed_neutral_callback_after_recovery_preserves_the_next_hold、stale_key_recovery_requires_complete_neutrality_and_no_intervening_input、stale_key_recovery_never_polls_holds_or_pending_gestures、locked_dictation_finishes_after_a_missing_key_release。证明边界:合成事件和采样状态夹具,并非每种物理键盘或键位映射。

recover.shortcut-press:Command-Tab 会吞掉 Tab 的释放,而 Kit 经常在松开 Command 后 100 毫秒内按下 Option。按下动作本身可以重新同步,而无需等待轮询。在 Toggle 或双击采集处于锁定状态时用 Command-Tab 切换应用,也会让 Tab 一直处于被跟踪状态;每个本应结束锁定的按下都会记录 locked with stale tracked keys 并且不执行任何操作;现在,结束锁定的按下会以同样的方式重新同步。

Command-Tab -> Tab still tracked (IDLE, DIRTY, or LOCKED)
  -> typing, Return, Shift …                          // don't matter
  -> binding press: the binding's own down edge
     ├── keyboard reported no modifiers after each tracked key's own press
     ├── only the binding's modifiers went down since
     └── tracked keys + every other modifier read up now  // same source as recovery
        -> clear tracked keys, fence input stamped before the press
           -> fresh press -> Start (IDLE, DIRTY) or Finish (LOCKED)
              ├── chord at the same tick -> Discard     // not fenced
              └── older delayed release of the key -> ignored
  -> any check fails -> press ignored (logged with reason + key_codes)
                     -> 100 ms poll as before
tracked key down in combined-session state but up in HID state  // #612
  -> reads as up for both the press check and the poll   // a latched Tab or Q
     no longer blocks every press until Hex restarts
tracked keyboard key reads down in both states but sent no key event,  // #1008
not even a key repeat, for 5 s (TRACKED_KEY_REPEAT_TIMEOUT)
  -> the press check treats it as released               // logged; mouse buttons excluded
     a Tab really held keeps repeating and still blocks the press
     (with key repeat turned off, a key held past 5 s no longer blocks it)
LOCKED + Escape -> Nothing, stale keys or not (#786)
fenced edges, edges before a disabled tap, suspension -> no neutral evidence

检查位于 suppression/tests.rs:option_soon_after_command_tab_records、shortcut_press_after_command_tab_from_dirty_records、typing_after_command_tab_does_not_block_the_next_press、a_delayed_release_cannot_untrack_a_resynchronized_key_press、a_chord_at_the_resynchronized_press_still_discards_it、fenced_and_pre_reset_modifier_edges_are_not_neutral_evidence、stale_press_waits_for_recovery_while_anything_else_is_held、stale_press_requires_a_neutral_keyboard_after_the_tracked_keys、stale_press_finishes_a_lock_and_leaves_escape_unchanged、toggle_mode_finishes_after_command_tab_while_recording、a_latched_key_without_key_repeats_stops_blocking_the_shortcut、a_key_latched_only_in_session_state_reads_as_released(这个测试只验证状态规则;还没有测试观察到真实的锁存键)。单元测试读取一个默认全部为抬起的键盘,除非某个测试单独设置一个键,因此每个快捷键测试都可能走到这条路径。

当绑定按键在过期(stale)或 DIRTY 状态下被忽略时,会连同原因记录 shortcut press ignored 到 process.log;重新同步的按下会记录 resynchronized stale input tracking on a shortcut press。证明边界:合成事件和注入的键盘状态;实时状态是在监听器处理按下事件时读取的,而不是在事件发生时读取的,因此在这期间释放的被跟踪键会读作抬起(中立键盘检查覆盖了常见的 Tab-then-Option 组合)。尚未在物理键盘上运行过。

Fn 元数据需要比“忽略 Fn”更窄的规则。

SecondaryFn 导航元数据
  ├── 带时间戳的修饰键变化证据说明 Fn 已松开
  │     -> 可因中立性忽略该元数据    // 完整扫描与延迟围栏保留
  └── Fn 被按住或状态未知 -> 将 Fn 保留为阻塞键

从快捷键匹配中全局剔除 Fn              // 不安全:不是恢复规则
见 suppression/tests.rs 中的检查: arrow_function_metadata_does_not_block_the_next_option_hold、 function_recovery_preserves_unknown_and_flags_only_fn_holds。 证明边界:提供的修饰键证据,而非通用原生 Fn 行为。

recover.tap-interruption:事件点按(tap)被禁用会破坏对输入历史的信任,而不仅仅是当前的修饰键标志。

事件点按被禁用
  -> 取消进行中的热键手势                     // 不会卡在录制中
  -> 使不确定的修饰键证据失效           // 不推断已松开
  -> 全新输入 -> 下一个有效手势 -> 预期输出
见 suppression/tests.rs 中的检查: tap_failure_cancels_recording_after_live_opt_in、 blind_input_periods_invalidate_function_modifier_evidence。 证明边界:对给定中断的 reducer 响应,而非 macOS 禁用并重新启用真实点按。

recover.tap-health:macOS 可能禁用事件点按(回调超时、用户输入),或在 Hex 空闲、休眠后、安全输入或辅助功能变更时静默禁用或使其失效。点按线程会修复全部三个点按(按键、修饰键、HID),并将每次修复记录到 process.log。

禁用事件(超时 | 用户输入)
  -> 重新启用每个点按                    // “macOS 禁用了事件点按;已重新启用”
  -> recover.tap-interruption
每 5 秒在点按线程上
  ├── 点按被静默禁用 -> 重新启用  // “事件点按在未通知的情况下被禁用”
  ├── 点按失效或重新启用失败 -> 重建  // “已重建事件点按”
  └── 创建失败 -> 下次检查重试    // “无法重建事件点按”,仅记录一次
  -> 按键或修饰键间隙 -> recover.tap-interruption
点按线程持有用户发起的活动  // App Nap 无法限流它;允许空闲休眠
来源:TapSet::check、reenable_disabled_taps 和 AppNapOptOut,见 suppression.rs。无检查项:它需要 macOS 禁用真实点按。 证明边界:报告的真实故障(#612),未被复现。

2.1.11 版本说明 描述了已发布的导航和漏放修复;它们是历史背景,而非当前代码树中的测试。

用新的音频重新打开

recover.microphone:启动时缺少输入和流中断使用有界恢复。“保持就绪”会在不更改设置或重启监听器的情况下持续重试,即使已取消。

初始打开失败 或 流中断
  -> 取消不完整的捕获 + 丢弃陈旧音频  // 中断的音频不能返回
  -> 有界退避 -> 重新打开已解析的输入     // 暖麦克风重试
  -> 替换投递新的音频
  -> 全新捕获 -> 释放 -> 预期输出
设备拒绝以其默认格式打开流     // #988,Intel Smart Sound on Windows
  -> 记录被拒绝的格式(声道、Hz、采样格式) // audio/input_format
  -> 尝试其列出的其他格式(F32、I16、U16、I32),最多 12 次
  -> 均无法打开 -> 默认格式的错误(含其格式)进入恢复流程
完全没有输入设备(macOS)
  -> 名称无法读取的设备被跳过     // audio::device_name,绝不显示
  -> 有界恢复;`hex status` 监听器:“No microphone”
启动时或中断后没有输入可打开(Linux) // #937
  -> 每次中断只报告一次“could not open the microphone: …” // take_failure
  -> 持续重试期间保留最后一次失败;一旦成功打开则重置
监听器工作线程返回错误或崩溃(macOS)
  -> 状态:running false、“Stopped”、capture 未知 // 绝不显示“Enabled”
已保存的麦克风缺失或无法打开
  -> 系统默认设备暂代,设置不变      // audio::Selection
  ├── 列表中存在但无法打开 -> 使用有界退避重新查找
  └── 不在列表中 -> 在设备列表变更时重新查找 // macOS 报告设备接入
                    └── Windows、Linux:有界退避,仅在切换时记录
  -> 找到后 -> 在捕获空闲时切换回来        // 不在挂起的启动或尾部期间
MacBook 盖子合上(macOS)                            // 内置麦克风已关闭但仍在列表中
  -> 内置麦克风与 Hex Microphone 一样被跳过  // AppleClamshellState,每次解析时读取
  ├── 耳机插孔麦克风(BuiltInHeadphoneInputDevice UID,或内置
  │   输入的 emic/line 数据源)-> 保持可用:在列表中、可选择、
  │   跟随默认设备,并可暂代(#712)
  ├── 默认或已保存的麦克风是内置的 -> 第一个其他真实输入暂代
  │     -> 设置:“… 在盖子合上时关闭,因此 Hex 正在收听 …”
  ├── 没有其他输入 -> “内置麦克风在盖子合上时处于关闭状态”
  └── 盖子打开或合上 -> 像设备列表变更一样重新解析 // IOKit clamshell 通知
        -> 在捕获空闲时切换
蓝牙默认输入(macOS,启用了 avoid_bluetooth_microphone) // 传输类型,每次解析时读取
  -> 内置麦克风暂代,否则使用第一个其他真实输入
  ├── 没有其他可用输入 -> 使用蓝牙默认设备
  └── 设置变更 -> 像设备列表变更一样重新解析 -> 空闲时切换
虚拟输入或 iPhone Continuity 麦克风(macOS:ccwd/ccwl 传输) // #1100
  -> 绝不暂代:手机会弹窗提示且可能无法打开
  ├── 盖子合上且没有其他设备 -> 显示“内置麦克风在盖子合上时处于关闭状态”
  └── 默认设备、已保存的麦克风或 --device -> 按指定名称使用

热流仍在运行但只传送全零数据(macOS,#1122)
  -> 该次听写按近乎静音丢弃,与之前相同
  -> 其结束像流失败一样重新打开麦克风 // 仅记录一次
  -> 下次听写从新流录制

打开的流停止传送音频但不报错(任意 OS,#1206)
  -> 4 秒无数据块(STREAM_STALL_TIMEOUT)-> 按流失败处理
  -> 像任何中断的流一样重新打开,无论空闲与否 // 记录“microphone stopped delivering audio”

程序化听写(本地 API)在流中断或出现间隙时
  ├── 丢失前已送达的音频 -> 按其 ID 保留 // recover.programmatic-loss
  │     -> /finish 转录它;/cancel、心跳和租约仍然生效
  │     -> 当 /finish 在丢失被读取之前到达时同样适用(take_loss)
  └── 自开始以来没有音频(打开失败,或仅有 pre-roll)
        -> /finish 应答 409 microphone-lost
见 audio.rs 中的检查: an_unreadable_device_name_is_skipped_instead_of_panicking、 a_microphone_that_cannot_open_is_reported_once_per_outage、 microphone_recovery_retries_with_bounded_backoff_and_resets、 startup_retry_recovers_metadata_and_audio_without_restarting、 a_missing_selection_uses_the_default_until_it_returns、 a_closed_lid_passes_over_the_built_in_microphone、 only_the_internal_microphone_is_switched_off_by_the_lid、 a_headphone_jack_microphone_is_used_with_the_lid_closed、 an_iphone_microphone_never_stands_in、 continuity_capture_transports_are_recognised(纯解析和 fourcc 值;未在盖子合上时观察过 iPhone)、 where_the_os_reports_new_devices_only_a_failed_open_is_retried、 only_an_idle_capture_lets_the_microphone_switch、 a_stream_that_stops_delivering_audio_is_reopened; 见 dictation/audio.rs: recovered_owner_delivers_audio_and_accepts_a_new_capture、 microphone_loss_keeps_only_a_programmatic_captures_delivered_audio、 microphone_loss_with_only_pre_roll_keeps_nothing、 finish_after_an_unread_microphone_loss_takes_the_kept_clip、 a_dictation_of_digital_silence_reopens_the_microphone; 见 recognition.rs: microphone_loss_keeps_a_programmatic_clip_for_finish(保留本身,而非经由 运行中的监听器发出的 /finish)。 见 rune_ui/tests.rs: a_closed_lid_names_the_input_standing_in_for_the_built_in_microphone. 证明边界:重试策略和基于通道的替换音频,而非物理设备重连或完整的原生麦克风到目标结果。合盖场景是纯解析和测试场景;IOKit 合盖读取及其通知未在真实 MacBook 合盖和开盖中观察过,耳机插孔 UID 和数据源代码来自 CoreAudio 的文档化值,而非在合盖状态下观察到的插孔麦克风。

recover.cold-open:“空闲时释放”为一次手势而打开,而非空闲重试循环。每次尝试都必须自行拥有其打开结果。

press A -> asynchronous open A
  -> release/cancel before ready -> abandon A
press B -> asynchronous open B
  ├── late ready A -> reject                          // belongs to abandoned open
  └── ready B -> capture B -> finish -> expected output
                   └── capture idle -> close device   // accepted jobs do not keep it open

release-when-idle enabled + capture idle -> stop retries
warm mode enabled during pending capture -> preserve capture; retry after cancellation

cold open -> selected microphone fails to open
  -> retry it twice, 100 ms apart -> then the system default (logged)
ready stream fails while the press is held
  ├── before the 300 ms hold threshold -> back to pending, reopen for the same press
  │     ├── release before ready -> discard
  │     └── ready -> capture from the original press -> expected output
  └── after the threshold -> fail with "Microphone capture was interrupted"
CoreAudio processor overload (Xrun) -> warn and keep the stream // not a failure
gap in the microphone timeline (any OS, warm or cold)
  ├── 50–250 ms -> filled with silence, capture continues // logged, #619
  └── over 250 ms -> fail as a capture discontinuity

检查见 dictation/audio.rs:cold_capture_restart_rejects_late_open_results_and_closes_after_finish、enabling_warm_microphone_preserves_pending_capture_and_recovers_after_cancellation、cold_stream_failure_before_hold_threshold_reopens_for_the_same_press、cold_stream_failure_after_hold_threshold_still_fails_the_capture、a_short_microphone_gap_is_filled_with_silence_instead_of_failing_the_capture;以及 audio.rs 中的:overload_glitches_do_not_interrupt_the_stream、cold_open_retries_the_selected_microphone_before_falling_back。

在 2.1.25 之后修复(#130): 空闲数分钟后的第一次按住会因错误提示音而失败。报告者的日志显示,冷启动流在打开后 20–76 ms 报告了“发生缓冲区欠载或溢出”。cpal 0.18 将来自 CoreAudio 的 kAudioDeviceProcessorOverload 监听器的该事件报告为 ErrorKind::Xrun;流仍在运行,但 Hex 将每一个错误回调都视为致命错误。在“空闲时释放”模式下,拥有者随后未重试便关闭了设备,识别系统挂起了按住的这个手势。现在,过载与被拒绝的实时提升会被记录并忽略;超过 50 ms 的实际采样间隔仍会作为不连续事件使捕获失败。在按住阈值之前发生的中断会将同一次按下重新挂起,而不是使其失败。同一份日志还显示,冷启动从所选麦克风回退到自动选择;该设备是不存在还是首次打开失败,目前尚不清楚,因此冷启动会在现有回退之前先重试所选麦克风。

未验证:报告者的硬件、对于其设备 200 ms 的重试是否足够长,以及空闲后的实际冷启动。

证明限制:受控的异步打开器与捕获所有权,而不涉及起始延迟或 macOS 麦克风指示器。

在 dev 之后修复(#619): 蓝牙耳机的流在启动后不久就跳过了大约 130–145 ms 的音频,而每处超过 50 ms 的间隔都会以错误提示音使当前按住的捕获失败。现在,最多 250 ms 的间隔会在时间轴上用静音填充,因此捕获(及其 pre-roll 缓冲环)会继续;更长的间隔仍会使其失败。Annotate 接收器仍会记录该间隔本身。未在报告者的耳机上验证;关于蓝牙设备在启用 avoid_bluetooth_microphone 的情况下使用的报告未被复现(保存的蓝牙麦克风,或没有其他可用输入时,会按设计保留它)。

在 dev 之后修复(#1122): WhatsApp 桌面版通话结束后,热的内置麦克风流在不报错的情况下继续运行,但只传送全零数据,因此每次听写都会被当作接近静音而丢弃(zero_fraction 为 1.0),直到 Hex 重启。现在,一次完成的听写如果从按下到释放期间的音频恰好全零,就会像流失败一样重新打开流;冷麦克风(release-while-idle)本来就会为每次按下全新打开。第一次这样的听写仍会被丢弃。未使用真实的 WhatsApp 通话进行验证。

在 3.3.0 之后修复(#1206): 在空闲时出现一连串溢出警告之后,一个热 USB 麦克风的流在未报告错误的情况下停止了音频传送,因此每次听写只包含过期的 pre-roll 数据,并被判定为无语音而丢弃,直到 Hex 重启。现在,一个已打开但在 4 秒内没有任何数据输出的流会被视为失败,并按照通常的有界恢复逻辑重新打开。未在报告者的麦克风上验证。

隔离迟到的捕获通知

recover.late-notification:保持失败可见是正确的,直到该失败会被归因于另一次捕获为止。

capture A fails -> finish/cancel A -> preserve A's terminal failure
start capture B -> new generation                     // fences obsolete notifications
  ├── late failure/ready A -> cannot finish, fail, or activate B
  └── B's own audio -> finish B -> expected output

检查见 dictation/audio.rs:capture_failures_survive_terminal_controls_but_not_a_new_capture、boundary_drain_preserves_finish_failure_but_does_not_fail_a_new_capture。

证明限制:涉及跨捕获边界传递错误,而不涉及任意跨线程或原生事件顺序。

仅降级失败的依赖

命令模型的准备过程不得让热键听写等待,也不得随其一起失败。

command model loads in background
  ├── slow/failing loader -> command preparation unavailable
  └── listener remains responsive -> fresh hotkey capture -> expected output

检查见 recognition.rs:command_preparation_does_not_block_the_listener_or_propagate_failure。

证明限制:受控的加载器响应能力,而非完整的模型下载或麦克风会话。依赖恢复操作请参见索引。

recover.gpu-full:在 Linux 和 Windows 上,更换模型、语言或语音引擎时,会在正在运行的模型旁边加载新内容,因此小型 GPU(2 GB)可能因第二份副本而耗尽内存(#1170)。

new model loads beside the running one -> GPU out of memory -> CPU copy loads
  -> the CPU copy is dropped (not swapped in); the GPU isn't remembered as failed
  -> once nothing records or transcribes -> engine drops its model, then loads
     the new one in its place (GPU again, CPU only if it still doesn't fit)
  -> commands sent meanwhile wait for the load; nothing recorded is lost

来源:ModelSwap::place/take_reload,见 transcription/local_transcriber.rs;以及 Command::Reload,见 transcription/engine.rs。检查项:a_model_without_room_on_the_gpu_reloads_in_place_once(仅交换状态)。未在真实的 2 GB GPU 上观察到此情况。第二个进程(另一条自行加载模型的 hex 命令)仍会加载自己的副本。

recover.command-pressure:命令识别是尽力而为;听写音频则不然。

microphone timeline
  ├── active dictation -> retain continuous audio -> expected output
  └── command backlog exceeds bound
        -> invalidate stale generation + reset recognition
        -> discard stale audio/updates                // no stale command may execute

command recovery -> next fresh dictation still works  // not just an emptied queue

来源:dictation/audio.rs 中的 overflow_recognition 和 forward_recognition,以及 recognition.rs 中的不连续性处理。证明限制:仅限源码支撑的契约。完整的全路径原生停滞检查仍需同时证实听写无损失和过期命令被拒绝。

recover.optional-processing:普通听写会回退到可用文本。

ordinary dictation -> corrections -> optional rewrite -> transformations -> paste
                                      │                  │
                                      v                  v
                             rewrite fails       chain fails
                             -> corrected text   -> chain's input // discard partial results
                             // the log says why: rewrite off for the mode, or the failure reason

later request -> expected output                       // recovery still needs observation

各阶段的来源和检查项见 index;失控脚本在下方说明。证明限制:仅凭回退行为并不能确定下一个请求会成功。

保持 Linux 监听器受管

recover.linux-listener:设置界面会显示监听器状态、启动/停止,以及重试/关闭。隐藏故障不等于重启其工作线程。

listener worker fails -> reap worker -> show failure
  ├── Retry -> new managed listener -> fresh capture -> expected output
  └── Dismiss -> clear error                            // not evidence of recovery

close the window -> Hex keeps running
  └── uncommitted shortcut capture -> cancel edit -> restore prior listening

Hex crashes -> systemd restart-on-failure (no window) OR explicit hex start
  -> hex / hex status -> live state, not stale log state

hex quit / Quit Hex -> drain accepted work -> release microphone and hotkeys -> exit 0

来源/检查项:linux/app.rs、Linux CI、test-wayland-paste.sh 和 test-linux-service.py。证明限制:这些是源码和可用的无头检查,而非当前受支持主机上的行为。原生合成器/设备验证仍然单独进行。单实例套接字仅限同一用户;有界 I/O 和队列可防止客户端阻塞影响音频循环。

自 #786 起 Linux 上的 Escape: 听写没有取消功能,因此 Escape 在 X11 和 Wayland 上仍归应用程序所有。Hex 仅在 Annotate 绘图时于 X11 上抓取它;此时 Escape 会停止绘图;抓取冲突只记录一次,绘图继续进行。portal 从不绑定 Escape 或任何取消快捷键,并且快捷键集合在单个会话中只绑定一次。

listener starts -> one session, one BindShortcuts: Hex's shortcuts only
  └── KDE -> remove the stale `cancel` and `cancel-dictation` entries first
desktop closes the shortcuts session -> cancel active capture
  -> listener stops with an error -> Retry / hex start binds again
X11, Annotate drawing -> grab Escape (AnyModifier, else bare + lock variants)
  └── BadAccess -> warn once, keep drawing; other grab errors stop the listener

recover.linux-portal:在 Wayland 上,如果缺少 GlobalShortcuts portal,会重新查找该 portal,而不是在整个会话中让快捷键保持关闭(#875)。

listener starts -> no GlobalShortcuts portal
  -> warn (desktop name, restart-the-portal hint) -> hex commands drive dictation
  -> check again on its own thread: after 15 s, doubling up to every 2 min
     └── portal appears (e.g. xdg-desktop-portal restarted) -> bind shortcuts as usual

dev-520 之后修复(#875):在 KDE Plasma Wayland 上,在 Plasma 导出其会话环境之前启动的 xdg-desktop-portal 会选错后端,并且不提供任何快捷键接口;Hex 会回退到命令,并且不再检查。来源:Backend::wait_for_portal,见 linux/input.rs;commands_alone_drive_a_listener_without_shortcuts 覆盖的是命令循环,而非 portal 的出现。未在真实的 Plasma 会话上运行过。

在 dev-249 之后修复(#482,由 ohlmann 在 KDE Plasma 上报告):Hex 在录制开始时于第二个 portal 会话中绑定了 Escape。KDE 按键按应用来设置快捷键,因此该会话的 BindShortcuts 移除了 Hex 的其他快捷键,导致录制从未开始。Hex 在单个会话中每个快捷键只绑定一次,并且从不在此处绑定 Escape。

linux/input.rs 中的检查项: escape_never_ends_a_dictation、escape_grab_conflict_is_not_fatal、 non_conflict_grab_errors_stay_fatal,以及被忽略的 X11 检查项 commands_and_grabs_share_the_x11_listener(在捕获录制期间 Escape 保持空闲)和 conflicted_escape_fallback_releases_partial_grabs。KDE 移除操作尚未在真实的 Plasma 会话中运行。

键盘布局解析

GUI startup -> require OS main thread -> initialize_layout // recover.keyboard-layout
  -> publish complete snapshot BEFORE settings/workers/Rune
     ├── cached character -> keycode
     ├── missing ASCII character -> filled from ASCII-capable layout
     └── Command layer (UCKeyTranslate with cmdKey) -> separate map
                                     // no worker-side TIS/TSM query

Selected input source changes (Dvorak -> U.S.)       // recover.keyboard-layout-change
  -> observer marks it -> pump on the main thread -> refresh_layout
  -> rebuild with the same fills -> publish whole       // workers see old or new
  -> changed -> resolve runtime shortcuts again from the last applied settings
     ├── letter key -> the key that types the letter now
     ├── other keys -> recorded key code
     └── listener rebinds each shortcut once its machine is idle

Synthetic shortcut with Command (⌘V)
  ├── Command layer has the character -> that keycode // "Dvorak – QWERTY ⌘"
  └── otherwise -> plain snapshot as above
Shortcuts without Command -> plain snapshot only

Headless lookup without snapshot -> shared native lock -> current layout query
  -> current layout misses ASCII character -> ASCII-capable fallback
Concurrent snapshot construction -> same lock          // serialized, not a separate race

来源:keyboard.rs、macos_rune.rs。 仅靠锁并不能保证 GUI 线程安全:成功的主线程预热和纯缓存未命中可防止后续 GUI worker 查询原生布局 API。 在 t1304 之前,GUI 快照在重启前始终保持不变:在 Dvorak 下启动并切换到 U.S. 后,⌘V 会上送键码 47(U.S. 的 .),因此听写记录显示“已粘贴”,但实际没有任何内容出现。现在有一个输入源观察者在应用整个生命周期内运行(键盘模型切换在它开启期间读取同一个观察者),并且每次变化都会在主线程上重建快照。 a_published_layout_replaces_the_one_before、 readers_see_a_whole_layout_while_it_is_replaced 和 letter_shortcuts_resolve_again_on_a_new_layout 覆盖了替换和快捷键重新解析;TIS 通知以及真实切换后粘贴的操作尚未在 Mac 上验证。没有快照的 CLI 查找仍会查询当前布局。

非拉丁字母的活动布局(俄语、希伯来语等)在空修饰键状态下不会产生任何 ASCII 字母,因此仅由这些布局构建的快照会漏掉诸如 ⌘V 之类的粘贴快捷键。macOS 会针对可输出 ASCII 的布局解析此类快捷键;现在 GUI 快照和实时查询都会以相同方式从 TISCopyCurrentASCIICapableKeyboardLayoutInputSource 补充缺失的 ASCII 字母。 即使某些字母已存在,也只添加缺失的 ASCII 字母;活动映射和非字母未命中保持不变。重复的后备字符保留最低的键码,这与当前布局快照一致。 non_latin_snapshot_gets_shortcut_letters_without_replacing_active_keys 使用西里尔/希伯来测试夹具覆盖合并策略;原生布局和物理粘贴验证仍保持独立。

布局也可以在按住 Command 时切换排列方式。“Dvorak – QWERTY ⌘”中,键码 47 会输入 v,但那里的 ⌘+47 是 ⌘.;⌘V 是键码 9。从普通层解析 ⌘V 会上送 ⌘.,导致目标应用发出提示音(kitlangton/Hex#162)。现在,快照和无头(headless)查找会先从布局的 Command 层解析 Command 快捷键。普通布局在两层上的字母映射相同。 command_shortcuts_resolve_against_the_command_layer 使用 Dvorak-QWERTY⌘、QWERTY 和非拉丁测试夹具覆盖了选择与回退逻辑;它不会查询真实的 Dvorak 布局,也不会发送粘贴事件。

执行于 2026 年 9 月 1 日: keyboard_layout.rs 包含生产模块,并在全新子进程中运行四个场景,每个场景运行三次,每个场景八个 worker。修复前,冷查找、混合初始化/查找以及预热未命中在所有九个受影响的运行中均中止;预热命中的运行未中止。修复后,所有十二个子进程运行在 debug 和优化后的 release 配置下均通过。

Retained regression
├── cold-lookups -> native queries serialized; headless lookups remain live
├── cold-mixed -> initialization and lookups coordinate through the same lock
├── warm-hits -> exactly one native snapshot acquisition
└── warm-misses -> exactly one native snapshot acquisition, not just "no crash"

After explicit initialization -> further hits AND misses add zero native queries

在 macOS 上使用仓库的原生构建依赖运行 cargo test --locked --test keyboard_layout;如需优化运行,请添加 --release。这些运行使用了 macOS 26.5.1 (25F80)、arm64、Rust 1.95.0,以及通过 CMAKE 指定的本地已安装 CMake 可执行文件。该测试框架设有 20 秒的子进程截止时间,并将原生 abort 限制在子进程内。它不会发送任何按键事件,也不会启动已安装的应用或捕获音频。

贡献者于 2026 年 9 月 9 日报告(PR #81,ASCII-capable 回退): 在 macOS 26.6.2 (25G83)、Apple M3 上,当俄语布局处于活动状态时,本地构建的 /Applications/Hex.app 在启动时解析出 paste_key_code=9,并保持了 Option-听写内容可粘贴到焦点应用;保留的 keyboard_layout 回归测试仍通过了全部十二个子进程运行。该回退与 AppKit 自身在非拉丁布局上解析 ⌘V 等快捷键的方式一致,因此拉丁布局活动时的快照保持不变。

2026年9月1日本地启动检查: 已从 /Applications/Hex.app 安装并启动了 Developer ID 签名版本 2.1.11,本地构建 20111.1。已验证捆绑包标识/签名,以及安装的可执行文件与签名构建之间的一致性。新进程报告键盘布局已解析、转录模型已加载,并进入 Listening 状态,没有出现启动错误或 panic。重启后,设置文件的 SHA-256 未发生变化。此本地构建未进行公证或发布;保留了之前已公证的应用程序,以便回滚。

2.1.12 的发布验证: 发布提交 f76c115、构建 20112 通过了 435 项 Rust 测试(9 项被忽略)、全部 12 次布局回归子运行(debug 和 release 两种 profile 均通过)、46 个 command-SDK 测试、Clippy、格式化和身份保护检查。源代码提交 f556f9b 通过了 Linux 和 Nix CI;发布提交仅更改了版本号和发布说明。

应用程序和 DMG 均已完成 Developer ID 签名、公证和装订;Gatekeeper 接受了该 DMG 及其中的应用程序。DMG 与 Sparkle ZIP 在 207 个载荷条目上完全一致,包括文件字节、权限和符号链接目标。在按 artifact-first/feed-last 顺序发布后,公开的 DMG、ZIP 和 latest-DMG 链接与预先准备的 SHA-256 校验和一致。ZIP 的 Ed25519 签名通过应用程序公钥验证,公开的 appcast 以构建 20112 为首条。请参阅发布说明和发布检查。

证明限制:这些是原生布局查询、本地应用启动和分发产物的检查,并非物理快捷键/粘贴接受测试,也不是实际执行过的 Sparkle 安装。签名后的发布预览创建了一个窗口,但由于屏幕录制权限不可用,截图捕获失败;因此不声称进行了截图验证。已安装的本地应用程序及其设置均未被更改。在正在运行的 GUI 会话中切换布局仍需要重启才能刷新其快照;实时 GUI 布局刷新不属于本次修复的范围。

差距与限制

这五个已知差距仍然限制着恢复方面的声明;它们不是提议的新功能。

重映射的 Control 键

remapped keyboard -> Quartz modifier event -> InputEvent::Flags
                                              └── keycode discarded
side-less Control fixture -> does not prove remapped-key identity
listen-only native metadata -> establish distinguishability before choosing a fix
来源/检查:suppression.rs, remapped_control_does_not_start_or_hold_right_control_dictation; 问题 #37。 缺失的证据:受影响键盘的元数据,以显示 Quartz 是否保留了足够的身份信息来区分重映射的按键。

原生静音与未被识别的按住

reported mute failure / missed hold
  -> record build + permissions + mic mode + exact devices
  -> observe hold -> capture -> release -> result       // missing native evidence

ownership/timing tests -> device-specific root cause    // UNSAFE inference
相关报告:#53、 #54。 证明限制:现有的所有权/时序测试不能确定这些原生失败的原因;仍需要一次当前的从按住到结果的完整序列记录。

失控脚本

transformation runs past its timeout -> interrupted -> that call fails
  // 1 s, or 30 s when the config uses fetch or node:child_process; 60 s cap
  -> its requests abandoned, its process trees killed
  -> transformation chain -> its input is kept           // ordered processing goes on
  -> later call on the same config -> succeeds
fetch or exec before the person allows the config -> throws -> input kept
config load past 2 s or past 64 MiB -> rejected -> previous config stays active
来源:script.rs、 personal_commands.rs; 检查 runaway_scripts_stop_at_the_time_limit_and_the_config_keeps_working、 the_transformation_timeout_ends_the_whole_process_tree(Linux)和 a_throwing_or_slow_transformation_pastes_the_text_unchanged,见 tests/host_apis.rs。 处理程序返回动作而不是等待原生结果,因此旧的迟到的原生工具回复(#20)不可能再发生。

慢速粘贴消费者

Chromium 155/156 frontmost? type instead (#212); else publish clipboard -> post Paste
  ├── settle for 100 ms -> allow next output
  └── schedule restore after 500 ms -> restore only if still owned
target consumes later?                                // no consumption acknowledgment

controlled delayed consumer -> output A -> output B -> restoration
                                                      // missing isolated evidence
来源:paste.rs; 问题 #24。 缺失的证据:缺少在受控延迟消费者下对两种输出及恢复行为的验证,且未使用用户的剪贴板。

不要为了通过恢复检查而重置设置或放宽快捷键匹配。应将自动恢复、显式重试和阻止恢复的缺陷明确区分开来。测试清理必须保留失败证据。

本页原文 Markdown:在 AtomGit 查看·内容源自开源项目 hex19/hex