USB与蓝牙同时断开,程序怎样只执行一次?
电脑隐私保护工具如果同时支持快捷键、USB、蓝牙和摄像头触发,功能数量并不是最难的部分。真正麻烦的是:同一次离开可能连续产生三四条信号,而保护动作只能稳定执行一次。
从实现上看,需要把原始设备变化变成统一事件,再做候选确认、会话合并、SingleFlight 和逐项结果记录。这样既能保留触发原因,也不会让多个回调重复操作窗口和资源。
▌独立回调为什么容易把同一件事做三遍
最直接的实现通常长这样:
void OnUsbRemoved() => ProtectNow();
void OnBluetoothDisconnected() => ProtectNow();
void OnAwayConfirmed() => ProtectNow();
代码很短,但三个回调互相不知道对方是否已经启动保护。若动作包含窗口处理、资源锁定和状态提示,就容易出现重复弹窗、重复请求、后到任务覆盖先到结果等问题。
更麻烦的是,原始回调的语义并不等价:
- USB 拔出通常是一次明确的设备变化;
- 蓝牙断开可能只是短暂抖动,之后又自动恢复;
- 摄像头分类结果可能只在单帧出现,需要连续确认;
- 快捷键则是用户明确发出的即时命令。
因此,不能只用一个全局布尔值表示“是否触发”。程序需要先把不同来源转换成统一事件,再根据来源完成各自的确认。
▌第一步:把不同信号归一化
原始回调只负责描述事实,不直接决定动作。可以先建立一个足够小的统一对象:
public sealed record TriggerSignal(
string Source,
string Kind,
string Subject,
long Sequence,
DateTimeOffset ObservedAt);
Source 表示信号来自 USB、蓝牙、摄像头还是快捷键;`Kind` 表示离开、断开或手动触发;`Subject` 用于区分不同设备或检测来源;`Sequence` 防止旧消息晚到后重新生效。
归一化之后,后续模块只处理 TriggerSignal,不需要知道底层回调来自哪一种系统接口。
USB 回调 ──────┐
蓝牙状态 ──────┤
摄像头结果 ────┼→ TriggerSignal → 来源确认器
快捷键消息 ────┘
▌第二步:每种来源分别确认,但输出同一种正式事件
统一格式不代表统一确认方法。蓝牙可以使用“候选可撤销”,摄像头可以使用连续结果,快捷键则可以直接确认。重要的是,确认器最后都输出同一种 ConfirmedTrigger。
async Task ConfirmAsync(
TriggerSignal signal,
CancellationToken token)
{
var rule = confirmationRules.For(signal.Source);
var version = versions.Advance(signal.Source, signal.Subject);
await rule.ObserveAsync(signal, token);
if (!versions.IsCurrent(signal.Source, signal.Subject, version))
return null; // 观察期间出现了更新事实
if (!rule.StillMatches(signal))
return null; // 候选已经恢复或被撤销
return ConfirmedTrigger.From(signal);
}
这里不应把确认时间写成所有来源共用的固定数字。可复用的是“旧候选能被新事实撤销”和“只有当前版本才能升级为正式事件”两条规则。
▌第三步:用合并器判断是不是同一次现实事件
多个正式事件可能来自同一次离开动作。合并器不应该简单丢弃后到事件,因为后到事件仍然提供了有价值的触发原因。更合理的做法是:第一条事件创建一次执行会话,短时间内到达的相关事件只补充原因,不再创建第二条动作链。
ConfirmedTrigger
↓
查找当前聚合会话
├─ 没有 → 创建会话,保存首次策略快照
└─ 已有 → 追加触发原因,不重置执行计划
↓
SingleFlight
↓
只允许一条动作链进入 Running
一个概念性聚合器可以这样写:
ProtectionSession Accept(ConfirmedTrigger trigger)
{
lock (_gate)
{
if (_active is { IsOpen: true } current &&
current.CanMerge(trigger))
{
current.AddReason(trigger.Source, trigger.Subject);
return current;
}
var plan = policy.BuildSnapshot(trigger);
_active = ProtectionSession.Create(trigger, plan);
return _active;
}
}
关键点是 BuildSnapshot 只在会话首次建立时运行。后续即使用户修改设置,正在执行的会话也不应该在中途换成另一套动作;否则日志、提示和最终结果会互相矛盾。
▌第四步:SingleFlight 只解决并发,还需要幂等结果
单飞控制保证同一时刻只有一条动作链运行,但程序崩溃重试、迟到消息或重复请求仍可能再次触发同一步骤。因此,每个步骤还应判断目标是否已经满足。
async Task RunAsync(ProtectionSession session)
{
if (!singleFlight.TryEnter(session.Id))
return;
try
{
foreach (var step in session.Plan.Steps)
{
if (await state.IsSatisfiedAsync(step))
{
session.RecordSkipped(step, "already satisfied");
continue;
}
var result = await executor.ExecuteAsync(step);
session.Record(step, result);
}
}
finally
{
singleFlight.Leave(session.Id);
}
}
“已经满足”不应该被记录成失败。例如某个资源在动作开始前已经处于关闭状态,再次请求关闭时,最合理的结果是幂等成功或跳过,而不是制造一条错误告警。
▌第五步:单个动作失败,不应让整条链失去结果
统一动作链常常包含多个相互独立的步骤。若某一步失败就直接抛出异常并丢弃后续结果,用户最终只会看到一个笼统失败,无法知道哪些部分已经完成。
更适合的结果结构是逐项记录:
public sealed record StepResult(
string Step,
StepStatus Status,
string? UserHint);
public sealed record PlanResult(
IReadOnlyList Steps)
{
public bool IsPartial =>
Steps.Any(x => x.Status == StepStatus.Failed) &&
Steps.Any(x => x.Status == StepStatus.Succeeded);
}
这样一个窗口动作失败时,不必阻止其它独立资源继续处理;最终也可以明确区分全部完成、部分完成和未执行。对于可能影响未保存工作的动作,则应由用户预先选择并得到清楚提示,不能因为前一步失败而自动升级成更激进的动作。
▌一条完整的数据流
把上面的步骤放在一起,完整流程如下:
设备 / 输入 / 摄像头原始状态
↓
统一 TriggerSignal
↓
来源专属确认与候选撤销
↓
ConfirmedTrigger
↓
聚合原因 + 固化计划快照
↓
SingleFlight
↓
逐项幂等执行并记录结果
↓
全部完成 / 部分完成 / 未执行
这套设计的价值不在于让代码显得复杂,而是把复杂度放到正确的位置:监听器只产生事实,确认器过滤抖动,聚合器解释多个事实是否属于同一次事件,执行器只处理已经固化的计划。
▌为什么这和真实使用场景有关
有人离开座位时,设备信号不会排队等程序处理。USB、蓝牙、键鼠状态或摄像头结果可能先后到达,也可能短暂恢复。如果每个入口都有一套独立动作,功能越多,重复和冲突反而越明显。
我们在开发超级看门狗 SuperWatchDog 时,需要同时考虑手动立即保护、快捷键、USB 拔出、蓝牙状态以及可选的手势和离席信号。这些入口的目的不是各自拥有一套“隐藏功能”,而是在用户提前选好保护方案后,从不同条件启动同一套保护思路,尽量同时照顾屏幕、指定窗口和本地私密文件。
它仍然需要用户先根据自己的环境测试触发条件,也不能替代保存工作、锁定会话等日常习惯。但从工程角度看,把多源回调收敛成一次可解释、可去重、可逐项记录的执行,比在每个回调里直接调用 ProtectNow() 更容易维护,也更符合突发场景的真实节奏。

作者声明本文存在利益相关性,请大家尊重作者及分享的内容,友善沟通,理性决策~
