This commit is contained in:
2026-09-12 13:59:12 +08:00
commit 941ce4baab
71 changed files with 13037 additions and 0 deletions

87
lib/echo-suppress.js Normal file
View File

@@ -0,0 +1,87 @@
'use strict';
/**
* 剪贴板"回声抑制"状态机 —— 区分"应用自己写进剪贴板的内容"与"外部新复制"。
*
* 纯逻辑,不依赖 Electronmain.js 的 pollClipboard / writePayloadToClipboard /
* initializeClipboardState 都用它。抽出来之前这份逻辑内联在 main.js 里,
* 测试只能克隆一份状态机自己测自己main.js 改坏了测试照样绿)—— 而这是
* 丢数据级的逻辑,必须有真保护。规则都从真实踩坑里固化,改动前先看
* test/echo-suppress.test.js。
*
* 规则:
* - 记**内容**而不是记 flag记下我们写进去的内容下一次轮询真正读到同样
* 内容时才吞。占位 flag 会被"两次 tick 之间夹的外部复制"错误消费,
* 把那次外部复制静默吞掉(丢数据)。
* - 读到不匹配的新内容时顺手清掉 pending避免它误伤之后内容相同的外部
* 复制(点复制 A → 外部复制 B → 外部又复制回 A第二个 A 必须入库)。
* - 文本被清空(''*不* 消化 pending外部中途清空剪贴板不该让回声标记
* 失效,否则用户接着复制同内容会重复入库。
* - 基线last随每次"变化"更新;自己写入时同时预置 pending 和 last
* 让下一个 tick 走"无变化"分支,连读图/编码的代价都不付。
* 为此 markOwnWrite 返回一个 undo 闭包:**写入抛错时必须调用它**——
* 失败时剪贴板上还是旧内容,基线却已预置成新值,不回滚的话下个 tick
* 会把真实的旧内容当成"新变化"再入一次库(旧内容早已在库里 → 重复条目)。
*/
function createEchoSuppress() {
let last = null; // 上次见到的内容变化检测基线null = 还没见过
let pending = null; // 我们自己写进去、等着"被读到"后吞掉的内容null = 无
return {
/** 启动采样:记住当前剪贴板内容当基线,不当新内容、不标记回声。 */
seed(value) { last = value; },
/**
* 应用自己写剪贴板:预置基线(下一 tick 判"无变化"+ 记下回声内容。
* 返回 undo 闭包实际写入clipboard.writeText / writeImage抛错时
* 调用它以回滚基线、清掉回声标记(见文件头最后一条规则)。
*/
markOwnWrite(value) {
const prevLast = last;
pending = value;
last = value;
return () => { pending = null; last = prevLast; };
},
/** 写入失败后调用:清回声标记,别吞掉用户接下来的真实复制。 */
clearPending() { pending = null; },
/** 1×1/空图出现时重置基线poll 侧按"没东西"处理,下一次任何图都算新)。 */
resetBaseline() { last = null; },
/**
* pollClipboard 入库失败时调用:撤销上一次 observe 对基线/pending 的修改,
* 让下一次 tick 重新探测同一条内容。
*
* 背景observe 一旦判定 'new',就已经把 last 设成新值;如果跟着的
* stmt.insertText / insertImage 抛错SQLite 临时锁、磁盘满、字段非法……),
* 不回滚的话下一次 tick 读到同一条内容会判 'unchanged'**那条内容被
* 永久吞掉** —— 直到用户重启initializeClipboardState 重新 seed 基线)
* 才能恢复。回滚后下次 tick 会再判 'new' 再试一次入库,符合预期。
*
* 一次 observe 只对应一次入库尝试last 在入库失败之前已被 observe 改过,
* 但 pending 通常仍是 null自己写入是 markOwnWrite 走的另一条路)——
* 不区分地统一置空足够,逻辑简单也避免回退到 observe 之前的精确快照。
*/
abortLastObserve() { last = null; pending = null; },
/**
* 喂入一次轮询读到的内容。
* @returns {'unchanged'|'empty'|'suppressed'|'new'}
* unchanged —— 与基线相同(文本:调用方继续图片分支;图片:直接 return
* empty —— 空串,剪贴板被清空(仅文本;不消化 pending
* suppressed —— 是我们自己写进去的回声,已吞
* new —— 外部新内容,调用方应入库
*/
observe(value) {
if (value === last) return 'unchanged';
last = value;
if (value === '') return 'empty';
if (pending !== null && value === pending) { pending = null; return 'suppressed'; }
// 不匹配的新内容顺手消化 pending见文件头规则 2
if (pending !== null) pending = null;
return 'new';
},
};
}
module.exports = { createEchoSuppress };