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

2
.gitignore vendored Normal file
View File

@@ -0,0 +1,2 @@
node_modules/
dist/

99
README.md Normal file
View File

@@ -0,0 +1,99 @@
# 剪贴板
基于 Electron 的轻量剪贴板管理器,灵感来自 [Ditto](https://github.com/sabrogden/Ditto)。
## 快速开始
环境要求Windows 10 / 11 · Node.js ≥ 18
```bash
npm install
npm start
```
启动后程序常驻系统托盘。复制任意文本或图片即自动记录;单击托盘图标唤出窗口,选中条目后双击或按回车写入系统剪贴板,再切到目标窗口 `Ctrl+V` 粘贴。
如安装时 `better-sqlite3` 编译失败,可安装 [Visual Studio Build Tools](https://visualstudio.microsoft.com/visual-cpp-build-tools/) 后执行 `npm run rebuild`
## 功能
- **自动记录**:默认每秒轮询系统剪贴板,文本和图片都自动入库
- **收藏**:独立 Tab 永久存放,源记录被删除后仍可复制,上限 1000 条
- **搜索**:实时过滤文本,支持中文子串
- **置顶**:点过「复制」的项自动置顶,避免被自动清理或「清空历史」误删
- **全局唤起**:默认 `Ctrl+Alt+V`,设置里可改或禁用
- **主题**6 套主题 + 5 种强调色,支持跟随系统
- **开机自启动**、单实例、窗口置顶切换
## 快捷键
全局:默认 `Ctrl+Alt+V` 唤起或隐藏历史窗口(设置中可更改或禁用)。
主窗口内:
| 快捷键 | 行为 |
| --- | --- |
| `↑` `↓` | 上下选择 |
| `PageUp` `PageDown` | 翻页 |
| `Home` `End` | 跳到首项或末项 |
| `Enter` | 复制当前选中项 |
| `1` ~ `9` | 直接复制对应项 |
| `Ctrl+L` | 聚焦搜索框 |
| `F5` | 刷新列表 |
| `Esc` | 清空搜索或隐藏窗口 |
| `F12` | 开发者工具 |
设置窗口:`←` / `→` 切换标签页,`Esc` 关闭。
## 数据存储
默认存放在 `D:\Clipboard Data`,独立于项目目录:
```
D:\Clipboard Data\
├── config.json
├── history.db
└── favorites.db
```
- D 盘不可用时自动降级到 `%USERPROFILE%\Clipboard Data`
- 设置 → 「更改…」可迁到其他位置;「打开文件夹」直达目录
- 启动前设 `CLIPBOARD_APP_DATA_DIR=D:\some\dir` 可临时覆盖
- 首次启动会自动从 `%USERPROFILE%\.clipboard-app` 迁移历史数据
## 开发
```bash
npm test # 单元测试(自动发现 test/*.test.js
npm run test:ui # UI 测试(需 Electron 窗口)
npm run test:all # 上面两套一起跑
npm run rebuild # 重新编译 better-sqlite3 到当前 Electron ABI
npm run build # 打包为 NSIS 安装包
npm run build:dir # 仅生成解压目录
```
测试由 `test/run.js` 自动发现,新增用例放入 `test/<name>.test.js` 即可。
## 常见问题
**点选条目后没有自动粘贴**
设计如此:只写入系统剪贴板,需手动 `Ctrl+V`。避免隐藏窗口导致的焦点闪烁。
**全局快捷键无效**
组合键被其他程序占用。在设置中更换档位,或释放占用方后重启。
**看不到托盘图标**
Windows 11 默认折叠托盘图标,需在「设置 → 系统 → 通知」中开启。
**数据库占用或损坏**
可直接删除 `D:\Clipboard Data` 下的 db 文件,重启后自动重建(配置和收藏数据除外)。
## 已知限制
- 不自动粘贴,必须手动 `Ctrl+V`
- 收藏上限 1000 条
## 致谢
- 灵感来自 [Ditto](https://github.com/sabrogden/Ditto)
- 开发者:[关济寰](https://www.guanjihuan.com/about)

247
config.js Normal file
View File

@@ -0,0 +1,247 @@
'use strict';
const fs = require('node:fs');
const os = require('node:os');
const path = require('node:path');
const { isSameDir } = require('./lib/same-dir');
const APP_DIRNAME = '.clipboard-app';
const DEFAULT_DATA_DIR = 'D:\\Clipboard Data';
// 默认目录不可用D 盘不存在 / 只读)时降级到的目录。
// 走 home 而不是 APPDATAAPPDATA 是 Roaming重装系统 / 换账号会丢;
// home 下的文件夹随账号走,更符合「剪贴板历史跟着用户走」的预期。
const FALLBACK_DATA_DIRNAME = 'Clipboard Data';
// v1.0 之前的数据目录:%USERPROFILE%\.clipboard-app
// 现在只作为"一次性迁移"的来源,不再是默认值。
function legacyDataDir() {
return path.join(os.homedir(), APP_DIRNAME);
}
/**
* 探测某个目录能不能真正写入。
*
* `mkdirSync(p, { recursive: true })` 只能创建中间文件夹,盘符本身不存在
* (如没 D 盘的机器)会直接抛 ENOENT —— 这正是启动失败的原因。盘符存在
* 但只读CD-ROM、网络盘、权限不足时 mkdir 反而会过,必须再写一个探针
* 文件才能拦住。任一步抛错都判为不可用。
*
* 探针文件在 mkdir 成功后立即 unlink —— 不在用户目录下留垃圾。
* 抛错时已经半创建的祖先目录无伤大雅,留着下次还能复用。
*
* @param {string} dir
* @returns {boolean}
*/
function isDirUsable(dir) {
if (!dir) return false;
try {
fs.mkdirSync(dir, { recursive: true });
const probe = path.join(dir, '.clipboard-write-test');
fs.writeFileSync(probe, '');
fs.unlinkSync(probe);
return true;
} catch (_) {
return false;
}
}
// 默认数据目录固定在 D:\Clipboard Data。
// 选固定路径而不是「项目根/data」是为了
// 1. 装/卸/重装项目时数据不丢;
// 2. 项目目录可以放进云同步 / 临时盘而不被数据库占空间;
// 3. 重装系统前备份一个文件夹就够了。
// D 盘不一定每台机器都有笔记本、无光驱、D: 是不可写设备)——
// 不可用时降级到 home 下的 Clipboard Data保证首次启动一定成功。
// 想用别的位置:设置 → 「更换目录」,或在启动前设环境变量
// CLIPBOARD_APP_DATA_DIR测试也用它隔离
let _defaultDataDirCache = null;
function defaultDataDir() {
if (_defaultDataDirCache !== null) return _defaultDataDirCache;
const env = process.env.CLIPBOARD_APP_DATA_DIR;
if (typeof env === 'string' && env.trim()) {
_defaultDataDirCache = path.resolve(env.trim());
return _defaultDataDirCache;
}
_defaultDataDirCache = isDirUsable(DEFAULT_DATA_DIR)
? DEFAULT_DATA_DIR
: path.join(os.homedir(), FALLBACK_DATA_DIRNAME);
return _defaultDataDirCache;
}
const DEFAULTS = {
schemaVersion: 3,
autoLaunch: false,
maxItems: 0, // 0 = 不限制(数据库最大保留条数)
displayLimit: 300, // 界面(历史 / 收藏)一次渲染的条数上限
pollMs: 1000,
dataDir: defaultDataDir(),
theme: 'linear',
accent: 'purple',
followSystem: false,
alwaysOnTop: true, // 窗口置顶开关,默认开启
hotkey: 'Ctrl+Alt+V', // 全局唤起面板快捷键;'' = 禁用
};
const ALLOWED_MAX = [0, 200, 500, 1000]; // 0 = 不限制
// 界面显示条数:只作用于渲染层,不裁数据库。不给「不限制」档,
// 避免一次建几千个 DOM 节点导致首屏卡顿。
const ALLOWED_DISPLAY = [100, 300, 500, 1000];
const ALLOWED_POLL = [500, 1000, 2000]; // 默认 1000500/1000/2000 三档
const ALLOWED_THEMES = ['linear', 'midnight', 'carbon', 'solarized', 'github', 'light'];
const ALLOWED_ACCENTS = ['purple', 'blue', 'green', 'orange', 'red'];
// 全局唤起快捷键预设。'' = 禁用;其余必须是 Electron globalShortcut
// 能解析的 accelerator。给白名单而不是自由输入非法组合键 register
// 会抛异常,被别的程序占用的永远注册不上 —— 不如只给确定能用的。
// 默认 Ctrl+Alt+V 而不是 Ctrl+Shift+V —— 后者是浏览器 / VS Code 的
// 「粘贴为纯文本」,全局劫持掉会惹骂。
const ALLOWED_HOTKEYS = ['', 'Ctrl+Alt+V', 'Ctrl+Shift+V', 'Ctrl+Alt+C', 'Alt+Shift+V'];
// config.json 固定在默认目录,不随 dataDir 迁移
function getConfigPath() {
return path.join(defaultDataDir(), 'config.json');
}
function sanitize(raw) {
const r = raw && typeof raw === 'object' ? raw : {};
return {
autoLaunch: r.autoLaunch === true,
maxItems: ALLOWED_MAX.includes(r.maxItems) ? r.maxItems : DEFAULTS.maxItems,
displayLimit: ALLOWED_DISPLAY.includes(r.displayLimit) ? r.displayLimit : DEFAULTS.displayLimit,
pollMs: ALLOWED_POLL.includes(r.pollMs) ? r.pollMs : DEFAULTS.pollMs,
dataDir: (typeof r.dataDir === 'string' && r.dataDir.trim()) ? r.dataDir : defaultDataDir(),
theme: ALLOWED_THEMES.includes(r.theme) ? r.theme : DEFAULTS.theme,
accent: ALLOWED_ACCENTS.includes(r.accent) ? r.accent : DEFAULTS.accent,
followSystem: r.followSystem === true,
// 缺省视为开启 —— 老用户首次升上来也是"置顶"
alwaysOnTop: r.alwaysOnTop === undefined ? DEFAULTS.alwaysOnTop : r.alwaysOnTop === true,
// 老配置没有 hotkey 字段 → 回落默认(等于老用户升上来自动获得快捷键)
hotkey: ALLOWED_HOTKEYS.includes(r.hotkey) ? r.hotkey : DEFAULTS.hotkey,
schemaVersion: 3, // always forward-fill to current
};
}
function loadConfig() {
try {
const txt = fs.readFileSync(getConfigPath(), 'utf8');
return sanitize(JSON.parse(txt));
} catch (e) {
return sanitize({});
}
}
function saveConfig(cfg) {
const clean = sanitize(cfg);
const p = getConfigPath();
fs.mkdirSync(path.dirname(p), { recursive: true });
const tmp = p + '.tmp';
fs.writeFileSync(tmp, JSON.stringify(clean, null, 2), 'utf8');
fs.renameSync(tmp, p);
}
// 迁移时要搬的数据文件。
// -shm 不搬:它是 WAL 的共享内存索引SQLite 打开时会自己重建,
// 搬一个不匹配的过去反而更糟。db + -wal 一起搬SQLite 能自行恢复。
const DATA_FILES = ['history.db', 'history.db-wal', 'favorites.db', 'favorites.db-wal'];
/**
* 一次性迁移:把老目录(%USERPROFILE%\.clipboard-app或老 config.json 里
* 指向的自定义目录的数据复制到新的默认目录D:\Clipboard Data
*
* 只在新目录还是空的时候做,所以是幂等的;老文件保留不删,出问题可以回退。
* 复制而不是移动 —— 万一新位置有权限问题,老数据还在。
*
* 必须在拿到单实例锁之后调用:否则第二个实例会去复制正在被写的 WAL。
*
* @returns {{migrated: boolean, reason?: string, from?: string, to?: string, files?: string[]}}
*/
function migrateLegacyData() {
const target = defaultDataDir();
const legacy = legacyDataDir();
// Windows 大小写不敏感:同目录不同写法必须判为相同,否则会自己迁自己
if (isSameDir(target, legacy)) return { migrated: false, reason: 'same-dir' };
// 新目录已经在用了 → 不碰(只迁一次)
if (fs.existsSync(path.join(target, 'config.json')) || fs.existsSync(path.join(target, 'history.db'))) {
return { migrated: false, reason: 'target-in-use' };
}
let rawCfg = null;
try { rawCfg = JSON.parse(fs.readFileSync(path.join(legacy, 'config.json'), 'utf8')); }
catch (_) { rawCfg = null; }
const legacyCfg = rawCfg && typeof rawCfg === 'object' && !Array.isArray(rawCfg)
? sanitize(rawCfg)
: null;
// 老数据实际在哪:老 config 指到哪就从哪搬(用户可能手动换过目录),
// 没有 config 就按老默认目录找。dataDir 必须取**原始**值sanitize 会把
// 缺省字段补成新默认目录(= target拿它当 src 就成了"从 target 搬到
// target"—— 什么也没搬却报 migrated: true老数据被永久跳过。
let src = legacy;
if (legacyCfg && typeof rawCfg.dataDir === 'string' && rawCfg.dataDir.trim()) {
src = path.resolve(rawCfg.dataDir.trim());
}
const hasDb = DATA_FILES.some((n) => fs.existsSync(path.join(src, n)));
if (!hasDb && !legacyCfg) return { migrated: false, reason: 'nothing-to-migrate' };
fs.mkdirSync(target, { recursive: true });
const files = [];
for (const name of DATA_FILES) {
const from = path.join(src, name);
if (!fs.existsSync(from)) continue;
try {
fs.copyFileSync(from, path.join(target, name), fs.constants.COPYFILE_EXCL);
files.push(name);
} catch (_) { /* 单个文件搬不动不该拦住启动 */ }
}
// 设置也跟着搬,但 dataDir 必须落到新目录 —— 否则原样写回去,
// 下次启动又跑回老地方,等于没迁。
saveConfig({ ...(legacyCfg || {}), dataDir: target });
return { migrated: true, from: src, to: target, files };
}
/**
* 在「跟随系统」开启时,主题按系统明暗二选一;关闭时直接用用户选的 theme。
*
* 这个判断在主进程broadcastTheme / theme:get / applyThemeChange和潜在的
* 测试里都会用到集中到这里避免重复实现又跑偏。注意raw 字段 cfg.theme
* 仍然要保留 —— 用户关掉「跟随系统」后要回到他之前选的那个主题。
*
* 旧版把这段逻辑直接内联在 main.js 的 broadcastTheme / IPC 处理器里,
* 跟「跟随系统」开关的 IPC 顺序竞争applyThemeChange 在 broadcast 之后
* 把 raw theme 写回 res.themesettings.js 拿到后用 res.theme 设
* data-theme —— 覆盖了刚刚广播的 effective 主题,开关「跟随系统」后
* 设置窗口主题卡的高亮和 DOM 不一致。修复方式见 main.js / settings.js。
*
* @param {{theme: string, followSystem: boolean}} cfg
* @param {boolean} sysDark 当前系统是否处于深色模式
* @returns {string}
*/
function effectiveTheme(cfg, sysDark) {
if (!cfg || !cfg.followSystem) return cfg && cfg.theme ? cfg.theme : DEFAULTS.theme;
return sysDark ? 'linear' : 'light';
}
/**
* 把每个 theme 映射到对应的窗口首帧背景色。
* 用法createFramelessWindow({ backgroundColor: themeBgColor(appConfig.theme) })
* backgroundColor 只影响首帧;切主题后整个渲染进程重绘,无需重新设置。
* 未知 theme 兜底到 light 背景(与 tokens.css 的 :root --bg-base 一致)。
*/
function themeBgColor(theme) {
const map = {
linear: '#0E0E12',
midnight: '#08090B',
carbon: '#1A1D23',
solarized: '#002B36',
github: '#0D1117',
light: '#FAFAFA',
};
return map[theme] || '#FAFAFA';
}
module.exports = {
DEFAULTS, ALLOWED_MAX, ALLOWED_DISPLAY, ALLOWED_POLL, ALLOWED_THEMES, ALLOWED_ACCENTS,
ALLOWED_HOTKEYS,
isDirUsable, defaultDataDir, legacyDataDir, getConfigPath, sanitize, loadConfig, saveConfig,
migrateLegacyData, themeBgColor, effectiveTheme,
};

134
favorites.js Normal file
View File

@@ -0,0 +1,134 @@
'use strict';
const Database = require('better-sqlite3');
const { checkAgainstCap } = require('./lib/favorites-cap');
const { likePatterns } = require('./lib/search-plan');
function open(dbPath) {
const db = new Database(dbPath);
db.pragma('journal_mode = WAL');
db.pragma('synchronous = NORMAL');
db.exec(`
CREATE TABLE IF NOT EXISTS favorites (
id INTEGER PRIMARY KEY AUTOINCREMENT,
type TEXT NOT NULL,
text TEXT,
image BLOB,
preview TEXT,
source_clip_id INTEGER,
created_at REAL NOT NULL,
favorited_at REAL NOT NULL,
UNIQUE(source_clip_id)
);
CREATE INDEX IF NOT EXISTS idx_fav_favorited_at ON favorites(favorited_at DESC);
`);
return db;
}
function close(db) {
if (db) db.close();
}
// 列表不取 image BLOB —— 图片走 getImage() 按需拉。
const LIST_COLS = `id, type, text, preview, source_clip_id, created_at, favorited_at,
(image IS NOT NULL) AS has_image`;
// 每个连接一套预编译语句,避免每次调用都重新 prepare
function stmts(db) {
if (!db._stmt) {
db._stmt = {
insert: db.prepare(`
INSERT INTO favorites(type, text, image, preview, source_clip_id, created_at, favorited_at)
VALUES(?, ?, ?, ?, ?, ?, ?)
`),
getMeta: db.prepare(`SELECT ${LIST_COLS} FROM favorites WHERE id = ?`),
getImage: db.prepare('SELECT image FROM favorites WHERE id = ?'),
getRaw: db.prepare('SELECT * FROM favorites WHERE id = ?'),
bySource: db.prepare('SELECT id FROM favorites WHERE source_clip_id = ?'),
remove: db.prepare('DELETE FROM favorites WHERE id = ?'),
count: db.prepare('SELECT COUNT(*) AS n FROM favorites'),
list: db.prepare(`SELECT ${LIST_COLS} FROM favorites ORDER BY favorited_at DESC LIMIT ?`),
};
}
return db._stmt;
}
function rowToClip(row) {
if (!row) return null;
return {
id: row.id,
type: row.type,
text: row.text || '',
hasImage: row.has_image != null ? row.has_image === 1 : row.image != null,
preview: row.preview || '',
sourceClipId: row.source_clip_id,
createdAt: row.created_at,
favoritedAt: row.favorited_at,
};
}
function add(db, sourceClip) {
const cap = checkAgainstCap(db);
if (!cap.ok) {
const err = new Error(cap.message);
err.ok = false; err.code = 'FAV_LIMIT';
throw err;
}
const now = Date.now() / 1000;
const info = stmts(db).insert.run(
sourceClip.type,
sourceClip.type === 'text' ? (sourceClip.text || '') : null,
sourceClip.type === 'image' ? (sourceClip.image || null) : null,
sourceClip.preview || '',
sourceClip.id,
sourceClip.createdAt || now,
now,
);
return rowToClip(stmts(db).getMeta.get(info.lastInsertRowid));
}
function isFavorited(db, clipId) {
const row = stmts(db).bySource.get(clipId);
return row ? row.id : null;
}
function remove(db, favId) {
return stmts(db).remove.run(favId).changes > 0;
}
function get(db, favId) {
return rowToClip(stmts(db).getMeta.get(favId));
}
function getImage(db, favId) {
const row = stmts(db).getImage.get(favId);
if (!row || !row.image) return null;
return Buffer.from(row.image).toString('base64');
}
// 原始行(含 image BLOB仅供主进程"复制收藏到系统剪贴板"用。
// 字段形状type / text / image与 clips 表一致,可直接喂 lib/clip-payload。
// 渲染进程不要用它 —— 图片走 getImage() 按需拉。
function getRaw(db, favId) {
return stmts(db).getRaw.get(favId) || null;
}
function count(db) {
return stmts(db).count.get().n;
}
function list(db, query = '', limit = 5000) {
const s = stmts(db);
// 与历史搜索同语义:每个 token 独立子串 AND% _ \ 已转义lib/search-plan
// 不转义时搜 "100%" 里的 % 会当通配符命中一片无关记录。
const patterns = likePatterns(query);
if (!patterns.length) return s.list.all(limit).map(rowToClip);
const where = patterns.map(() => `text LIKE ? ESCAPE '\\'`).join(' AND ');
const rows = db.prepare(
`SELECT ${LIST_COLS} FROM favorites WHERE ${where} ORDER BY favorited_at DESC LIMIT ?`
).all(...patterns, limit);
return rows.map(rowToClip);
}
module.exports = {
open, close, rowToClip, add, isFavorited, remove, get, getImage, getRaw, count, list,
};

BIN
icon.ico Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 40 KiB

174
keyhelper.js Normal file
View File

@@ -0,0 +1,174 @@
/**
* keyhelper.js — 长驻 PowerShell 子进程。
*
* ⚠ 非生产代码 —— 当前没有任何主进程路径 require 它或调用它。
* 历史:早期版本的「点选一条 → 自动粘贴到目标窗口」功能走这条路,
* 需要 PowerShell 用 SendKeys 发 Ctrl+V。在 2026-07-30 那一轮把
* 自动粘贴整体移除了(用户明确不想要自动粘贴触发的「窗口缩一下」
* 动画),主进程只剩手动 Ctrl+V 这条路。
*
* 用途(保留作备用 / bug 复现):
* - 在 Windows 上发 Ctrl+V避开 robotjs/nut-js 之类的原生模块编译)
* - 通过 stdin/stdout JSON-RPC 与主进程通信,单进程常驻,延迟 ~5ms
*
* 不在 package.json 的 build.files 里 —— 打包后不会进用户机器。
* 想恢复自动粘贴:把 require('./keyhelper') 加回 main.js把发键的
* 逻辑塞到 clips:movetop 处理器的"clipboard.writeText 之后、IPC 返回
* 之前"那段窗口隐藏逻辑即可(参见 README「不自动粘贴」说明
*
* 协议:
* in : {"id":1,"action":"sendCtrlV"}
* out: {"id":1,"ok":true}
*/
'use strict';
const { spawn } = require('node:child_process');
const PS_SCRIPT = String.raw`
$ErrorActionPreference = 'Stop'
Add-Type -AssemblyName System.Windows.Forms
while ($true) {
$line = [Console]::In.ReadLine()
if ([string]::IsNullOrEmpty($line)) { continue }
try {
$req = $line | ConvertFrom-Json
$id = $req.id
switch ($req.action) {
'sendCtrlV' {
[System.Windows.Forms.SendKeys]::SendWait('^v')
[Console]::Out.WriteLine(('{"id":' + $id + ',"ok":true}'))
}
default {
[Console]::Out.WriteLine(('{"id":' + $id + ',"ok":false,"error":"unknown action"}'))
}
}
} catch {
[Console]::Out.WriteLine(('{"id":-1,"ok":false,"error":"' + ($_.Exception.Message -replace '"','\\"') + '"}'))
}
}
`;
class KeyHelper {
constructor() {
this.id = 0;
this.pending = new Map();
this.buffer = '';
this._reconnectTimer = null;
this.disposed = false;
this._spawn();
}
_spawn() {
this.ps = spawn(
'powershell.exe',
['-NoProfile', '-ExecutionPolicy', 'Bypass', '-Command', PS_SCRIPT],
{ stdio: ['pipe', 'pipe', 'pipe'], windowsHide: true }
);
this.ps.stdout.on('data', (chunk) => this._onStdout(chunk));
this.ps.stderr.on('data', (chunk) => {
console.error('[keyhelper stderr]', chunk.toString());
});
this.ps.on('exit', (code) => {
console.error('[keyhelper] powershell exited with code', code);
this._rejectAll(new Error('keyhelper exited'));
this._reconnect();
});
}
_reconnect(retryMs = 500) {
if (this.disposed || this._reconnectTimer) return;
const tryOnce = () => {
this._reconnectTimer = null;
if (this.disposed) return;
try {
this._spawn();
} catch (e) {
console.error('[keyhelper] respawn failed:', e.message);
this._reconnectTimer = setTimeout(tryOnce, Math.min(retryMs * 2, 8000));
}
};
this._reconnectTimer = setTimeout(tryOnce, retryMs);
}
_onStdout(chunk) {
this.buffer += chunk.toString('utf8');
let nl;
while ((nl = this.buffer.indexOf('\n')) >= 0) {
const line = this.buffer.slice(0, nl).trim();
this.buffer = this.buffer.slice(nl + 1);
if (!line) continue;
try {
const resp = JSON.parse(line);
if (resp.id === -1) {
console.error('[keyhelper error]', resp.error);
continue;
}
const cb = this.pending.get(resp.id);
if (cb) {
this.pending.delete(resp.id);
cb.resolve(resp);
}
} catch (e) {
// ignore parse error
}
}
}
_send(action, params = {}, timeoutMs = 10000) {
return new Promise((resolve, reject) => {
if (!this.ps || !this.ps.stdin || this.ps.stdin.destroyed) {
return reject(new Error('keyhelper not running'));
}
const id = ++this.id;
let settled = false;
const t = setTimeout(() => {
if (settled) return;
settled = true;
this.pending.delete(id);
reject(new Error('keyhelper timeout'));
}, timeoutMs);
// 定时器句柄要存下来dispose() / 进程退出时必须能取消,
// 否则挂起的请求会在 10 秒后抛一个没人接的 rejection
// 同时这个 timer 还会一直吊着事件循环。
this.pending.set(id, {
timer: t,
resolve: (v) => { if (settled) return; settled = true; clearTimeout(t); resolve(v); },
reject: (e) => { if (settled) return; settled = true; clearTimeout(t); reject(e); },
});
try {
this.ps.stdin.write(JSON.stringify({ id, action, ...params }) + '\n');
} catch (e) {
if (settled) return;
settled = true;
clearTimeout(t);
this.pending.delete(id);
reject(e);
}
});
}
sendCtrlV() {
return this._send('sendCtrlV');
}
_rejectAll(err) {
for (const [, p] of this.pending) p.reject(err);
this.pending.clear();
}
dispose() {
this.disposed = true;
if (this._reconnectTimer) { clearTimeout(this._reconnectTimer); this._reconnectTimer = null; }
this._rejectAll(new Error('keyhelper disposed'));
if (this.ps) {
// 别再因为我们自己的 kill 触发重连
this.ps.removeAllListeners('exit');
try { this.ps.stdin.end(); } catch (e) {}
try { this.ps.kill(); } catch (e) {}
}
}
}
module.exports = KeyHelper;

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

1454
main.js Normal file

File diff suppressed because it is too large Load Diff

4419
package-lock.json generated Normal file

File diff suppressed because it is too large Load Diff

43
package.json Normal file
View File

@@ -0,0 +1,43 @@
{
"name": "clipboard-app",
"version": "1.0.0",
"description": "剪贴板 — 简洁的剪贴板管理器Electron 实现)",
"main": "main.js",
"scripts": {
"start": "set \"ELECTRON_RUN_AS_NODE=\" && electron .",
"test": "node test/run.js",
"test:ui": "set \"ELECTRON_RUN_AS_NODE=\" && electron test/settings-tabs.test.js && electron test/settings-render.test.js",
"test:all": "npm test && npm run test:ui",
"rebuild": "electron-rebuild -f -w better-sqlite3",
"postinstall": "electron-builder install-app-deps",
"build": "electron-builder",
"build:dir": "electron-builder --dir"
},
"dependencies": {
"better-sqlite3": "^11.10.0"
},
"devDependencies": {
"@electron/rebuild": "^3.7.0",
"electron": "^31.7.7",
"electron-builder": "^24.13.3"
},
"build": {
"appId": "com.clipboard.app",
"productName": "剪贴板",
"files": [
"main.js",
"config.js",
"tray-menu.js",
"preload.js",
"favorites.js",
"lib/**/*",
"renderer/**/*",
"icon.ico",
"package.json"
],
"win": {
"target": "nsis",
"icon": "icon.ico"
}
}
}

87
preload.js Normal file
View File

@@ -0,0 +1,87 @@
/**
* preload.js — contextBridge 暴露给渲染进程的安全 IPC API
*/
'use strict';
const { contextBridge, ipcRenderer } = require('electron');
contextBridge.exposeInMainWorld('api', {
// 数据查询(列表不含图片数据,图片走 getImage 按需拉)。
// limit 留空 → 主进程用配置里的 displayLimit默认 300改设置后下次拉就生效。
list: (query, limit) => ipcRenderer.invoke('clips:list', { query: query || '', limit: limit || 0 }),
getImage: (id) => ipcRenderer.invoke('clips:get-image', { id }),
count: () => ipcRenderer.invoke('clips:count'),
// 操作
movetop: (id) => ipcRenderer.invoke('clips:movetop', { id }),
remove: (id) => ipcRenderer.invoke('clips:delete', { id }),
hide: () => ipcRenderer.invoke('window:hide'),
openSettings: () => ipcRenderer.invoke('settings:open'),
// 窗口置顶
toggleAlwaysOnTop: (on) => ipcRenderer.invoke('window:toggle-always-on-top', { on }),
getAlwaysOnTop: () => ipcRenderer.invoke('window:get-always-on-top'),
// 应用图标(标题栏左上角小图标):返回 PNG data URL喂给 <img src>。
// 主进程会缓存 .ico→PNG 的转换结果,每个窗口首次调用一次即可。
getAppIcon: () => ipcRenderer.invoke('app:get-icon'),
// 订阅
subscribe: () => ipcRenderer.invoke('clips:subscribe'),
onAdded: (cb) => {
const handler = (_e, clip) => cb(clip);
ipcRenderer.on('clips:added', handler);
return () => ipcRenderer.removeListener('clips:added', handler);
},
onRefresh: (cb) => {
const handler = () => cb();
ipcRenderer.on('clips:refresh', handler);
return () => ipcRenderer.removeListener('clips:refresh', handler);
},
});
contextBridge.exposeInMainWorld('favoritesApi', {
add: (clipId) => ipcRenderer.invoke('favorites:add', { clipId }),
remove: (favId) => ipcRenderer.invoke('favorites:remove', { favId }),
// 收藏 Tab 的复制写收藏自身存的内容favId 与历史 id 不同库,不能走 movetop
copy: (favId) => ipcRenderer.invoke('favorites:copy', { favId }),
list: (query, limit) => ipcRenderer.invoke('favorites:list', { query: query || '', limit: limit || 0 }),
getImage: (favId) => ipcRenderer.invoke('favorites:get-image', { favId }),
count: () => ipcRenderer.invoke('favorites:count'),
subscribe: () => ipcRenderer.invoke('favorites:subscribe'),
onChanged: (cb) => {
const handler = () => cb();
ipcRenderer.on('favorites:changed', handler);
return () => ipcRenderer.removeListener('favorites:changed', handler);
},
});
contextBridge.exposeInMainWorld('settingsApi', {
get: () => ipcRenderer.invoke('settings:get'),
set: (key, value) => ipcRenderer.invoke('settings:set', { key, value }),
clearHistory: () => ipcRenderer.invoke('settings:clear-history'),
openDir: () => ipcRenderer.invoke('settings:open-dir'),
chooseDir: () => ipcRenderer.invoke('settings:choose-dir'),
// 外链交给系统浏览器关于页。scheme 白名单在主进程卡,渲染进程只管递 URL
openExternal: (url) => ipcRenderer.invoke('settings:open-external', { url }),
// 设置窗口订阅 displayLimit 变更,主进程设置被改后给所有窗口推一条刷新通知
onDisplayLimitChanged: (cb) => {
const handler = (_e, payload) => cb(payload);
ipcRenderer.on('settings:display-limit-changed', handler);
return () => ipcRenderer.removeListener('settings:display-limit-changed', handler);
},
});
contextBridge.exposeInMainWorld('themeApi', {
get: () => ipcRenderer.invoke('theme:get'),
set: (theme) => ipcRenderer.invoke('theme:set', { theme }),
setAccent: (accent) => ipcRenderer.invoke('theme:set-accent', { accent }),
setFollowSystem: (followSystem)=> ipcRenderer.invoke('theme:set-follow-system', { followSystem }),
// 必须先订阅,主进程才会把 theme:system-changed 发过来
subscribe: () => ipcRenderer.invoke('theme:subscribe'),
onSystemChanged: (cb) => {
const handler = (_e, payload) => cb(payload);
ipcRenderer.on('theme:system-changed', handler);
return () => ipcRenderer.removeListener('theme:system-changed', handler);
},
});

134
renderer/index.html Normal file
View File

@@ -0,0 +1,134 @@
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8" />
<meta http-equiv="Content-Security-Policy" content="default-src 'self'; style-src 'self' 'unsafe-inline'; img-src 'self' data:;" />
<title>剪贴板历史</title>
<link rel="stylesheet" href="themes/fonts.css" />
<link rel="stylesheet" href="themes/tokens.css" />
<link rel="stylesheet" href="themes/themes.css" />
<link rel="stylesheet" href="themes/accents.css" />
<link rel="stylesheet" href="styles.css" />
<script src="theme-boot.js"></script>
</head>
<body>
<div id="root">
<!-- 拖动区 -->
<header id="drag-bar">
<img id="app-icon" alt="" />
<h1 id="title">剪贴板历史</h1>
<span id="ipc-status" class="ipc-dot" title="IPC 订阅状态" aria-label="IPC 状态"></span>
<span class="spacer"></span>
<button id="refresh-btn" class="icon-btn" title="刷新 (F5)" aria-label="刷新">
<svg viewBox="0 0 16 16" width="14" height="14" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
<path d="M14 8a6 6 0 0 0-10.5-4L2 5.5"/>
<polyline points="2 2 2 5.5 5.5 5.5"/>
<path d="M2 8a6 6 0 0 0 10.5 4L14 10.5"/>
<polyline points="14 14 14 10.5 10.5 10.5"/>
</svg>
</button>
<button id="settings-btn" class="icon-btn" title="设置" aria-label="设置">
<svg viewBox="0 0 16 16" width="14" height="14" fill="none" stroke="currentColor" stroke-width="1.3" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
<path d="M12.33 10.5L14.4 9.1L14.4 6.9L12.33 5.5L12.2 3L10.2 1.9L8 3L5.8 1.9L3.8 3L3.7 5.5L1.6 6.9L1.6 9.1L3.7 10.5L3.8 13L5.8 14.1L8 13L10.2 14.1L12.2 13Z"/>
<circle cx="8" cy="8" r="2"/>
</svg>
</button>
<button id="pin-btn" class="icon-btn" title="窗口置顶" aria-label="窗口置顶" aria-pressed="true">
<svg viewBox="0 0 16 16" width="14" height="14" fill="none" stroke="currentColor" stroke-width="1.4" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
<line x1="8" y1="11.3" x2="8" y2="14.7"/>
<path d="M3.3 11.3h9.4v-1.2a1.3 1.3 0 0 0-.7-1.2l-1.2-.6a1.3 1.3 0 0 1-.7-1.2V4a1.7 1.7 0 0 0-3.4 0v3.1a1.3 1.3 0 0 1-.7 1.2l-1.2.6a1.3 1.3 0 0 0-.7 1.2Z"/>
</svg>
</button>
<button id="close-btn" class="icon-btn" title="关闭 (Esc)" aria-label="关闭">
<svg viewBox="0 0 16 16" width="14" height="14" fill="none" stroke="currentColor" stroke-width="1.6" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
<path d="M3.5 3.5l9 9M12.5 3.5l-9 9"/>
</svg>
</button>
</header>
<!-- Tab 栏 -->
<nav id="tabs">
<button class="tab active" data-tab="history" type="button">历史<span class="tab-count" id="history-count">0</span></button>
<button class="tab" data-tab="favorites" type="button">收藏<span class="tab-count" id="fav-count">0</span></button>
</nav>
<!-- 搜索 -->
<div id="search-row">
<div id="search-wrap">
<svg id="search-icon" viewBox="0 0 16 16" width="14" height="14" fill="none" stroke="currentColor" stroke-width="1.6" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
<circle cx="7" cy="7" r="4.5"/>
<path d="m13.5 13.5-3-3"/>
</svg>
<input id="search" type="text" placeholder="搜索剪贴板内容…" autocomplete="off" spellcheck="false" />
<button id="search-clear" type="button" title="清空搜索" aria-label="清空搜索">
<svg viewBox="0 0 16 16" width="12" height="12" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
<circle cx="8" cy="8" r="6"/>
<path d="M5.5 5.5l5 5M10.5 5.5l-5 5"/>
</svg>
</button>
</div>
</div>
<!-- 列表 -->
<main id="list-wrap">
<ul id="list"></ul>
<div id="empty" class="hidden">
<svg class="empty-icon" viewBox="0 0 64 64" width="56" height="56" fill="none" stroke="currentColor" stroke-width="1.6" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
<rect x="22" y="12" width="20" height="8" rx="2"/>
<rect x="14" y="18" width="36" height="34" rx="4"/>
<path d="M22 30h20M22 36h14M22 42h10"/>
</svg>
<p class="empty-title">还没有剪贴板记录</p>
<p class="muted">复制一段文本或图片,就会自动出现在这里</p>
</div>
</main>
<!-- 状态栏 -->
<footer id="status">
<span id="status-left">就绪</span>
<span class="shortcuts">
<kbd></kbd><kbd></kbd> 选择
<span class="dot">·</span>
<kbd>1</kbd><kbd>9</kbd> / <kbd>Enter</kbd> 复制
<span class="dot">·</span>
<kbd>Esc</kbd> 关闭
</span>
</footer>
</div>
<!-- 单条卡片模板 -->
<template id="card-tpl">
<li class="card" tabindex="0">
<div class="card-head">
<span class="slot hidden"></span>
<span class="time"></span>
<span class="badge type"></span>
<span class="spacer"></span>
<button class="action fav" title="收藏" aria-label="收藏">
<svg viewBox="0 0 16 16" width="14" height="14" fill="none" stroke="currentColor" stroke-width="1.4" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
<path d="M8 1.8l1.95 4.05 4.45.6-3.25 3.1.8 4.45L8 11.9l-3.95 2.1.8-4.45L1.6 6.45l4.45-.6z"/>
</svg>
</button>
<button class="action copy" title="复制 (Enter)" aria-label="复制">
<svg viewBox="0 0 16 16" width="13" height="13" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
<rect x="5" y="5" width="8" height="9" rx="1.5"/>
<path d="M3 11V3.5A1.5 1.5 0 0 1 4.5 2H10"/>
</svg>
<span>复制</span>
</button>
<button class="action delete" title="删除" aria-label="删除">
<svg viewBox="0 0 16 16" width="13" height="13" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
<path d="M3 4.5h10M6.5 4.5V3a1 1 0 0 1 1-1h1a1 1 0 0 1 1 1v1.5M4.5 4.5l.6 9a1.5 1.5 0 0 0 1.5 1.4h2.8a1.5 1.5 0 0 0 1.5-1.4l.6-9"/>
</svg>
</button>
</div>
<div class="card-body">
<p class="text-preview"></p>
<img class="image-preview hidden" alt="" />
</div>
</li>
</template>
<script src="renderer.js"></script>
</body>
</html>

713
renderer/renderer.js Normal file
View File

@@ -0,0 +1,713 @@
/**
* renderer.js — 历史窗口 UI 逻辑
*/
'use strict';
// 直接从 window.api 解构,避免 'api' 这个名字与 contextBridge 暴露的属性冲突
const {
list, count, movetop, remove, hide,
subscribe, onAdded, onRefresh, openSettings,
toggleAlwaysOnTop, getAlwaysOnTop, getImage,
} = window.api;
const {
add: favAdd,
remove: favRemove,
copy: favCopy,
list: favList,
count: favCountOf,
getImage: favGetImage,
subscribe: favSubscribe,
onChanged: onFavoritesChanged,
} = window.favoritesApi;
const { onDisplayLimitChanged, get: settingsGet } = window.settingsApi;
// ---------- DOM ----------
const $list = document.getElementById('list');
const $listWrap = document.getElementById('list-wrap');
const $empty = document.getElementById('empty');
const $statusL = document.getElementById('status-left');
const $search = document.getElementById('search');
const $searchClear = document.getElementById('search-clear');
const $tpl = document.getElementById('card-tpl');
const $closeBtn = document.getElementById('close-btn');
const $refreshBtn = document.getElementById('refresh-btn');
const $settingsBtn = document.getElementById('settings-btn');
const $pinBtn = document.getElementById('pin-btn');
const $ipcDot = document.getElementById('ipc-status');
const $tabs = document.getElementById('tabs');
const $historyCount = document.getElementById('history-count');
const $favCount = document.getElementById('fav-count');
// ---------- 状态 ----------
let clips = [];
let selectedIdx = -1;
let currentTab = 'history'; // 'history' | 'favorites'
let displayLimit = 300; // 来自 config.displayLimit预加载时拉一次IPC 变更时刷新
let $cards = [];
// ---------- 时间格式化 ----------
// todayKey 由 render() / prependClip() 入口处刷新一次,避免每张卡片都
// new Date() 一次displayLimit=1000 时就是 1000 次 Date 构造 + toDateString
let _todayKey = '';
function refreshTodayKey() {
_todayKey = new Date().toDateString();
}
function fmtTime(ts) {
if (!ts) return '';
const d = new Date(ts * 1000);
const sameDay = d.toDateString() === _todayKey;
const hh = String(d.getHours()).padStart(2, '0');
const mm = String(d.getMinutes()).padStart(2, '0');
if (sameDay) return `今天 ${hh}:${mm}`;
const y = d.getFullYear();
const m = String(d.getMonth() + 1).padStart(2, '0');
const day = String(d.getDate()).padStart(2, '0');
return `${y}-${m}-${day} ${hh}:${mm}`;
}
function setTab(name) {
if (name !== 'history' && name !== 'favorites') return;
currentTab = name;
for (const t of $tabs.querySelectorAll('.tab')) {
t.classList.toggle('active', t.dataset.tab === name);
}
selectedIdx = -1;
// 切到 favorites 时先取真实总数favorites:count IPC列表本身只取 200 条,
// 徽标反映真实值而不被截断。
if (name === 'favorites') updateFavCount();
reload();
}
$tabs.addEventListener('click', (e) => {
const btn = e.target.closest('.tab');
if (btn && btn.dataset.tab) setTab(btn.dataset.tab);
});
// ---------- 图片懒加载 ----------
// 列表只带 hasImage 标记,真正的图片数据滚到视口里才拉。
// 这样打开窗口不再需要把几百 MB 的 base64 从主进程搬过来。
// FIFO 缓存 —— 满 60 条时删最早插入的那条。Map.keys() 按插入顺序排列,
// 所以 delete(keys().next().value) 就够了不要命名成「LRU」那是按访问
// 顺序淘汰,不是这里的行为。
const fifoCache = new Map(); // id -> base64上限 60 条,避免无限增长)
function cacheImage(key, data) {
if (fifoCache.size >= 60) fifoCache.delete(fifoCache.keys().next().value);
fifoCache.set(key, data);
}
const imgObserver = new IntersectionObserver((entries) => {
for (const entry of entries) {
if (!entry.isIntersecting) continue;
const el = entry.target;
imgObserver.unobserve(el);
loadImageInto(el);
}
}, { root: document.getElementById('list-wrap'), rootMargin: '200px' });
async function loadImageInto(el) {
const id = Number(el.dataset.imgId);
const fromFav = el.dataset.imgFav === '1';
const key = (fromFav ? 'f' : 'c') + id;
if (fifoCache.has(key)) {
const cached = fifoCache.get(key);
if (cached) el.src = `data:image/png;base64,${cached}`;
return;
}
try {
const b64 = await (fromFav ? favGetImage(id) : getImage(id));
if (!b64) return; // 没有数据就保持占位,不要设一个坏 src
cacheImage(key, b64);
el.src = `data:image/png;base64,${b64}`;
} catch (e) {
console.error('[renderer] getImage failed:', e);
}
}
// ---------- 渲染 ----------
// 单张卡片:纯 DOM 构造,任何状态都不在这里改。
function buildCard(c, i) {
const node = $tpl.content.firstElementChild.cloneNode(true);
node.dataset.id = String(c.id);
node.querySelector('.time').textContent = fmtTime(c.createdAt);
const typeBadge = node.querySelector('.badge.type');
if (c.type === 'text') {
typeBadge.innerHTML = '<svg viewBox="0 0 16 16" width="11" height="11" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M3 4.5h10M3 8h7M3 11.5h10"/></svg><span>文本</span>';
typeBadge.classList.add('badge-text');
} else {
typeBadge.innerHTML = '<svg viewBox="0 0 16 16" width="11" height="11" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><rect x="2" y="3" width="12" height="10" rx="1.5"/><circle cx="6" cy="6.5" r="0.8"/><path d="m3 12 3-3 3 3 4-4"/></svg><span>图片</span>';
typeBadge.classList.add('badge-image');
}
const slotEl = node.querySelector('.slot');
if (i < 9) {
slotEl.textContent = String(i + 1);
slotEl.classList.remove('hidden');
} else {
slotEl.classList.add('hidden');
}
const favBtn = node.querySelector('.action.fav');
if (currentTab === 'favorites' || c.favorited) {
favBtn.classList.add('favorited');
favBtn.title = currentTab === 'favorites' ? '取消收藏' : '已收藏(点击取消)';
} else {
favBtn.classList.remove('favorited');
favBtn.title = '收藏';
}
favBtn.onclick = (e) => {
e.stopPropagation();
// 捕获点击时刻的 Tab回调进串行队列延迟执行期间用户切了 Tab
// 若在执行时读全局 currentTab历史 id 会被当 favId 发往
// favorites:remove静默删掉一条同号的无关收藏两库自增撞号几乎必然
toggleFavorite(c, currentTab);
};
const pinBtn = node.querySelector('.action.copy');
const delBtn = node.querySelector('.action.delete');
// 「复制」按钮在两个 Tab 都用:历史 Tab 走 movetop写剪贴板 + 置顶),
// 收藏 Tab 走 favorites:copy写收藏自己存的内容favId 与历史 id 不同库)。
pinBtn.classList.remove('hidden');
if (currentTab === 'favorites') {
// 收藏 Tab 没有"置顶"语义,也没"删除"按钮(收藏 Tab 用单独的取消收藏)
delBtn.classList.add('hidden');
pinBtn.title = '复制到剪贴板';
pinBtn.onclick = (e) => { e.stopPropagation(); copyEntry(c); };
} else {
delBtn.classList.remove('hidden');
pinBtn.title = '复制(按 Enter';
pinBtn.onclick = (e) => { e.stopPropagation(); moveToTop(c.id); };
delBtn.onclick = (e) => { e.stopPropagation(); removeClip(c.id); };
}
const textEl = node.querySelector('.text-preview');
const imgEl = node.querySelector('.image-preview');
if (c.type === 'text') {
textEl.textContent = c.text || c.preview || '';
imgEl.classList.add('hidden');
} else {
textEl.classList.add('hidden');
imgEl.classList.remove('hidden');
imgEl.removeAttribute('src');
if (c.hasImage) {
imgEl.dataset.imgId = String(c.id);
imgEl.dataset.imgFav = currentTab === 'favorites' ? '1' : '0';
imgObserver.observe(imgEl);
}
}
// 点击选中按下标 —— 但必须点击时再从 DOM 现取prependClip 增量插入
// 会把已有卡片整体后移,构建时捕获的 i 会过期,选中错误的项。
node.onclick = () => select($cards.indexOf(node));
node.ondblclick = () => copyEntry(c);
return node;
}
function render() {
refreshTodayKey();
imgObserver.disconnect();
$list.innerHTML = '';
if (clips.length === 0) {
$empty.classList.remove('hidden');
$statusL.textContent = '无内容';
selectedIdx = -1;
$cards = [];
return;
}
$empty.classList.add('hidden');
const frag = document.createDocumentFragment();
for (let i = 0; i < clips.length; i++) {
frag.appendChild(buildCard(clips[i], i));
}
$list.appendChild(frag);
$cards = Array.from($list.querySelectorAll('.card'));
// 选中态reload() 现在统一走 select(0),所以这里 selectedIdx = -1条件不成立。
// 留下这段是为了万一将来 render() 被别处复用时还能保持选中态可视化。
if (selectedIdx >= 0 && selectedIdx < $cards.length) {
$cards[selectedIdx].classList.add('selected');
}
$statusL.textContent = `显示 ${clips.length}`;
}
// 滚到列表顶部($listWrap.scrollTop = 0。两处调用
// - reload() 末尾:覆盖「窗口被隐藏又重新打开」路径 —— 此时 list-wrap 的
// scrollTop 是上次关闭前的位置innerHTML='' 重建 DOM 不会自动归零,
// select(0) 配套的 scrollIntoView({block:'nearest'}) 又依赖可见性判定,
// 不写死 0 可能在边界情况下漏滚。
// - prependClip() 末尾:新复制的卡片插到 DOM 顶端,但 list-wrap.scrollTop
// 还停在用户之前滚到的位置,新条目不可见。
// 直接写 scrollTop 比依赖 scrollIntoView 更可控(后者行为依赖浏览器对
// 可见性的判定,且会同时滚所有祖先滚动容器)。
function scrollListToTop() {
if ($listWrap) $listWrap.scrollTop = 0;
}
// 把新记录增量插入列表。失败时由调用方 fallback 到 reload()。
// 插入位 = 0主进程顺序就是 created_at DESC最新活跃在最顶新插入的
// 这条记录入库时 created_at = now天然就是最顶上 —— 直接 unshift 即可。
// 选中态:不再保留旧选中,统一在末尾用 select(0) 把新条目点亮并滚到顶部。
function prependClip(clip) {
refreshTodayKey();
// 空列表时 render() 亮起了空状态;第一条增量进来必须熄掉,
// 否则卡片和"暂无内容"会同时显示。
$empty.classList.add('hidden');
clips.unshift(clip);
// 渲染端的二次裁剪 —— 主进程 SQL LIMIT 是配置里的 displayLimit这里再守一道
// 防止 IPC 之间收到 N 条新增时把数组无限撑大;超过上限的尾部从数组+DOM 一起裁掉。
const cap = displayLimit;
if (clips.length > cap) {
clips.pop(); // 刚 unshift 的在最顶,被裁的永远是尾部的旧项
const trimmedEl = $cards[$cards.length - 1];
$cards.pop();
// IntersectionObserver 持有已观察元素的强引用:不 unobserve
// 被裁的 <img> 和 buildCard 闭包会滞留到下一次整表 render 才能 GC。
const img = trimmedEl.querySelector('.image-preview');
if (img) imgObserver.unobserve(img);
// FIFO 缓存里若有这个 id 的 base64主动清掉不依赖自然淘汰
// 否则会占着一个 slot 直到被新插入挤掉。
if (img && img.dataset.imgId) {
const fromFav = img.dataset.imgFav === '1';
fifoCache.delete((fromFav ? 'f' : 'c') + img.dataset.imgId);
}
trimmedEl.remove();
}
// 选中态:所有已有卡片整体后移一位(之前的 +1 / clamp 是为了"保留旧选中"
// 现在策略改为"新复制 = 跳到第一条",旧选中下标没有意义了,直接交给下面的 select(0) 接管。
const newCard = buildCard(clip, 0);
$list.insertBefore(newCard, $cards[0] || null); // null = 追加到末尾
$cards.unshift(newCard);
// Slot badge 重排:每次 prepend 之后,只有原来第 8 位(现在第 9 位)的卡需要
// 从「显示 9」变成「隐藏」其它卡0-8 已可见、9+ 已隐藏)都不动。
// 全表 1000 张卡都跑一遍是浪费。
const boundary = $cards[9]; // 第 10 位(下标 9需要被隐藏
if (boundary) {
const slotEl = boundary.querySelector('.slot');
slotEl.classList.add('hidden');
}
// 选中态:强制选中第一条(最新复制的条目),并滚到顶部让 Enter / 数字键 1 复制它。
// select() 内部会遍历 $cards 把旧 'selected' 清掉、把 $cards[0] 点亮,
// 然后 scrollIntoView({block:'nearest'}) —— 这里再调 scrollListToTop() 兜底,
// 避免 nearest 把"已可见"误判导致不滚(参见 scrollListToTop 注释)。
if (clips.length > 0) select(0);
scrollListToTop();
$statusL.textContent = `显示 ${clips.length}`;
}
// ---------- 选择 ----------
function select(idx) {
if (idx < 0 || idx >= clips.length) return;
selectedIdx = idx;
if (!$cards.length) return;
$cards.forEach((el, i) => el.classList.toggle('selected', i === idx));
const sel = $cards[idx];
if (sel) sel.scrollIntoView({ block: 'nearest' });
}
function moveSelection(delta) {
if (clips.length === 0) return;
if (selectedIdx < 0) { select(0); return; }
select(Math.max(0, Math.min(clips.length - 1, selectedIdx + delta)));
}
// ---------- 操作 ----------
// 收藏 Tab 的复制c.id 是 favId —— 与历史 id 分属两个库、自增数会撞号,
// 绝不能走 moveToTopclips:movetop 会按同号查到一条无关的历史记录)。
// 走 favorites:copy写收藏行自身存下的内容源记录被清了也照样能复制
async function copyFavorite(favId) {
try {
const result = await favCopy(favId);
$statusL.textContent = result && result.copied
? '已写入剪贴板(按 Ctrl+V 粘贴)'
: '复制失败';
} catch (e) {
console.error('[renderer] favCopy failed:', e);
$statusL.textContent = '复制失败:' + (e.message || e);
}
}
// 「复制一条」的统一入口:历史 Tab 走 movetop写剪贴板 + 置顶),
// 收藏 Tab 走 favorites:copy。双击 / Enter / 数字键都收口到这里。
function copyEntry(c) {
if (currentTab === 'favorites') return copyFavorite(c.id);
return moveToTop(c.id);
}
async function moveToTop(id) {
try {
const result = await movetop(id);
await reload();
// 点选只写系统剪贴板(不自动粘贴):成功 → 提示按 Ctrl+V失败 → 复制失败
$statusL.textContent = result && result.pasted
? '已粘贴'
: (result && result.copied ? '已写入剪贴板(按 Ctrl+V 粘贴)' : '复制失败');
} catch (e) {
console.error('[renderer] movetop failed:', e);
$statusL.textContent = '复制失败:' + (e.message || e);
}
}
async function removeClip(id) {
if (!confirm('确定删除此条?此操作不可撤销。')) return;
await remove(id);
await reload();
updateHistoryCount();
}
// 并发守卫reload 的触发方很多(搜索防抖 / IPC 推送 / 切 Tab / 复制回显),
// 两次重叠时渲染结果必须由"最后发起的"决定,而不是"最后返回的" ——
// 否则慢查询的旧结果会盖掉新查询(搜索框显示 ab、列表停在 a 的结果上)。
let reloadSeq = 0;
async function reload() {
const seq = ++reloadSeq;
const query = $search.value.trim();
$refreshBtn.classList.add('spinning');
// 先落局部变量过期响应seq 失配)在下面被丢弃时碰不到全局 clips
// 否则慢响应乱序到达会把 clips 数组污染成旧查询的结果DOM 新、数组旧 → 分叉)。
let rows;
try {
if (currentTab === 'history') {
rows = await list(query);
} else {
// 收藏列表走配置里的 displayLimit默认 300避免一次建几千个 DOM 节点。
// 徽标计数不能用 clips.length被 displayLimit 截断会失真)——交给
// updateFavCount()favorites:count IPC在每条 reload 路径上保证真实值。
rows = await favList(query);
updateFavCount();
}
} catch (e) {
if (seq !== reloadSeq) return; // 已有更新的请求:错误状态交给它处理
console.error('[renderer] reload failed:', e);
$statusL.textContent = '加载失败:' + (e.message || e);
$refreshBtn.classList.remove('spinning');
return;
}
if (seq !== reloadSeq) return; // 过期响应:丢弃,别拿旧查询的结果渲染
clips = rows;
selectedIdx = -1;
render();
// 窗口刚被唤起 / 切 Tab / 刷新列表 / 搜索结果更新:都强制选中第一条
// 并滚到顶部,让最新内容成为焦点(按 1 / Enter 复制即拷贝最新条目)。
// 之前用 prevId → restored 保留旧选中,但用户的旧选中此时通常已不在
// 视口scrollTop 仍是关闭前的位置),体验不连贯。
if (clips.length > 0) select(0);
scrollListToTop();
$refreshBtn.classList.remove('spinning');
}
let favPromise = Promise.resolve();
async function toggleFavorite(c, tab) {
favPromise = favPromise.then(async () => {
try {
// 全程用点击时捕获的 tab 分发 —— 不在执行时读全局 currentTab见调用处注释
let willFavorite;
if (tab === 'favorites') {
// 收藏 Tab 的卡片 —— c.id 是 favId
await favRemove(c.id);
willFavorite = false;
} else if (c.favorited) {
await favRemove(c.favId);
willFavorite = false;
} else {
// favAdd 通过 IPC 返回新 fav rowrow.id 是 favId —— 必须存到 c 上,否则下次 ★ 无法 remove
const row = await favAdd(c.id);
if (row && row.id) c.favId = row.id;
willFavorite = true;
}
if (tab === 'favorites') {
// 收藏 Tab 取消后,重新加载列表(被取消的卡片需从列表消失)
await reload();
} else {
// 历史 Tab只更新当前卡片 DOM
const node = $list.querySelector(`.card[data-id="${c.id}"]`);
if (node) {
const btn = node.querySelector('.action.fav');
btn.classList.toggle('favorited', willFavorite);
btn.title = willFavorite ? '已收藏(点击取消)' : '收藏';
}
// 同步状态对象
c.favorited = willFavorite;
if (!willFavorite) delete c.favId;
}
// 触发收藏徽标更新
updateFavCount();
} catch (e) {
$statusL.textContent = '收藏操作失败:' + (e.message || e);
}
});
return favPromise;
}
// ---------- 键盘 ----------
document.addEventListener('keydown', (e) => {
// 全局快捷键:不看焦点(在搜索框里也能 F5 / Ctrl+L
if (e.key === 'F5') {
e.preventDefault();
reload();
return;
}
if ((e.ctrlKey || e.metaKey) && !e.shiftKey && !e.altKey && e.key.toLowerCase() === 'l') {
// Ctrl+L 聚焦搜索框。组合键完整匹配避免吃掉 Ctrl+Shift+L 之类。
e.preventDefault();
$search.focus();
$search.select();
return;
}
// 搜索框聚焦时不抢键盘
const inSearch = document.activeElement === $search;
if (e.key === 'Escape') {
// 搜索框里按 Esc 先退出搜索,不要直接把窗口关掉。
// (这个分支必须在无条件关闭之前,否则永远走不到。)
if (inSearch) {
clearSearch();
$search.blur();
return;
}
// 用户主动关闭:隐藏窗口而不是销毁它(应用留在托盘)。
// 销毁后再唤出要走 BrowserWindow 冷路径100~300ms 不可感延迟。
hide();
return;
}
if (!inSearch) {
if (e.key === 'ArrowDown') { e.preventDefault(); moveSelection(1); return; }
if (e.key === 'ArrowUp') { e.preventDefault(); moveSelection(-1); return; }
if (e.key === 'PageDown') { e.preventDefault(); moveSelection(5); return; }
if (e.key === 'PageUp') { e.preventDefault(); moveSelection(-5); return; }
if (e.key === 'Home') { e.preventDefault(); select(0); return; }
if (e.key === 'End') { e.preventDefault(); select(clips.length - 1); return; }
// 数字键 1..9 按当前位置复制(最顶上 = 1历史 Tab 同时置顶
if (/^[1-9]$/.test(e.key)) {
e.preventDefault();
const slot = parseInt(e.key, 10) - 1;
if (slot < clips.length) copyEntry(clips[slot]);
return;
}
// Enter与"复制"按钮功能一致 —— 复制选中卡片(历史 Tab 同时置顶)
// 当焦点在卡片上的某个按钮时,由按钮自身默认行为处理,避免重复触发
if (e.key === 'Enter') {
const active = document.activeElement;
if (active && active.tagName === 'BUTTON') return;
e.preventDefault();
if (selectedIdx >= 0 && selectedIdx < clips.length) {
copyEntry(clips[selectedIdx]);
}
return;
}
}
});
// ---------- 搜索 ----------
let searchTimer = null;
function syncSearchClear() {
// 输入框有任何字符就显示清空按钮空了就藏起来。trim 前判断,
// 让用户敲一两个空格也能用按钮清掉reload 走 trim不会真的去搜
$searchClear.classList.toggle('visible', $search.value.length > 0);
}
function clearSearch() {
if (!$search.value) return;
$search.value = '';
syncSearchClear();
reload();
}
$search.addEventListener('input', () => {
syncSearchClear();
clearTimeout(searchTimer);
searchTimer = setTimeout(reload, 80);
});
$search.addEventListener('keydown', (e) => {
if (e.key === 'Enter') {
// Just select the first result; don't paste (Enter semantic conflict).
if (clips.length > 0) select(0);
e.preventDefault();
}
});
$searchClear.addEventListener('click', () => {
clearSearch();
// 点完按钮焦点会落在按钮上 —— 拉回输入框,让用户能继续敲而不是按 Tab
$search.focus();
});
// 启动时同步一次(兜底:将来若有"恢复上次搜索"逻辑也能直接复用)
syncSearchClear();
// ---------- 实时更新 ----------
subscribe()
.then(() => {
$ipcDot.classList.add('connected');
$ipcDot.title = 'IPC 已连接';
})
.catch((e) => {
console.error('[renderer] subscribe failed:', e);
$ipcDot.classList.add('error');
$ipcDot.title = 'IPC 失败: ' + (e.message || e);
});
onAdded((clip) => {
// 收藏 Tab 走自己的 refresh搜索时新项可能不匹配结果集也走 reload。
// 只在 history Tab + 搜索框为空时走增量插入,避免全表 innerHTML 重建。
if (currentTab !== 'history' || $search.value.trim() !== '') {
reload();
updateHistoryCount();
return;
}
try {
prependClip(clip);
updateHistoryCount();
} catch (e) {
console.error('[renderer] prepend failed, falling back to reload:', e);
reload();
updateHistoryCount();
}
});
onRefresh(() => { reload(); updateHistoryCount(); });
favSubscribe().catch((e) => console.error('[renderer] favSubscribe failed:', e));
onFavoritesChanged(() => {
if (currentTab === 'favorites') reload();
else updateFavCount();
});
async function updateFavCount() {
try {
// 只要一个数字:以前这里拉的是全部收藏(含图片 base64只为读 .length
$favCount.textContent = String(await favCountOf());
} catch (e) {}
}
async function updateHistoryCount() {
try {
// clips:count 走主进程内存里的行数缓存stmt.count 命中时不再 COUNT(*) 全表),
// 每条新增 / 删除 / 清空历史都会实时同步,徽标总能反映真实总数。
$historyCount.textContent = String(await count());
} catch (e) {}
}
// ---------- 主题 ----------
const { get: themeGet, subscribe: themeSubscribe, onSystemChanged } = window.themeApi;
async function applyTheme() {
try {
const cfg = await themeGet();
// 优先用 effectiveTheme「跟随系统」开时 cfg.theme 已经是 effective
// 但要兼容旧主进程(旧版只发 raw theme用 || 兜底。
const theme = cfg.effectiveTheme || cfg.theme;
document.documentElement.setAttribute('data-theme', theme);
document.documentElement.setAttribute('data-accent', cfg.accent);
try {
// 同时缓存 raw用于下次启动 theme-boot 兜底)和 effective用于
// 「跟随系统」开时直接跳过 flash。如果主进程旧版没给 rawTheme
// 就退回到 cfg.theme 字段本身。
localStorage.setItem('clipboard-theme', JSON.stringify({
theme: cfg.rawTheme || cfg.theme,
effectiveTheme: cfg.effectiveTheme || cfg.theme,
accent: cfg.accent,
followSystem: cfg.followSystem,
}));
} catch (_) {}
} catch (e) {
console.error('[theme] applyTheme failed:', e.message);
}
}
onSystemChanged((payload) => {
// 系统主题切换只更新 DOM不写 localStoragepayload 里没有 rawTheme
// 写下去会让后续启动在「跟随系统」关掉时拿不到用户真正选的主题。
// localStorage 由 applyTheme()(持有完整 rawTheme统一维护。
const theme = payload.effectiveTheme || payload.theme;
document.documentElement.setAttribute('data-theme', theme);
document.documentElement.setAttribute('data-accent', payload.accent);
});
// 不订阅的话,主进程的 theme:system-changed 根本不会发到这个窗口
themeSubscribe().catch((e) => console.error('[renderer] themeSubscribe failed:', e));
applyTheme();
// ---------- 启动 ----------
// 关闭按钮:用户主动关闭走 hide(),不销毁窗口(理由见 Esc 分支)。
$closeBtn.addEventListener('click', () => hide());
// 刷新按钮
$refreshBtn.addEventListener('click', () => reload());
// 设置按钮
$settingsBtn.addEventListener('click', () => openSettings());
// 置顶按钮:图标状态由 aria-pressed 表达(开启=true
function syncPinBtn(on) {
$pinBtn.setAttribute('aria-pressed', on ? 'true' : 'false');
$pinBtn.title = on ? '取消窗口置顶' : '窗口置顶';
// 图标走 SVG开/关靠 CSS 上色区分accent vs muted
}
$pinBtn.addEventListener('click', async () => {
try {
const next = await toggleAlwaysOnTop(); // 不传 on → 翻转
syncPinBtn(next === true);
$statusL.textContent = next ? '已开启窗口置顶' : '已关闭窗口置顶';
} catch (e) {
console.error('[renderer] toggleAlwaysOnTop failed:', e);
$statusL.textContent = '切换置顶失败:' + (e.message || e);
}
});
// 启动时同步主进程的初始状态(默认开启)
getAlwaysOnTop()
.then((on) => syncPinBtn(on === true))
.catch((e) => console.error('[renderer] getAlwaysOnTop failed:', e));
// 启动时直接刷新两个 tab 的计数——不依赖当前 Tab
updateHistoryCount();
updateFavCount();
reload();
// ---------- 界面显示条数displayLimit ----------
// 启动时拉一次配置,让前端 displayLimit 跟主进程保持一致;
// 设置窗口改了之后主进程会推 settings:display-limit-changed所有窗口重拉。
async function syncDisplayLimit() {
try {
const c = await settingsGet();
if (c && c.displayLimit) displayLimit = c.displayLimit;
} catch (_) {}
}
syncDisplayLimit();
onDisplayLimitChanged(({ displayLimit: next }) => {
if (typeof next === 'number' && next > 0) {
displayLimit = next;
reload();
}
});
// ---------- 应用图标(标题栏左上角) ----------
// 一次性从主进程拿 PNG data URL 喂给 <img>。主进程已缓存 .ico→PNG 的转换,
// IPC 命中后基本零成本;图标缺失静默失败,不影响功能。
window.api.getAppIcon().then((dataUrl) => {
if (!dataUrl) return;
const el = document.getElementById('app-icon');
if (el) el.src = dataUrl;
}).catch(() => { /* 图标缺失不影响功能 */ });

80
renderer/settings.html Normal file
View File

@@ -0,0 +1,80 @@
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8" />
<meta http-equiv="Content-Security-Policy" content="default-src 'self'; style-src 'self' 'unsafe-inline'; img-src 'self' data:;" />
<title>设置</title>
<link rel="stylesheet" href="themes/fonts.css" />
<link rel="stylesheet" href="themes/tokens.css" />
<link rel="stylesheet" href="themes/themes.css" />
<link rel="stylesheet" href="themes/accents.css" />
<link rel="stylesheet" href="styles.css" />
<script src="theme-boot.js"></script>
</head>
<body>
<div id="root">
<header id="drag-bar"><img id="app-icon" alt="" /><h1 id="title">设置</h1><span class="spacer"></span><button id="close-btn" class="icon-btn" title="关闭 (Esc)" aria-label="关闭">
<svg viewBox="0 0 16 16" width="14" height="14" fill="none" stroke="currentColor" stroke-width="1.6" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
<path d="M3.5 3.5l9 9M12.5 3.5l-9 9"/>
</svg>
</button></header>
<nav id="tab-bar" role="tablist" aria-label="设置分类">
<button class="tab" role="tab" id="tab-appearance" aria-controls="panel-appearance" aria-selected="true" tabindex="0">外观</button>
<button class="tab" role="tab" id="tab-general" aria-controls="panel-general" aria-selected="false" tabindex="-1">通用</button>
<button class="tab" role="tab" id="tab-about" aria-controls="panel-about" aria-selected="false" tabindex="-1">关于</button>
</nav>
<main id="settings-body">
<section class="tab-panel" role="tabpanel" id="panel-appearance" aria-labelledby="tab-appearance">
<div class="setting-row column">
<div class="setting-label">主题</div>
<div id="theme-grid" class="theme-grid"></div>
</div>
<div class="setting-row">
<label class="setting-label" for="opt-follow-system">跟随系统主题</label>
<label class="switch">
<input type="checkbox" id="opt-follow-system" />
<span class="slider-track"></span>
</label>
</div>
<div class="setting-row">
<div class="setting-label">强调色</div>
<div id="accent-grid" class="accent-grid"></div>
</div>
</section>
<section class="tab-panel" role="tabpanel" id="panel-general" aria-labelledby="tab-general" hidden>
<div class="setting-row"><label class="setting-label" for="opt-hotkey">唤起面板快捷键</label><select id="opt-hotkey"><option value="Ctrl+Alt+V">Ctrl + Alt + V</option><option value="Ctrl+Shift+V">Ctrl + Shift + V</option><option value="Alt+Shift+V">Alt + Shift + V</option><option value="Ctrl+Alt+C">Ctrl + Alt + C</option><option value="">禁用</option></select></div>
<div class="setting-row"><label class="setting-label" for="opt-autolaunch">开机自启动</label><label class="switch"><input type="checkbox" id="opt-autolaunch" /><span class="slider-track"></span></label></div>
<div class="setting-row"><label class="setting-label" for="opt-maxitems">最大保留条数</label><select id="opt-maxitems"><option value="0">不限制</option><option value="200">200</option><option value="500">500</option><option value="1000">1000</option></select></div>
<div class="setting-row"><label class="setting-label" for="opt-displaylimit">界面显示条数</label><select id="opt-displaylimit"><option value="100">100</option><option value="300">300</option><option value="500">500</option><option value="1000">1000</option></select></div>
<div class="setting-row"><label class="setting-label" for="opt-pollms">轮询间隔</label><select id="opt-pollms"><option value="500">500 ms</option><option value="1000">1000 ms</option><option value="2000">2000 ms</option></select></div>
<div class="setting-row column"><div class="setting-label">数据保存位置</div><div class="path-box"><span id="opt-datadir" class="path-text"></span></div><div class="btn-row"><button id="btn-change-dir" class="btn">更改…</button><button id="btn-open-dir" class="btn">打开文件夹</button></div></div>
<div class="setting-row column danger-zone"><div class="setting-label">清空历史(保留置顶)</div><button id="btn-clear" class="btn btn-danger">清空历史</button></div>
</section>
<section class="tab-panel" role="tabpanel" id="panel-about" aria-labelledby="tab-about" hidden>
<div class="about-head">
<div class="about-icon">
<svg viewBox="0 0 24 24" width="22" height="22" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
<rect x="8" y="3" width="8" height="3" rx="1"/>
<rect x="5" y="5" width="14" height="16" rx="2"/>
<path d="M9 11h6M9 14.5h6M9 18h3"/>
</svg>
</div>
<div class="about-name">剪贴板</div>
<div class="about-tagline">简洁的剪贴板管理器</div>
</div>
<div class="setting-row column">
<div class="setting-label">开发者</div>
<div class="about-devline">
<span class="about-dev">关济寰</span>
<a class="ext-link" href="https://www.guanjihuan.com/about" target="_blank" rel="noopener">https://www.guanjihuan.com/about</a>
</div>
</div>
</section>
</main>
<footer id="status"><span id="status-left">就绪</span></footer>
</div>
<script src="settings.js"></script>
</body>
</html>

227
renderer/settings.js Normal file
View File

@@ -0,0 +1,227 @@
'use strict';
// ---------- 标签页 ----------
// 放在 IPC 解构之前:即使 preload 没注入,标签页也要能切换。
(() => {
const bar = document.getElementById('tab-bar');
if (!bar) return;
const tabs = Array.from(bar.querySelectorAll('[role="tab"]'));
if (!tabs.length) return;
function select(tab) {
for (const t of tabs) {
const on = t === tab;
t.setAttribute('aria-selected', on ? 'true' : 'false');
t.tabIndex = on ? 0 : -1;
const panel = document.getElementById(t.getAttribute('aria-controls'));
if (panel) panel.hidden = !on;
}
}
for (const t of tabs) {
t.addEventListener('click', () => { select(t); t.focus(); });
}
bar.addEventListener('keydown', (e) => {
if (e.key !== 'ArrowLeft' && e.key !== 'ArrowRight') return;
e.preventDefault();
// 按「当前选中」而不是 document.activeElement 找位置:
// 窗口未获焦点时 activeElement 不可靠,测试里也无法聚焦隐藏窗口。
const i = tabs.findIndex((t) => t.getAttribute('aria-selected') === 'true');
const step = e.key === 'ArrowRight' ? 1 : tabs.length - 1;
const next = tabs[((i < 0 ? 0 : i) + step) % tabs.length];
select(next);
next.focus();
});
select(tabs[0]);
})();
const { get, set, clearHistory, openDir, chooseDir, openExternal } = window.settingsApi;
const $auto=document.getElementById('opt-autolaunch'),$hotkey=document.getElementById('opt-hotkey'),$max=document.getElementById('opt-maxitems'),$display=document.getElementById('opt-displaylimit'),$poll=document.getElementById('opt-pollms'),$dir=document.getElementById('opt-datadir'),$change=document.getElementById('btn-change-dir'),$open=document.getElementById('btn-open-dir'),$clear=document.getElementById('btn-clear'),$close=document.getElementById('close-btn'),$status=document.getElementById('status-left');
function flash(msg){$status.textContent=msg;setTimeout(()=>$status.textContent='就绪',2000)}
// settings:set 在主进程先改运行时状态、后写盘;写盘失败(磁盘满/只读/杀软锁文件)
// 会带 saved:false 回来 —— 本次会话已生效但重启还原,必须明确提示,
// 不能让用户以为设置已经保存。
function flashIfNotSaved(res){
if(res&&res.saved===false){flash('⚠ 设置未保存到磁盘,重启后还原');return true}
return false
}
async function refresh(){const c=await get();$auto.checked=!!c.autoLaunch;$hotkey.value=c.hotkey||'';$max.value=String(c.maxItems);$display.value=String(c.displayLimit);$poll.value=String(c.pollMs);$dir.textContent=c.dataDir}
$auto.addEventListener('change',async()=>{const res=await set('autoLaunch',$auto.checked);if(!flashIfNotSaved(res))flash('已'+($auto.checked?'开启':'关闭')+'开机自启动')});
$max.addEventListener('change',async()=>{const res=await set('maxItems',parseInt($max.value,10));if(!flashIfNotSaved(res))flash('最大条数已设为 '+$max.value)});
$display.addEventListener('change',async()=>{const res=await set('displayLimit',parseInt($display.value,10));if(!flashIfNotSaved(res))flash('界面显示条数已设为 '+$display.value)});
$poll.addEventListener('change',async()=>{const res=await set('pollMs',parseInt($poll.value,10));if(!flashIfNotSaved(res))flash('轮询间隔已设为 '+$poll.value+' ms')});
$hotkey.addEventListener('change',async()=>{
const res=await set('hotkey',$hotkey.value);
if(flashIfNotSaved(res))return;
if($hotkey.value&&res&&res.hotkeyRegistered===false){
// 主进程 register 返回 false组合键被别的程序占用。配置照存
// 但必须告诉用户,否则他会以为快捷键坏了。
flash('快捷键可能被其他程序占用,未生效');
}else{
flash($hotkey.value?'唤起快捷键已设为 '+$hotkey.value:'已禁用唤起快捷键');
}
});
$open.addEventListener('click',()=>openDir());
$clear.addEventListener('click',async()=>{if(!confirm('确定清空所有非置顶历史记录?此操作不可撤销。'))return;flash('已清除 '+await clearHistory()+' 条')});
$change.addEventListener('click',async()=>{if(typeof chooseDir!=='function')return;const res=await chooseDir();if(res&&res.ok===false&&res.message)flash(res.message)});
$close.addEventListener('click',()=>window.close());document.addEventListener('keydown',e=>{if(e.key==='Escape')window.close()});refresh();
// ---------- 应用图标(标题栏左上角)----------
// 一次性从主进程拿 PNG data URL 喂给 <img>。与主窗口共用同一个 IPC
// 主进程缓存命中后零成本;图标缺失静默失败,不影响设置面板功能。
window.api.getAppIcon().then((dataUrl) => {
if (!dataUrl) return;
const el = document.getElementById('app-icon');
if (el) el.src = dataUrl;
}).catch(() => { /* 图标缺失不影响功能 */ });
// ---------- 外链(关于页)----------
// 窗口不能直接导航到外部 URL会把设置页整个顶掉点击统一拦下来
// 走主进程 shell.openExternal 交给系统浏览器scheme 白名单在主进程卡。
for (const a of document.querySelectorAll('a.ext-link')) {
a.addEventListener('click', (e) => { e.preventDefault(); if (typeof openExternal === 'function') openExternal(a.href); });
}
// ---------- 主题 ----------
// 不要用 `const themeApi`contextBridge 暴露的 window.themeApi 是不可配置属性,
// 顶层同名 const 会让整个脚本在实例化阶段抛 SyntaxError一行都不执行
const themeIpc = window.themeApi;
const { set: themeSet, setAccent, setFollowSystem, onSystemChanged } = themeIpc;
const THEMES = [
{ id: 'linear', name: 'Linear Dark', desc: '极简暗色专业', bg: '#0E0E12', text: '#E6E6E9', accent: '#5E6AD2' },
{ id: 'midnight', name: 'Midnight', desc: '极致暗色', bg: '#08090B', text: '#E8E8EA', accent: '#7C3AED' },
{ id: 'carbon', name: 'Carbon', desc: 'VS Code 灰', bg: '#1A1D23', text: '#D4D4D4', accent: '#3B82F6' },
{ id: 'solarized', name: 'Solarized', desc: '经典 Sola', bg: '#002B36', text: '#93A1A1', accent: '#268BD2' },
{ id: 'github', name: 'GitHub Dark', desc: 'GitHub 风', bg: '#0D1117', text: '#C9D1D9', accent: '#8957E5' },
{ id: 'light', name: 'Light', desc: '白天模式', bg: '#FAFAFA', text: '#111827', accent: '#5E6AD2' },
];
const ACCENTS = [
{ id: 'purple', color: '#5E6AD2' },
{ id: 'blue', color: '#3B82F6' },
{ id: 'green', color: '#10B981' },
{ id: 'orange', color: '#F59E0B' },
{ id: 'red', color: '#EF4444' },
];
function renderThemes(currentTheme) {
const grid = document.getElementById('theme-grid');
if (!grid) return;
grid.innerHTML = '';
for (const t of THEMES) {
const card = document.createElement('div');
card.className = 'theme-card' + (t.id === currentTheme ? ' active' : '');
card.dataset.theme = t.id;
card.innerHTML = `
<div class="theme-preview" style="background:${t.bg};color:${t.text}">
<span class="theme-preview-text">Aa</span>
<span class="theme-preview-accent" style="background:${t.accent}"></span>
</div>
<div class="theme-info">
<div class="theme-name">${t.name}</div>
<div class="theme-desc">${t.desc}</div>
</div>
`;
card.addEventListener('click', async () => {
const res = await themeSet(t.id);
if (res) {
const eff = res.effectiveTheme || res.theme;
document.documentElement.setAttribute('data-theme', eff);
document.documentElement.setAttribute('data-accent', res.accent);
try {
// rawTheme 在 themeSet 路径上 = eff顺手把 followSystem 关了),
// 但旧主进程没发 rawTheme 字段,这里用 eff 兜底。
localStorage.setItem('clipboard-theme', JSON.stringify({
theme: res.rawTheme || eff,
effectiveTheme: eff,
accent: res.accent,
followSystem: res.followSystem,
}));
} catch (_) {}
document.getElementById('opt-follow-system').checked = false;
renderThemes(eff);
flashIfNotSaved(res);
}
});
grid.appendChild(card);
}
}
function renderAccents(currentAccent) {
const grid = document.getElementById('accent-grid');
if (!grid) return;
grid.innerHTML = '';
for (const a of ACCENTS) {
const sw = document.createElement('button');
sw.className = 'accent-swatch' + (a.id === currentAccent ? ' active' : '');
sw.dataset.accent = a.id;
sw.style.background = a.color;
sw.title = a.id;
sw.addEventListener('click', async () => {
const res = await setAccent(a.id);
if (res) {
document.documentElement.setAttribute('data-accent', res.accent);
try {
localStorage.setItem('clipboard-theme', JSON.stringify({
theme: res.rawTheme || res.theme,
effectiveTheme: res.effectiveTheme || res.theme,
accent: res.accent,
followSystem: res.followSystem,
}));
} catch (_) {}
renderAccents(res.accent);
flashIfNotSaved(res);
}
});
grid.appendChild(sw);
}
}
const $follow = document.getElementById('opt-follow-system');
if ($follow) {
$follow.addEventListener('change', async () => {
const res = await setFollowSystem($follow.checked);
if (res) {
try {
localStorage.setItem('clipboard-theme', JSON.stringify({
theme: res.rawTheme || res.theme,
effectiveTheme: res.effectiveTheme || res.theme,
accent: res.accent,
followSystem: res.followSystem,
}));
} catch (_) {}
if (!flashIfNotSaved(res)) flash($follow.checked ? '已开启跟随系统' : '已关闭跟随系统');
}
});
}
onSystemChanged((payload) => {
// 同 renderer只更新 DOM不写 localStoragepayload 里没有 rawTheme
const eff = payload.effectiveTheme || payload.theme;
document.documentElement.setAttribute('data-theme', eff);
document.documentElement.setAttribute('data-accent', payload.accent);
renderThemes(eff);
});
async function initTheme() {
try {
// 先订阅再拉当前值,否则跟随系统改主题时这个窗口收不到广播
try { await themeIpc.subscribe(); }
catch (e) { console.error('[theme] subscribe failed:', e.message); }
const cfg = await themeIpc.get();
const eff = cfg.effectiveTheme || cfg.theme;
document.documentElement.setAttribute('data-theme', eff);
document.documentElement.setAttribute('data-accent', cfg.accent);
if ($follow) $follow.checked = cfg.followSystem;
renderThemes(eff);
renderAccents(cfg.accent);
} catch (e) {
console.error('[theme] initTheme failed:', e.message);
}
}
initTheme();

952
renderer/styles.css Normal file
View File

@@ -0,0 +1,952 @@
/* 现代简约样式 —— 通过 token 系统接入 6 主题 / 5 强调色 */
* {
box-sizing: border-box;
margin: 0;
padding: 0;
}
html, body {
height: 100%;
background: var(--bg-base);
color: var(--text);
font-family: var(--font-ui);
font-size: var(--fs-base);
line-height: var(--lh-base);
letter-spacing: var(--tracking-cn);
/* Windows + CJK 小字清晰度关键设置:
* - 默认 subpixel-antialiased 保留 RGB 子像素渲染,对中文笔画描边至关重要;
* 之前强制 antialiased/grayscale 会让 12px 以下的汉字变成"雾蒙蒙"的灰块。
* - text-rendering 用 auto让浏览器按字号自适应中文小字走 subpixel
* 英文大标题再走 optimizeLegibility 拿连字。
* - font-feature 只在 ≥14px 的西文场景开启,避免给小字号增加渲染负担。 */
-webkit-font-smoothing: subpixel-antialiased;
-moz-osx-font-smoothing: auto;
text-rendering: auto;
font-feature-settings: normal;
user-select: none;
}
/* 大字号西文(标题、关于页)单独开启连字,小字号不参与 */
#title, .about-name, .theme-preview-text {
-webkit-font-smoothing: subpixel-antialiased;
text-rendering: optimizeLegibility;
font-feature-settings: "kern" 1, "liga" 1, "calt" 1;
}
#root {
display: flex;
flex-direction: column;
height: 100vh;
border: 1px solid var(--border);
border-radius: var(--radius-lg);
overflow: hidden;
}
/* ---------- 拖动区 ---------- */
#drag-bar {
-webkit-app-region: drag;
display: flex;
align-items: center;
gap: 6px;
padding: 8px 12px 7px;
border-bottom: 1px solid var(--border-soft);
background: var(--bg-base);
}
#title {
font-size: var(--fs-md);
font-weight: var(--fw-semibold);
color: var(--text);
letter-spacing: 0.2px;
/* 原本 margin-left: 4px 用来撑开标题与左边框的距离;
* 现在 #app-icon 在更左侧,由 #app-icon 的 margin-right 控制间隙更合理。 */
margin-left: 0;
/* 标题单行body 的 line-height=1.6 在这里会撑出多余空白,
* 关掉让标题跟按钮在同一基线。 */
line-height: 1;
}
/* 标题栏左上角小图标16×16 显示位,源是 32×32 PNG主进程从 .ico resize
* 而来),对常见 1.0/1.25/1.5/2x 缩放都锐利。
* 父级 #drag-bar 已设 -webkit-app-region: drag按住图标仍可拖动窗口。 */
#app-icon {
width: 16px;
height: 16px;
display: block;
flex-shrink: 0;
margin-right: 6px;
border-radius: 3px;
}
.spacer { flex: 1; }
.icon-btn {
-webkit-app-region: no-drag;
width: 26px;
height: 26px;
padding: 0;
background: transparent;
border: none;
border-radius: 6px;
color: var(--text-muted);
line-height: 1;
cursor: pointer;
display: inline-flex;
align-items: center;
justify-content: center;
transition: background var(--transition-fast), color var(--transition-fast);
}
/* SVG 图标:颜色继承 currentColor让 aria-pressed 状态色直接生效 */
.icon-btn svg {
display: block;
color: inherit;
}
.icon-btn:hover {
background: var(--bg-hover);
color: var(--text);
}
#close-btn:hover {
background: var(--danger-bg);
color: var(--danger);
}
/* 置顶开启:图标上色 + 轻微背景,关闭时回到 muted */
#pin-btn[aria-pressed="true"] {
color: var(--accent);
background: var(--accent-soft);
}
#pin-btn[aria-pressed="true"]:hover {
/* 开启态下 hover 不要洗掉上色,只把背景加深一点 */
background: var(--accent-hover);
color: var(--accent);
}
#refresh-btn.spinning svg {
animation: spin 0.8s linear infinite;
transform-origin: 50% 50%;
}
@keyframes spin {
to { transform: rotate(360deg); }
}
.ipc-dot {
display: inline-block;
width: 6px;
height: 6px;
margin: 0 4px 0 2px;
border-radius: 50%;
background: var(--text-muted);
transition: background var(--transition-base), box-shadow var(--transition-base);
}
.ipc-dot.connected {
background: var(--success);
box-shadow: 0 0 0 3px rgba(16, 185, 129, 0.15);
}
.ipc-dot.error {
background: var(--danger);
box-shadow: 0 0 0 3px rgba(239, 68, 68, 0.15);
}
/* ---------- Tab 栏 ---------- */
#tabs {
-webkit-app-region: no-drag;
display: flex;
gap: 2px;
padding: 0 12px;
background: var(--bg-base);
border-bottom: 1px solid var(--border-soft);
}
/* 历史窗口的 Tab —— 作用域限定到 #tabs避免被下面 settings 面板的
* 通用 .tab 规则覆盖(同一文件后写的同优先级规则会赢,导致 padding /
* transition 跟设计稿不一致)。 */
#tabs .tab {
padding: 8px 10px 7px;
border: none;
border-radius: 6px 6px 0 0;
background: transparent;
color: var(--text-soft);
font-size: var(--fs-sm);
font-weight: var(--fw-medium);
cursor: pointer;
transition: background var(--transition-fast), color var(--transition-fast);
border-bottom: 2px solid transparent;
margin-bottom: -1px;
/* 与 #title 一致:行高压成 1文字基线跟标题对齐不要靠 padding 顶。 */
line-height: 1;
}
#tabs .tab:hover {
color: var(--text);
}
#tabs .tab.active {
color: var(--accent);
border-bottom-color: var(--accent);
}
.tab-count {
display: inline-block;
margin-left: 5px;
padding: 0 6px;
background: var(--bg-hover);
border-radius: 8px;
font-size: 10.5px;
color: var(--text-muted);
line-height: 16px;
height: 16px;
font-weight: var(--fw-medium);
vertical-align: 1px;
font-variant-numeric: tabular-nums;
transition: background var(--transition-fast), color var(--transition-fast);
}
#tabs .tab.active .tab-count {
background: var(--accent-soft);
color: var(--accent);
}
/* ---------- 搜索 ---------- */
#search-row {
padding: 8px 12px 10px;
background: var(--bg-base);
}
#search-wrap {
position: relative;
display: flex;
align-items: center;
}
#search-icon {
position: absolute;
left: 10px;
color: var(--text-muted);
pointer-events: none;
transition: color var(--transition-fast);
}
#search {
-webkit-app-region: no-drag;
width: 100%;
height: 32px;
padding: 0 32px 0 32px;
background: var(--bg-soft);
border: 1px solid transparent;
border-radius: 8px;
color: var(--text);
font-size: var(--fs-base);
line-height: 1.4;
outline: none;
transition: background var(--transition-base), border-color var(--transition-base), box-shadow var(--transition-base);
}
#search:focus {
background: var(--bg-overlay);
border-color: var(--accent);
box-shadow: 0 0 0 3px var(--accent-soft);
}
#search:focus + #search-clear,
#search-wrap:focus-within #search-icon {
color: var(--accent);
}
#search::placeholder {
color: var(--text-muted);
}
#search-clear {
-webkit-app-region: no-drag;
position: absolute;
right: 4px;
top: 50%;
transform: translateY(-50%);
width: 24px;
height: 24px;
padding: 0;
background: transparent;
border: none;
border-radius: 50%;
color: var(--text-muted);
line-height: 1;
cursor: pointer;
display: none;
align-items: center;
justify-content: center;
transition: background var(--transition-fast), color var(--transition-fast);
}
#search-clear.visible {
display: inline-flex;
}
#search-clear:hover {
background: var(--bg-hover);
color: var(--text);
}
/* ---------- 列表 ---------- */
#list-wrap {
flex: 1;
overflow-y: auto;
padding: 8px 12px 8px;
background: var(--bg-base);
scroll-behavior: smooth;
}
#list {
list-style: none;
display: flex;
flex-direction: column;
gap: 6px;
}
/* 自定义滚动条 */
#list-wrap::-webkit-scrollbar {
width: 8px;
}
#list-wrap::-webkit-scrollbar-track {
background: transparent;
}
#list-wrap::-webkit-scrollbar-thumb {
background: var(--border);
border-radius: 4px;
border: none;
}
#list-wrap::-webkit-scrollbar-thumb:hover {
background: var(--text-muted);
}
/* ---------- 卡片 ---------- */
.card {
background: var(--bg-base);
border: 1px solid var(--border-soft);
border-radius: var(--radius-md);
padding: 9px 12px 10px;
cursor: pointer;
transition: background var(--transition-base), border-color var(--transition-base), box-shadow var(--transition-base), transform var(--transition-fast);
outline: none;
position: relative;
}
.card:hover {
background: var(--bg-soft);
border-color: var(--border);
box-shadow: var(--shadow-sm);
}
.card.selected {
background: var(--bg-overlay);
border-color: var(--border-strong);
border-left: 2px solid var(--accent);
padding-left: 11px;
}
.card.selected:hover {
border-color: var(--accent);
}
.card-head {
display: flex;
align-items: center;
gap: 6px;
margin-bottom: 4px;
}
.time {
font-size: var(--fs-xs);
color: var(--text-soft);
font-family: var(--font-mono);
font-variant-numeric: tabular-nums;
}
.badge {
display: inline-flex;
align-items: center;
gap: 3px;
font-size: 10.5px;
color: var(--text-soft);
background: var(--bg-hover);
border-radius: 4px;
padding: 1px 6px 1px 5px;
font-weight: var(--fw-medium);
line-height: 1.5;
letter-spacing: 0;
}
.badge svg {
display: block;
flex-shrink: 0;
opacity: 0.75;
}
.badge-image {
color: var(--accent);
}
.badge-image svg {
color: var(--accent);
opacity: 0.85;
}
.slot {
display: inline-flex;
align-items: center;
justify-content: center;
min-width: 20px;
height: 20px;
padding: 0 5px;
font-size: 11px;
font-weight: var(--fw-semibold);
color: var(--accent-text);
background: var(--accent);
border-radius: 5px;
letter-spacing: 0;
font-family: var(--font-mono);
line-height: 1;
}
.action {
-webkit-app-region: no-drag;
display: inline-flex;
align-items: center;
gap: 4px;
font-size: var(--fs-xs);
color: var(--text-muted);
background: transparent;
border: none;
padding: 4px 8px;
border-radius: 6px;
cursor: pointer;
opacity: 0;
line-height: 1;
transition: opacity var(--transition-fast), background var(--transition-fast), color var(--transition-fast);
}
.action svg {
display: block;
flex-shrink: 0;
}
.card:hover .action,
.card.selected .action {
opacity: 1;
}
.action:hover {
color: var(--text);
background: var(--bg-hover);
}
.action.copy {
color: var(--accent);
padding-right: 10px;
}
.action.copy:hover {
color: var(--accent);
background: var(--accent-hover);
}
.action.fav {
padding: 5px 7px;
color: var(--text-muted);
}
.action.fav svg {
fill: none;
transition: fill var(--transition-fast), color var(--transition-fast);
}
.action.fav.favorited {
color: var(--warn);
}
.action.fav.favorited svg {
fill: var(--warn);
}
.action.fav:hover {
color: var(--warn);
background: var(--warn-bg);
}
.action.delete {
padding: 5px 7px;
}
.action.delete:hover {
color: var(--danger);
background: var(--danger-bg);
}
.card-body {
display: flex;
align-items: center;
gap: 10px;
}
.text-preview {
flex: 1;
min-width: 0;
font-size: var(--fs-base);
color: var(--text);
/* 中文正文行高 ≥1.55 才不会出现笔画"黏连",这里设 1.6 让 13px 中文清爽 */
line-height: 1.6;
word-break: break-all;
display: -webkit-box;
-webkit-line-clamp: 2;
-webkit-box-orient: vertical;
overflow: hidden;
user-select: text;
cursor: text;
}
.image-preview {
width: 88px;
height: 88px;
border-radius: 8px;
border: 1px solid var(--border-soft);
object-fit: contain;
background:
linear-gradient(45deg, var(--bg-soft) 25%, transparent 25%),
linear-gradient(-45deg, var(--bg-soft) 25%, transparent 25%),
linear-gradient(45deg, transparent 75%, var(--bg-soft) 75%),
linear-gradient(-45deg, transparent 75%, var(--bg-soft) 75%);
background-size: 12px 12px;
background-position: 0 0, 0 6px, 6px -6px, -6px 0;
}
/* ---------- 空状态 ---------- */
#empty {
text-align: center;
padding: 64px 24px 56px;
color: var(--text-soft);
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
}
.empty-icon {
color: var(--text-muted);
opacity: 0.55;
margin-bottom: 14px;
}
.empty-title {
font-size: var(--fs-md);
color: var(--text-soft);
font-weight: var(--fw-medium);
margin-bottom: 6px;
}
#empty .muted {
font-size: var(--fs-sm);
color: var(--text-muted);
line-height: 1.5;
}
.hidden { display: none !important; }
/* ---------- 状态栏 ---------- */
#status {
display: flex;
justify-content: space-between;
align-items: center;
padding: 7px 14px;
border-top: 1px solid var(--border-soft);
background: var(--bg-base);
font-size: var(--fs-xs);
line-height: 1.4;
color: var(--text-muted);
user-select: none;
}
#status-left {
color: var(--text-soft);
}
.shortcuts {
display: inline-flex;
align-items: center;
gap: 4px;
flex-wrap: nowrap;
white-space: nowrap;
overflow: hidden;
}
.shortcuts .dot {
color: var(--text-muted);
opacity: 0.5;
margin: 0 1px;
}
kbd {
display: inline-flex;
align-items: center;
justify-content: center;
min-width: 18px;
height: 18px;
padding: 0 5px;
font-family: var(--font-mono);
font-size: 10.5px;
font-weight: var(--fw-medium);
color: var(--text-soft);
background: var(--bg-soft);
border: 1px solid var(--border);
border-radius: 4px;
line-height: 1;
box-shadow: 0 1px 0 rgba(0, 0, 0, 0.04);
}
/* ---------- 设置面板 ---------- */
#settings-body {
flex: 1;
min-height: 0;
overflow: hidden;
padding: 0;
background: var(--bg-base);
display: flex;
flex-direction: column;
}
/* ---------- 设置面板:标签栏 ---------- */
/* 用 #tab-bar 前缀隔离:上面「历史/收藏」Tab 也用 .tab但 padding/transition
* 跟设置页 Tab 不一样。两份样式同特异性时位置靠后的赢,会把主窗口 Tab
* 的 padding 从 5px 12px 改成 6px 10px 5px —— 显式框死作用域避免串味。 */
#tab-bar {
display: flex;
gap: 2px;
padding: 0 12px;
background: var(--bg-base);
border-bottom: 1px solid var(--border-soft);
}
#tab-bar .tab {
appearance: none;
background: transparent;
border: none;
border-bottom: 2px solid transparent;
padding: 8px 10px 7px;
font-family: inherit;
font-size: var(--fs-sm);
color: var(--text-soft);
cursor: pointer;
line-height: 1;
margin-bottom: -1px;
transition: color 0.12s ease, border-color 0.12s ease;
}
#tab-bar .tab:hover { color: var(--text); }
#tab-bar .tab[aria-selected="true"] {
color: var(--text);
border-bottom-color: var(--accent);
font-weight: var(--fw-semibold);
}
#tab-bar .tab:focus-visible { outline: 1px solid var(--accent); outline-offset: -2px; }
.tab-panel {
flex: 1;
min-height: 0;
overflow-y: auto;
padding: 14px 16px;
display: flex;
flex-direction: column;
gap: 8px;
}
/* 面板滚动条 —— 跟主列表统一 */
.tab-panel::-webkit-scrollbar { width: 8px; }
.tab-panel::-webkit-scrollbar-track { background: transparent; }
.tab-panel::-webkit-scrollbar-thumb {
background: var(--border);
border-radius: 4px;
}
.tab-panel::-webkit-scrollbar-thumb:hover { background: var(--text-muted); }
.tab-panel[hidden] { display: none !important; }
.setting-row {
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
padding: 11px 14px;
border: 1px solid var(--border-soft);
border-radius: var(--radius-md);
background: var(--bg-base);
line-height: 1.4;
transition: border-color var(--transition-fast);
}
.setting-row:hover {
border-color: var(--border);
}
.setting-row.column {
flex-direction: column;
align-items: stretch;
gap: 8px;
}
.setting-label { font-size: var(--fs-base); color: var(--text); font-weight: var(--fw-medium); }
.setting-row select {
height: 30px;
padding: 0 28px 0 10px;
border: 1px solid var(--border);
border-radius: 6px;
background: var(--bg-soft);
color: var(--text);
font-size: var(--fs-base);
line-height: 1.4;
outline: none;
cursor: pointer;
transition: border-color var(--transition-fast), box-shadow var(--transition-fast);
appearance: none;
-webkit-appearance: none;
background-image:
linear-gradient(45deg, transparent 50%, var(--text-muted) 50%),
linear-gradient(-45deg, transparent 50%, var(--text-muted) 50%);
background-position: calc(100% - 13px) 12px, calc(100% - 8px) 12px;
background-size: 5px 5px, 5px 5px;
background-repeat: no-repeat;
}
.setting-row select:hover { border-color: var(--border-strong); }
.setting-row select:focus {
border-color: var(--accent);
box-shadow: 0 0 0 3px var(--accent-soft);
}
.path-box {
margin-top: 4px;
padding: 9px 11px;
background: var(--bg-soft);
border-radius: 6px;
border: 1px solid var(--border-soft);
}
.path-text { font-size: var(--fs-sm); color: var(--text-soft); word-break: break-all; user-select: text; font-family: var(--font-mono); }
.btn-row { display: flex; gap: 8px; margin-top: 4px; }
.btn {
height: 30px;
padding: 0 12px;
border: 1px solid var(--border);
border-radius: 6px;
background: var(--bg-base);
color: var(--text);
font-size: var(--fs-sm);
line-height: 1;
cursor: pointer;
transition: background var(--transition-fast), border-color var(--transition-fast);
font-weight: var(--fw-medium);
}
.btn:hover { background: var(--bg-hover); border-color: var(--border-strong); }
.btn:active { background: var(--bg-soft); }
.btn-danger { color: var(--danger); border-color: var(--danger-bg); background: var(--danger-bg); }
.btn-danger:hover { background: var(--danger); color: var(--accent-text); border-color: var(--danger); }
.danger-zone { border-color: var(--danger-bg); }
.danger-zone:hover { border-color: rgba(239, 68, 68, 0.4); }
/* 开关 */
.switch { position: relative; display: inline-block; width: 36px; height: 20px; flex-shrink: 0; }
.switch input { opacity: 0; width: 0; height: 0; }
.slider-track {
position: absolute; inset: 0; cursor: pointer;
background: var(--border-strong); border-radius: 20px;
transition: background var(--transition-base);
}
.slider-track::before {
content: ""; position: absolute; height: 16px; width: 16px; left: 2px; top: 2px;
background: var(--bg-overlay); border-radius: 50%;
box-shadow: 0 1px 2px rgba(0, 0, 0, 0.15);
transition: transform var(--transition-base);
}
.switch input:checked + .slider-track { background: var(--accent); }
.switch input:checked + .slider-track::before { transform: translateX(16px); }
.switch input:focus-visible + .slider-track { box-shadow: 0 0 0 3px var(--accent-soft); }
/* 主题网格 */
.theme-grid {
display: grid;
grid-template-columns: repeat(3, 1fr);
gap: 10px;
margin-top: 4px;
}
.theme-card {
cursor: pointer;
border: 2px solid var(--border-soft);
border-radius: var(--radius-md);
padding: 6px;
background: var(--bg-base);
transition: border-color var(--transition-base), transform var(--transition-fast), box-shadow var(--transition-base);
text-align: left;
}
.theme-card:hover {
border-color: var(--border-strong);
transform: translateY(-1px);
box-shadow: var(--shadow-sm);
}
.theme-card.active {
border-color: var(--accent);
box-shadow: 0 0 0 1px var(--accent) inset;
}
.theme-preview {
position: relative;
height: 56px;
border-radius: var(--radius-sm);
display: flex;
align-items: center;
justify-content: flex-start;
padding: 8px 12px;
border: 1px solid transparent;
overflow: hidden;
}
.theme-preview-text {
font-family: var(--font-ui);
font-weight: var(--fw-semibold);
font-size: 22px;
letter-spacing: -0.5px;
}
.theme-preview-accent {
position: absolute;
right: 10px;
top: 10px;
width: 12px;
height: 12px;
border-radius: 50%;
box-shadow: 0 0 0 2px rgba(255, 255, 255, 0.1);
}
.theme-info {
margin-top: 7px;
}
.theme-name {
font-size: var(--fs-sm);
color: var(--text);
font-weight: var(--fw-medium);
}
.theme-desc {
font-size: 11px;
color: var(--text-muted);
margin-top: 1px;
}
/* 强调色 */
.accent-grid {
display: flex;
gap: 10px;
margin-top: 4px;
}
.accent-swatch {
width: 28px;
height: 28px;
border-radius: 50%;
border: 2px solid var(--border-soft);
cursor: pointer;
padding: 0;
position: relative;
transition: transform var(--transition-fast), box-shadow var(--transition-base);
}
.accent-swatch:hover {
transform: scale(1.08);
}
.accent-swatch.active {
box-shadow: 0 0 0 2px var(--bg-base), 0 0 0 4px var(--text);
}
.accent-swatch.active::after {
content: "";
position: absolute;
inset: 0;
border-radius: 50%;
background-image: url("data:image/svg+xml;utf8,<svg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 16 16' fill='none' stroke='white' stroke-width='2' stroke-linecap='round' stroke-linejoin='round'><path d='M3.5 8.5l3 3 6-6.5'/></svg>");
background-size: 16px 16px;
background-position: center;
background-repeat: no-repeat;
}
/* ---------- 设置面板:关于页 ---------- */
.about-head {
text-align: center;
padding: 18px 0 8px;
display: flex;
flex-direction: column;
align-items: center;
gap: 6px;
}
.about-icon {
width: 44px;
height: 44px;
border-radius: 10px;
background: linear-gradient(135deg, var(--accent), var(--accent-hover));
display: flex;
align-items: center;
justify-content: center;
color: var(--accent-text);
margin-bottom: 6px;
box-shadow: 0 2px 8px var(--accent-soft);
}
.about-icon svg {
display: block;
}
.about-name {
font-size: var(--fs-lg);
font-weight: var(--fw-semibold);
color: var(--text);
letter-spacing: 0.2px;
}
.about-tagline {
font-size: var(--fs-xs);
color: var(--text-muted);
margin-top: 2px;
}
.about-devline {
display: flex;
align-items: baseline;
flex-wrap: wrap;
gap: 10px;
margin-top: 4px;
}
.about-dev { font-size: var(--fs-base); color: var(--text); }
.ext-link {
font-size: var(--fs-sm);
color: var(--accent);
text-decoration: none;
word-break: break-all;
cursor: pointer;
transition: opacity var(--transition-fast);
}
.ext-link:hover { text-decoration: underline; opacity: 0.85; }

