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

8
lib/adaptive-poll.js Normal file
View File

@@ -0,0 +1,8 @@
'use strict';
function computePollMs(base, visible, { cap = 4000 } = {}) {
if (visible) return base;
return Math.min(base * 2, cap);
}
module.exports = { computePollMs };

31
lib/clip-payload.js Normal file
View File

@@ -0,0 +1,31 @@
'use strict';
/**
* 把一行 clips 记录转成"要写进系统剪贴板的东西"。
*
* 单独抽出来是因为这里踩过坑copyClipToClipboard 里读的是
* rowToClip() 的返回值上的 .image而 rowToClip() 只产出 imageBase64
* 于是 Buffer.from(undefined) 抛错 —— 图片条目的"复制"整个静默失败,
* 而且错误被 catch 吞掉,用户只会觉得"点了没反应"。
*
* 这一层不碰 electron可以直接单测。
*
* @param {object} row 来自 `SELECT * FROM clips`(必须含 image 列)
* @returns {{kind:'text', text:string} | {kind:'image', buffer:Buffer}}
* @throws {Error} row 缺失或图片数据为空时
*/
function clipPayload(row) {
if (!row) throw new Error('记录不存在');
if (row.type === 'text') {
return { kind: 'text', text: row.text || '' };
}
if (row.type === 'image') {
if (row.image == null) throw new Error('图片数据为空');
const buffer = Buffer.isBuffer(row.image) ? row.image : Buffer.from(row.image);
if (buffer.length === 0) throw new Error('图片数据为空');
return { kind: 'image', buffer };
}
throw new Error(`未知的记录类型: ${row.type}`);
}
module.exports = { clipPayload };

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 };

15
lib/favorites-cap.js Normal file
View File

@@ -0,0 +1,15 @@
'use strict';
const DEFAULT_MAX = 1000;
/**
* Returns { ok: true } if current row count < max, else { ok: false, message }.
* Pure: takes a db-like object so tests can mock.
*/
function checkAgainstCap(db, max = DEFAULT_MAX) {
const n = db.prepare('SELECT COUNT(*) AS n FROM favorites').get().n;
if (n < max) return { ok: true };
return { ok: false, message: `已达收藏上限 ${max}` };
}
module.exports = { checkAgainstCap, DEFAULT_MAX };

39
lib/image-cap.js Normal file
View File

@@ -0,0 +1,39 @@
'use strict';
const { nativeImage } = require('electron');
const PREVIEW_BYTES = 12 * 1024; // >12 KB → needs resizing for inline preview
const SOFT_CAP_BYTES = 2 * 1024 * 1024; // 2 MB
const RESIZE_WIDTH = 800; // 大图压到这个宽度,保证 SOFT_CAP_BYTES 不爆
/**
* Returns { buf, preview }.
* - buf: input buffer unchanged.
* - preview: null if the buffer is < PREVIEW_BYTES (12 KB), otherwise the
* literal string '[图片]'. The caller (main.js) is responsible for
* actually resizing when buf.length > SOFT_CAP_BYTES (2 MB); the
* preview marker is purely a signal that the buffer is non-trivial.
*/
function capImage(buf) {
const b = Buffer.isBuffer(buf) ? buf : Buffer.from(buf || []);
if (b.length < PREVIEW_BYTES) return { buf: b, preview: null };
return { buf: b, preview: '[图片]' };
}
/**
* 把图片 buffer 按 SOFT_CAP_BYTES 收纳:超出的图压成 RESIZE_WIDTH 宽。
* 三处共用pollClipboard / bringFavoriteIntoHistory。
*
* 返回 { buf, preview }preview 由 capImage 给出(无图/小图 = null
* 给数据库的 preview 列用。
*/
function normalizeImageForStorage(buf) {
const cap = capImage(buf);
// 小图/空图:直接原样入库
if (!cap.preview) return cap;
if (cap.buf.length <= SOFT_CAP_BYTES) return cap;
// 大图nativeImage 重新编码到 RESIZE_WIDTH 宽PNG 重编码比原图小一个数量级
const img = nativeImage.createFromBuffer(cap.buf);
return { buf: img.resize({ width: RESIZE_WIDTH }).toPNG(), preview: cap.preview };
}
module.exports = { capImage, normalizeImageForStorage, PREVIEW_BYTES, SOFT_CAP_BYTES, RESIZE_WIDTH };

8
lib/log.js Normal file
View File

