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
1,264 changes: 1,069 additions & 195 deletions scripts/VibeMic.cs

Large diffs are not rendered by default.

68 changes: 55 additions & 13 deletions scripts/VoxDeckInputBridge.cs
Original file line number Diff line number Diff line change
Expand Up @@ -188,6 +188,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;
Expand All @@ -200,13 +207,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)
Expand Down Expand Up @@ -656,14 +686,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);
Expand Down Expand Up @@ -709,9 +750,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))
{
Expand Down Expand Up @@ -4600,6 +4639,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;
Expand Down
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