21
renderer/theme-boot.js Normal file
View File

@@ -0,0 +1,21 @@
'use strict';
/**
* 首屏主题预应用:在首次绘制前从 localStorage 读取上次的主题/强调色,
* 避免等 IPC 返回期间的白屏闪烁。
*
* 必须是「外部脚本」——页面 CSP 为 default-src 'self',未开 script-src 'unsafe-inline'
* 内联 <script> 会被直接拒绝执行。
*/
try {
const cached = JSON.parse(localStorage.getItem('clipboard-theme') || 'null');
if (cached) {
// 优先用 effectiveTheme「跟随系统」开时它就是上次实际显示的主题
// 直接套上去能省一次闪。旧版缓存里只有 theme旧版在 followSystem
// 开时存的是 raw会闪一下这里向后兼容。
const theme = cached.effectiveTheme || cached.theme;
if (theme) {
document.documentElement.setAttribute('data-theme', theme);
document.documentElement.setAttribute('data-accent', cached.accent || 'purple');
}
}
} catch (e) { /* 静默 */ }

View File

@@ -0,0 +1,36 @@
/**
* accents.css — 5 套强调色覆盖
* 通过 <html data-accent="..."> 切换
*/
/* 默认(兜底)—— Linear Purple */
:root,
:root[data-accent="purple"] {
--accent: #5E6AD2;
--accent-soft: rgba(94, 106, 210, 0.12);
--accent-hover: rgba(94, 106, 210, 0.20);
}
:root[data-accent="blue"] {
--accent: #3B82F6;
--accent-soft: rgba(59, 130, 246, 0.12);
--accent-hover: rgba(59, 130, 246, 0.20);
}
:root[data-accent="green"] {
--accent: #10B981;
--accent-soft: rgba(16, 185, 129, 0.12);
--accent-hover: rgba(16, 185, 129, 0.20);
}
:root[data-accent="orange"] {
--accent: #F59E0B;
--accent-soft: rgba(245, 158, 11, 0.12);
--accent-hover: rgba(245, 158, 11, 0.20);
}
:root[data-accent="red"] {
--accent: #EF4444;
--accent-soft: rgba(239, 68, 68, 0.12);
--accent-hover: rgba(239, 68, 68, 0.20);
}