@@ -0,0 +1,8 @@
'use strict';
const on = process.env.CB_LOG === '1';
function devLog(...args) { if (on) console.log(...args); }
function devWarn(...args) { if (on) console.warn(...args); }
function devError(...args) { console.error(...args); } // errors always log
module.exports = { devLog, devWarn, devError, isDev: on };

98
lib/migrate.js Normal file
View File

@@ -0,0 +1,98 @@
'use strict';
const { devWarn } = require('./log');
// clips -> clips_fts 的同步触发器。v2 / v3 共用。
const FTS_TRIGGERS = `
CREATE TRIGGER IF NOT EXISTS clips_ai AFTER INSERT ON clips BEGIN
INSERT INTO clips_fts(rowid, text) VALUES (new.id, new.text);
END;
CREATE TRIGGER IF NOT EXISTS clips_ad AFTER DELETE ON clips BEGIN
INSERT INTO clips_fts(clips_fts, rowid, text) VALUES('delete', old.id, old.text);
END;
-- 只在 text 真的变了才重建索引。没有这个 WHEN 的话,每次
-- "UPDATE clips SET top_at = ?"(也就是每次复制/置顶)都会白白
-- 把该行从 FTS 删掉再插回去。
CREATE TRIGGER IF NOT EXISTS clips_au AFTER UPDATE ON clips
WHEN old.text IS NOT new.text BEGIN
INSERT INTO clips_fts(clips_fts, rowid, text) VALUES('delete', old.id, old.text);
INSERT INTO clips_fts(rowid, text) VALUES (new.id, new.text);
END;
`;
// STEPS[i] upgrades the schema from version (i + 1) to (i + 2).
// STEPS[0] therefore is the v1 -> v2 step.
const STEPS = [
// v1 -> v2: add FTS5 table + triggers (idempotent).
function v1_to_v2(db) {
db.exec(`
CREATE VIRTUAL TABLE IF NOT EXISTS clips_fts USING fts5(text, content='clips', content_rowid='id');
${FTS_TRIGGERS}
INSERT INTO clips_fts(rowid, text) SELECT id, text FROM clips WHERE text IS NOT NULL;
`);
},
// v2 -> v3: 换成 trigram 分词器。
//
// v2 用的是默认的 unicode61 分词器,它把一整串连续的中文当成**一个** token
// 而 FTS5 的 MATCH 是整 token 匹配、不做子串匹配。结果:搜"公园"在
// "今天去公园散步"里一条都搜不到 —— 对一个中文剪贴板工具来说等于搜索全废。
// v2 这一步之前因为索引下标算错从没真正执行过,所以线上一直悄悄退回 LIKE
// 反而"正好"是对的;把 v2 修好之后这个坑才会暴露出来。)
//
// trigram 分词器按 3 字符滑窗切分,支持子串匹配,中文可用。
// 代价≤2 字符的查询它匹配不到,这部分由调用方退回 LIKE 处理。
function v2_to_v3(db) {
db.exec(`
DROP TRIGGER IF EXISTS clips_ai;
DROP TRIGGER IF EXISTS clips_ad;
DROP TRIGGER IF EXISTS clips_au;
DROP TABLE IF EXISTS clips_fts;
CREATE VIRTUAL TABLE clips_fts USING fts5(
text, content='clips', content_rowid='id', tokenize='trigram'
);
${FTS_TRIGGERS}
INSERT INTO clips_fts(rowid, text) SELECT id, text FROM clips WHERE text IS NOT NULL;
`);
},
];
/**
* Applies each pending schema step in order.
*
* The DB's own `PRAGMA user_version` is the source of truth for what has already
* been applied — not the config file, which only records the version the app
* *wants*. Passing a config-derived `fromVersion` would re-run every step on
* every launch (re-indexing the whole FTS table each time the app starts).
*
* Returns the version actually reached, so callers can tell whether a step was
* skipped because of an error.
*/
function migrate(db, fromVersion, toVersion) {
let current = fromVersion;
try {
const row = db.pragma('user_version', { simple: true });
if (typeof row === 'number' && row > 0) current = row;
} catch (e) {
// Non-sqlite stub (tests) — fall back to the caller-supplied version.
}
for (let v = current + 1; v <= toVersion; v++) {
const step = STEPS[v - 2];
if (!step) continue;
try {
step(db);
current = v;
try { db.pragma(`user_version = ${v}`); } catch (e) {}
} catch (e) {
// Most likely FTS5 is not compiled into this SQLite build. Leave the
// version marker where it is so a later launch can retry, and let the
// caller fall back to LIKE-based search.
devWarn('[migrate] step', v, 'failed, leaving schema at v' + current + ':', e.message);
break;
}
}
return current;
}
module.exports = { migrate, STEPS };

