Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 下载

Expand Down
11 changes: 11 additions & 0 deletions docs/V2_0_PROGRESS.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 豆包输入法链路彻查与最终选型(用户真机迭代)

- **结论:豆包无法接入遥控器自动链路**(已用全部可行手段实测):
Expand Down
1,405 changes: 1,203 additions & 202 deletions scripts/VibeMic.cs

Large diffs are not rendered by default.

338 changes: 199 additions & 139 deletions scripts/VoxDeckInputBridge.cs

Large diffs are not rendered by default.

26 changes: 24 additions & 2 deletions scripts/features/ActionResult.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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();
Expand Down
12 changes: 12 additions & 0 deletions scripts/features/FocusTargetService.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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<string, bool> processRunningProbe;

private static bool IsProcessRunning(string processName)
{
string normalized = FocusTargetDescriptor.NormalizeProcessName(processName);
if (normalized.Length == 0) return false;
Func<string, bool> probe = processRunningProbe;
if (probe != null)
{
try { return probe(normalized); }
catch { }
}
try
{
Process[] processes = Process.GetProcessesByName(normalized);
Expand Down
21 changes: 21 additions & 0 deletions scripts/tests/FeatureSurfaceTests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ private static int Main()
TestVoiceTargetObservationFailsClosed();
TestTransientFocusedVoiceTargetPolicy();
TestVoiceFocusPreflightRequiresCurrentLease();
TestUnverifiedWakeTargetIsNotAnnouncedOverUsableFocus();
TestVoiceWakeRecoveryRequiresFocusWhenCold();
TestRecordingKeepsVerifiedVoiceFocusLock();
TestVoiceFocusRestoreOnlyRepairsProviderTakeover();
Expand Down Expand Up @@ -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(
Expand Down
34 changes: 32 additions & 2 deletions scripts/ui/LiveHudForm.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand All @@ -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);
Expand Down Expand Up @@ -742,20 +758,26 @@ 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);
if (latestUiSnapshot.RealAudioActive && contextDeck.Visible) contextDeck.HideInactive();
}
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;
Expand All @@ -765,6 +787,7 @@ private VibeUiStatusSnapshot BuildFeedbackSnapshot()
currentVisualState == "error" ? "语音链路需要检查" :
bridgeReady ? "语音桥接就绪" : IsCapturing ? "语音桥接正在连接" : "语音桥接已暂停";
string highlighted = DateTime.Now < remoteHighlightUntil ? latestHighlightedControl : "";
double mapStartedAt = PollClockMs();
var mappings = new Dictionary<string, string>(StringComparer.OrdinalIgnoreCase);
mappings["录音键"] = "按住开始 · 松开结束";
mappings["上键"] = MappingCardActionText(GetMapping("上键", "up"));
Expand All @@ -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)
Expand Down
Loading