30
renderer/themes/fonts.css Normal file
View File

@@ -0,0 +1,30 @@
/**
* fonts.css — Inter + JetBrains Mono 本地字体加载
*/
@font-face {
font-family: 'Inter';
src: url('./fonts/Inter-Variable.woff2') format('woff2');
font-weight: 100 900;
font-style: normal;
font-display: swap;
}
@font-face {
font-family: 'JetBrains Mono';
src: url('./fonts/JetBrainsMono-Regular.woff2') format('woff2');
font-weight: 400 700;
font-style: normal;
font-display: swap;
}
/* 字体变量赋值
* 中文优先西文Inter排在中文兜底之后App 里大部分文本是中文,
* 让 YaHei/PingFang 先匹配笔画渲染CJK 之外再走 Inter 拿更好的西文观感。 */
:root {
--font-ui: 'Inter', 'Microsoft YaHei UI', 'Microsoft YaHei', 'PingFang SC',
'Hiragino Sans GB', 'Segoe UI', system-ui, -apple-system,
BlinkMacSystemFont, sans-serif;
--font-mono: 'JetBrains Mono', 'Cascadia Code', 'Consolas',
'Microsoft YaHei', monospace;
}

Binary file not shown.

Binary file not shown.

View File

@@ -0,0 +1,96 @@
/**
* themes.css — 6 套主题的颜色定义
* 通过 <html data-theme="..."> 切换
*/
/* 默认(兜底)—— Linear Dark */
:root,
:root[data-theme="linear"] {
--bg-base: #0E0E12;
--bg-soft: #16161B;
--bg-overlay: #1F1F26;
--bg-hover: #22222A;
--border: #2A2A33;
--border-soft: #1E1E25;
--border-strong: #3A3A45;
--text: #ECECEF;
--text-soft: #B4B4BC;
--text-muted: #95959F;
--shadow-sm: 0 1px 2px rgba(0, 0, 0, 0.30);
}
/* Midnight */
:root[data-theme="midnight"] {
--bg-base: #08090B;
--bg-soft: #0F1013;
--bg-overlay: #18191E;
--bg-hover: #1C1D22;
--border: #26272D;
--border-soft: #1B1C20;
--border-strong: #34353C;
--text: #ECECEE;
--text-soft: #ACADB2;
--text-muted: #8E8F96;
--shadow-sm: 0 1px 2px rgba(0, 0, 0, 0.40);
}
/* Carbon */
:root[data-theme="carbon"] {
--bg-base: #1A1D23;
--bg-soft: #22262D;
--bg-overlay: #2A2F37;
--bg-hover: #2E333B;
--border: #353A42;
--border-soft: #252930;
--border-strong: #42474F;
--text: #DCDCDC;
--text-soft: #9DA3AC;
--text-muted: #8A929C;
--shadow-sm: 0 1px 2px rgba(0, 0, 0, 0.25);
}
/* Solarized Dark —— 原本 muted 在 #002B36 上只有 ~3.7:1对中文小字会"雾蒙蒙"
全部提亮一档让所有层级都达到 ≥5:1 的可读舒适线。 */
:root[data-theme="solarized"] {
--bg-base: #002B36;
--bg-soft: #073642;
--bg-overlay: #0A4554;
--bg-hover: #0E5466;
--border: #1F6779;
--border-soft: #0A4554;
--border-strong: #2680A5;
--text: #B6CAD0;
--text-soft: #9DB1B8;
--text-muted: #88A0A8;
--shadow-sm: 0 1px 2px rgba(0, 0, 0, 0.35);
}
/* GitHub Dark */
:root[data-theme="github"] {
--bg-base: #0D1117;
--bg-soft: #161B22;
--bg-overlay: #1F242C;
--bg-hover: #22272E;
--border: #30363D;
--border-soft: #21262D;
--border-strong: #444C56;
--text: #DCE3EA;
--text-soft: #9DA8B3;
--text-muted: #8A95A0;
--shadow-sm: 0 1px 2px rgba(0, 0, 0, 0.30);
}
/* Light Companion —— 把 muted 略压暗一档,避免浅底浅字反差不足。 */
:root[data-theme="light"] {
--bg-base: #FAFAFA;
--bg-soft: #F4F4F5;
--bg-overlay: #FFFFFF;
--bg-hover: #ECECEE;
--border: #E4E4E7;
--border-soft: #F0F0F2;
--border-strong: #D4D4D8;
--text: #111827;
--text-soft: #4B5563;
--text-muted: #5C6573;
--shadow-sm: 0 1px 2px rgba(0, 0, 0, 0.06);
}