21
lib/notify-fanout.js Normal file
View File

@@ -0,0 +1,21 @@
'use strict';
function createSubscribers() {
const clip = new Set();
const fav = new Set();
const theme = new Set();
return {
addClip: (wc) => clip.add(wc),
addFav: (wc) => fav.add(wc),
addTheme: (wc) => theme.add(wc),
drop: (wc) => { clip.delete(wc); fav.delete(wc); theme.delete(wc); },
notifyClip: (ch, payload) => { for (const wc of clip) try { wc.send(ch, payload); } catch {} },
notifyFav: (ch, payload) => { for (const wc of fav) try { wc.send(ch, payload); } catch {} },
notifyTheme: (ch, payload) => { for (const wc of theme) try { wc.send(ch, payload); } catch {} },
get clip() { return clip; },
get fav() { return fav; },
get theme() { return theme; },
};
}
module.exports = { createSubscribers };

25
lib/poll-guard.js Normal file
View File

@@ -0,0 +1,25 @@
'use strict';
/**
* 轮询守卫:决定本 tick 是否进入图片分支。
*
* 背景:默认 pollClipboard 每 tick 都跑 `clipboard.readImage().getBitmap()`
* + sha1剪贴板上有 4K 截图时约 33 MB memcpy + ~40ms。先用 availableFormats()
* 探一下,文本剪贴板直接跳过图片分支,零开销。
*
* 已知残留:当剪贴板确实有大图时仍然每 tick 重哈希。彻底解决需要启发式
* 风险(尺寸相同可能漏抓变更)或订阅 clipboard-sequence-numberElectron
* 不暴露)。本次只做最稳妥的格式守卫。
*
* @param {unknown} formats 来自 clipboard.availableFormats() 的列表
* @returns {boolean} 是否需要读图片
*/
function shouldReadImage(formats) {
if (!Array.isArray(formats)) return false;
for (const f of formats) {
if (typeof f === 'string' && f.startsWith('image/')) return true;
}
return false;
}
module.exports = { shouldReadImage };

44
lib/same-dir.js Normal file
View File

@@ -0,0 +1,44 @@
'use strict';
const path = require('node:path');
const fs = require('node:fs');
/**
* 把路径规整到 OS 真实指向symlink / 8.3 短名 / 盘符大小写都消除)。
* 失败时退回到 path.resolve —— 比如路径根本不存在realpath 抛 ENOENT
* 用 resolve 至少能让 path.resolve(a) === path.resolve(b) 这条兜底成立。
*
* @param {string} p
* @returns {string}
*/
function real(p) {
try { return fs.realpathSync.native(p); } catch (_) { return path.resolve(p); }
}
/**
* 判断两个路径是否指向同一个目录。
*
* Windows 文件系统大小写不敏感:`D:\Clipboard Data` 与 `d:\clipboard data`
* 是同一个目录,必须按小写比较。原始 `path.resolve(a) === path.resolve(b)`
* 在大小写不一致时会误判为不同目录 —— settings:choose-dir 里这会让用户
* 重选当前目录时绕过「已经是当前数据位置」提示,弹出「目标已有数据」
* 迁移对话框自己迁自己。
*
* 此外 NTFS 还做 Unicode-normalization 不敏感:`café` 的 NFC / NFD 两种写法
* 指向同一文件。光是 toLowerCase 比较会把两种写法判成不同,触发自我迁移。
* 走 realpathSync.native 让 OS 自己解析到内部规范形式再比较。
*
* POSIX 大小写敏感、symlink 敏感:严格比较。
*
* @param {string} a
* @param {string} b
* @returns {boolean}
*/
function isSameDir(a, b) {
const ra = real(a);
const rb = real(b);
return process.platform === 'win32'
? ra.toLowerCase() === rb.toLowerCase()
: ra === rb;
}
module.exports = { isSameDir };

58
lib/search-plan.js Normal file
View File