View File

@@ -0,0 +1,71 @@
/**
* tokens.css — 颜色/间距/字号/圆角/阴影/字体语义化变量
* 仅定义变量,具体值由 themes.css 和 accents.css 提供。
*/
:root {
/* 背景层级(从底层到顶层) */
--bg-base: #FFFFFF;
--bg-soft: #F7F8FA;
--bg-overlay: #FFFFFF;
--bg-hover: #F3F4F6;
/* 边框 */
--border: #E5E7EB;
--border-soft: #F1F2F4;
--border-strong: #D1D5DB;
/* 文字层级muted 保持可读:对底色对比 ≥ 3:1仅作 placeholder/辅助信息) */
--text: #111827;
--text-soft: #4B5563;
--text-muted: #6B7280;
/* 强调色(被 accents.css 覆盖) */
--accent: #5E6AD2;
--accent-soft: rgba(94, 106, 210, 0.12);
--accent-hover: rgba(94, 106, 210, 0.20);
--accent-text: #FFFFFF;
/* 状态色 */
--success: #10B981;
--warn: #F59E0B;
--warn-bg: rgba(245, 158, 11, 0.12);
--danger: #EF4444;
--danger-bg: rgba(239, 68, 68, 0.12);
/* 阴影 */
--shadow-sm: 0 1px 2px rgba(0, 0, 0, 0.05);
/* 圆角 */
--radius-sm: 6px;
--radius-md: 10px;
--radius-lg: 12px;
/* 字号(中文偏小容易糊,整体往上提一档;列表正文单独压一档避免占空间) */
--fs-xs: 11px; /* 时间戳/角标:再小就糊 */
--fs-sm: 12px; /* Tab/状态栏/设置副文字 */
--fs-base: 13px; /* 列表正文:清晰度优先 */
--fs-md: 14px; /* 标题/按钮/设置项 */
--fs-lg: 16px; /* 「关于」大标题 */
/* 行高中文需要更大行高才不会笔画粘连1.6 是 Windows 上 CJK 舒适区) */
--lh-base: 1.6;
/* 字间距提一档让小字笔画清晰0.02em 在 13px 以上会显得字间距过大,收到 0.01em */
--tracking-cn: 0.01em;
/* 字体fonts.css 会覆盖这两个) */
--font-ui: 'Microsoft YaHei UI', 'Microsoft YaHei', 'PingFang SC',
'Hiragino Sans GB', 'Segoe UI', system-ui, -apple-system,
BlinkMacSystemFont, sans-serif;
--font-mono: 'JetBrains Mono', 'Cascadia Code', 'Consolas',
'Microsoft YaHei', monospace;
/* 字重 */
--fw-medium: 500;
--fw-semibold: 600;
/* 动效 */
--transition-fast: 100ms ease-out;
--transition-base: 150ms ease-out;
}

View File

@@ -0,0 +1,20 @@
'use strict';
const assert = require('node:assert');
const { computePollMs } = require('../lib/adaptive-poll.js');
let passed = 0;
function check(name, fn) {
try { fn(); console.log(' ok -', name); passed++; }
catch (e) { console.error(' FAIL -', name, e.message); process.exitCode = 1; }
}
check('visible returns base interval', () => {
assert.strictEqual(computePollMs(1000, true), 1000);
});
check('hidden doubles the interval, capped at 4s', () => {
assert.strictEqual(computePollMs(500, false), 1000);
assert.strictEqual(computePollMs(1000, false), 2000);
assert.strictEqual(computePollMs(2000, false), 4000);
assert.strictEqual(computePollMs(2000, false, { cap: 3000 }), 3000);
});

View File

@@ -0,0 +1,62 @@
'use strict';
// 回归测试:选择与当前相同目录的不同大小写形式,应当判为"已是当前数据位置"。
//
// 被测对象是 lib/same-dir.js —— main.jssettings:choose-dir与 config.js
// migrateLegacyData共用它。之前本测试自己内联了一份实现来测而 main.js
// 实际用的是原始 `===`Windows 大小写不一致时误判为不同目录,自己迁自己),
// 测试却一路绿灯 —— 内联副本与生产代码脱节。现在直接测共享逻辑本体。
const assert = require('node:assert');
const path = require('node:path');
const { isSameDir } = require('../lib/same-dir');
let passed = 0;
function check(name, fn) {
try { fn(); console.log(' ok -', name); passed++; }
catch (e) { console.error(' FAIL -', name, '\n', e.message); process.exitCode = 1; }
}
check('Windows 同目录不同大小写:应判为相同', () => {
if (process.platform !== 'win32') {
console.log(' (skipped on non-Windows)');
return;
}
assert.strictEqual(
isSameDir('C:\\Users\\Foo\\.clipboard-app', 'c:\\users\\foo\\.clipboard-app'),
true
);
});
check('Windows 反斜杠/正斜杠混用:应判为相同', () => {
if (process.platform !== 'win32') {
console.log(' (skipped on non-Windows)');
return;
}
// path.resolve 会统一为反斜杠
const a = path.resolve('C:/Users/Foo/.clipboard-app');
const b = path.resolve('C:\\Users\\Foo\\.clipboard-app');
assert.strictEqual(isSameDir(a, b), true);
});
check('Windows 不同盘符:应判为不同', () => {
if (process.platform !== 'win32') {
console.log(' (skipped on non-Windows)');
return;
}
assert.strictEqual(
isSameDir('C:\\foo', 'D:\\foo'),
false
);
});
check('POSIX 严格比较(不同大小写应判为不同)', () => {
if (process.platform === 'win32') {
console.log(' (skipped on Windows)');
return;
}
assert.strictEqual(
isSameDir('/home/Foo/.clipboard-app', '/home/foo/.clipboard-app'),
false
);
});
console.log(`\n${passed} checks passed`);

177
test/clip-limit.test.js Normal file
View File

@@ -0,0 +1,177 @@
'use strict';
/**
* 入库绑定 + 限额清理:应用里唯一会主动删用户数据的两条路径。
*
* 起因2026-07-31 体检insertText / insertImage 的调用把 `now` 绑到了
* top_at 列(占位符第 3 个是 top_at导致每条新记录都"被置顶"——
* trimOldest / clearNonTop 只删 top_at IS NULL 的行,于是限额清理和
* 「清空历史」在会话内全部静默失效history.db 可无限膨胀。启动时会清空
* 所有 top_at所以重启后"看起来正常",藏了很久。
*
* main.js 不能脱离 Electron 加载,这里两层守:
* 1. 静态:插入调用的第 3 个实参必须是 nulltop_at
* 2. 行为:把 main.js 源码里的 trimOldest / clearNonTop / INSERT **原样
* 提取出来**在真 SQLite 上跑 —— 测的是生产 SQL 本身,不是克隆副本。
*/
const assert = require('node:assert');
const fs = require('node:fs');
const os = require('node:os');
const path = require('node:path');
const Database = require('better-sqlite3');
const mainSrc = fs.readFileSync(path.join(__dirname, '..', 'main.js'), 'utf8');
let passed = 0;
function check(name, fn) {
try { fn(); console.log(' ok -', name); passed++; }
catch (e) { console.error(' FAIL -', name, '\n', e.message); process.exitCode = 1; }
}
// ---------- 静态:插入调用必须给 top_at 绑 null ----------
check('insertText 调用给 top_at 绑 null不是 now', () => {
assert.match(
mainSrc,
/stmt\.insertText\.run\(\s*text\s*,\s*makePreview\(text\)\s*,\s*null\s*,\s*now\s*\)/,
'insertText.run 的第 3 个实参top_at 占位符)必须是 null —— 绑 now 会让每条新记录被当成置顶,限额清理全废'
);
});
check('insertImage 调用给 top_at 绑 null不是 now', () => {
assert.match(
mainSrc,
/stmt\.insertImage\.run\(\s*resized\s*,\s*preview\s*,\s*null\s*,\s*now\s*\)/,
'insertImage.run 的第 3 个实参top_at 占位符)必须是 null'
);
});
// ---------- 提取生产 SQL ----------
function extractSql(anchor) {
const re = new RegExp(`stmt\\.${anchor}\\s*=\\s*db\\.prepare\\(\\s*\`([\\s\\S]*?)\`\\s*\\)`);
const m = mainSrc.match(re);
assert.ok(m, `main.js 里找不到 stmt.${anchor} 的 prepare`);
return m[1];
}
const INSERT_TEXT = extractSql('insertText');
const TRIM_OLDEST = extractSql('trimOldest');
const CLEAR_NON_TOP = extractSql('clearNonTop');
const MOVE_TO_TOP = extractSql('moveToTop');
check('INSERT 语句的占位符顺序确实是 (text, preview, top_at, created_at)', () => {
// 静态断言成立的前提占位符顺序变了而调用没跟着变null 会落到别的列
assert.match(INSERT_TEXT, /INSERT INTO clips\(\s*type\s*,\s*text\s*,\s*preview\s*,\s*top_at\s*,\s*created_at\s*\)/);
assert.match(INSERT_TEXT, /VALUES\(\s*'text'\s*,\s*\?\s*,\s*\?\s*,\s*\?\s*,\s*\?\s*\)/);
});
check('trimOldest 只删 top_at IS NULL、按 created_at ASC', () => {
assert.match(TRIM_OLDEST, /top_at IS NULL/, '少了 top_at IS NULL 会删掉用户置顶项');
assert.match(TRIM_OLDEST, /ORDER BY created_at ASC/, '删的顺序必须是最旧优先');
});
// ---------- 行为:拿提取出来的 SQL 在真库上跑 ----------
function makeDb() {
const tmp = fs.mkdtempSync(path.join(os.tmpdir(), 'clip-limit-'));
const db = new Database(path.join(tmp, 'history.db'));
db.exec(`
CREATE TABLE clips (
id INTEGER PRIMARY KEY AUTOINCREMENT,
type TEXT NOT NULL,
text TEXT,
image BLOB,
preview TEXT,
top_at REAL,
created_at REAL NOT NULL
)
`);
return db;
}
// 与 main.js 现在一致的绑定方式:(text, preview, top_at=null, created_at)
function seedNatural(db, n) {
const ins = db.prepare(INSERT_TEXT);
for (let i = 1; i <= n; i++) ins.run(`clip-${i}`, `clip-${i}`, null, i);
}
check('新插入的记录 top_at 为 NULL自然位置', () => {
const db = makeDb();
seedNatural(db, 3);
const rows = db.prepare('SELECT id, top_at FROM clips').all();
assert.strictEqual(rows.length, 3);
for (const r of rows) assert.strictEqual(r.top_at, null, `id=${r.id} 不应被置顶`);
db.close();
});
check('超限清理:只删最旧的自然项,置顶项不动', () => {
const db = makeDb();
seedNatural(db, 5); // created_at 1..5
db.prepare('UPDATE clips SET top_at = 100 WHERE created_at IN (4, 5)').run();
const trim = db.prepare(TRIM_OLDEST);
const r = trim.run(2); // 5 条保留 3 条 → 删 2
assert.strictEqual(r.changes, 2);
const left = db.prepare('SELECT created_at FROM clips ORDER BY created_at').all().map(x => x.created_at);
assert.deepStrictEqual(left, [3, 4, 5], '应删掉最旧的两条自然项1、2置顶的 4、5 保留');
db.close();
});
check('过量删除也碰不到置顶项', () => {
const db = makeDb();
seedNatural(db, 5);
db.prepare('UPDATE clips SET top_at = 100 WHERE created_at IN (4, 5)').run();
const r = db.prepare(TRIM_OLDEST).run(99);
assert.strictEqual(r.changes, 3, '只能删掉 3 条自然项');
const left = db.prepare('SELECT created_at FROM clips ORDER BY created_at').all().map(x => x.created_at);
assert.deepStrictEqual(left, [4, 5], '置顶项必须全部幸存');
db.close();
});
check('clearNonTop清空历史同样只动自然项', () => {
const db = makeDb();
seedNatural(db, 4);
db.prepare('UPDATE clips SET top_at = 100 WHERE created_at = 4').run();
const r = db.prepare(CLEAR_NON_TOP).run();
assert.strictEqual(r.changes, 3);
const left = db.prepare('SELECT created_at FROM clips').all().map(x => x.created_at);
assert.deepStrictEqual(left, [4], '只剩置顶的那条');
db.close();
});
check('置顶是会话级的:启动清顶后这些行又能被正常清理', () => {
const db = makeDb();
seedNatural(db, 3);
db.prepare('UPDATE clips SET top_at = 100').run();
// 模拟 main.js openDb 里的启动清顶
db.prepare('UPDATE clips SET top_at = NULL WHERE top_at IS NOT NULL').run();
const r = db.prepare(TRIM_OLDEST).run(3);
assert.strictEqual(r.changes, 3, '清顶后所有行都回到自然位置,可被删');
db.close();
});
check('moveToTop 同时更新 top_at 和 created_at让点过的项保持「最近活跃」', () => {
// 用户点过复制的项要排到顶 —— 单纯更新 top_at 不够created_at 也得一起
// bump 到 now否则列表排序ORDER BY created_at DESC看不到这次活跃。
assert.match(MOVE_TO_TOP, /UPDATE\s+clips\s+SET\s+top_at\s*=\s*\?\s*,\s*created_at\s*=\s*\?\s+WHERE\s+id\s*=\s*\?/,
'moveToTop 必须同时更新 top_at 和 created_at');
});
check('moveToTop 把 created_at bump 到 now —— 已点项重新活跃,排到顶', () => {
const db = makeDb();
seedNatural(db, 3); // created_at 1..3
const move = db.prepare(MOVE_TO_TOP);
// 点 id=1 的「复制」:把它的 top_at / created_at 都置到 1000
move.run(1000, 1000, 1);
// 用新排序取 → 刚被点过的 id=1 必须在最顶
const rows = db.prepare(`SELECT id FROM clips ORDER BY created_at DESC, id DESC`).all();
assert.deepStrictEqual(rows.map(r => r.id), [1, 3, 2],
'点过的项created_at=1000必须排在原始 created_at=3、2 的前面');
// 而且 top_at 也被置上了trim / 清空历史 仍会跳过它
const r1 = db.prepare('SELECT top_at, created_at FROM clips WHERE id = 1').get();
assert.strictEqual(r1.top_at, 1000);
assert.strictEqual(r1.created_at, 1000);
db.close();
});
console.log(`\n${passed} checks passed`);

View File