@@ -0,0 +1,58 @@
'use strict';
// trigram 分词器按 3 字符滑窗建索引,因此**任何**短于 3 字符的词都匹配不到。
const TRIGRAM_MIN = 3;
/**
* 转义 LIKE 元字符(% _ \),供 `LIKE ? ESCAPE '\'` 使用。
* 用户输入里的 % / _ 不转义会被当通配符:搜 "100%" 变成匹配一切以 100 开头的串。
*/
function escapeLike(s) {
return String(s).replace(/[\\%_]/g, (c) => '\\' + c);
}
/**
* 把查询拆成「每个 token 一个 LIKE 模式」的数组(已转义)。
* 多 token 是 AND 关系 —— 与 FTS MATCH 多短语的语义对齐,
* 且各 token 独立匹配,不要求原文里连空格一起逐字出现。
*
* 空查询返回空数组(调用方按"列出全部"处理)。
*/
function likePatterns(query) {
const trimmed = String(query == null ? '' : query).trim();
if (!trimmed) return [];
return trimmed.split(/\s+/).filter(Boolean).map((tok) => `%${escapeLike(tok)}%`);
}
/**
* 决定一个搜索词该走 FTS 还是退回 LIKE并给出安全转义后的 FTS 查询串。
*
* 背景clips_fts 用的是 trigram 分词器(见 lib/migrate.js 的 v2->v3
* 选它是因为默认的 unicode61 会把一整串中文当成一个 token导致
* "公园" 搜不到 "今天去公园散步" —— 中文场景下搜索基本全废。
* trigram 解决了子串匹配,但代价是 ≤2 字符的词一条也匹配不到,
* 所以这类查询必须退回 LIKE"公园""会议""复制" 这种两字词非常常见)。
*
* 另外,用户输入直接塞进 MATCH 会因 FTS5 保留字符(- " ( ) * ^ :)报语法错误,
* 所以每个 token 都要包成 FTS5 字符串字面量。
*
* @param {string} query 用户原始输入
* @returns {{mode:'all'} | {mode:'like', patterns:string[]} | {mode:'fts', match:string}}
*/
function planSearch(query) {
const patterns = likePatterns(query);
if (patterns.length === 0) return { mode: 'all' };
// 任何一个 token 太短 → 整个 MATCH 都会返回空(多 token 是 AND 关系),
// 所以只要有一个短 token 就整体退回 LIKE。
// 注意用 [...tok] 按码点算长度,别用 .length代理对会算成 2
const trimmed = String(query == null ? '' : query).trim();
const tokens = trimmed.split(/\s+/).filter(Boolean);
const tooShort = tokens.some((tok) => [...tok].length < TRIGRAM_MIN);
if (tooShort) return { mode: 'like', patterns };
const match = tokens.map((tok) => `"${tok.replace(/"/g, '""')}"`).join(' ');
return { mode: 'fts', match };
}
module.exports = { planSearch, likePatterns, escapeLike, TRIGRAM_MIN };

37
lib/settings-handlers.js Normal file
View File

@@ -0,0 +1,37 @@
'use strict';
const cfg = require('../config.js');
const SETTINGS_HANDLERS = {
autoLaunch: {
// 宽松强制转换:布尔、真值字符串('true' / 'yes'、1 都视为开启
apply: (cur, v) => cfg.sanitize({ ...cur, autoLaunch: Boolean(v) }),
},
maxItems: {
apply: (cur, v) => cfg.sanitize({ ...cur, maxItems: v }),
},
displayLimit: {
// 界面显示条数 —— 只影响渲染层,不动数据库。运行时改完要通知所有窗口
// 重拉preload 的默认值才会跟着变(见 main.js settings:set 分支)。
apply: (cur, v) => cfg.sanitize({ ...cur, displayLimit: v }),
},
pollMs: {
apply: (cur, v) => cfg.sanitize({ ...cur, pollMs: v }),
},
hotkey: {
// 预设白名单校验在 cfg.sanitize 里(非法值回落默认 Ctrl+Alt+V
// 运行时的注册/解绑在 main.js settings:set 的 hotkey 分支,
// 那里才拿得到 globalShortcut。
apply: (cur, v) => cfg.sanitize({ ...cur, hotkey: v }),
},
// dataDir 刻意没有 handler更换目录必须走 settings:choose-dir
// (关库 → 搬文件 → 校验 → 改配置 → 重启)。只在这里改 config.dataDir
// 运行中的数据库连接仍指向旧目录 —— 内存与落盘分叉,重启后数据"消失"。
};
function applyPatch(current, { key, value }) {
const h = SETTINGS_HANDLERS[key];
if (!h) return { ...current };
return h.apply(current, value);
}
module.exports = { SETTINGS_HANDLERS, applyPatch };