@@ -0,0 +1,91 @@
'use strict';
const assert = require('node:assert');
const fs = require('node:fs');
const path = require('node:path');
const rendererSrc = fs.readFileSync(
path.join(__dirname, '..', 'renderer', 'renderer.js'), 'utf8'
);
let passed = 0;
function check(name, fn) {
try { fn(); console.log(' ok -', name); passed++; }
catch (e) { console.error(' FAIL -', name, e.message); process.exitCode = 1; }
}
// ---------- buildCard 抽函数 ----------
check('renderer.js 定义了 buildCard 函数', () => {
assert.ok(
/function\s+buildCard\s*\(/.test(rendererSrc),
'找不到 buildCard(c, i) 定义'
);
});
check('render() 调用 buildCard 而不是手写卡片构建', () => {
const renderFn = rendererSrc.match(/function\s+render\s*\(\s*\)\s*\{[\s\S]*?\n\}/);
assert.ok(renderFn, '找不到 render()');
assert.ok(
/buildCard\s*\(/.test(renderFn[0]),
'render() 没调用 buildCard'
);
// 抽函数后render() 内不应该有 cloneNode(true) 这种属于 buildCard 的细节
assert.ok(
!/cloneNode\s*\(\s*true\s*\)/.test(renderFn[0]),
'render() 还在做 cloneNodebuildCard 抽得不够彻底'
);
});
// ---------- onAdded 增量插入路径 ----------
check('onAdded 在 history Tab + 无搜索时走 prepend 而非 reload', () => {
const onAdded = rendererSrc.match(/onAdded\s*\(\s*\(\s*\w+\s*\)\s*=>\s*\{[\s\S]*?\}\s*\)/);
assert.ok(onAdded, '找不到 onAdded 回调');
// 存在非 reload 的快路径增量插入。reload 只应出现在跳过分支(收藏 Tab/
// 搜索中)和 catch 兜底里 —— 最多 2 次。快路径由下一条断言insertBefore/
// unshift单独保证。原计划写死 <=1 与 spec 强制的 catch fallback 冲突,
// 这里修正为 <=2 以匹配 spec见 task-7 报告的 controller addendum
const reloadCount = (onAdded[0].match(/\breload\s*\(/g) || []).length;
assert.ok(reloadCount <= 2, `onAdded 内 reload 次数 ${reloadCount},应是跳过分支 + catch 兜底 ≤ 2 次`);
// 应该有 prepend 路径或 append 增量逻辑
assert.ok(
/insertBefore|prepend|unshift/.test(onAdded[0]),
'onAdded 没走增量插入路径(看不到 insertBefore / unshift'
);
});
check('onAdded 跳过条件:搜索框非空或非 history Tab', () => {
const onAdded = rendererSrc.match(/onAdded\s*\(\s*\(\s*\w+\s*\)\s*=>\s*\{[\s\S]*?\}\s*\)/);
assert.ok(onAdded, '找不到 onAdded 回调');
assert.ok(
/currentTab\s*!==\s*'history'/.test(onAdded[0]),
'onAdded 没判断非 history Tab 时跳过 prepend'
);
assert.ok(
/\$search\.value\.trim\s*\(\s*\)\s*!==\s*''/.test(onAdded[0]),
'onAdded 没判断搜索框非空时跳过 prepend'
);
});
// ---------- 增量插入的两个回归坑 ----------
check('prependClip 熄灭空状态(#empty', () => {
// 空列表时 render() 亮起 #empty第一条增量不走 render()
// 不主动隐藏就会出现"卡片 + 暂无内容"同屏。
const prepend = rendererSrc.match(/function\s+prependClip\s*\([\s\S]*?\n\}/);
assert.ok(prepend, '找不到 prependClip()');
assert.ok(
/\$empty\.classList\.add\(\s*'hidden'\s*\)/.test(prepend[0]),
'prependClip 没有隐藏 #empty —— 空列表收到第一条增量时空状态不熄灭'
);
});
check('buildCard 的点击选中按运行时下标,不捕获构建时 i', () => {
// prependClip 会把已有卡片整体后移,捕获构建时下标的闭包会选中错误的项。
assert.ok(
/node\.onclick\s*=\s*\(\)\s*=>\s*select\(\s*\$cards\.indexOf\(node\)\s*\)/.test(rendererSrc),
'node.onclick 应在点击时从 $cards 现取下标select($cards.indexOf(node))),而不是捕获构建时的 i'
);
});
console.log(`\n${passed} checks passed`);

64
test/clip-payload.test.js Normal file
View File

@@ -0,0 +1,64 @@
'use strict';
/**
* 回归测试:图片条目复制到剪贴板
*
* 曾经的 bugcopyClipToClipboard 先 rowToClip(row) 再读 clip.image
* 但 rowToClip 产出的字段叫 imageBase64没有 .image。
* Buffer.from(undefined) 抛 TypeError被 catch 吞掉 —— 所有图片记录
* 的"复制"都静默失败,用户按 Ctrl+V 粘到的是上一次剪贴板里的东西。
*/
const assert = require('node:assert');
const { clipPayload } = require('../lib/clip-payload.js');
let passed = 0;
function check(name, fn) {
try { fn(); console.log(' ok -', name); passed++; }
catch (e) { console.error(' FAIL -', name, e.message); process.exitCode = 1; }
}
check('text row yields its text', () => {
const p = clipPayload({ type: 'text', text: 'hello', image: null });
assert.deepStrictEqual(p, { kind: 'text', text: 'hello' });
});
check('text row with null text yields empty string, not crash', () => {
assert.deepStrictEqual(clipPayload({ type: 'text', text: null }), { kind: 'text', text: '' });
});
check('image row yields the raw BLOB as a Buffer', () => {
const blob = Buffer.from([0x89, 0x50, 0x4e, 0x47]);
const p = clipPayload({ type: 'image', text: null, image: blob });
assert.strictEqual(p.kind, 'image');
assert.ok(Buffer.isBuffer(p.buffer));
assert.deepStrictEqual([...p.buffer], [0x89, 0x50, 0x4e, 0x47]);
});
check('image row survives a Uint8Array (what sqlite may hand back)', () => {
const p = clipPayload({ type: 'image', image: new Uint8Array([1, 2, 3]) });
assert.ok(Buffer.isBuffer(p.buffer));
assert.deepStrictEqual([...p.buffer], [1, 2, 3]);
});
// 这条正是当年的失败模式:把 rowToClip 的产物喂进来(只有 imageBase64没有 image
check('a row missing the image column throws a clear error, not a TypeError', () => {
const rowToClipShaped = { type: 'image', text: '', imageBase64: 'iVBORw0KGgo=', preview: '[图片]' };
assert.throws(() => clipPayload(rowToClipShaped), (e) => {
assert.ok(!(e instanceof TypeError), '不应是 Buffer.from(undefined) 那种 TypeError');
assert.match(e.message, /图片数据为空/);
return true;
});
});
check('empty image blob is rejected', () => {
assert.throws(() => clipPayload({ type: 'image', image: Buffer.alloc(0) }), /图片数据为空/);
});
check('missing row is rejected', () => {
assert.throws(() => clipPayload(null), /记录不存在/);
});
check('unknown type is rejected', () => {
assert.throws(() => clipPayload({ type: 'video' }), /未知的记录类型/);
});
console.log(`\n${passed} checks passed`);

110
test/config.test.js Normal file
View File

@@ -0,0 +1,110 @@
'use strict';
const assert = require('node:assert');
const fs = require('node:fs');
const os = require('node:os');
const path = require('node:path');
// 用临时目录隔离,避免污染真实配置。
// 默认数据目录现在是"项目根/data",不再跟 HOME 走 —— 必须用
// CLIPBOARD_APP_DATA_DIR 覆盖,否则测试会写进仓库里的 data/。
const tmpHome = fs.mkdtempSync(path.join(os.tmpdir(), 'clip-cfg-'));
process.env.USERPROFILE = tmpHome;
process.env.HOME = tmpHome;
process.env.CLIPBOARD_APP_DATA_DIR = path.join(tmpHome, 'data');
const cfg = require('../config.js');
let passed = 0;
function check(name, fn) {
try { fn(); console.log(' ok -', name); passed++; }
catch (e) { console.error(' FAIL -', name, '\n', e.message); process.exitCode = 1; }
}
check('defaults 结构完整', () => {
assert.strictEqual(cfg.DEFAULTS.autoLaunch, false);
assert.strictEqual(cfg.DEFAULTS.maxItems, 0);
assert.strictEqual(cfg.DEFAULTS.displayLimit, 300);
assert.strictEqual(cfg.DEFAULTS.pollMs, 1000);
assert.strictEqual(cfg.DEFAULTS.hotkey, 'Ctrl+Alt+V');
assert.ok(typeof cfg.DEFAULTS.dataDir === 'string' && cfg.DEFAULTS.dataDir.length > 0);
});
check('默认数据目录不是 ~/.clipboard-app且被 CLIPBOARD_APP_DATA_DIR 覆盖', () => {
// 不去硬编码 'D:\\Clipboard Data':那是个环境相关的常量,谁换机就炸。
// 这里要测的是两条契约1) 不再是 home 下的 .clipboard-app2) 环境变量能覆盖。
assert.notStrictEqual(path.resolve(cfg.defaultDataDir()), path.resolve(cfg.legacyDataDir()));
assert.strictEqual(path.resolve(cfg.defaultDataDir()), path.resolve(tmpHome, 'data'));
// config.json 跟着默认目录走
assert.strictEqual(path.dirname(cfg.getConfigPath()), path.resolve(cfg.defaultDataDir()));
});
check('默认数据目录D 盘可用时首选 D 盘', () => {
// 默认目录的契约有两个 caseD 盘存在 → 首选 D:\Clipboard Data
// 不存在 → 降级到 home 下的 Clipboard Data。这条只测前者。
const saved = process.env.CLIPBOARD_APP_DATA_DIR;
delete process.env.CLIPBOARD_APP_DATA_DIR;
delete require.cache[require.resolve('../config.js')];
const cfg2 = require('../config.js');
// D 盘在 CI / 开发机上不一定存在,强行断言会让套件不可移植。
// 这里只断言「D 盘可用时返回 D 盘」:先探测一次,能用才断言。
process.env.CLIPBOARD_APP_DATA_DIR = saved;
if (cfg2.isDirUsable('D:\\Clipboard Data')) {
assert.strictEqual(path.resolve(cfg2.defaultDataDir()), path.resolve('D:\\Clipboard Data'));
} else {
// 当前机器 D 盘不可用 → 跳过本断言fallback case 在 data-dir-fallback.test.js
console.log(' (D:\\Clipboard Data 不可用,跳过 — 见 data-dir-fallback.test.js)');
}
});
check('缺文件时 loadConfig 返回全默认', () => {
const c = cfg.loadConfig();
assert.strictEqual(c.maxItems, 0);
assert.strictEqual(c.displayLimit, 300);
assert.strictEqual(c.pollMs, 1000);
assert.strictEqual(c.autoLaunch, false);
});
check('sanitize 回落非法枚举值', () => {
const c = cfg.sanitize({ maxItems: 999, displayLimit: 42, pollMs: 17, autoLaunch: 'yes', dataDir: 42 });
assert.strictEqual(c.maxItems, 0);
assert.strictEqual(c.displayLimit, 300);
assert.strictEqual(c.pollMs, 1000);
assert.strictEqual(c.autoLaunch, false);
assert.strictEqual(typeof c.dataDir, 'string');
});
check('sanitize hotkey非法值回落默认合法预设保留空串 = 禁用不算非法', () => {
// 自由组合键不认 —— 设置界面只给预设档位,白名单外的一律回落
assert.strictEqual(cfg.sanitize({ hotkey: 'Ctrl+Shift+F13' }).hotkey, 'Ctrl+Alt+V');
assert.strictEqual(cfg.sanitize({ hotkey: 42 }).hotkey, 'Ctrl+Alt+V');
assert.strictEqual(cfg.sanitize({ hotkey: 'Alt+Shift+V' }).hotkey, 'Alt+Shift+V');
// '' 是明确的「禁用」语义,绝不能回落默认 —— 否则禁不掉
assert.strictEqual(cfg.sanitize({ hotkey: '' }).hotkey, '');
// 老配置没有 hotkey 字段(升级场景)→ 默认,等于自动获得快捷键
assert.strictEqual(cfg.sanitize({}).hotkey, 'Ctrl+Alt+V');
// 默认值本身必须在白名单里(否则首次启动的 register 必炸)
assert.ok(cfg.ALLOWED_HOTKEYS.includes(cfg.DEFAULTS.hotkey));
// 白名单里的每一个值都得能通过自己(防 ALLOWED_HOTKEYS 与默认值写岔)
for (const k of cfg.ALLOWED_HOTKEYS) {
assert.strictEqual(cfg.sanitize({ hotkey: k }).hotkey, k, `白名单成员 ${JSON.stringify(k)} 应通过校验`);
}
});
check('save 后 load 往返一致', () => {
cfg.saveConfig({ autoLaunch: true, maxItems: 1000, displayLimit: 500, pollMs: 500, dataDir: tmpHome, hotkey: 'Ctrl+Alt+C' });
const c = cfg.loadConfig();
assert.strictEqual(c.autoLaunch, true);
assert.strictEqual(c.maxItems, 1000);
assert.strictEqual(c.displayLimit, 500);
assert.strictEqual(c.pollMs, 500);
assert.strictEqual(c.dataDir, tmpHome);
assert.strictEqual(c.hotkey, 'Ctrl+Alt+C');
});
check('坏 JSON 不抛异常,返回默认', () => {
fs.writeFileSync(cfg.getConfigPath(), '{ this is not json ');
const c = cfg.loadConfig();
assert.strictEqual(c.maxItems, 0);
});
console.log(`\n${passed} checks passed`);

View File

@@ -0,0 +1,141 @@
'use strict';
/**
* 数据目录降级测试 —— 解决「默认 D:\Clipboard Data 不可用就启动失败」的兼容问题。
*
* 被测对象是 config.js 的 isDirUsable / defaultDataDir。三条契约
* 1. isDirUsable 能正确判断目录真的能写入;
* 2. defaultDataDir 在默认目录不可用时降级到 home 下的 Clipboard Data
* 3. 探针文件不会残留(每次探测都自清理)。
*
* 模拟「D 盘不存在」的方法:临时替换 fs.mkdirSync让它对 D:\\ 开头的路径
* 抛 ENOENT —— 跟项目里「不引入 mock 框架、靠临时替换内置模块方法」的风格
* 一致。restore 严格用 finally / afterEach 兜住,避免污染其它测试。
*/
const assert = require('node:assert');
const fs = require('node:fs');
const os = require('node:os');
const path = require('node:path');
let passed = 0;
function check(name, fn) {
try { fn(); console.log(' ok -', name); passed++; }
catch (e) { console.error(' FAIL -', name, '\n', e.message); process.exitCode = 1; }
}
// 用临时 HOME 隔离 —— defaultDataDir() 降级时是 %USERPROFILE%\Clipboard Data
// 我们希望探针 / 副作用都落在临时目录,不污染真实 home。
const tmpHome = fs.mkdtempSync(path.join(os.tmpdir(), 'clip-fb-'));
process.env.USERPROFILE = tmpHome;
process.env.HOME = tmpHome;
// 不预设 CLIPBOARD_APP_DATA_DIR —— 本测试就是要验证它没设时的降级行为
delete process.env.CLIPBOARD_APP_DATA_DIR;
const cfg = require('../config.js');
// 「D 盘不存在」的 mockfs.mkdirSync 对 D:\ 前缀抛 ENOENT。
// 不影响其它调用 —— D: 在大多数机器上不一定存在,我们的判断「按前缀」
// 已经足够准;同时不影响 home 下的真实写入。
const originalMkdir = fs.mkdirSync;
function mockNoDDrive() {
fs.mkdirSync = function patched(p, opts) {
if (typeof p === 'string' && /^[dD]:[\\/]/.test(p)) {
const e = new Error(`ENOENT: no such file or directory, mkdir '${p}'`);
e.code = 'ENOENT';
throw e;
}
return originalMkdir.call(fs, p, opts);
};
}
function restoreFs() {
fs.mkdirSync = originalMkdir;
}
process.on('exit', restoreFs);
check('isDirUsable: 真实可写目录返回 true', () => {
// tmpHome 必然可写
assert.strictEqual(cfg.isDirUsable(tmpHome), true);
});
check('isDirUsable: 不存在的盘符D:\\__no_such__在 mock 下返回 false', () => {
mockNoDDrive();
try {
assert.strictEqual(cfg.isDirUsable('D:\\__no_such_drive_xyz__'), false);
} finally { restoreFs(); }
});
check('isDirUsable: 空字符串返回 false不打 fs.mkdirSync 空路径)', () => {
assert.strictEqual(cfg.isDirUsable(''), false);
assert.strictEqual(cfg.isDirUsable(null), false);
assert.strictEqual(cfg.isDirUsable(undefined), false);
});
check('isDirUsable: 探针文件被清理,不在目录里残留', () => {
// 探针名字固定 —— 跑两次探测,第二次还能成功说明第一次 unlink 了。
const probePath = path.join(tmpHome, '.clipboard-write-test');
assert.ok(!fs.existsSync(probePath), '开始前不应有残留');
assert.strictEqual(cfg.isDirUsable(tmpHome), true);
assert.ok(!fs.existsSync(probePath), '探测后不应残留探针文件');
assert.strictEqual(cfg.isDirUsable(tmpHome), true, '第二次探测照样过 → 第一次 unlink 干净');
});
check('defaultDataDir: D 盘不可用时降级到 home 下的 Clipboard Data', () => {
mockNoDDrive();
try {
// 重新加载模块,让 defaultDataDir() 重新走一遍降级逻辑
delete require.cache[require.resolve('../config.js')];
const cfg2 = require('../config.js');
const dir = cfg2.defaultDataDir();
// 降级路径必须等于 home + Clipboard Data
assert.strictEqual(
path.resolve(dir),
path.resolve(tmpHome, 'Clipboard Data'),
'D 盘被 mock 掉时必须降级到 home\\Clipboard Data'
);
// 降级目录真的可用 —— 否则还是炸
assert.strictEqual(cfg2.isDirUsable(dir), true);
} finally {
restoreFs();
delete require.cache[require.resolve('../config.js')];
require('../config.js'); // 重新挂回正常状态给后面的 case
}
});
check('defaultDataDir: env 覆盖在降级链路里仍最高优先级', () => {
mockNoDDrive();
const custom = path.join(tmpHome, 'env-override-dir');
process.env.CLIPBOARD_APP_DATA_DIR = custom;
try {
delete require.cache[require.resolve('../config.js')];
const cfg2 = require('../config.js');
assert.strictEqual(path.resolve(cfg2.defaultDataDir()), path.resolve(custom),
'env 覆盖必须胜过 D 盘 / home 的降级选择');
} finally {
restoreFs();
delete process.env.CLIPBOARD_APP_DATA_DIR;
delete require.cache[require.resolve('../config.js')];
require('../config.js');
}
});
check('defaultDataDir: D 盘可用时首选 D 盘(契约保留)', () => {
// 不 mock —— 用真实环境。仅当 D 盘真的可用时断言,否则跳过。
if (!cfg.isDirUsable('D:\\Clipboard Data')) {
console.log(' (D:\\Clipboard Data 在本机不可用,跳过)');
return;
}
// 不设 env 时必须首选 D 盘
const saved = process.env.CLIPBOARD_APP_DATA_DIR;
delete process.env.CLIPBOARD_APP_DATA_DIR;
delete require.cache[require.resolve('../config.js')];
try {
const cfg2 = require('../config.js');
assert.strictEqual(path.resolve(cfg2.defaultDataDir()), path.resolve('D:\\Clipboard Data'));
} finally {
process.env.CLIPBOARD_APP_DATA_DIR = saved;
delete require.cache[require.resolve('../config.js')];
require('../config.js');
}
});
// 清理
fs.rmSync(tmpHome, { recursive: true, force: true });
console.log(`\n${passed} checks passed`);

44
test/db-prepared.test.js Normal file
View File

@@ -0,0 +1,44 @@
'use strict';
const assert = require('node:assert');
const fs = require('node:fs');
const os = require('node:os');
const path = require('node:path');
const tmp = fs.mkdtempSync(path.join(os.tmpdir(), 'clip-stmt-'));
process.env.USERPROFILE = tmp;
process.env.HOME = tmp;
const Database = require('better-sqlite3');
const dbPath = path.join(tmp, 'history.db');
const db = new Database(dbPath);
db.exec(`
CREATE TABLE IF NOT EXISTS clips (
id INTEGER PRIMARY KEY AUTOINCREMENT,
type TEXT NOT NULL, text TEXT, image BLOB, preview TEXT,
top_at REAL, created_at REAL NOT NULL
);
`);
let passed = 0;
function check(name, fn) {
try { fn(); console.log(' ok -', name); passed++; }
catch (e) { console.error(' FAIL -', name, e.message); process.exitCode = 1; }
}
const cached = {
insert: db.prepare('INSERT INTO clips(type, text, preview, top_at, created_at) VALUES(?, ?, ?, ?, ?)'),
};
check('prepare reuse returns same statement object (sanity)', () => {
const a = db.prepare('SELECT 1');
const b = db.prepare('SELECT 1');
assert.ok(a !== b, 'sanity: each prepare() returns new object');
});
check('cached insert works repeatedly', () => {
for (let i = 0; i < 5; i++) {
cached.insert.run('text', `m${i}`, `p${i}`, null, Date.now()/1000);
}
const n = db.prepare('SELECT COUNT(*) AS n FROM clips').get().n;
assert.strictEqual(n, 5);
});

182
test/echo-suppress.test.js Normal file
View File

@@ -0,0 +1,182 @@
'use strict';
/**
* 回声抑制状态机lib/echo-suppress.js—— 测的是 main.js 实际引用的本体。
*
* 这份逻辑以前内联在 main.js 里旧测试poll-suppress.test.js克隆了一份
* 状态机自己测自己main.js 改坏了测试照样绿,而这是丢数据级的逻辑
* (吞错一次 = 用户的一次真实复制静默消失)。现在抽到 lib测本体。
*
* 每个用例对应一条真实踩坑规则,注释里写了放掉会发生什么。
*/
const assert = require('node:assert');
const { createEchoSuppress } = require('../lib/echo-suppress.js');
let passed = 0;
function check(name, fn) {
try { fn(); console.log(' ok -', name); passed++; }
catch (e) { console.error(' FAIL -', name, '\n', e.message); process.exitCode = 1; }
}
check('启动采样:基线内容第一次见到不算新', () => {
const s = createEchoSuppress();
s.seed('已经在剪贴板里的旧内容');
assert.strictEqual(s.observe('已经在剪贴板里的旧内容'), 'unchanged');
// 放开这条:每次重启都会把剪贴板里的旧内容重复入库一次
assert.strictEqual(s.observe('外部新复制'), 'new');
});
check('自己写进去的回声:下一 tick 不会把它当新内容', () => {
const s = createEchoSuppress();
s.markOwnWrite('A'); // pending=A同时基线预置为 A
// 读到的回声与预置基线一致 → 'unchanged'(同样不入库)。
// suppressed 分支出现在基线与 pending 分叉之后(见下条)。
assert.strictEqual(s.observe('A'), 'unchanged');
assert.strictEqual(s.observe('A'), 'unchanged');
});
check('suppressed 分支:基线与 pending 分叉后,匹配 pending 的内容被吞', () => {
const s = createEchoSuppress();
s.markOwnWrite('A');
// 外部中途清空剪贴板:基线变 ''pending 不被消化
assert.strictEqual(s.observe(''), 'empty');
// 此后读到与 pending 一致的内容 → suppressed设计取舍见 lib 头注释:
// 宁可漏掉"清空后外部再复制同内容",也不让回声重复入库)
assert.strictEqual(s.observe('A'), 'suppressed');
});
check('回声被外部复制顶掉:外部那次必须入库(占位 flag 实现会在这里丢数据)', () => {
const s = createEchoSuppress();
s.markOwnWrite('A');
// 两次 tick 之间用户在外面复制了 Btick 直接读到 B
assert.strictEqual(s.observe('B'), 'new', '外部复制 B 不能被回声标记吞掉');
});
check('不匹配的新内容消化 pending之后相同内容的外部复制不被误伤', () => {
const s = createEchoSuppress();
s.markOwnWrite('A');
assert.strictEqual(s.observe('B'), 'new');
// 用户又复制回 A外部—— pending 已被 B 消化A 正常入库
// 放开这条:点复制 A → 外部 B → 外部再复制 A第二个 A 被静默吞掉
assert.strictEqual(s.observe('A'), 'new');
});
check('剪贴板被清空不消化 pending清空后复制同内容仍被识别为回声', () => {
const s = createEchoSuppress();
s.markOwnWrite('A');
assert.strictEqual(s.observe(''), 'empty');
// 外部中途清空剪贴板不该让回声标记失效,否则紧接着读到 A 会重复入库
assert.strictEqual(s.observe('A'), 'suppressed');
});
check('基线已是空串再读空unchanged 而不是 empty保住 main.js 的图片分支)', () => {
const s = createEchoSuppress();
s.seed(''); // 启动时剪贴板没有文本 —— 纯图片场景的常态
assert.strictEqual(s.observe(''), 'unchanged');
// 放开这条(把 empty 判断挪到 unchanged 之前):文本恒空时每个 tick 都在
// 文本分支早退main.js 永远走不到图片分支 —— 用户复制图片整条静默丢失。
assert.strictEqual(s.observe('外部复制'), 'new');
});
check('写入失败清标记:失败后用户的真实复制不被吞', () => {
const s = createEchoSuppress();
s.markOwnWrite('A');
s.clearPending(); // 只清 pending不动基线
assert.strictEqual(s.observe(''), 'empty'); // 让基线与已清的pending 分叉
// pending 已清 → 读 A 走 'new' 而不是 'suppressed',真实复制不丢
assert.strictEqual(s.observe('A'), 'new');
});
check('写入失败回滚基线:失败后真实旧内容不被当新内容重复入库', () => {
const s = createEchoSuppress();
s.seed('旧内容(已在库里)');
const undo = s.markOwnWrite('新内容'); // 模拟点"复制":预置基线 + 记回声
undo(); // 模拟 clipboard.writeText 抛错后回滚
// 剪贴板上其实还是旧内容,下个 tick 读到它必须判 unchanged。
// 放开这条markOwnWrite 不提供回滚):基线停在'新内容',下个 tick 读到
// 真实旧内容判 'new',早已在库里的旧内容被重复插入一条。
assert.strictEqual(s.observe('旧内容(已在库里)'), 'unchanged');
// 回滚同时清了回声标记:用户之后真的复制了那个内容,照常入库不被吞
assert.strictEqual(s.observe('新内容'), 'new');
});
check('基线随每次变化更新:重复读同内容不重复入库', () => {
const s = createEchoSuppress();
assert.strictEqual(s.observe('X'), 'new');
assert.strictEqual(s.observe('X'), 'unchanged');
assert.strictEqual(s.observe('X'), 'unchanged');
assert.strictEqual(s.observe('Y'), 'new');
});
check('resetBaseline1×1 空图之后任何真图都算新', () => {
const s = createEchoSuppress();
s.seed('hash-of-small');
s.resetBaseline();
assert.strictEqual(s.observe('hash-of-real-image'), 'new');
});
check('abortLastObserve入库失败回滚后同一条内容能再次入库', () => {
// 用户报告的"卡住"场景observe 判 'new' → 入库失败SQLite 临时锁、
// 磁盘满、字段非法等)→ 下次 tick 读到同内容判 'unchanged' → 永久吞掉。
// abortLastObserve 必须撤销 observe 对基线的修改,让下次 tick 再判 'new'。
const s = createEchoSuppress();
s.seed('之前的内容');
assert.strictEqual(s.observe('新内容'), 'new', '第一次观察应判 new');
// 模拟 stmt.insertText 抛错:入库失败方在 catch 里调 abortLastObserve
s.abortLastObserve();
// 下次 tick 读到同样内容 → 必须重新判 'new',让 pollClipboard 再试入库
assert.strictEqual(s.observe('新内容'), 'new', 'abort 后应允许重试,不该被吞');
// 这次入库成功后基线更新,下次再读 → 'unchanged',正常路径
assert.strictEqual(s.observe('新内容'), 'unchanged', '入库成功后基线已建立');
});
check('abortLastObserve同时清掉 pending之前 markOwnWrite 留下的回声标记)', () => {
// 极小概率但可能:上一次 tick 已经 markOwnWrite自己写入留了 pending
// 紧接着入库失败 abort —— 这时 pending 是上次的回声标记,不该在 abort
// 后继续生效(否则下次 tick 会把外部真实复制当回声吞掉)。
const s = createEchoSuppress();
s.markOwnWrite('A'); // pending='A', last='A'
s.observe('B'); // 'new'pending 被消化)
s.markOwnWrite('C'); // pending='C', last='C'
s.observe('C'); // 'unchanged'(预置基线命中)
// 上面没有 'new' 路径,构造一个会判 'new' 的场景:
s.seed('基线');
s.markOwnWrite('X'); // pending='X', last='X'
s.observe('Y'); // pending 被消化, last='Y', 'new'
// 现在状态last='Y', pending=null。再来一次会判 'new' 的写入:
s.markOwnWrite('Z'); // pending='Z', last='Z'
assert.strictEqual(s.observe('Z'), 'unchanged');
// 模拟 pollClipboard 在 'new' 路径上失败 → 这里没有 new 路径,但语义上
// abortLastObserve 应同时把 last / pending 都清掉。下次 tick 读到任何
// 内容都重新判 'new'pendign 也不会错误地吞掉外部真实复制。
s.abortLastObserve();
assert.strictEqual(s.observe('anything'), 'new', 'abort 后 last 必须为空');
// 再 markOwnWrite 后能正常工作(说明 pending 也被清掉了,没残留)
s.markOwnWrite('P');
assert.strictEqual(s.observe('P'), 'unchanged', 'markOwnWrite 在 abort 后照常工作');
});
check('abortLastObserve无副作用last/pending 本来就 null 时调用不破坏)', () => {
const s = createEchoSuppress();
s.abortLastObserve(); // last=null, pending=null无副作用
assert.strictEqual(s.observe('A'), 'new');
s.abortLastObserve(); // last='A' → null下次再判 'new'
assert.strictEqual(s.observe('A'), 'new');
});
check('图片场景端到端:自己的图不入库、外部的图入库', () => {
const s = createEchoSuppress();
s.markOwnWrite('our-hash');
assert.strictEqual(s.observe('our-hash'), 'unchanged'); // 与预置基线一致,不入库
assert.strictEqual(s.observe('external-hash'), 'new'); // 外部换了图
assert.strictEqual(s.observe('external-hash'), 'unchanged');
});
check('文本与图片两份状态互不串扰main.js 用两个独立实例)', () => {
const text = createEchoSuppress();
const image = createEchoSuppress();
text.markOwnWrite('A');
assert.strictEqual(image.observe('A'), 'new', '图片状态机不该认识文本的 pending');
assert.strictEqual(text.observe('A'), 'unchanged', '文本状态机命中自己的预置基线');
});
console.log(`\n${passed} checks passed`);

84
test/echo-wiring.test.js Normal file
View File

@@ -0,0 +1,84 @@
'use strict';
/**
* 回声抑制的**接线**断言main.js 必须真的在用 lib/echo-suppress.js 的本体。
*
* 为什么需要这份测试echo-suppress.test.js 只测 lib 本体。本体的行为全对,
* 也挡不住 main.js 把接线改坏 —— 比如漏调 seed每次重启把剪贴板里的旧内容
* 重入一次)、漏调 markOwnWrite自己的复制被当新内容重复入库、observe 的
* 返回值不再按四态分发('empty' 当 'new' 入库 = 空剪贴板噪音灌满列表)。
* lib 测试对这类回归永远是绿的 —— 正是这个文件头当年批评旧测试的那句话。
*
* 风格沿用 window-factory.test.js / window-lifecycle.test.js 的静态断言:
* grep main.js 源码里的调用点。静态断言测不了运行时,但能拦住"重构时把
* 调用点删了/改名了"这类最常见的接线回归,成本几乎为零。
*/
const assert = require('node:assert');
const fs = require('node:fs');
const path = require('node:path');
const MAIN = fs.readFileSync(path.join(__dirname, '..', 'main.js'), 'utf8');
let passed = 0;
function check(name, fn) {
try { fn(); console.log(' ok -', name); passed++; }
catch (e) { console.error(' FAIL -', name, '\n', e.message); process.exitCode = 1; }
}
check('main.js 引用的是 lib/echo-suppress 本体', () => {
assert.match(MAIN, /require\('\.\/lib\/echo-suppress'\)/);
const { createEchoSuppress } = require('../lib/echo-suppress.js');
assert.strictEqual(typeof createEchoSuppress, 'function');
});
check('文本 / 图片各一个独立状态机实例(两份状态不能共用)', () => {
assert.match(MAIN, /textSuppress\s*=\s*createEchoSuppress\(\)/);
assert.match(MAIN, /imageSuppress\s*=\s*createEchoSuppress\(\)/);
const instances = MAIN.match(/createEchoSuppress\(\)/g) || [];
assert.strictEqual(instances.length, 2, `应有且仅有 2 个实例,实际 ${instances.length}`);
});
check('启动采样两条线都调 seed漏掉会导致重启时旧内容重入库', () => {
assert.match(MAIN, /textSuppress\.seed\(/);
assert.match(MAIN, /imageSuppress\.seed\(/);
});
check('轮询两条线都调 observe漏掉 text 的 = 搜索不到新复制,漏掉 image 的 = 图片全丢', () => {
assert.match(MAIN, /textSuppress\.observe\(/);
assert.match(MAIN, /imageSuppress\.observe\(/);
});
check('observe 的四态都被分发empty / suppressed / new 各自有分支', () => {
// unchanged 不需要显式分支(落空即图片分支),但其余三态必须显式处理。
// 漏 'empty':清空剪贴板的 '' 会被当新文本入库;漏 'suppressed':自己的回声重复入库。
for (const verdict of ['empty', 'suppressed', 'new']) {
assert.ok(
MAIN.includes(`verdict === '${verdict}'`),
`pollClipboard 缺少对 '${verdict}' 的分发`
);
}
});
check('写剪贴板两条线都调 markOwnWrite漏掉 = 点"复制"后自己的回声被当新内容入库', () => {
assert.match(MAIN, /textSuppress\.markOwnWrite\(/);
assert.match(MAIN, /imageSuppress\.markOwnWrite\(/);
});
check('1×1 空图走 resetBaseline 而不是直接赋基线', () => {
assert.match(MAIN, /imageSuppress\.resetBaseline\(\)/);
});
check('入库失败两条线都 abortLastObserve漏掉 = 用户那条复制永久吞掉直到重启', () => {
// 用户报告"卡住"的核心修法observe 把 last 设成新内容后入库失败,
// 必须回滚基线,否则下次 tick 读到同一条内容判 'unchanged' 静默吞掉。
assert.match(MAIN, /textSuppress\.abortLastObserve\(\)/);
assert.match(MAIN, /imageSuppress\.abortLastObserve\(\)/);
});
check('复制失败的两条 catch 都 clearPending漏掉会吞掉用户下一次真实复制', () => {
const clears = MAIN.match(/textSuppress\.clearPending\(\)/g) || [];
assert.ok(clears.length >= 2, `textSuppress.clearPending 应至少 2 处clip / fav 复制),实际 ${clears.length}`);
const imgClears = MAIN.match(/imageSuppress\.clearPending\(\)/g) || [];
assert.ok(imgClears.length >= 2, `imageSuppress.clearPending 应至少 2 处,实际 ${imgClears.length}`);
});
console.log(`\n${passed} checks passed`);

View File

@@ -0,0 +1,221 @@
'use strict';
/**
* 「收藏 Tab 复制」也要把内容带到历史 Tab —— 行为与接线回归。
*
* 起因2026-08-09 用户反馈):用户点收藏卡的「复制」后,历史 Tab
* 最前面没冒出新条目。原因copyFavToClipboard 只调 writePayloadToClipboard
* 写完系统剪贴板就 return。下一轮询 tick 读到同样内容,被回声抑制当成
* 「自己写的」吞掉,历史表里完全没新行。
*
* 修法:写完剪贴板后调用 bringFavoriteIntoHistory(row),源记录还在就置顶
* (与历史 Tab 复制行为对齐),不在 / 无源就当作新条目入库。
*
* main.js 不能脱离 Electron 加载,仿 clip-limit.test.js 两层守:接线 +
* 在真 SQLite 上跑提取出来的生产 SQL。
*/
const assert = require('node:assert');
const fs = require('node:fs');
const os = require('node:os');
const path = require('node:path');
const Database = require('better-sqlite3');
const mainSrc = fs.readFileSync(path.join(__dirname, '..', 'main.js'), 'utf8');
let passed = 0;
function check(name, fn) {
try { fn(); console.log(' ok -', name); passed++; }
catch (e) { console.error(' FAIL -', name, '\n', e.message); process.exitCode = 1; }
}
// ---------- 静态favorites:copy 处理器调了 bridge ----------
check('favorites:copy IPC 处理器最终调到 bringFavoriteIntoHistory穿透 copyFavToClipboard', () => {
// handler 自己只调 copyFavToClipboard —— 函数体里直接 grep 不到 bridge。
// 但 handler → copyFavToClipboard → bridge 这条链断了就会让历史 Tab
// 安静地不冒条目所以两端都要验证handler 必须经由 copyFavToClipboard
// 间接桥接下面那条「copyFavToClipboard → bridge」已钉死
const handler = mainSrc.match(/ipcMain\.handle\(\s*'favorites:copy'[\s\S]*?\n\s{2}\}\s*\)\s*;/);
assert.ok(handler, '找不到 favorites:copy IPC 处理器');
assert.match(handler[0], /copyFavToClipboard\s*\(/,
'favorites:copy 处理器没走 copyFavToClipboard —— 桥接链断点');
});
check('copyFavToClipboard 在剪贴板写完后调 bridge且失败被吞', () => {
const fn = mainSrc.match(/function\s+copyFavToClipboard\s*\([\s\S]*?\n\}/);
assert.ok(fn, '找不到 copyFavToClipboard');
// 顺序:先 writePayloadToClipboard再 bridgebridge 必须放在 try/catch 里
assert.match(fn[0], /writePayloadToClipboard[\s\S]*?bringFavoriteIntoHistory/,
'bridge 调用必须在写完剪贴板之后');
// bridge 失败不能把整个复制操作搞砸(剪贴板已经写好了)
assert.match(fn[0], /try\s*\{[\s\S]*?bringFavoriteIntoHistory[\s\S]*?\}\s*catch/,
'bridge 调用必须放在 try/catch —— DB 写失败不能让用户看到「复制失败」');
});
// ---------- 静态bridge 函数两条分支都在 ----------
check('bringFavoriteIntoHistory源在历史里 → 置顶 + notifyRefresh', () => {
const fn = mainSrc.match(/function\s+bringFavoriteIntoHistory\s*\([\s\S]*?\n\}/);
assert.ok(fn, '找不到 bringFavoriteIntoHistory');
assert.match(fn[0], /stmt\.moveToTop\.run\(/,
'bridge 缺置顶分支 —— 源记录还在历史时不会置顶');
assert.match(fn[0], /notifyRefresh\s*\(\s*\)/,
'置顶分支没调 notifyRefresh —— 渲染端拿不到位置变更');
});
check('bringFavoriteIntoHistory源不在 / 无源 → insertText/insertImage + notifyNew', () => {
const fn = mainSrc.match(/function\s+bringFavoriteIntoHistory\s*\([\s\S]*?\n\}/);
assert.ok(fn, '找不到 bringFavoriteIntoHistory');
assert.match(fn[0], /stmt\.insertText\.run\(/, 'bridge 缺文本入库分支');
assert.match(fn[0], /stmt\.insertImage\.run\(/, 'bridge 缺图片入库分支');
// notifyNew 是新条目通知 —— 渲染端靠这个知道要 prepend
assert.match(fn[0], /notifyNew\s*\(/, 'bridge 缺 notifyNew —— 新条目不会冒头');
// 入库后要刷上限 + 计数,跟 pollClipboard 对齐
assert.match(fn[0], /enforceLimit\s*\(\s*\)/, 'bridge 缺 enforceLimit —— 限额失效');
assert.match(fn[0], /clipCount\s*\+\+/, 'bridge 没维护 clipCount 计数');
});
check('bridge 入库 top_at 必须是 null不能绑 now', () => {
// 跟 clip-limit.test.js 同坑insertText/insertImage 第 3 个实参必须是 null
// —— 绑 now 会让每条新记录「被置顶」,限额清理全废。
const fn = mainSrc.match(/function\s+bringFavoriteIntoHistory\s*\([\s\S]*?\n\}/);
assert.ok(fn);
// 行级断言:找两行 .run(...),看它们的第 3 个实参是不是 null
const textRun = fn[0].match(/insertText\.run\([^)]*\)/);
assert.ok(textRun, '找不到 insertText.run 调用');
const args = textRun[0].slice(textRun[0].indexOf('(') + 1, -1).split(',').map(s => s.trim());
assert.strictEqual(args.length, 4, 'insertText 应是 4 个实参');
assert.strictEqual(args[2], 'null', 'bridge.insertText 第 3 个实参top_at必须是 null');
const imageRun = fn[0].match(/insertImage\.run\([^)]*\)/);
assert.ok(imageRun, '找不到 insertImage.run 调用');
const iargs = imageRun[0].slice(imageRun[0].indexOf('(') + 1, -1).split(',').map(s => s.trim());
assert.strictEqual(iargs.length, 4, 'insertImage 应是 4 个实参');
assert.strictEqual(iargs[2], 'null', 'bridge.insertImage 第 3 个实参top_at必须是 null');
});
// ---------- 提取生产 SQL 跑行为验证 ----------
function extractSql(anchor) {
const re = new RegExp(`stmt\\.${anchor}\\s*=\\s*db\\.prepare\\(\\s*\`([\\s\\S]*?)\`\\s*\\)`);
const m = mainSrc.match(re);
assert.ok(m, `main.js 里找不到 stmt.${anchor} 的 prepare`);
return m[1];
}
const INSERT_TEXT_SQL = extractSql('insertText');
const INSERT_IMAGE_SQL = extractSql('insertImage');
const MOVE_TO_TOP_SQL = extractSql('moveToTop');
const GET_SQL = extractSql('get');
function makeDb() {
const tmp = fs.mkdtempSync(path.join(os.tmpdir(), 'fav-copy-bridge-'));
const db = new Database(path.join(tmp, 'history.db'));
db.exec(`
CREATE TABLE clips (
id INTEGER PRIMARY KEY AUTOINCREMENT,
type TEXT NOT NULL,
text TEXT,
image BLOB,
preview TEXT,
top_at REAL,
created_at REAL NOT NULL
)
`);
return db;
}
// 模拟生产环境bind = (text, preview, top_at=null, created_at)
const insertText = (db) => db.prepare(INSERT_TEXT_SQL);
const insertImage = (db) => db.prepare(INSERT_IMAGE_SQL);
const moveToTop = (db) => db.prepare(MOVE_TO_TOP_SQL);
const getRow = (db) => db.prepare(GET_SQL);
check('桥接 SQL 的列顺序确实兼容 main.js 入参 (text, preview, top_at, created_at)', () => {
// 静态断言成立的前提 —— 占位符顺序变了而调用没跟着变null 会落到别的列
assert.match(INSERT_TEXT_SQL, /VALUES\(\s*'text'\s*,\s*\?\s*,\s*\?\s*,\s*\?\s*,\s*\?\s*\)/,
'insertText 占位符顺序必须与桥接调用一致 —— (text, preview, top_at, created_at)');
assert.match(INSERT_IMAGE_SQL, /VALUES\(\s*'image'\s*,\s*\?\s*,\s*\?\s*,\s*\?\s*,\s*\?\s*\)/);
});
check('行为:源记录在历史里 → 置顶top_at 与 created_at 同步更新到 now', () => {
const db = makeDb();
// 历史里先有一条 id=1 的源记录top_at 原本为 null自然位置
const ins = insertText(db);
const r = ins.run('源记录内容', '源记录内容', null, 100);
const id = r.lastInsertRowid;
// 模拟 bridge 的「源在」分支movetop 把 top_at 与 created_at 同时置到 now
// —— 这样该行在列表里就是「最近活跃」,排到顶。
const now = 1000.5;
moveToTop(db).run(now, now, id);
const row = getRow(db).get(id);
assert.strictEqual(row.top_at, now, '置顶后 top_at 必须是当前时间戳');
assert.strictEqual(row.text, '源记录内容', '置顶不能改文本内容');
assert.strictEqual(row.created_at, now, '置顶必须同步 bump created_at否则列表里看不到这次活跃');
db.close();
});
check('行为:源记录不在历史里 → 当作新条目入库', () => {
const db = makeDb();
// 历史为空,源记录 id=999 不在表里
const ins = insertText(db);
const now = 2000;
// 模拟 bridge 的「新条目」分支
const info = ins.run('收藏里的文本', '收藏里的文本', null, now);
const id = info.lastInsertRowid;
// 验证:库里多了一行,类型/文本/preview 都对top_at=null自然位置
const all = db.prepare('SELECT * FROM clips').all();
assert.strictEqual(all.length, 1, '应该多了一条历史');
assert.strictEqual(all[0].id, id);
assert.strictEqual(all[0].type, 'text');
assert.strictEqual(all[0].text, '收藏里的文本');
assert.strictEqual(all[0].preview, '收藏里的文本');
assert.strictEqual(all[0].top_at, null, '新入库的记录必须在自然位置 —— top_at=null');
assert.strictEqual(all[0].created_at, now);
db.close();
});
check('行为:图片分支 —— 收藏复制 → 入库 BLOB + preview', () => {
const db = makeDb();
const now = 3000;
const buf = Buffer.from([0xff, 0xd8, 0xff, 0xe0, 0x00, 0x10]); // PNG 头几字节
const preview = '[图片]';
// 模拟 bridge 的图片分支(不走 capImage 真实逻辑 —— 那是 main.js 的职责,
// 这里只验证 insertImage 本身的入参约定)
const info = insertImage(db).run(buf, preview, null, now);
const id = info.lastInsertRowid;
const row = db.prepare('SELECT type, image, preview, top_at, created_at FROM clips WHERE id = ?').get(id);
assert.strictEqual(row.type, 'image');
assert.deepStrictEqual(Array.from(row.image), [0xff, 0xd8, 0xff, 0xe0, 0x00, 0x10],
'图片 BLOB 必须原样入库');
assert.strictEqual(row.preview, '[图片]');
assert.strictEqual(row.top_at, null, '图片入库也必须在自然位置');
assert.strictEqual(row.created_at, now);
db.close();
});
check('行为:重复点收藏复制(源在历史里)→ 不会冒出第二条重复行', () => {
// 用户场景:复制一次收藏,置顶;再复制一次收藏,不应该出现重复条目
const db = makeDb();
const ins = insertText(db);
const r = ins.run('hello', 'hello', null, 100);
const id = r.lastInsertRowid;
// 第一次复制:源在 → 置顶top_at 与 created_at 同时 bump
moveToTop(db).run(200, 200, id);
// 第二次复制:源仍在 → 仍然置顶
moveToTop(db).run(300, 300, id);
const rows = db.prepare('SELECT * FROM clips').all();
assert.strictEqual(rows.length, 1, '重复置顶不能插入新行');
assert.strictEqual(rows[0].top_at, 300, 'top_at 取最新一次的时间戳');
assert.strictEqual(rows[0].created_at, 300, 'created_at 也取最新一次的时间戳');
db.close();
});
console.log(`\n${passed} checks passed`);

View File

@@ -0,0 +1,70 @@
'use strict';
const assert = require('node:assert');
const fs = require('node:fs');
const path = require('node:path');
const rendererSrc = fs.readFileSync(
path.join(__dirname, '..', 'renderer', 'renderer.js'), 'utf8'
);
const preloadSrc = fs.readFileSync(
path.join(__dirname, '..', 'preload.js'), 'utf8'
);
let passed = 0;
function check(name, fn) {
try { fn(); console.log(' ok -', name); passed++; }
catch (e) { console.error(' FAIL -', name, e.message); process.exitCode = 1; }
}
check('renderer 调用 favList 不传硬编码 limit', () => {
// displayLimit 改由配置驱动(默认 300renderer 不再硬编码 200。
// 主进程 clips:list / favorites:list 在 limit=0 时回退到 appConfig.displayLimit。
const reload = rendererSrc.match(/async\s+function\s+reload\s*\(\s*\)\s*\{[\s\S]*?\n\}/);
assert.ok(reload, '找不到 reload()');
const call = reload[0].match(/favList\s*\([^)]*\)/);
assert.ok(call, 'reload() 找不到 favList 调用');
assert.ok(
!/,\s*200\s*\)/.test(call[0]) && !/,\s*5000\s*\)/.test(call[0]),
`favList 不应再传硬编码 200/5000实际是: ${call[0]}`
);
});
check('renderer 收藏 Tab 的徽标不用 clips.length', () => {
// 切到 favorites 时,$favCount.textContent 不能来自 clips.length
// (被 200 截断后会失真)。
const favText = rendererSrc.match(/\$favCount\.textContent\s*=\s*[^;]+;/g) || [];
for (const line of favText) {
assert.ok(
!/clips\.length/.test(line),
`徽标计数值不能从 clips.length 取(限制 200 后会失真): ${line}`
);
}
});
check('setTab 切到 favorites 时先 updateFavCount 再 reload', () => {
const setTab = rendererSrc.match(/function\s+setTab\s*\(\s*\w+\s*\)\s*\{[\s\S]*?\n\}/);
assert.ok(setTab, '找不到 setTab');
// 切到 favorites 的分支里,两个调用都得出现;顺序 updateFavCount 必须先于 reload
const idxFav = setTab[0].indexOf("'favorites'");
assert.ok(idxFav >= 0, 'setTab 找不到 favorites 分支');
// 截 favorites 分支之后到函数末尾
const favBranch = setTab[0].slice(idxFav);
const idxCount = favBranch.indexOf('updateFavCount');
const idxReload = favBranch.indexOf('reload');
assert.ok(idxCount >= 0, '切到 favorites 时没调 updateFavCount()');
assert.ok(idxReload >= 0, '切到 favorites 时没调 reload()');
assert.ok(idxCount < idxReload, '必须先 updateFavCount 再 reload');
});
check('preload favList 把第二个参数 limit 透传给 IPC', () => {
// preload 的 favoritesApi.list 必须接收并转发 limit。
// 之前传 5000现在传 0主进程在 limit <= 0 时回退到 appConfig.displayLimit。
const call = preloadSrc.match(/list:\s*\(query,\s*limit\)\s*=>\s*ipcRenderer\.invoke\(\s*'favorites:list'[^)]*\)/);
assert.ok(call, 'preload favorites list 没有 (query, limit) 签名或没转发 limit');
assert.ok(
/limit:\s*limit\s*\|\|\s*0/.test(call[0]),
`preload favList 必须把 limit 透传limit: limit || 0实际: ${call[0]}`
);
});
console.log(`\n${passed} checks passed`);

View File

@@ -0,0 +1,21 @@
'use strict';
const assert = require('node:assert');
const { checkAgainstCap, DEFAULT_MAX } = require('../lib/favorites-cap.js');
let passed = 0;
function check(name, fn) {
try { fn(); console.log(' ok -', name); passed++; }
catch (e) { console.error(' FAIL -', name, e.message); process.exitCode = 1; }
}
const mockDb = (n) => ({ prepare: () => ({ get: () => ({ n }) }) });
check('under cap returns ok', () => {
assert.deepStrictEqual(checkAgainstCap(mockDb(0)), { ok: true });
});
check('at cap returns { ok: false, message }', () => {
const r = checkAgainstCap(mockDb(DEFAULT_MAX));
assert.strictEqual(r.ok, false);
assert.ok(r.message.includes(String(DEFAULT_MAX)));
});

247
test/favorites.test.js Normal file
View File

@@ -0,0 +1,247 @@
'use strict';
const assert = require('node:assert');
const fs = require('node:fs');
const os = require('node:os');
const path = require('node:path');
const fav = require('../favorites.js');
// check 支持 async fn —— 之前是同步的,传入 async 函数时断言在 await 之后
// 失败会变成 unhandledRejectionsummary 还先把这条计进了 passed
async function check(name, fn) {
try { await fn(); console.log(' ok -', name); passed++; }
catch (e) { console.error(' FAIL -', name, '\n', e.message); process.exitCode = 1; }
}
let passed = 0;
const tmpHome = fs.mkdtempSync(path.join(os.tmpdir(), 'clip-fav-'));
const dbPath = path.join(tmpHome, 'favorites.db');
check('open 创建 db 文件并返回实例', () => {
const db = fav.open(dbPath);
assert.ok(db, 'db 应该非空');
assert.ok(fs.existsSync(dbPath), 'db 文件应存在');
fav.close(db);
});
check('建表favorites 表存在且列正确', () => {
const db = fav.open(dbPath);
const cols = db.prepare("PRAGMA table_info(favorites)").all();
const names = cols.map(c => c.name);
assert.deepStrictEqual(names, ['id','type','text','image','preview','source_clip_id','created_at','favorited_at']);
fav.close(db);
});
check('索引 idx_fav_favorited_at 存在', () => {
const db = fav.open(dbPath);
const idx = db.prepare("SELECT name FROM sqlite_master WHERE type='index' AND name='idx_fav_favorited_at'").get();
assert.ok(idx, '索引应存在');
fav.close(db);
});
check('source_clip_id 上有 UNIQUE 约束', () => {
const db = fav.open(dbPath);
const idx = db.prepare("SELECT sql FROM sqlite_master WHERE type='table' AND name='favorites'").get();
assert.ok(/UNIQUE\s*\(\s*source_clip_id\s*\)/i.test(idx.sql), '建表 SQL 应包含 UNIQUE(source_clip_id)');
fav.close(db);
});
check('add 插入文本并能取回', () => {
const dbPath2 = path.join(tmpHome, 'add-text.db');
const db = fav.open(dbPath2);
const now = Date.now() / 1000;
const clip = { id: 101, type: 'text', text: 'hello world', preview: 'hello world', createdAt: now };
const row = fav.add(db, clip);
assert.ok(row.id > 0, '应返回新 id');
assert.strictEqual(row.type, 'text');
assert.strictEqual(row.text, 'hello world');
assert.strictEqual(row.sourceClipId, 101);
assert.strictEqual(row.preview, 'hello world');
fav.close(db);
});
check('add 重复 source_clip_id 抛错', () => {
const dbPath2 = path.join(tmpHome, 'add-dup.db');
const db = fav.open(dbPath2);
const now = Date.now() / 1000;
const clip = { id: 102, type: 'text', text: 'first', preview: 'first', createdAt: now };
fav.add(db, clip);
let threw = false;
try { fav.add(db, { ...clip, text: 'second' }); }
catch (e) { threw = /UNIQUE/i.test(e.message); }
assert.ok(threw, '重复 source_clip_id 应抛 UNIQUE 错误');
fav.close(db);
});
check('add 插入图片并保留 BLOB', () => {
const dbPath2 = path.join(tmpHome, 'add-img.db');
const db = fav.open(dbPath2);
const now = Date.now() / 1000;
const buf = Buffer.from([0x89, 0x50, 0x4E, 0x47]); // 假 PNG 头
const clip = { id: 103, type: 'image', image: buf, preview: '[图片]', createdAt: now };
const row = fav.add(db, clip);
assert.strictEqual(row.type, 'image');
// 列表/详情不再内联图片数据,只给一个 hasImage 标记;
// 真正的字节走 getImage() 按需取(否则列表要背几百 MB 的 base64
assert.strictEqual(row.hasImage, true);
assert.strictEqual(row.imageBase64, undefined, '不应再内联 base64');
const b64 = fav.getImage(db, row.id);
assert.strictEqual(typeof b64, 'string');
assert.strictEqual(Buffer.compare(Buffer.from(b64, 'base64'), buf), 0);
fav.close(db);
});
check('getImage 对文本条目返回 null', () => {
const dbPath2 = path.join(tmpHome, 'getimg-text.db');
const db = fav.open(dbPath2);
const row = fav.add(db, { id: 201, type: 'text', text: 'x', preview: 'x', createdAt: Date.now()/1000 });
assert.strictEqual(fav.getImage(db, row.id), null);
fav.close(db);
});
check('count 返回条数且不加载 BLOB', () => {
const dbPath2 = path.join(tmpHome, 'count.db');
const db = fav.open(dbPath2);
assert.strictEqual(fav.count(db), 0);
fav.add(db, { id: 301, type: 'text', text: 'a', preview: 'a', createdAt: Date.now()/1000 });
fav.add(db, { id: 302, type: 'image', image: Buffer.from([1,2,3]), preview: '[图片]', createdAt: Date.now()/1000 });
assert.strictEqual(fav.count(db), 2);
fav.close(db);
});
check('list 不内联图片数据', () => {
const dbPath2 = path.join(tmpHome, 'list-noimg.db');
const db = fav.open(dbPath2);
fav.add(db, { id: 401, type: 'image', image: Buffer.from([9,9,9]), preview: '[图片]', createdAt: Date.now()/1000 });
const rows = fav.list(db, '', 10);
assert.strictEqual(rows.length, 1);
assert.strictEqual(rows[0].hasImage, true);
assert.strictEqual(rows[0].imageBase64, undefined);
fav.close(db);
});
check('isFavorited 返回 favId 或 null', () => {
const dbPath2 = path.join(tmpHome, 'isfav.db');
const db = fav.open(dbPath2);
assert.strictEqual(fav.isFavorited(db, 103), null, '未收藏');
fav.add(db, { id: 104, type: 'text', text: 'x', preview: 'x', createdAt: Date.now()/1000 });
const id = fav.isFavorited(db, 104);
assert.ok(typeof id === 'number' && id > 0, '已收藏应返回数字 favId');
fav.close(db);
});
function sleep(ms) { return new Promise(r => setTimeout(r, ms)); }
(async () => {
check('remove 删除存在的 fav', () => {
const dbPath2 = path.join(tmpHome, 'remove.db');
const db = fav.open(dbPath2);
const row = fav.add(db, { id: 201, type: 'text', text: 'a', preview: 'a', createdAt: Date.now()/1000 });
assert.strictEqual(fav.remove(db, row.id), true);
assert.strictEqual(fav.get(db, row.id), null);
fav.close(db);
});
check('remove 不存在的 fav 返回 false', () => {
const dbPath2 = path.join(tmpHome, 'remove-missing.db');
const db = fav.open(dbPath2);
assert.strictEqual(fav.remove(db, 99999), false);
fav.close(db);
});
check('get 取单条', () => {
const dbPath2 = path.join(tmpHome, 'get.db');
const db = fav.open(dbPath2);
const row = fav.add(db, { id: 202, type: 'text', text: 'b', preview: 'b', createdAt: Date.now()/1000 });
const got = fav.get(db, row.id);
assert.strictEqual(got.text, 'b');
assert.strictEqual(got.id, row.id);
fav.close(db);
});
await check('list 默认按 favorited_at DESC', async () => {
const dbPath2 = path.join(tmpHome, 'list-order.db');
const db = fav.open(dbPath2);
fav.add(db, { id: 301, type: 'text', text: 'first', preview: 'first', createdAt: Date.now()/1000 });
await sleep(10);
fav.add(db, { id: 302, type: 'text', text: 'second', preview: 'second', createdAt: Date.now()/1000 });
await sleep(10);
fav.add(db, { id: 303, type: 'text', text: 'third', preview: 'third', createdAt: Date.now()/1000 });
const all = fav.list(db);
assert.strictEqual(all.length, 3);
assert.strictEqual(all[0].text, 'third');
assert.strictEqual(all[1].text, 'second');
assert.strictEqual(all[2].text, 'first');
fav.close(db);
});
check('list 支持 limit', () => {
const dbPath2 = path.join(tmpHome, 'list-limit.db');
const db = fav.open(dbPath2);
for (let i = 0; i < 5; i++) fav.add(db, { id: 400+i, type: 'text', text: 'x'+i, preview: 'x'+i, createdAt: Date.now()/1000 });
assert.strictEqual(fav.list(db, '', 2).length, 2);
fav.close(db);
});
// getRaw主进程「复制收藏到系统剪贴板」的取数路径favorites:copy
// 必须带 image BLOB 且字段形状与 clips 表一致type / text / image
// 才能直接喂 lib/clip-payload。
check('getRaw 取文本收藏的原始行', () => {
const dbPath2 = path.join(tmpHome, 'getraw-text.db');
const db = fav.open(dbPath2);
const row = fav.add(db, { id: 501, type: 'text', text: 'raw-text', preview: 'raw-text', createdAt: Date.now()/1000 });
const raw = fav.getRaw(db, row.id);
assert.ok(raw, '应取到原始行');
assert.strictEqual(raw.type, 'text');
assert.strictEqual(raw.text, 'raw-text');
assert.strictEqual(raw.image, null);
fav.close(db);
});
check('getRaw 取图片收藏时带回 BLOB', () => {
const dbPath2 = path.join(tmpHome, 'getraw-img.db');
const db = fav.open(dbPath2);
const buf = Buffer.from([0x89, 0x50, 0x4E, 0x47, 7, 7]);
const row = fav.add(db, { id: 502, type: 'image', image: buf, preview: '[图片]', createdAt: Date.now()/1000 });
const raw = fav.getRaw(db, row.id);
assert.ok(raw, '应取到原始行');
assert.strictEqual(raw.type, 'image');
assert.ok(Buffer.isBuffer(raw.image), 'image 列应是 Buffer');
assert.strictEqual(Buffer.compare(raw.image, buf), 0, 'BLOB 内容应原样取回');
fav.close(db);
});
check('getRaw 对不存在的 favId 返回 null', () => {
const dbPath2 = path.join(tmpHome, 'getraw-missing.db');
const db = fav.open(dbPath2);
assert.strictEqual(fav.getRaw(db, 99999), null);
fav.close(db);
});
// ---------- 搜索 ----------
// 与历史搜索同语义lib/search-plan多 token AND、% _ \ 转义。
check('list 搜索LIKE 元字符按字面匹配(% _ 不是通配符)', () => {
const dbPath2 = path.join(tmpHome, 'search-esc.db');
const db = fav.open(dbPath2);
fav.add(db, { id: 601, type: 'text', text: '100% off_sale promo', preview: '', createdAt: Date.now()/1000 });
fav.add(db, { id: 602, type: 'text', text: '100 apples for sale', preview: '', createdAt: Date.now()/1000 });
fav.add(db, { id: 603, type: 'text', text: 'aXb wildcard victim', preview: '', createdAt: Date.now()/1000 });
assert.strictEqual(fav.list(db, '100%').length, 1, '% 不能当通配符匹配一切以 100 开头的内容');
assert.strictEqual(fav.list(db, 'off_sale').length, 1, '_ 不能匹配任意单字符');
assert.strictEqual(fav.list(db, 'a_b').length, 0, '没有字面含 a_b 的记录');
fav.close(db);
});
check('list 搜索:多 token 是 AND、顺序无关', () => {
const dbPath2 = path.join(tmpHome, 'search-and.db');
const db = fav.open(dbPath2);
fav.add(db, { id: 611, type: 'text', text: 'walk in the park', preview: '', createdAt: Date.now()/1000 });
fav.add(db, { id: 612, type: 'text', text: 'park bench only', preview: '', createdAt: Date.now()/1000 });
assert.strictEqual(fav.list(db, 'park walk').length, 1, '两个 token 都出现才命中');
assert.strictEqual(fav.list(db, 'walk park').length, 1, '顺序无关');
assert.strictEqual(fav.list(db, 'park missing').length, 0, '缺一个就不命中');
fav.close(db);
});
console.log(`\n${passed} checks passed`);
})();

40
test/fts-search.test.js Normal file
View File

@@ -0,0 +1,40 @@
'use strict';
const assert = require('node:assert');
const fs = require('node:fs');
const os = require('node:os');
const path = require('node:path');
const Database = require('better-sqlite3');
const tmp = fs.mkdtempSync(path.join(os.tmpdir(), 'clip-fts-'));
const db = new Database(path.join(tmp, 'h.db'));
db.exec(`CREATE TABLE clips (id INTEGER PRIMARY KEY AUTOINCREMENT, type TEXT, text TEXT)`);
let hasFts5 = true;
try { db.exec(`CREATE VIRTUAL TABLE clips_fts USING fts5(text, content='clips', content_rowid='id')`); }
catch (e) {
hasFts5 = false;
db.close();
console.log(' SKIP - no FTS5 available');
process.exit(0);
}
db.exec(`
CREATE TRIGGER clips_ai AFTER INSERT ON clips BEGIN INSERT INTO clips_fts(rowid,text) VALUES (new.id, new.text); END;
`);
for (let i = 0; i < 50; i++) {
const txt = i % 3 === 0 ? `hello world ${i}` : `lorem ipsum ${i}`;
db.prepare('INSERT INTO clips(type,text) VALUES(?,?)').run('text', txt);
}
let passed = 0;
function check(name, fn) {
try { fn(); console.log(' ok -', name); passed++; }
catch (e) { console.error(' FAIL -', name, e.message); process.exitCode = 1; }
}
check('FTS5 MATCH finds exact substring', () => {
const r = db.prepare(`SELECT rowid FROM clips_fts WHERE clips_fts MATCH ?`).all('hello');
assert.ok(r.length > 0);
});
db.close();

23
test/image-cap.test.js Normal file
View File

@@ -0,0 +1,23 @@
'use strict';
const assert = require('node:assert');
const { capImage } = require('../lib/image-cap');
let passed = 0;
function check(name, fn) {
try { fn(); console.log(' ok -', name); passed++; }
catch (e) { console.error(' FAIL -', name, e.message); process.exitCode = 1; }
}
check('small buffer returned untouched', () => {
const buf = Buffer.alloc(1024); // 1 KB
const r = capImage(buf);
assert.strictEqual(r.buf, buf, 'small images should not be modified');
assert.strictEqual(r.preview, null);
});
check('oversized buffer triggers preview placeholder', () => {
const buf = Buffer.alloc(3 * 1024 * 1024); // 3 MB
const r = capImage(buf);
assert.ok(r.preview !== null, 'oversized images should produce preview');
assert.strictEqual(r.preview, '[图片]');
});

248
test/ipc-wiring.test.js Normal file
View File

@@ -0,0 +1,248 @@
'use strict';
/**
* 接线测试:把"存在但没接上"这一类 bug 挡住。
*
* 这次审查里同时出现了三个同类问题:
* 1. notify-fanout 有 addTheme(),但没有任何 theme:subscribe 处理器往里加订阅者,
* 于是 broadcastTheme() 永远在往空集合发消息 —— "跟随系统"静默失效。
* 2. package.json 的 build.files 漏了 lib/,打出来的包一启动就 MODULE_NOT_FOUND。
* 3. preload 暴露了 listLite但没有任何调用方。
*
* 这些都不是逻辑错误,靠读单个文件看不出来,只有跨文件对照才能发现。
*/
const assert = require('node:assert');
const fs = require('node:fs');
const path = require('node:path');
const root = path.join(__dirname, '..');
const read = (p) => fs.readFileSync(path.join(root, p), 'utf8');
const mainSrc = read('main.js');
const preloadSrc = read('preload.js');
const pkg = JSON.parse(read('package.json'));
let passed = 0;
function check(name, fn) {
try { fn(); console.log(' ok -', name); passed++; }
catch (e) { console.error(' FAIL -', name, e.message); process.exitCode = 1; }
}
// ---------- IPC 通道两端必须对齐 ----------
const handled = new Set(
[...mainSrc.matchAll(/ipcMain\.handle\(\s*'([^']+)'/g)].map((m) => m[1])
);
const invoked = new Set(
[...preloadSrc.matchAll(/ipcRenderer\.invoke\(\s*'([^']+)'/g)].map((m) => m[1])
);
check('preload 调用的每个通道,主进程都有处理器', () => {
const missing = [...invoked].filter((c) => !handled.has(c));
assert.deepStrictEqual(missing, [], `preload 调用了未注册的通道: ${missing.join(', ')}`);
});
check('主进程注册的每个通道,都有 preload 出口', () => {
const unreachable = [...handled].filter((c) => !invoked.has(c));
assert.deepStrictEqual(unreachable, [], `注册了但渲染进程够不着: ${unreachable.join(', ')}`);
});
// ---------- 推送通道:发送方与订阅方必须成对 ----------
const sentChannels = new Set(
[...mainSrc.matchAll(/notify(?:Clip|Fav|Theme)\(\s*'([^']+)'/g)].map((m) => m[1])
);
const listened = new Set(
[...preloadSrc.matchAll(/ipcRenderer\.on\(\s*'([^']+)'/g)].map((m) => m[1])
);
check('主进程广播的每个事件preload 都在监听', () => {
const deaf = [...sentChannels].filter((c) => !listened.has(c));
assert.deepStrictEqual(deaf, [], `广播了但没人听: ${deaf.join(', ')}`);
});
check('每个 fanout 集合都有对应的 subscribe 处理器', () => {
// addClip / addFav / addTheme 各自都必须被某个 ipcMain.handle 调用到
for (const adder of ['addClip', 'addFav', 'addTheme']) {
assert.ok(
new RegExp(`subscribers\\.${adder}\\(`).test(mainSrc),
`subscribers.${adder}() 从未被调用 —— 对应的广播会发进空集合`
);
}
});
check('theme:subscribe 存在(跟随系统主题依赖它)', () => {
assert.ok(handled.has('theme:subscribe'), '主进程缺少 theme:subscribe');
assert.ok(invoked.has('theme:subscribe'), 'preload 未暴露 theme:subscribe');
});
// ---------- 打包清单必须覆盖所有本地 require ----------
check('build.files 覆盖了 main/favorites 本地 require 的每个目录', () => {
const files = pkg.build.files;
const needed = new Set();
for (const src of [mainSrc, read('favorites.js')]) {
for (const m of src.matchAll(/require\(\s*'\.\/([^']+)'/g)) {
const spec = m[1];
if (spec.includes('/')) {
needed.add(spec.split('/')[0] + '/'); // 目录,如 lib/
} else {
needed.add(spec.endsWith('.js') ? spec : spec + '.js'); // 单文件,如 config -> config.js
}
}
}
for (const entry of needed) {
const covered = entry.endsWith('/')
? files.some((f) => f === entry.slice(0, -1) || f.startsWith(entry))
: files.includes(entry);
assert.ok(covered, `${entry} 被 require 但不在 build.files 里 —— 打包后会 MODULE_NOT_FOUND`);
}
});
check('lib/ 显式在打包清单里', () => {
assert.ok(
pkg.build.files.some((f) => f === 'lib' || f.startsWith('lib/')),
'build.files 缺少 lib/'
);
});
// ---------- 依赖不能是死重量 ----------
check('每个 dependency 都真的被 require 了', () => {
const srcFiles = ['main.js', 'preload.js', 'favorites.js', 'config.js', 'tray-menu.js']
.map(read)
.concat(fs.readdirSync(path.join(root, 'lib')).map((f) => read(path.join('lib', f))))
.join('\n');
const unused = Object.keys(pkg.dependencies).filter(
(dep) => !new RegExp(`require\\(\\s*'${dep.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')}'`).test(srcFiles)
);
assert.deepStrictEqual(unused, [], `声明了但没用到的依赖: ${unused.join(', ')}`);
});
// ---------- 渲染进程只能用 preload 真的暴露了的东西 ----------
check('renderer 从 window.* 解构的字段都存在于 preload主窗口 + 设置窗口)', () => {
const exposed = {};
for (const m of preloadSrc.matchAll(/exposeInMainWorld\('(\w+)',\s*\{([\s\S]*?)\n\}\)/g)) {
exposed[m[1]] = new Set([...m[2].matchAll(/^\s{2}(\w+):/gm)].map((x) => x[1]));
}
// 两个渲染窗口都要查settings.js 的 openExternal 解构曾经漏在这张网外面
for (const file of ['renderer/renderer.js', 'renderer/settings.js']) {
const rendererSrc = read(file);
for (const m of rendererSrc.matchAll(/const\s*\{([\s\S]*?)\}\s*=\s*window\.(\w+);/g)) {
const bridge = m[2];
if (!exposed[bridge]) continue;
for (const entry of m[1].split(',')) {
const name = entry.split(':')[0].trim();
if (!name || name.startsWith('//')) continue;
assert.ok(
exposed[bridge].has(name),
`${file} 解构了 window.${bridge}.${name},但 preload 没暴露它`
);
}
}
}
});
// ---------- 自动粘贴已移除2026-07-30界面任何时候都不隐藏 ----------
// 曾用 keyhelper 发 Ctrl+V需要先隐藏窗口交出焦点会有"缩一下"的动画,
// 用户明确不要。现在只写系统剪贴板,用户自己切窗口 Ctrl+V。
check('main.js 不再 require keyhelper自动粘贴已移除', () => {
assert.ok(
!/require\(\s*'\.\/keyhelper'\s*\)/.test(mainSrc),
'main.js 仍在 require keyhelper —— 自动粘贴应已整体移除'
);
});
check('clips:movetop 处理器里不再调用 keyHelper.sendCtrlV', () => {
const handler = mainSrc.match(/ipcMain\.handle\(\s*'clips:movetop'[\s\S]*?\n\s*\}\s*\);/);
assert.ok(handler, '找不到 clips:movetop 处理器');
assert.ok(
!/sendCtrlV/.test(handler[0]),
'clips:movetop 仍在调 sendCtrlV —— 自动粘贴应已移除'
);
});
check('clips:movetop 处理器里不再隐藏/最小化窗口', () => {
const handler = mainSrc.match(/ipcMain\.handle\(\s*'clips:movetop'[\s\S]*?\n\s*\}\s*\);/);
assert.ok(handler, '找不到 clips:movetop 处理器');
assert.ok(
!/mainWindow\s*\.\s*(hide|minimize)\s*\(/.test(handler[0]),
'clips:movetop 仍在 hide()/minimize() 窗口 —— 界面应始终保持可见'
);
});
// ---------- 两个 id 空间 / 两份缓存:容易"复用错"的地方 ----------
check('settings:clear-history 作废行数缓存并通知窗口刷新', () => {
// 不回源 clipCount设了 maxItems 时下次入库会按过期行数误删新记录;
// 不 notifyRefresh开着的主窗口停在已删除的项上。
const handler = mainSrc.match(/ipcMain\.handle\(\s*'settings:clear-history'[\s\S]*?\n\s*\}\s*\);/);
assert.ok(handler, '找不到 settings:clear-history 处理器');
assert.ok(
/clipCount\s*=\s*null/.test(handler[0]),
'settings:clear-history 没作废 clipCount —— enforceLimit 会按过期行数误删'
);
assert.ok(
/notifyRefresh\(\)/.test(handler[0]),
'settings:clear-history 没通知窗口刷新'
);
});
check('favorites:copy 两端接线(收藏复制不能复用 clips 的 id 空间)', () => {
// favId 与历史 id 分属两个库、自增数会撞号 —— 收藏 Tab 的复制
// 必须有自己从收藏行取内容的通道,不能走 clips:movetop。
assert.ok(handled.has('favorites:copy'), '主进程缺少 favorites:copy 处理器');
assert.ok(invoked.has('favorites:copy'), 'preload 未暴露 favorites:copy');
});
// ---------- settings:choose-dir动数据库的处理器坏一次就丢数据 ----------
// choose-dir 是 registerIpc 里最后一个 handler从它起捕获到 registerIpc 结尾
// (列 0 的 `}`handler 内部的闭合括号都有缩进,不会被误截)。
// 仓库文件是 CRLF 行尾,锚点要容忍 \r其余检查靠 \s* 天然兼容)。
const chooseDir = mainSrc.match(/ipcMain\.handle\(\s*'settings:choose-dir'[\s\S]*?\r?\n\}\r?\n/);
check('settings:choose-dir 存在', () => {
assert.ok(chooseDir, '找不到 settings:choose-dir 处理器');
});
check('settings:choose-dir 用 isSameDir 判"已是当前目录"Windows 大小写不敏感)', () => {
assert.ok(chooseDir, '找不到 settings:choose-dir 处理器');
// 比 effectiveDataDir()(含 session 兜底),不是裸 appConfig.dataDir
// 启动时盘符不可用会切到 session 兜底目录,此时用户重选这个目录就不
// 该弹迁移对话框自己迁自己。
assert.ok(
/isSameDir\(\s*newDir\s*,\s*effectiveDataDir\(\s*\)\s*\)/.test(chooseDir[0]),
'settings:choose-dir 未走 isSameDir —— Windows 上大小写不一致的重选会绕过' +
'"已经是当前数据位置"提示,弹出迁移对话框自己迁自己'
);
});
check('settings:choose-dir 先停轮询再关数据库', () => {
assert.ok(chooseDir, '找不到 settings:choose-dir 处理器');
const stopAt = chooseDir[0].indexOf('stopPolling()');
// db.close() 调用名可能因局部变量d.close / favDb.close 等)写得不完全一致,
// 任何形如 "<ident>.close()" 且 stopPolling 在它之前就足以说明顺序正确。
const closeAt = chooseDir[0].search(/[a-zA-Z_$][\w$]*\.close\(\)/);
assert.ok(stopAt >= 0, 'settings:choose-dir 没有 stopPolling —— 迁移期间 tick 会打中已关闭的连接');
assert.ok(closeAt >= 0 && stopAt < closeAt, 'stopPolling() 必须在关闭数据库之前');
});
// ---------- npm scripts终端环境差异不能让应用起不来 ----------
check('start / test:ui 脚本隔离 ELECTRON_RUN_AS_NODE', () => {
// VS Code及任何 Electron 派生)终端会注入 ELECTRON_RUN_AS_NODE=1
// 不加防护时 `electron .` 退化成纯 Node 跑 main.jsrequire('electron')
// 解构即抛 TypeError —— npm start / npm run test:ui 在这些终端里必挂。
// 注意必须带引号:`set X= && ...` 会把值设成一个空格(尾随空格进了值),
// 变量仍在、Electron 照样退化;`set "X=" && ...` 才是真的删除。
// npm scripts 在 Windows 上一律经 cmd.exe与调用方终端无关。
for (const name of ['start', 'test:ui']) {
assert.ok(
/set\s+"ELECTRON_RUN_AS_NODE="\s*&&/.test(pkg.scripts[name] || ''),
`scripts.${name} 未正确清掉 ELECTRON_RUN_AS_NODE —— VS Code 终端里会崩`
);
}
});
console.log(`\n${passed} checks passed`);

View File

@@ -0,0 +1,67 @@
'use strict';
const assert = require('node:assert');
const events = require('node:events');
let spawnedCount = 0;
class MockChild extends events.EventEmitter {
constructor() {
super();
this.stdin = { write: (s) => { this._lastWrite = s; }, end: () => {} };
this.stdout = new events.EventEmitter();
this.stderr = new events.EventEmitter();
spawnedCount++;
}
}
const cp = require('node:child_process');
const origSpawn = cp.spawn;
cp.spawn = () => new MockChild();
const KeyHelper = require('../keyhelper.js');
let passed = 0;
function check(name, fn) {
return Promise.resolve()
.then(fn)
.then(() => { console.log(' ok -', name); passed++; })
.catch((e) => { console.error(' FAIL -', name, e.message); process.exitCode = 1; });
}
(async () => {
await check('spawn is invoked when KeyHelper is constructed', () => {
const kh = new KeyHelper();
assert.strictEqual(spawnedCount >= 1, true);
kh.dispose();
});
await check('sendCtrlV returns a promise (no immediate throw)', () => {
const kh = new KeyHelper();
const p = kh.sendCtrlV();
assert.ok(p instanceof Promise);
kh.dispose();
// dispose() 必须把挂起的请求结掉。否则 10 秒后的超时会变成一个
// 没人接的 rejection并且那个 timer 会一直吊着事件循环。
return assert.rejects(p, /disposed/);
});
await check('dispose() clears pending map and does not leave timers', () => {
const kh = new KeyHelper();
const p = kh.sendCtrlV();
assert.strictEqual(kh.pending.size, 1);
kh.dispose();
assert.strictEqual(kh.pending.size, 0);
return p.catch(() => {});
});
await check('dispose() stops the reconnect loop', () => {
const kh = new KeyHelper();
const before = spawnedCount;
kh.dispose();
kh.ps.emit('exit', 1); // 已 dispose不应再重连
return new Promise((r) => setTimeout(r, 700))
.then(() => assert.strictEqual(spawnedCount, before));
});
cp.spawn = origSpawn;
console.log(`\n${passed} checks passed`);
})();

119
test/legacy-migrate.test.js Normal file
View File

@@ -0,0 +1,119 @@
'use strict';
/**
* 老数据目录(%USERPROFILE%\.clipboard-app→ 新默认目录(项目/exe 同级 data/
* 的一次性迁移。
*
* 三条底线:
* 1. 迁完 config.json 里的 dataDir 必须指向新目录 —— 否则下次启动又跑回老地方;
* 2. 新目录已经在用时不得覆盖(幂等,重复启动不会拿老库盖新库);
* 3. 老文件保留 —— 复制不是移动,出问题能回退。
*/
const assert = require('node:assert');
const fs = require('node:fs');
const os = require('node:os');
const path = require('node:path');
const root = fs.mkdtempSync(path.join(os.tmpdir(), 'clip-mig-'));
const home = path.join(root, 'home');
const target = path.join(root, 'Clipboard Data'); // 模拟 D:\Clipboard Data
const legacy = path.join(home, '.clipboard-app');
process.env.USERPROFILE = home;
process.env.HOME = home;
process.env.CLIPBOARD_APP_DATA_DIR = target;
const cfg = require('../config.js');
let passed = 0;
function check(name, fn) {
try { fn(); console.log(' ok -', name); passed++; }
catch (e) { console.error(' FAIL -', name, '\n', e.message); process.exitCode = 1; }
}
function resetTarget() {
fs.rmSync(target, { recursive: true, force: true });
}
function seedLegacy(extraCfg) {
fs.rmSync(legacy, { recursive: true, force: true });
fs.mkdirSync(legacy, { recursive: true });
fs.writeFileSync(path.join(legacy, 'history.db'), 'OLD-HISTORY');
fs.writeFileSync(path.join(legacy, 'history.db-wal'), 'OLD-WAL');
fs.writeFileSync(path.join(legacy, 'favorites.db'), 'OLD-FAV');
fs.writeFileSync(path.join(legacy, 'history.db-shm'), 'OLD-SHM');
fs.writeFileSync(
path.join(legacy, 'config.json'),
JSON.stringify({ maxItems: 500, pollMs: 500, theme: 'carbon', dataDir: legacy, ...extraCfg }),
);
}
check('老数据被复制到新目录,且 dataDir 改写为新目录', () => {
resetTarget();
seedLegacy();
const r = cfg.migrateLegacyData();
assert.strictEqual(r.migrated, true);
assert.strictEqual(fs.readFileSync(path.join(target, 'history.db'), 'utf8'), 'OLD-HISTORY');
assert.strictEqual(fs.readFileSync(path.join(target, 'history.db-wal'), 'utf8'), 'OLD-WAL');
assert.strictEqual(fs.readFileSync(path.join(target, 'favorites.db'), 'utf8'), 'OLD-FAV');
const c = cfg.loadConfig();
assert.strictEqual(path.resolve(c.dataDir), path.resolve(target), 'dataDir 必须指向新目录');
assert.strictEqual(c.maxItems, 500, '其他设置要保留');
assert.strictEqual(c.theme, 'carbon');
});
check('-shm 不搬SQLite 自己重建)', () => {
assert.ok(!fs.existsSync(path.join(target, 'history.db-shm')));
});
check('老文件保留,不是移动', () => {
assert.ok(fs.existsSync(path.join(legacy, 'history.db')));
assert.ok(fs.existsSync(path.join(legacy, 'config.json')));
});
check('幂等:新目录已在用时不覆盖', () => {
fs.writeFileSync(path.join(target, 'history.db'), 'NEW-HISTORY');
const r = cfg.migrateLegacyData();
assert.strictEqual(r.migrated, false);
assert.strictEqual(r.reason, 'target-in-use');
assert.strictEqual(fs.readFileSync(path.join(target, 'history.db'), 'utf8'), 'NEW-HISTORY');
});
check('没有老数据时什么都不做', () => {
resetTarget();
fs.rmSync(legacy, { recursive: true, force: true });
const r = cfg.migrateLegacyData();
assert.strictEqual(r.migrated, false);
assert.strictEqual(r.reason, 'nothing-to-migrate');
assert.ok(!fs.existsSync(path.join(target, 'config.json')));
});
check('老 config 指向自定义目录时,从那个目录搬', () => {
resetTarget();
const custom = path.join(root, 'custom');
fs.mkdirSync(custom, { recursive: true });
fs.writeFileSync(path.join(custom, 'history.db'), 'CUSTOM-HISTORY');
seedLegacy({ dataDir: custom });
const r = cfg.migrateLegacyData();
assert.strictEqual(r.migrated, true);
assert.strictEqual(path.resolve(r.from), path.resolve(custom));
assert.strictEqual(fs.readFileSync(path.join(target, 'history.db'), 'utf8'), 'CUSTOM-HISTORY');
assert.strictEqual(path.resolve(cfg.loadConfig().dataDir), path.resolve(target));
});
check('老 config 缺 dataDir 字段时,从老目录搬(不能用 sanitize 回填的新默认目录当源)', () => {
// sanitize 会把缺省的 dataDir 补成新默认目录(= target拿它当 src
// 就是"从 target 搬到 target":什么也没搬却报 migrated老数据永久遗留。
resetTarget();
fs.rmSync(legacy, { recursive: true, force: true });
fs.mkdirSync(legacy, { recursive: true });
fs.writeFileSync(path.join(legacy, 'history.db'), 'OLD-HISTORY');
fs.writeFileSync(path.join(legacy, 'config.json'), JSON.stringify({ maxItems: 100, pollMs: 500 }));
const r = cfg.migrateLegacyData();
assert.strictEqual(r.migrated, true);
assert.strictEqual(path.resolve(r.from), path.resolve(legacy), 'src 必须是老目录');
assert.strictEqual(fs.readFileSync(path.join(target, 'history.db'), 'utf8'), 'OLD-HISTORY');
});
fs.rmSync(root, { recursive: true, force: true });
console.log(`\n${passed} checks passed`);

53
test/list-join.test.js Normal file
View File

@@ -0,0 +1,53 @@
'use strict';
const assert = require('node:assert');
const fs = require('node:fs');
const os = require('node:os');
const path = require('node:path');
const Database = require('better-sqlite3');
const tmp = fs.mkdtempSync(path.join(os.tmpdir(), 'clip-join-'));
const db = new Database(path.join(tmp, 'h.db'));
db.exec(`
CREATE TABLE clips (
id INTEGER PRIMARY KEY AUTOINCREMENT,
type TEXT, text TEXT, image BLOB, preview TEXT,
top_at REAL, created_at REAL NOT NULL
);
CREATE TABLE favorites (
id INTEGER PRIMARY KEY AUTOINCREMENT,
source_clip_id INTEGER UNIQUE, favorited_at REAL NOT NULL
);
`);
const now = Date.now()/1000;
// created_at 必须互不相同,否则 ORDER BY created_at DESC 的次序未定义,断言就是碰运气
db.prepare('INSERT INTO clips(type,text,preview,top_at,created_at) VALUES(?,?,?,?,?)').run('text','a','a',null,now - 2);
db.prepare('INSERT INTO clips(type,text,preview,top_at,created_at) VALUES(?,?,?,?,?)').run('text','b','b',null,now - 1);
db.prepare('INSERT INTO clips(type,text,preview,top_at,created_at) VALUES(?,?,?,?,?)').run('text','c','c',null,now);
const favInfo = db.prepare('INSERT INTO favorites(source_clip_id, favorited_at) VALUES(?,?)').run(2, now);
const favIdOfClip2 = favInfo.lastInsertRowid; // = favorites.id不是 clip.id
let passed = 0;
function check(name, fn) {
try { fn(); console.log(' ok -', name); passed++; }
catch (e) { console.error(' FAIL -', name, e.message); process.exitCode = 1; }
}
check('LEFT JOIN returns one row per clip with fav_id when matched', () => {
const rows = db.prepare(`
SELECT c.id, c.text, f.id AS fav_id
FROM clips c
LEFT JOIN favorites f ON f.source_clip_id = c.id
ORDER BY c.created_at DESC
`).all();
assert.strictEqual(rows.length, 3, 'JOIN 不应放大行数');
// created_at DESC → c(3), b(2), a(1);被收藏的是 clip 2 = 中间那条
assert.deepStrictEqual(rows.map(r => r.id), [3, 2, 1]);
assert.strictEqual(rows[0].fav_id, null);
// fav_id 是 favorites 表的主键,不是 source_clip_id —— 这两个值以前被搞混了
assert.strictEqual(rows[1].fav_id, favIdOfClip2);
assert.notStrictEqual(rows[1].fav_id, rows[1].id, 'favId 与 clipId 是不同的命名空间');
assert.strictEqual(rows[2].fav_id, null);
});
db.close();

View File

@@ -0,0 +1,128 @@
'use strict';
/**
* 回归测试v1 -> v2 迁移
*
* 曾经的 bugmigrate() 用 STEPS[v - 1] 取步骤,但 STEPS[0] 就是 v1->v2 的步骤,
* 所以 v=2 时取到的是 STEPS[1] === undefined整个 FTS5 迁移从来没跑过。
* 表现clips_fts 永远不存在,搜索一直静默退回 LIKE 全表扫描。
*/
const assert = require('node:assert');
const fs = require('node:fs');
const os = require('node:os');
const path = require('node:path');
const Database = require('better-sqlite3');
const { migrate, STEPS } = require('../lib/migrate.js');
let passed = 0;
function check(name, fn) {
try { fn(); console.log(' ok -', name); passed++; }
catch (e) { console.error(' FAIL -', name, e.message); process.exitCode = 1; }
}
function freshDb() {
const tmp = fs.mkdtempSync(path.join(os.tmpdir(), 'clip-mig2-'));
const db = new Database(path.join(tmp, 'h.db'));
db.exec(`CREATE TABLE clips (
id INTEGER PRIMARY KEY AUTOINCREMENT, type TEXT, text TEXT,
top_at REAL, created_at REAL NOT NULL DEFAULT 0)`);
return db;
}
check('STEPS[0] is the v1->v2 step (index maps version-2, not version-1)', () => {
assert.strictEqual(typeof STEPS[0], 'function');
assert.match(STEPS[0].name, /v1_to_v2/);
});
// 探测这个 SQLite 构建是否带 FTS5没有的话下面的断言换成"优雅降级"
let fts5 = true;
{
const db = freshDb();
try { db.exec(`CREATE VIRTUAL TABLE t USING fts5(x)`); } catch (_) { fts5 = false; }
db.close();
}
check('migrate actually reaches v2 and reports it', () => {
const db = freshDb();
const reached = migrate(db, 1, 2);
if (fts5) {
assert.strictEqual(reached, 2, '迁移应到达 v2');
assert.strictEqual(db.pragma('user_version', { simple: true }), 2);
} else {
assert.strictEqual(reached, 1, '没有 FTS5 时应停在 v1而不是谎报成功');
}
db.close();
});
if (fts5) {
check('clips_fts exists and is searchable after migrate', () => {
const db = freshDb();
db.prepare('INSERT INTO clips(type,text) VALUES(?,?)').run('text', 'hello world');
migrate(db, 1, 2);
const rows = db.prepare(`SELECT rowid FROM clips_fts WHERE clips_fts MATCH 'hello'`).all();
assert.strictEqual(rows.length, 1, '已有数据应被回填进 FTS 索引');
db.close();
});
check('migrate is idempotent across restarts (no duplicate index entries)', () => {
const db = freshDb();
db.prepare('INSERT INTO clips(type,text) VALUES(?,?)').run('text', 'hello world');
for (let launch = 0; launch < 3; launch++) migrate(db, 1, 2);
const n = db.prepare(`SELECT count(*) c FROM clips_fts WHERE clips_fts MATCH 'hello'`).get().c;
assert.strictEqual(n, 1, '重复启动不应重复回填');
db.exec(`INSERT INTO clips_fts(clips_fts) VALUES('integrity-check')`);
db.close();
});
check('user_version short-circuits the backfill on subsequent runs', () => {
const db = freshDb();
migrate(db, 1, 2);
// 直接往 clips 插一行但绕开触发器是做不到的,所以改为验证:
// 第二次 migrate 不再执行回填(否则这行会被索引两次)
db.prepare('INSERT INTO clips(type,text) VALUES(?,?)').run('text', 'unique token zzz');
migrate(db, 1, 2);
const n = db.prepare(`SELECT count(*) c FROM clips_fts WHERE clips_fts MATCH 'zzz'`).get().c;
assert.strictEqual(n, 1);
db.close();
});
check('AFTER UPDATE trigger skips FTS churn when only top_at changes', () => {
const db = freshDb();
db.prepare('INSERT INTO clips(type,text) VALUES(?,?)').run('text', 'hello world');
migrate(db, 1, 2);
db.prepare('UPDATE clips SET top_at = ? WHERE id = 1').run(123);
assert.strictEqual(
db.prepare(`SELECT count(*) c FROM clips_fts WHERE clips_fts MATCH 'hello'`).get().c, 1,
'置顶不应影响可搜索性');
db.exec(`INSERT INTO clips_fts(clips_fts) VALUES('integrity-check')`);
db.close();
});
check('AFTER UPDATE trigger still re-indexes when text changes', () => {
const db = freshDb();
db.prepare('INSERT INTO clips(type,text) VALUES(?,?)').run('text', 'hello world');
migrate(db, 1, 2);
db.prepare('UPDATE clips SET text = ? WHERE id = 1').run('goodbye moon');
assert.strictEqual(db.prepare(`SELECT count(*) c FROM clips_fts WHERE clips_fts MATCH 'hello'`).get().c, 0);
assert.strictEqual(db.prepare(`SELECT count(*) c FROM clips_fts WHERE clips_fts MATCH 'goodbye'`).get().c, 1);
db.close();
});
check('DELETE keeps the FTS index consistent', () => {
const db = freshDb();
db.prepare('INSERT INTO clips(type,text) VALUES(?,?)').run('text', 'hello world');
migrate(db, 1, 2);
db.prepare('DELETE FROM clips WHERE id = 1').run();
assert.strictEqual(db.prepare(`SELECT count(*) c FROM clips_fts WHERE clips_fts MATCH 'hello'`).get().c, 0);
db.exec(`INSERT INTO clips_fts(clips_fts) VALUES('integrity-check')`);
db.close();
});
} else {
console.log(' SKIP - FTS5 unavailable in this SQLite build');
}
check('migrate on a broken db degrades instead of throwing', () => {
const bad = { pragma() { throw new Error('nope'); }, exec() { throw new Error('nope'); } };
assert.strictEqual(migrate(bad, 1, 2), 1);
});
console.log(`\n${passed} checks passed`);

28
test/notify-sets.test.js Normal file
View File

@@ -0,0 +1,28 @@
'use strict';
const assert = require('node:assert');
const { createSubscribers } = require('../lib/notify-fanout.js');
let passed = 0;
function check(name, fn) {
try { fn(); console.log(' ok -', name); passed++; }
catch (e) { console.error(' FAIL -', name, e.message); process.exitCode = 1; }
}
const subs = createSubscribers();
const wcA = { id: 'A', send: function (ch, payload) { this._last = [ch, payload]; } };
const wcB = { id: 'B', send: function (ch, payload) { this._last = [ch, payload]; } };
subs.addClip(wcA);
subs.addFav(wcB);
check('notifyClip only sends to clip subs', () => {
subs.notifyClip('clips:added', { id: 1 });
assert.deepStrictEqual(wcA._last, ['clips:added', { id: 1 }]);
assert.strictEqual(wcB._last, undefined);
});
check('notifyFav only sends to fav subs', () => {
subs.notifyFav('favorites:changed');
assert.strictEqual(wcA._last[0], 'clips:added', 'A untouched');
assert.strictEqual(wcB._last[0], 'favorites:changed');
});

186
test/order-by.test.js Normal file
View File

@@ -0,0 +1,186 @@
'use strict';
/**
* 列表排序:验证 main.js 里 orderBy() 的实现。
*
* 排序契约:列表按「最近活跃」倒序,即 created_at DESC。
*
* - 外部新复制:插入时 created_at = now → 自然排到顶
* - 用户点过「复制」movetop 把 created_at 同步更新到 now → 同样排到顶
* - 其它项按 created_at DESC 自然排列
*
* 早期版本用过 `ORDER BY top_at DESC, created_at DESC`,但它让「外部新复制」
* 排在「会话内置顶项」之后 —— 即使新复制才是当前剪贴板内容,列表顶上
* 显示的还是上一会话里被点过复制的旧项。这违反「第一个就是当前剪贴板
* 内容」的契约。改成 created_at DESC 后,无论外部复制还是点过复制,
* 都按最近活跃时间排序,问题自然消失。
*
* 用 id DESC 作 tiebreaker同 ms 内多次变更给一个稳定次序。
*/
const assert = require('node:assert');
const fs = require('node:fs');
const os = require('node:os');
const path = require('node:path');
const Database = require('better-sqlite3');
const NEW = 'ORDER BY created_at DESC, id DESC';
let passed = 0;
function check(name, fn) {
try { fn(); console.log(' ok -', name); passed++; }
catch (e) { console.error(' FAIL -', name, e.message); process.exitCode = 1; }
}
function makeDb() {
const tmp = fs.mkdtempSync(path.join(os.tmpdir(), 'clip-ob-'));
const db = new Database(path.join(tmp, 'h.db'));
db.exec(`CREATE TABLE clips(
id INTEGER PRIMARY KEY AUTOINCREMENT, type TEXT, text TEXT, image BLOB,
preview TEXT, top_at REAL, created_at REAL NOT NULL)`);
// 复合索引created_at DESC, id DESC —— 完整覆盖 ORDER BY 的两列,
// 不需要「TEMP B-TREE FOR LAST TERM OF ORDER BY」给 id DESC 兜底。
db.exec(`CREATE INDEX idx_clips_created_id ON clips(created_at DESC, id DESC)`);
return db;
}
const ids = (db, order, limit = 1000) =>
db.prepare(`SELECT id FROM clips ${order} LIMIT ?`).all(limit).map((r) => r.id);
check('外部新复制天然排到顶created_at 最大的在最前', () => {
const db = makeDb();
const ins = db.prepare('INSERT INTO clips(type,text,top_at,created_at) VALUES(?,?,?,?)');
ins.run('text', 'a', null, 100); // id=1, created_at=100
ins.run('text', 'b', null, 300); // id=2, created_at=300
ins.run('text', 'c', 500, 200); // id=3, created_at=200之前被点过复制
ins.run('text', 'd', null, 200); // id=4, created_at=200
ins.run('text', 'e', 900, 100); // id=5, created_at=100之前被点过复制
ins.run('text', 'f', null, 400); // id=6, created_at=400
const got = db.prepare(`SELECT text FROM clips ${NEW}`).all().map((r) => r.text);
// created_at DESCf(400), b(300), d(200), c(200), e(100), a(100)
// created_at 相同的 (d/c=200, e/a=100) 之间用 id DESC 决出次序:
// - 同为 200d(id=4) > c(id=3) → d 在前
// - 同为 100e(id=5) > a(id=1) → e 在前
assert.deepStrictEqual(got, ['f', 'b', 'd', 'c', 'e', 'a']);
db.close();
});
check('点过复制的项也排到顶movetop 把 created_at 同步更新到 now', () => {
const db = makeDb();
const ins = db.prepare('INSERT INTO clips(type,text,top_at,created_at) VALUES(?,?,?,?)');
ins.run('text', 'a', null, 100);
ins.run('text', 'b', null, 200);
ins.run('text', 'c', null, 300);
// 模拟点过复制 c —— movetop 把 top_at 和 created_at 都更新到 now
db.prepare('UPDATE clips SET top_at = ?, created_at = ? WHERE id = (SELECT id FROM clips WHERE text = ?)').run(1000, 1000, 'a');
const got = db.prepare(`SELECT text FROM clips ${NEW}`).all().map((r) => r.text);
// a(1000) > c(300) > b(200)
assert.deepStrictEqual(got, ['a', 'c', 'b']);
db.close();
});
check('外部新复制总会超过任何会话内置顶项(契约)', () => {
// 这个 case 就是用户报的 bug会话内点了 c 的复制 → c: top_at=500, created_at=200
// 接着又从外面复制了 f → f: top_at=NULL, created_at=400
// 老排序 (top_at DESC) 会把 c 排到 f 前面,错。
const db = makeDb();
const ins = db.prepare('INSERT INTO clips(type,text,top_at,created_at) VALUES(?,?,?,?)');
ins.run('text', 'c', 500, 200);
ins.run('text', 'f', null, 400);
const got = db.prepare(`SELECT text FROM clips ${NEW}`).all().map((r) => r.text);
assert.deepStrictEqual(got, ['f', 'c'], '最新复制 (f) 必须在被点过复制的 (c) 前面');
db.close();
});
check('全部 created_at 相同时按 id DESC 给出稳定次序', () => {
const db = makeDb();
const ins = db.prepare('INSERT INTO clips(type,text,top_at,created_at) VALUES(?,?,?,?)');
ins.run('text', 'a', null, 100);
ins.run('text', 'b', null, 100);
ins.run('text', 'c', null, 100);
const got = db.prepare(`SELECT text FROM clips ${NEW}`).all().map((r) => r.text);
// 同一 created_atid DESC → c, b, a
assert.deepStrictEqual(got, ['c', 'b', 'a']);
db.close();
});
check('1 万行随机数据:按 created_at DESC + id DESC 排序稳定', () => {
const db = makeDb();
const ins = db.prepare('INSERT INTO clips(type,text,top_at,created_at) VALUES(?,?,?,?)');
db.transaction(() => {
for (let i = 0; i < 10000; i++) {
ins.run('text', 't' + i, i % 7 === 0 ? (i * 13) % 5000 : null, (i * 31) % 9999);
}
})();
// 不论 top_at 是什么值ORDER BY created_at DESC, id DESC 永远稳定
const rows = db.prepare(`SELECT id FROM clips ORDER BY created_at DESC, id DESC LIMIT 500`).all().map(r => r.id);
// 跑两次结果必须完全一致id DESC tiebreaker 起作用)
const again = db.prepare(`SELECT id FROM clips ORDER BY created_at DESC, id DESC LIMIT 500`).all().map(r => r.id);
assert.deepStrictEqual(rows, again, 'id DESC 兜底让排序可重复');
// 行数对上
assert.strictEqual(rows.length, 500);
db.close();
});
check('新 ORDER BY 能走 idx_clips_created_id 复合索引(不再全表排序)', () => {
const db = makeDb();
const ins = db.prepare('INSERT INTO clips(type,text,top_at,created_at) VALUES(?,?,?,?)');
db.transaction(() => {
for (let i = 0; i < 2000; i++) ins.run('text', 't' + i, null, i);
})();
db.exec('ANALYZE');
const plan = db
.prepare(`EXPLAIN QUERY PLAN SELECT id, top_at, created_at FROM clips ${NEW} LIMIT 200`)
.all().map((r) => r.detail).join(' | ');
// 视 SELECT 的列能否被索引完全覆盖SQLite 会报 "USING INDEX" 或
// "USING COVERING INDEX" —— 两者都算走上了索引。
assert.match(plan, /USING (COVERING )?INDEX idx_clips_created_id/, `应走 idx_clips_created_id实际: ${plan}`);
assert.doesNotMatch(plan, /TEMP B-TREE/, `不应再有临时排序,实际: ${plan}`);
db.close();
});
check('带完整列的真实列表查询同样不会退回全表排序', () => {
const db = makeDb();
const ins = db.prepare('INSERT INTO clips(type,text,image,preview,top_at,created_at) VALUES(?,?,?,?,?,?)');
db.transaction(() => {
for (let i = 0; i < 2000; i++) {
ins.run('text', 't' + i, null, 'p' + i, i % 7 === 0 ? (i * 13) % 5000 : null, i);
}
})();
const cols = 'id, type, text, preview, top_at, created_at, (image IS NOT NULL) AS has_image';
const detail = db
.prepare(`EXPLAIN QUERY PLAN SELECT ${cols} FROM clips ${NEW} LIMIT 200`)
.all().map((r) => r.detail).join(' | ');
assert.match(detail, /idx_clips_created_id/, `应走 idx_clips_created_id实际: ${detail}`);
assert.doesNotMatch(detail, /TEMP B-TREE/, `不应临时排序,实际: ${detail}`);
db.close();
});
check('main.js 的 orderBy 返回 created_at DESC, id DESC不再用 top_at 排序)', () => {
const src = fs.readFileSync(path.join(__dirname, '..', 'main.js'), 'utf8');
const orderByMatch = src.match(/const\s+orderBy\s*=\s*\([^)]*\)\s*=>\s*`([^`]+)`/);
assert.ok(orderByMatch, '找不到 orderBy 定义');
const expr = orderByMatch[1];
assert.ok(
/created_at\s+DESC/.test(expr),
`orderBy 必须按 created_at DESC 排序,实际: ${expr}`
);
assert.ok(
!/top_at\s+DESC/.test(expr),
`orderBy 不应再用 top_at 排序,实际: ${expr}`
);
});
check('main.js 不再残留 (top_at IS NULL) 排序表达式', () => {
// 早期版本写过 `(top_at IS NULL), top_at DESC, created_at DESC`
// 那个 (top_at IS NULL) 是表达式索引satisfy不了会强制全表排序。
// 现在新排序不用 top_at 了,这个坑不应该再出现。
const src = fs.readFileSync(path.join(__dirname, '..', 'main.js'), 'utf8');
const orderByLines = src.split('\n').filter((l) => /ORDER BY|orderBy =/.test(l));
for (const line of orderByLines) {
assert.ok(
!/top_at\s+IS\s+NULL\s*\)\s*,/.test(line),
`排序表达式又回来了,会导致全表排序: ${line.trim()}`
);
}
});
console.log(`\n${passed} checks passed`);

View File

@@ -0,0 +1,73 @@
'use strict';
const assert = require('node:assert');
const { shouldReadImage } = require('../lib/poll-guard');
let passed = 0;
function check(name, fn) {
try { fn(); console.log(' ok -', name); passed++; }
catch (e) { console.error(' FAIL -', name, e.message); process.exitCode = 1; }
}
check('纯文本时返回 false', () => {
assert.strictEqual(shouldReadImage(['text/plain']), false);
assert.strictEqual(shouldReadImage(['text/html']), false);
});
check('空数组返回 false', () => {
assert.strictEqual(shouldReadImage([]), false);
});
check('空格式列表返回 false', () => {
assert.strictEqual(shouldReadImage(['']), false);
});
check('纯图片返回 true', () => {
assert.strictEqual(shouldReadImage(['image/png']), true);
assert.strictEqual(shouldReadImage(['image/bmp']), true);
assert.strictEqual(shouldReadImage(['image/jpeg']), true);
});
check('文本+图片混合返回 true', () => {
assert.strictEqual(shouldReadImage(['text/plain', 'image/png']), true);
});
check('非数组容错', () => {
assert.strictEqual(shouldReadImage(null), false);
assert.strictEqual(shouldReadImage(undefined), false);
assert.strictEqual(shouldReadImage('image/png'), false);
});
check('非字符串元素容错', () => {
assert.strictEqual(shouldReadImage([null, 'image/png']), true);
assert.strictEqual(shouldReadImage([42, 'text/plain']), false);
});
check('只匹配 image/ 开头,不被 text/image 之类误判', () => {
assert.strictEqual(shouldReadImage(['text/image']), false);
});
// ---------- 主进程接入回归 ----------
const fs = require('node:fs');
const path = require('node:path');
const mainSrc = fs.readFileSync(path.join(__dirname, '..', 'main.js'), 'utf8');
check('main.js 已 require poll-guard', () => {
assert.ok(
/require\(\s*'\.\/lib\/poll-guard'\s*\)/.test(mainSrc),
'main.js 没 require poll-guard'
);
});
check('pollClipboard 在 readImage 前调用了 shouldReadImage', () => {
// 抓出 pollClipboard 整个函数体
const fn = mainSrc.match(/function pollClipboard[\s\S]*?\n\}/);
assert.ok(fn, '找不到 pollClipboard');
const idxGuard = fn[0].indexOf('shouldReadImage');
const idxRead = fn[0].indexOf('readImage');
assert.ok(idxGuard > 0, 'pollClipboard 没调用 shouldReadImage');
assert.ok(idxRead > 0, 'pollClipboard 没调用 readImage');
assert.ok(idxGuard < idxRead, 'shouldReadImage 必须在 readImage 之前出现');
});
console.log(`\n${passed} checks passed`);

67
test/run.js Normal file
View File

@@ -0,0 +1,67 @@
'use strict';
/**
* 测试入口。
*
* 为什么要有这个文件,而不是在 package.json 里串一长串 &&
*
* 1. ABI。better-sqlite3 是原生模块,只能编译给一个运行时。仓库里它是按
* Electron 的 ABI 编译的postinstall / npm run rebuild所以用普通
* `node` 跑任何碰 DB 的测试都会直接 ERR_DLOPEN_FAILED。
* 解法:用 ELECTRON_RUN_AS_NODE=1 借 Electron 自带的 node 来跑 ——
* ABI 天然对得上,而且测的就是应用真正加载的那个二进制。
*
* 2. 手写清单会漏。settings-render.test.js 就这么被漏掉过:文件在、
* 专门防一类回归,但从来没被执行。这里改成扫目录,新加的测试自动进来。
*/
const { spawnSync } = require('node:child_process');
const fs = require('node:fs');
const path = require('node:path');
const testDir = __dirname;
// 需要真实 Electron GUIBrowserWindow / app走 npm run test:ui
const GUI_ONLY = new Set(['settings-tabs.test.js', 'settings-render.test.js']);
// 用 node:test 运行器的
const NODE_TEST_RUNNER = new Set(['theme.test.js']);
const files = fs.readdirSync(testDir)
.filter((f) => f.endsWith('.test.js') && !GUI_ONLY.has(f))
.sort();
if (files.length === 0) {
console.error('没有找到任何测试文件');
process.exit(1);
}
function electronBin() {
try {
return require('electron'); // 返回可执行文件的绝对路径
} catch (_) {
return null;
}
}
const bin = electronBin();
if (!bin || typeof bin !== 'string') {
console.error('找不到 electron。先跑 npm install。');
process.exit(1);
}
const failed = [];
for (const f of files) {
const args = NODE_TEST_RUNNER.has(f) ? ['--test', path.join(testDir, f)] : [path.join(testDir, f)];
console.log(`\n─── ${f} ${'─'.repeat(Math.max(0, 56 - f.length))}`);
const r = spawnSync(bin, args, {
stdio: 'inherit',
env: { ...process.env, ELECTRON_RUN_AS_NODE: '1' },
});
if (r.status !== 0) failed.push(f);
}
console.log('\n' + '═'.repeat(64));
if (failed.length) {
console.error(`${failed.length}/${files.length} 个测试文件失败:`);
for (const f of failed) console.error(' - ' + f);
process.exit(1);
}
console.log(`${files.length} 个测试文件全部通过`);

View File

@@ -0,0 +1,35 @@
'use strict';
const assert = require('node:assert');
const fs = require('node:fs');
const os = require('node:os');
const path = require('node:path');
const Database = require('better-sqlite3');
const tmp = fs.mkdtempSync(path.join(os.tmpdir(), 'clip-mig-'));
const db = new Database(path.join(tmp, 'h.db'));
// Simulate an old DB without FTS5.
db.exec(`CREATE TABLE clips (id INTEGER PRIMARY KEY AUTOINCREMENT, type TEXT, text TEXT)`);
const { migrate } = require('../lib/migrate.js');
let passed = 0;
function check(name, fn) {
try { fn(); console.log(' ok -', name); passed++; }
catch (e) { console.error(' FAIL -', name, e.message); process.exitCode = 1; }
}
check('migrate from v1 runs without throwing', () => {
migrate(db, 1, 2);
});
let skipped = false;
try { db.prepare('SELECT 1 FROM clips_fts LIMIT 0').all(); }
catch (_) { skipped = true; }
check(skipped ? 'clips_fts not present (FTS5 unavailable)' : 'clips_fts present', () => {
if (!skipped) {
const rows = db.prepare('SELECT 1 FROM clips_fts LIMIT 0').all();
assert.deepStrictEqual(rows, []);
}
});
db.close();

254
test/search-plan.test.js Normal file
View File

@@ -0,0 +1,254 @@
'use strict';
/**
* 搜索策略 + trigram 分词器。
*
* 这里守的是一个很容易被"优化"掉的正确性问题:
* FTS5 默认的 unicode61 分词器把一整串连续中文当成一个 token
* 而 MATCH 是整 token 匹配,所以搜"公园"在"今天去公园散步"里搜不到。
* 改用 trigram 后子串能匹配了,但 ≤2 字符的词 trigram 一条也匹配不到,
* 必须退回 LIKE —— 而两字中文词(公园/会议/复制)恰恰是最常见的搜索。
*/
const assert = require('node:assert');
const fs = require('node:fs');
const os = require('node:os');
const path = require('node:path');
const Database = require('better-sqlite3');
const { planSearch, likePatterns, escapeLike, TRIGRAM_MIN } = require('../lib/search-plan.js');
const { migrate } = require('../lib/migrate.js');
let passed = 0;
function check(name, fn) {
try { fn(); console.log(' ok -', name); passed++; }
catch (e) { console.error(' FAIL -', name, e.message); process.exitCode = 1; }
}
// ---------- planSearch纯逻辑 ----------
check('空查询 -> 全部列出', () => {
for (const q of ['', ' ', null, undefined]) {
assert.strictEqual(planSearch(q).mode, 'all', JSON.stringify(q));
}
});
check('短查询(<3 字符)退回 LIKE', () => {
for (const q of ['公园', '会议', '复制', 'ab', 'a']) {
const p = planSearch(q);
assert.strictEqual(p.mode, 'like', `"${q}" 应走 LIKE`);
assert.deepStrictEqual(p.patterns, [`%${q}%`]);
}
});
check('长查询走 FTS', () => {
const p = planSearch('天气很好');
assert.strictEqual(p.mode, 'fts');
assert.strictEqual(p.match, '"天气很好"');
});
check('多 token 全部够长时组成 AND 查询', () => {
assert.strictEqual(planSearch('hello world').match, '"hello" "world"');
});
check('多 token 里只要有一个太短就整体退回 LIKE', () => {
// MATCH 里多个短语是 AND 关系2 字符那个匹配不到会让整个查询返回空
const p = planSearch('天气很好 公园');
assert.strictEqual(p.mode, 'like');
// 每个 token 独立子串、AND 组合(不是把整串连空格当子串)
assert.deepStrictEqual(p.patterns, ['%天气很好%', '%公园%']);
});
// ---------- likePatterns / escapeLikeLIKE 元字符 ----------
check('likePatterns空查询返回空数组', () => {
for (const q of ['', ' ', null, undefined]) {
assert.deepStrictEqual(likePatterns(q), [], JSON.stringify(q));
}
});
check('likePatterns多 token 拆成独立模式', () => {
assert.deepStrictEqual(likePatterns(' hello world '), ['%hello%', '%world%']);
});
check('escapeLike% _ \\ 三个元字符都被转义', () => {
assert.strictEqual(escapeLike('100%'), '100\\%');
assert.strictEqual(escapeLike('a_b'), 'a\\_b');
assert.strictEqual(escapeLike('C:\\path'), 'C:\\\\path');
assert.strictEqual(escapeLike('plain'), 'plain');
});
check('LIKE 元字符进了查询不再被当通配符', () => {
// 不转义时搜 "100%" 会变成 "以 100 开头",匹配一切 100xx
assert.deepStrictEqual(likePatterns('100%'), ['%100\\%%']);
assert.deepStrictEqual(likePatterns('a_b c%d'), ['%a\\_b%', '%c\\%d%']);
});
check('FTS5 保留字符被安全转义(不会变成语法错误)', () => {
assert.strictEqual(planSearch('a"bc').match, '"a""bc"');
for (const q of ['foo-bar', 'a(b)c', 'x*yz', '^abc', 'a:b:c', 'NEAR(a b)']) {
const p = planSearch(q);
if (p.mode === 'fts') assert.ok(p.match.startsWith('"'), q);
}
});
check('按码点算长度,代理对不会被误判为够长', () => {
// 两个 emoji = 2 个码点,但 .length 是 4不做码点处理就会错走 FTS
assert.strictEqual(planSearch('😀😀').mode, 'like');
assert.strictEqual(TRIGRAM_MIN, 3);
});
// ---------- 端到端:真的建库、真的搜 ----------
function makeDb() {
const tmp = fs.mkdtempSync(path.join(os.tmpdir(), 'clip-sp-'));
const db = new Database(path.join(tmp, 'h.db'));
db.exec(`CREATE TABLE clips(
id INTEGER PRIMARY KEY AUTOINCREMENT, type TEXT, text TEXT, image BLOB,
preview TEXT, top_at REAL, created_at REAL NOT NULL DEFAULT 0)`);
return db;
}
let fts5 = true;
{
const db = makeDb();
try { db.exec(`CREATE VIRTUAL TABLE t USING fts5(x, tokenize='trigram')`); }
catch (_) { fts5 = false; }
db.close();
}
if (!fts5) {
console.log(' SKIP - 这个 SQLite 构建不支持 trigram FTS5');
} else {
const CORPUS = [
'今天天气很好,我们去公园散步',
'会议记录:项目进度延后两周',
'hello world foo bar',
'请把这段文字复制到剪贴板',
'100% off_sale promo',
'off sale prices today',
];
function seeded() {
const db = makeDb();
const ins = db.prepare('INSERT INTO clips(type,text,created_at) VALUES(?,?,?)');
CORPUS.forEach((t, i) => ins.run('text', t, i));
assert.strictEqual(migrate(db, 1, 3), 3, '应迁移到 v3');
return db;
}
// 模拟 main.js 的 clips:list 分支searchLikeRows多 token AND + ESCAPE
function search(db, query) {
const plan = planSearch(query);
if (plan.mode === 'all') return db.prepare('SELECT id FROM clips').all().length;
if (plan.mode === 'like') {
const where = plan.patterns.map(() => `text LIKE ? ESCAPE '\\'`).join(' AND ');
return db.prepare(`SELECT id FROM clips WHERE ${where}`).all(...plan.patterns).length;
}
return db.prepare(
'SELECT c.id FROM clips_fts f JOIN clips c ON c.id=f.rowid WHERE clips_fts MATCH ?'
).all(plan.match).length;
}
check('v3 迁移后 clips_fts 用的是 trigram 分词器', () => {
const db = seeded();
const sql = db.prepare(
"SELECT sql FROM sqlite_master WHERE name='clips_fts'"
).get().sql;
assert.match(sql, /tokenize\s*=\s*'trigram'/, `实际: ${sql}`);
db.close();
});
check('中文子串搜索能搜到(这正是 unicode61 做不到的)', () => {
const db = seeded();
assert.strictEqual(search(db, '天气很好'), 1, '4 字中文子串');
assert.strictEqual(search(db, '项目进度'), 1);
assert.strictEqual(search(db, '复制到'), 1, '3 字中文子串');
db.close();
});
check('两字中文词经 LIKE 兜底同样能搜到', () => {
const db = seeded();
for (const [q, n] of [['公园', 1], ['会议', 1], ['复制', 1], ['天气', 1]]) {
assert.strictEqual(search(db, q), n, `"${q}"`);
}
db.close();
});
check('英文搜索照常工作', () => {
const db = seeded();
assert.strictEqual(search(db, 'hello'), 1);
assert.strictEqual(search(db, 'world'), 1);
db.close();
});
check('多 token 搜索是 AND都在才命中', () => {
const db = seeded();
assert.strictEqual(search(db, 'hello world'), 1, '两词都在第 3 条');
assert.strictEqual(search(db, 'hello 公园'), 0, '跨条目不应命中');
db.close();
});
check('搜 LIKE 元字符不会变通配符(端到端)', () => {
const db = seeded();
db.prepare('INSERT INTO clips(type,text,created_at) VALUES(?,?,?)').run('text', '折扣 100% off', 99);
db.prepare('INSERT INTO clips(type,text,created_at) VALUES(?,?,?)').run('text', '打折活动 10086', 100);
// '折' 是短 token强制走 LIKE 分支;'100%' 若不转义,% 是通配符,
// '打折活动 10086'(含子串 100也会命中 → 不转义得 2转义后应得 1。
assert.strictEqual(search(db, '折 100%'), 1, '应只命中字面量含 100% 的条目');
db.close();
});
check('搜不到的东西返回 0不是全部', () => {
const db = seeded();
assert.strictEqual(search(db, '不存在的内容'), 0);
db.close();
});
check('每种查询形态的结果都与 LIKE 基准一致', () => {
const db = seeded();
const like = (q) => db.prepare('SELECT id FROM clips WHERE text LIKE ?').all(`%${q}%`).length;
for (const q of ['天气很好', '项目进度', '复制到', 'hello', 'world', '剪贴板', '散步', '公园']) {
assert.strictEqual(search(db, q), like(q), `"${q}" 与 LIKE 基准不一致`);
}
db.close();
});
check('保留字符不会让搜索抛异常', () => {
const db = seeded();
for (const q of ['foo-bar', 'a"b"c', 'x*y', '(paren)', 'a:b', 'NEAR(x y)', '^^^']) {
assert.doesNotThrow(() => search(db, q), `"${q}" 抛异常了`);
}
db.close();
});
check('v2 库能就地升到 v3不丢数据', () => {
const db = makeDb();
const ins = db.prepare('INSERT INTO clips(type,text,created_at) VALUES(?,?,?)');
CORPUS.forEach((t, i) => ins.run('text', t, i));
assert.strictEqual(migrate(db, 1, 2), 2); // 先停在 v2unicode61
assert.strictEqual(search(db, '天气很好'), 0, 'v2 下中文子串确实搜不到');
assert.strictEqual(migrate(db, 1, 3), 3); // 再升 v3
assert.strictEqual(search(db, '天气很好'), 1, 'v3 修好了');
assert.strictEqual(db.prepare('SELECT COUNT(*) n FROM clips').get().n, CORPUS.length);
db.exec(`INSERT INTO clips_fts(clips_fts) VALUES('integrity-check')`);
db.close();
});
check('升到 v3 后触发器仍然同步新增/删除', () => {
const db = seeded();
db.prepare('INSERT INTO clips(type,text,created_at) VALUES(?,?,?)').run('text', '新增的一条测试内容', 99);
assert.strictEqual(search(db, '新增的一条'), 1, '新插入的应可搜到');
db.prepare("DELETE FROM clips WHERE text='新增的一条测试内容'").run();
assert.strictEqual(search(db, '新增的一条'), 0, '删除后应搜不到');
db.exec(`INSERT INTO clips_fts(clips_fts) VALUES('integrity-check')`);
db.close();
});
check('v3 迁移可重复执行(幂等)', () => {
const db = seeded();
for (let i = 0; i < 3; i++) migrate(db, 1, 3);
assert.strictEqual(search(db, '天气很好'), 1, '不应出现重复命中');
db.exec(`INSERT INTO clips_fts(clips_fts) VALUES('integrity-check')`);
db.close();
});
}
console.log(`\n${passed} checks passed`);

View File

@@ -0,0 +1,45 @@
'use strict';
const assert = require('node:assert');
const { SETTINGS_HANDLERS, applyPatch } = require('../lib/settings-handlers.js');
let passed = 0;
function check(name, fn) {
try { fn(); console.log(' ok -', name); passed++; }
catch (e) { console.error(' FAIL -', name, e.message); process.exitCode = 1; }
}
check('autoLaunch handler coerces truthy to boolean', () => {
const r = applyPatch({ autoLaunch: false }, { key: 'autoLaunch', value: 'yes' });
assert.strictEqual(r.autoLaunch, true);
});
check('maxItems accepts valid number', () => {
const r = applyPatch({ maxItems: 500 }, { key: 'maxItems', value: 500 });
assert.strictEqual(r.maxItems, 500);
});
check('displayLimit accepts valid enum and falls back on invalid', () => {
const r1 = applyPatch({ displayLimit: 300 }, { key: 'displayLimit', value: 500 });
assert.strictEqual(r1.displayLimit, 500);
const r2 = applyPatch({}, { key: 'displayLimit', value: 9999 });
assert.strictEqual(r2.displayLimit, 300);
});
check('unknown key is a no-op (returns shallow copy)', () => {
const r = applyPatch({}, { key: 'unknown', value: 1 });
assert.deepStrictEqual(r, {});
});
check('dataDir 刻意没有 handler只能走 settings:choose-dir', () => {
const r = applyPatch({ dataDir: 'D:\\Old' }, { key: 'dataDir', value: 'D:\\New' });
assert.strictEqual(r.dataDir, 'D:\\Old', 'settings:set dataDir 必须是 no-op否则运行中的库与配置分叉');
});
check('hotkey accepts preset values and falls back on invalid', () => {
const r1 = applyPatch({ hotkey: 'Ctrl+Alt+V' }, { key: 'hotkey', value: 'Alt+Shift+V' });
assert.strictEqual(r1.hotkey, 'Alt+Shift+V');
const r2 = applyPatch({ hotkey: 'Ctrl+Alt+V' }, { key: 'hotkey', value: '' });
assert.strictEqual(r2.hotkey, '', '空串是合法的「禁用」');
const r3 = applyPatch({ hotkey: 'Ctrl+Alt+V' }, { key: 'hotkey', value: 'Super+X' });
assert.strictEqual(r3.hotkey, 'Ctrl+Alt+V', '白名单外回落默认');
});

View File

@@ -0,0 +1,107 @@
'use strict';
/**
* 回归测试:设置窗口必须真正渲染出配置内容(尤其是"数据保存位置"路径)。
*
* 守护两类曾经发生过的静默失败:
* 1. settings.js 顶层 `const themeApi` 与 contextBridge 暴露的不可配置全局属性
* window.themeApi 同名 → 脚本实例化阶段抛 SyntaxError整个文件一行都不执行。
* 2. 重构时误删了末尾的 refresh() 调用 → 函数定义了却从未调用,界面全空白。
*
* 两种情况下界面都"看起来正常"但内容为空,只有断言 DOM 实际文本才能发现。
* 运行npx electron test/settings-render.test.js
*/
const { app, BrowserWindow, ipcMain } = require('electron');
const path = require('node:path');
const assert = require('node:assert');
const DATA_DIR = 'C:\\Users\\probe\\.clipboard-app';
const cfg = {
// 只用 config.js ALLOWED_* 里的合法值,且必须是 settings.html 里存在的 <option>
autoLaunch: false, maxItems: 0, displayLimit: 300, pollMs: 1000,
hotkey: 'Ctrl+Alt+V',
dataDir: DATA_DIR, theme: 'linear', accent: 'purple', followSystem: false,
};
ipcMain.handle('settings:get', () => ({ ...cfg }));
ipcMain.handle('settings:set', () => ({ ...cfg }));
ipcMain.handle('settings:clear-history', () => 0);
ipcMain.handle('settings:open-dir', () => {});
ipcMain.handle('settings:choose-dir', () => ({ ok: false }));
ipcMain.handle('theme:get', () => ({ ...cfg }));
ipcMain.handle('theme:set', (_e, { theme }) => ({ theme, accent: cfg.accent }));
ipcMain.handle('theme:set-accent', (_e, { accent }) => ({ theme: cfg.theme, accent }));
ipcMain.handle('theme:set-follow-system', () => ({ theme: cfg.theme, accent: cfg.accent }));
// settings.js 的 initTheme() 会订阅主题变更stub 里缺这个 handler 时
// invoke 会抛错,渲染层的 catch 打 console.error被下面的"无脚本错误"
// 断言当成 SyntaxError 一类的致命问题误杀。
ipcMain.handle('theme:subscribe', () => true);
let failed = 0;
function check(name, fn) {
try { fn(); console.log(' ok -', name); }
catch (e) { console.error(' FAIL -', name, '\n ', e.message); failed++; }
}
app.whenReady().then(async () => {
// 与 main.js openSettings() 一致的窗口尺寸
const win = new BrowserWindow({
width: 420, height: 690, show: false, frame: false, resizable: false,
webPreferences: {
preload: path.join(__dirname, '..', 'preload.js'),
contextIsolation: true, nodeIntegration: false, sandbox: false,
},
});
const errors = [];
win.webContents.on('console-message', (_e, level, message) => {
// level 3 = error
if (level === 3) errors.push(message);
});
await win.loadFile(path.join(__dirname, '..', 'renderer', 'settings.html'));
await new Promise((r) => setTimeout(r, 900));
const state = await win.webContents.executeJavaScript(`(() => {
const dir = document.getElementById('opt-datadir');
const grid = document.getElementById('theme-grid');
const accents = document.getElementById('accent-grid');
return {
dirText: dir ? dir.textContent : null,
themeCards: grid ? grid.children.length : -1,
accentSwatches: accents ? accents.children.length : -1,
maxItems: (document.getElementById('opt-maxitems')||{}).value,
displayLimit: (document.getElementById('opt-displaylimit')||{}).value,
pollMs: (document.getElementById('opt-pollms')||{}).value,
hotkey: (document.getElementById('opt-hotkey')||{}).value,
};
})()`);
console.log('设置窗口渲染回归测试:');
check('settings.js 无脚本错误SyntaxError 会让整个文件不执行)', () => {
assert.deepStrictEqual(errors, []);
});
check('"数据保存位置" 渲染出真实路径', () => {
assert.strictEqual(state.dirText, DATA_DIR);
});
check('主题网格已渲染出 6 张卡片', () => {
assert.strictEqual(state.themeCards, 6);
});
check('强调色已渲染出 5 个色块', () => {
assert.strictEqual(state.accentSwatches, 5);
});
check('最大保留条数已回填配置值', () => {
assert.strictEqual(state.maxItems, '0');
});
check('界面显示条数已回填配置值', () => {
assert.strictEqual(state.displayLimit, '300');
});
check('轮询间隔已回填配置值', () => {
assert.strictEqual(state.pollMs, '1000');
});
check('唤起快捷键已回填配置值', () => {
assert.strictEqual(state.hotkey, 'Ctrl+Alt+V');
});
console.log(failed === 0 ? '\n全部通过' : `\n${failed} 项失败`);
app.exit(failed === 0 ? 0 : 1);
});

107
test/settings-tabs.test.js Normal file
View File

@@ -0,0 +1,107 @@
'use strict';
// 在真实 Electron 窗口尺寸(420x690与 main.js openSettingsWindow 一致)下
// 验证设置窗口的标签页与布局。
// 运行electron test/settings-tabs.test.js
const assert = require('node:assert');
const path = require('node:path');
const { app, BrowserWindow, ipcMain } = require('electron');
const cfg = {
autoLaunch: false, maxItems: 0, displayLimit: 300, pollMs: 1000,
dataDir: 'C:\\Users\\guan\\.clipboard-app',
theme: 'linear', accent: 'purple', followSystem: false,
};
ipcMain.handle('settings:get', () => ({ ...cfg }));
ipcMain.handle('settings:set', () => ({ ...cfg }));
ipcMain.handle('settings:clear-history', () => 0);
ipcMain.handle('settings:open-dir', () => {});
ipcMain.handle('settings:choose-dir', () => ({ ok: false }));
ipcMain.handle('theme:get', () => ({ ...cfg }));
ipcMain.handle('theme:set', (_e, { theme }) => ({ theme, accent: cfg.accent }));
ipcMain.handle('theme:set-accent', (_e, { accent }) => ({ theme: cfg.theme, accent }));
ipcMain.handle('theme:set-follow-system', () => ({ theme: cfg.theme, accent: cfg.accent }));
// 与 settings-render.test.js 同理settings.js 启动时会订阅主题变更,
// stub 缺这个 handler 会往终端打一条未捕获错误(此测试不断言 console
// 但留着噪音会掩盖真问题)。
ipcMain.handle('theme:subscribe', () => true);
let passed = 0;
async function check(name, fn) {
try { await fn(); console.log(' ok -', name); passed++; }
catch (e) { console.error(' FAIL -', name, '\n', e.message); process.exitCode = 1; }
}
app.whenReady().then(async () => {
const win = new BrowserWindow({
width: 420, height: 690, show: false, frame: false, resizable: false,
webPreferences: {
preload: path.join(__dirname, '..', 'preload.js'),
contextIsolation: true, nodeIntegration: false, sandbox: false,
},
});
await win.loadFile(path.join(__dirname, '..', 'renderer', 'settings.html'));
await new Promise((r) => setTimeout(r, 900));
const js = (code) => win.webContents.executeJavaScript(code);
await check('三个标签存在且文案顺序正确', async () => {
const labels = JSON.parse(await js(
`JSON.stringify([...document.querySelectorAll('#tab-bar [role="tab"]')].map(t => t.textContent.trim()))`
));
assert.deepStrictEqual(labels, ['外观', '通用', '关于']);
});
await check('初始只显示「外观」页', async () => {
const r = JSON.parse(await js(`JSON.stringify({
visible: [...document.querySelectorAll('.tab-panel')].filter(p => !p.hidden).map(p => p.id),
selected: document.querySelector('[role="tab"][aria-selected="true"]').textContent.trim(),
})`));
assert.deepStrictEqual(r.visible, ['panel-appearance']);
assert.strictEqual(r.selected, '外观');
});
await check('点击「通用」切到通用页并互斥', async () => {
const r = JSON.parse(await js(`(() => {
document.getElementById('tab-general').click();
return JSON.stringify({
visible: [...document.querySelectorAll('.tab-panel')].filter(p => !p.hidden).map(p => p.id),
cur: document.getElementById('tab-general').getAttribute('aria-selected'),
prev: document.getElementById('tab-appearance').getAttribute('aria-selected'),
});
})()`));
assert.deepStrictEqual(r.visible, ['panel-general']);
assert.strictEqual(r.cur, 'true');
assert.strictEqual(r.prev, 'false');
});
await check('→ 键切到下一个标签,← 键回上一个', async () => {
const r = JSON.parse(await js(`(() => {
const bar = document.getElementById('tab-bar');
const cur = () => document.querySelector('[role="tab"][aria-selected="true"]').textContent.trim();
const key = (k) => bar.dispatchEvent(new KeyboardEvent('keydown', { key: k, bubbles: true }));
document.getElementById('tab-appearance').click();
key('ArrowRight'); const right = cur();
key('ArrowLeft'); const back = cur();
return JSON.stringify({ right, back });
})()`));
assert.strictEqual(r.right, '通用');
assert.strictEqual(r.back, '外观');
});
await check('每一页在 690px 窗口内都放得下(不需要滚动)', async () => {
const rows = JSON.parse(await js(`JSON.stringify(
[...document.querySelectorAll('#tab-bar [role="tab"]')].map(t => {
t.click();
const p = document.getElementById(t.getAttribute('aria-controls'));
return { id: p.id, clientH: p.clientHeight, scrollH: p.scrollHeight };
})
)`));
for (const r of rows) {
assert.ok(r.scrollH <= r.clientH + 1,
`${r.id} 内容溢出:${r.scrollH}px > 可视 ${r.clientH}px`);
}
});
console.log(`\n${passed} checks passed`);
app.exit(process.exitCode ? 1 : 0);
});

31
test/theme-bg.test.js Normal file
View File

@@ -0,0 +1,31 @@
'use strict';
const assert = require('node:assert');
const { themeBgColor } = require('../config');
let passed = 0;
function check(name, fn) {
try { fn(); console.log(' ok -', name); passed++; }
catch (e) { console.error(' FAIL -', name, e.message); process.exitCode = 1; }
}
const expected = {
linear: '#0E0E12',
midnight: '#08090B',
carbon: '#1A1D23',
solarized: '#002B36',
github: '#0D1117',
light: '#FAFAFA',
};
for (const [theme, bg] of Object.entries(expected)) {
check(`themeBgColor('${theme}') === ${bg}`, () => {
assert.strictEqual(themeBgColor(theme), bg);
});
}
check('未知 theme 兜底为 light 背景', () => {
assert.strictEqual(themeBgColor('unknown'), '#FAFAFA');
assert.strictEqual(themeBgColor(''), '#FAFAFA');
assert.strictEqual(themeBgColor(null), '#FAFAFA');
});
console.log(`\n${passed} checks passed`);

View File

@@ -0,0 +1,117 @@
'use strict';
/**
* 「跟随系统」开 / 关 时主题显示一致性的接线测试。
*
* 历史问题theme:get / applyThemeChange 旧实现返回的是 appConfig.themeraw
* 渲染端拿到后直接设 data-theme ——「跟随系统」开着的状态下,初次打开设置窗口
* 显示的是 raw 主题(比如 midnight只有等到系统主题切换广播来才换。
* settings 窗口根本收不到那条广播(没切换),于是永远显示错的。
*
* 修法main.js 返回 effectiveTheme带 rawTheme 字段兜底),渲染端按
* effectiveTheme 设 DOM。theme-boot 也读 effectiveTheme 以避免冷启动闪。
*
* 这里钉住三件事,避免后续改动把 effective 字段删了回退到 raw
* 1. main.js 的 theme:get / applyThemeChange / broadcastTheme 三个出口
* 都给 effectiveTheme 字段;
* 2. 渲染端renderer + settings优先用 effectiveTheme 设置 data-theme
* 3. theme-boot.js 在缓存命中时也用 effectiveTheme。
*/
const assert = require('node:assert');
const fs = require('node:fs');
const path = require('node:path');
const root = path.join(__dirname, '..');
const read = (p) => fs.readFileSync(path.join(root, p), 'utf8');
const mainSrc = read('main.js');
const rendererSrc = read('renderer/renderer.js');
const settingsSrc = read('renderer/settings.js');
const themeBootSrc = read('renderer/theme-boot.js');
let passed = 0;
function check(name, fn) {
try { fn(); console.log(' ok -', name); passed++; }
catch (e) { console.error(' FAIL -', name, e.message); process.exitCode = 1; }
}
// ---------- 主进程:所有主题出口都带 effectiveTheme ----------
check('config.effectiveTheme 存在并被 main.js 引用', () => {
assert.match(mainSrc, /configMod\.effectiveTheme\s*\(/,
'main.js 没用 configMod.effectiveTheme —— 跟随系统仍可能在 IPC 出口回退到 raw theme');
});
check('broadcastTheme 广播 effectiveTheme 字段', () => {
const fn = mainSrc.match(/function\s+broadcastTheme\s*\([\s\S]*?\n\}/);
assert.ok(fn, '找不到 broadcastTheme');
assert.match(fn[0], /effectiveTheme\s*:/,
'broadcastTheme 没带 effectiveTheme 字段,订阅的窗口拿不到跟随系统状态');
});
check('applyThemeChange 返回值带 effectiveTheme', () => {
const fn = mainSrc.match(/function\s+applyThemeChange\s*\([\s\S]*?\n\}/);
assert.ok(fn, '找不到 applyThemeChange');
assert.match(fn[0], /effectiveTheme\s*:/,
'applyThemeChange 返回值没带 effectiveTheme —— settings.js 收到 raw theme 会盖掉刚广播的 effective');
});
check('theme:get 处理器返回 effectiveTheme', () => {
const handler = mainSrc.match(/ipcMain\.handle\(\s*'theme:get'[\s\S]*?\n\s{2}\}\s*\)\s*;/);
assert.ok(handler, '找不到 theme:get 处理器');
assert.match(handler[0], /effectiveTheme/,
'theme:get 返回值没带 effectiveTheme —— 启动时设置窗口 / 主窗口会显示 raw 主题');
});
// ---------- 渲染端:用 effectiveTheme 设 data-theme ----------
check('renderer.js 的 applyTheme 用 effectiveTheme 设 data-theme', () => {
const fn = rendererSrc.match(/async\s+function\s+applyTheme\s*\([\s\S]*?\n\}/);
assert.ok(fn, '找不到 applyTheme');
// 必须显式读 cfg.effectiveTheme 而不是 cfg.theme
assert.match(fn[0], /cfg\.effectiveTheme\s*\|\|\s*cfg\.theme/,
'applyTheme 没读 effectiveTheme ——「跟随系统」开时 data-theme 是 raw 主题');
});
check('renderer.js 的 applyTheme 在 localStorage 里同时缓存 raw 和 effective', () => {
// theme-boot 启动读 cache需要 effectiveTheme 才能避免冷启动闪
const fn = rendererSrc.match(/async\s+function\s+applyTheme\s*\([\s\S]*?\n\}/);
assert.ok(fn, '找不到 applyTheme');
assert.match(fn[0], /rawTheme[\s\S]*?effectiveTheme/,
'applyTheme 没把 raw 和 effective 同时写 localStorage —— 启动会有闪');
});
check('renderer.js 的 onSystemChanged 不再写 localStorage避免覆盖 raw', () => {
const fn = rendererSrc.match(/onSystemChanged\(\s*\(payload\)\s*=>\s*\{[\s\S]*?\}\s*\)/);
assert.ok(fn, '找不到 onSystemChanged 回调');
// 注释也明确写了为什么不写payload 里没 rawTheme
assert.ok(
!/localStorage\.setItem/.test(fn[0]),
'onSystemChanged 在写 localStorage —— payload 没有 rawTheme下次启动会拿不到 raw'
);
// DOM 更新仍然要用 effective
assert.match(fn[0], /effectiveTheme\s*\|\|\s*payload\.theme/,
'onSystemChanged 没读 effectiveTheme');
});
check('settings.js initTheme 用 effectiveTheme', () => {
const fn = settingsSrc.match(/async\s+function\s+initTheme\s*\([\s\S]*?\n\}/);
assert.ok(fn, '找不到 initTheme');
assert.match(fn[0], /cfg\.effectiveTheme\s*\|\|\s*cfg\.theme/,
'initTheme 没读 effectiveTheme —— 设置窗口初次打开「跟随系统」开时显示 raw');
});
check('settings.js onSystemChanged 用 effectiveTheme', () => {
const fn = settingsSrc.match(/onSystemChanged\(\s*\(payload\)\s*=>\s*\{[\s\S]*?\}\s*\)/);
assert.ok(fn, '找不到 settings.js onSystemChanged');
assert.match(fn[0], /effectiveTheme\s*\|\|\s*payload\.theme/,
'settings.js onSystemChanged 没读 effectiveTheme');
});
// ---------- theme-boot冷启动也用 effectiveTheme 避免闪 ----------
check('theme-boot.js 优先读 effectiveTheme避免「跟随系统」开时冷启动闪', () => {
assert.match(themeBootSrc, /effectiveTheme\s*\|\|\s*cached\.theme/,
'theme-boot.js 没读 effectiveTheme —— 冷启动在「跟随系统」开时会闪一下 raw 主题');
});
console.log(`\n${passed} checks passed`);

67
test/theme.test.js Normal file
View File

@@ -0,0 +1,67 @@
'use strict';
const assert = require('node:assert');
const { test } = require('node:test');
const configMod = require('../config');
test('DEFAULTS 包含 theme/accent/followSystem', () => {
assert.strictEqual(configMod.DEFAULTS.theme, 'linear');
assert.strictEqual(configMod.DEFAULTS.accent, 'purple');
assert.strictEqual(configMod.DEFAULTS.followSystem, false);
});
test('sanitize 接受合法 theme', () => {
assert.strictEqual(configMod.sanitize({ theme: 'midnight' }).theme, 'midnight');
assert.strictEqual(configMod.sanitize({ theme: 'github' }).theme, 'github');
});
test('sanitize 拒绝未知 theme 回落 linear', () => {
assert.strictEqual(configMod.sanitize({ theme: 'foo' }).theme, 'linear');
assert.strictEqual(configMod.sanitize({ theme: null }).theme, 'linear');
assert.strictEqual(configMod.sanitize({}).theme, 'linear');
});
test('sanitize 接受 5 种 accent', () => {
for (const a of ['purple', 'blue', 'green', 'orange', 'red']) {
assert.strictEqual(configMod.sanitize({ accent: a }).accent, a);
}
});
test('sanitize 拒绝未知 accent 回落 purple', () => {
assert.strictEqual(configMod.sanitize({ accent: 'cyan' }).accent, 'purple');
});
test('sanitize 接受 followSystem=true', () => {
assert.strictEqual(configMod.sanitize({ followSystem: true }).followSystem, true);
assert.strictEqual(configMod.sanitize({ followSystem: false }).followSystem, false);
assert.strictEqual(configMod.sanitize({ followSystem: 'yes' }).followSystem, false);
});
// ---------- effectiveTheme跟随系统开关与系统明暗组合 ----------
// 旧版把这段逻辑内联在 main.js 的 broadcastTheme / IPC 处理器里,
// 跟「跟随系统」开关的 IPC 顺序竞争出过显示主题不一致的问题。把判断
// 收到 config.effectiveTheme 后这里能直接单测,确保 4 个组合都正确。
test('effectiveThemefollowSystem=false 时用 raw theme', () => {
const cfg = { theme: 'midnight', followSystem: false };
assert.strictEqual(configMod.effectiveTheme(cfg, true), 'midnight');
assert.strictEqual(configMod.effectiveTheme(cfg, false), 'midnight');
});
test('effectiveThemefollowSystem=true + 深色系统 = linear', () => {
const cfg = { theme: 'midnight', followSystem: true };
assert.strictEqual(configMod.effectiveTheme(cfg, true), 'linear');
});
test('effectiveThemefollowSystem=true + 浅色系统 = light', () => {
const cfg = { theme: 'midnight', followSystem: true };
assert.strictEqual(configMod.effectiveTheme(cfg, false), 'light');
});
test('effectiveThemeraw theme 缺失时回落到默认 linear', () => {
// 极端防御cfg 为 null/undefined 时不让应用崩。
// followSystem 也没有 → 视同关闭,回到 DEFAULTS.themelinear
// 关键是不抛异常 + 返回合法值,不让调用方去 switch-case 'linear' 之类。
assert.strictEqual(configMod.effectiveTheme(null, true), 'linear');
assert.strictEqual(configMod.effectiveTheme(undefined, false), 'linear');
assert.strictEqual(configMod.effectiveTheme({ followSystem: false }, false), 'linear');
});

51
test/tray-menu.test.js Normal file
View File

@@ -0,0 +1,51 @@
'use strict';
const assert = require('node:assert');
const { buildTrayMenuTemplate } = require('../tray-menu.js');
let passed = 0;
function check(name, fn) {
try { fn(); console.log(' ok -', name); passed++; }
catch (e) { console.error(' FAIL -', name, '\n', e.message); process.exitCode = 1; }
}
const noop = () => {};
const actions = { onShow: noop, onSettings: noop, onQuit: noop };
check('菜单只有 3 个可点项', () => {
const items = buildTrayMenuTemplate(actions).filter((i) => i.type !== 'separator');
assert.strictEqual(items.length, 3,
`期望 3 项,实际 ${items.length}${items.map((i) => i.label).join(' / ')}`);
});
check('菜单项顺序与文案固定', () => {
const t = buildTrayMenuTemplate(actions);
assert.deepStrictEqual(
t.map((i) => (i.type === 'separator' ? '---' : i.label)),
['显示历史', '设置...', '---', '退出']
);
});
check('不再包含主题 / 强调色 / 快捷键 / 关于', () => {
const labels = buildTrayMenuTemplate(actions).map((i) => i.label || '').join('|');
for (const gone of ['主题', '强调色', '快捷键', '关于']) {
assert.ok(!labels.includes(gone), `${gone}」应已迁移到设置窗口`);
}
});
check('没有任何子菜单', () => {
const t = buildTrayMenuTemplate(actions);
assert.ok(t.every((i) => !i.submenu), '托盘不应再有子菜单');
});
check('click 回调按顺序接线到传入的动作', () => {
const calls = [];
const t = buildTrayMenuTemplate({
onShow: () => calls.push('show'),
onSettings: () => calls.push('settings'),
onQuit: () => calls.push('quit'),
});
for (const i of t) if (typeof i.click === 'function') i.click();
assert.deepStrictEqual(calls, ['show', 'settings', 'quit']);
});
console.log(`\n${passed} checks passed`);

View File

@@ -0,0 +1,60 @@
'use strict';
const assert = require('node:assert');
const fs = require('node:fs');
const path = require('node:path');
// Pure file-content check (this test doesn't require electron).
const main = fs.readFileSync(path.join(__dirname, '..', 'main.js'), 'utf8');
let passed = 0;
function check(name, fn) {
try { fn(); console.log(' ok -', name); passed++; }
catch (e) { console.error(' FAIL -', name, e.message); process.exitCode = 1; }
}
check('main.js contains exactly one createFramelessWindow definition', () => {
const re = /function createFramelessWindow\s*\(/g;
const matches = main.match(re) || [];
assert.strictEqual(matches.length, 1, `found ${matches.length} occurrences, expected 1`);
});
check('createMainWindow and openSettingsWindow both call createFramelessWindow', () => {
const calls = main.match(/createFramelessWindow\s*\(/g) || [];
// 1 definition + at least 2 callers (main + settings) = 3+ occurrences
assert.ok(calls.length >= 3, `expected >=3 occurrences, got ${calls.length}`);
});
check('createFramelessWindow 不再硬编码 #FFFFFF', () => {
// 抓出 createFramelessWindow 的整个函数体
const fn = main.match(/function createFramelessWindow\s*\([\s\S]*?\n\}/);
assert.ok(fn, '找不到 createFramelessWindow');
assert.ok(
!/backgroundColor:\s*['"]#FFFFFF['"]/.test(fn[0]),
'createFramelessWindow 还硬编码 #FFFFFF 首帧背景色'
);
});
check('createFramelessWindow 引用 themeBgColor', () => {
const fn = main.match(/function createFramelessWindow\s*\([\s\S]*?\n\}/);
assert.ok(fn, '找不到 createFramelessWindow');
assert.ok(
/themeBgColor\s*\(/.test(fn[0]),
'createFramelessWindow 没引用 themeBgColor'
);
});
check('createFramelessWindow 拒绝新开窗口请求(外链不能弹裸窗口)', () => {
// 关于页链接走 shell.openExternal这里是纵深防御漏过点击拦截的
// 打开方式(中键 / 拖拽)也不能弹出一个没有 preload 的外部窗口。
const fn = main.match(/function createFramelessWindow\s*\([\s\S]*?\n\}/);
assert.ok(fn, '找不到 createFramelessWindow');
assert.match(fn[0], /setWindowOpenHandler\(/, '未接线 setWindowOpenHandler');
assert.match(fn[0], /action:\s*['"]deny['"]/, '新开窗口未被拒绝');
});
check('createFramelessWindow 拦截页内导航(外部 URL 不能把窗口顶掉)', () => {
const fn = main.match(/function createFramelessWindow\s*\([\s\S]*?\n\}/);
assert.ok(fn, '找不到 createFramelessWindow');
assert.match(fn[0], /will-navigate/, '未接线 will-navigate');
assert.match(fn[0], /preventDefault\(\)/, '导航未被阻止');
});

View File

@@ -0,0 +1,86 @@
'use strict';
const assert = require('node:assert');
const fs = require('node:fs');
const path = require('node:path');
const mainSrc = fs.readFileSync(path.join(__dirname, '..', 'main.js'), 'utf8');
const rendererSrc = fs.readFileSync(path.join(__dirname, '..', 'renderer', 'renderer.js'), 'utf8');
let passed = 0;
function check(name, fn) {
try { fn(); console.log(' ok -', name); passed++; }
catch (e) { console.error(' FAIL -', name, e.message); process.exitCode = 1; }
}
const toggleWindow = mainSrc.match(/function toggleWindow\s*\([\s\S]*?\n\}/);
check('toggleWindow 函数存在', () => {
assert.ok(toggleWindow, '找不到 toggleWindow');
});
check('toggleWindow 在 show() 之前调用 positionAtTopCenter', () => {
const fn = toggleWindow[0];
// 两次 show() 调用的索引位置
const showIndices = [];
let from = 0;
while (true) {
const i = fn.indexOf('mainWindow.show()', from);
if (i < 0) break;
showIndices.push(i);
from = i + 1;
}
assert.ok(showIndices.length >= 2, `期望至少 2 处 mainWindow.show(),找到 ${showIndices.length}`);
const posIndices = [];
from = 0;
while (true) {
const i = fn.indexOf('positionAtTopCenter()', from);
if (i < 0) break;
posIndices.push(i);
from = i + 1;
}
assert.strictEqual(posIndices.length, showIndices.length,
`positionAtTopCenter 调用次数(${posIndices.length}) 应与 show 调用次数(${showIndices.length}) 相同`);
for (let k = 0; k < showIndices.length; k++) {
assert.ok(posIndices[k] < showIndices[k],
`${k + 1}positionAtTopCenter 必须在 show 之前 (pos=${posIndices[k]}, show=${showIndices[k]})`);
}
});
// ---------- renderer 端Esc / × 走 hide(),不再销毁窗口 ----------
check('renderer.js 已解构 hide来自 preload', () => {
assert.ok(
/const\s*\{[\s\S]*?\bhide\b[\s\S]*?\}\s*=\s*window\.api/.test(rendererSrc),
'renderer.js 没从 window.api 解构 hide()'
);
});
check('Esc 路径调用 hide(),不再 window.close()', () => {
// 抓出 Escape 分支整个 if 块(包含 body
const escBlock = rendererSrc.match(/if\s*\(\s*e\.key\s*===\s*'Escape'\s*\)\s*\{[\s\S]*?\n\s{2}\}/);
assert.ok(escBlock, '找不到 Escape 分支');
assert.ok(
/\bhide\s*\(/.test(escBlock[0]),
'Escape 分支没调 hide()'
);
assert.ok(
!/window\.close\s*\(/.test(escBlock[0]),
'Escape 分支还在用 window.close()'
);
});
check('× 按钮($closeBtnclick 调用 hide(),不再 window.close()', () => {
const onClick = rendererSrc.match(/\$closeBtn\.addEventListener\(\s*'click'[\s\S]*?\}\s*\)\s*;/);
assert.ok(onClick, '找不到 $closeBtn click 处理');
assert.ok(
/\bhide\s*\(/.test(onClick[0]),
'$closeBtn click 没调 hide()'
);
assert.ok(
!/window\.close\s*\(/.test(onClick[0]),
'$closeBtn click 还在用 window.close()'
);
});
console.log(`\n${passed} checks passed`);

21
tray-menu.js Normal file
View File

@@ -0,0 +1,21 @@
'use strict';
/**
* 构建托盘右键菜单模板。纯函数,不 require electron便于单测。
*
* 托盘只负责三件事:开关历史窗口、进入设置、退出。
* 主题 / 强调色 / 快捷键 / 关于一律在设置窗口里,不要往这儿加。
*
* @param {{onShow: () => void, onSettings: () => void, onQuit: () => void}} actions
* @returns {Array<object>} 交给 Menu.buildFromTemplate() 的模板数组
*/
function buildTrayMenuTemplate({ onShow, onSettings, onQuit }) {
return [
{ label: '显示历史', click: onShow },
{ label: '设置...', click: onSettings },
{ type: 'separator' },
{ label: '退出', click: onQuit },
];
}
module.exports = { buildTrayMenuTemplate };