Files
Clipboard/main.js
2026-09-12 13:59:12 +08:00

1454 lines
61 KiB
JavaScript
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/**
* Electron 主进程
*
* 职责:
* - 应用生命周期 + 单实例锁
* - 系统托盘(含右键菜单,单击打开历史窗口)
* - 主窗口无边框、置顶、Frameless
* - 轮询剪贴板(默认 1000ms可在设置里改→ 去重 → 写 SQLite
* - IPClist / search / movetop / delete / clear / favorites / theme / settings
* - 点选一条:写系统剪贴板 + 置顶(不自动粘贴,用户自己按 Ctrl+V
*/
'use strict';
const {
app, BrowserWindow, Tray, Menu, ipcMain,
clipboard, nativeImage, screen, dialog, shell,
nativeTheme, globalShortcut,
} = require('electron');
const path = require('node:path');
const crypto = require('node:crypto');
const fs = require('node:fs');
const Database = require('better-sqlite3');
const configMod = require('./config');
const favorites = require('./favorites');
const { buildTrayMenuTemplate } = require('./tray-menu.js');
const { devLog, devWarn } = require('./lib/log');
const { normalizeImageForStorage } = require('./lib/image-cap');
const { applyPatch } = require('./lib/settings-handlers');
const { createSubscribers } = require('./lib/notify-fanout');
const { migrate } = require('./lib/migrate');
const { computePollMs } = require('./lib/adaptive-poll');
const { shouldReadImage } = require('./lib/poll-guard');
const { clipPayload } = require('./lib/clip-payload');
const { planSearch, likePatterns } = require('./lib/search-plan');
const { isSameDir } = require('./lib/same-dir');
const { createEchoSuppress } = require('./lib/echo-suppress');
// ---------- 配置 ----------
const APP_NAME = '剪贴板';
let appConfig = configMod.loadConfig();
let MAX_ITEMS = appConfig.maxItems; // 运行时可变
let POLL_MS = appConfig.pollMs; // 运行时可变
// 数据目录默认固定在 D:\Clipboard Data见 config.js可在设置里迁移
// 或用环境变量 CLIPBOARD_APP_DATA_DIR 临时覆盖(测试也用它隔离)。
// 一律走 appConfig.dataDir 现取,不缓存到常量 —— 迁移后旧值就是错的。
//
// sessionDataDir本会话内的临时目录。仅当用户配置的 dataDir 在本次启动
// 时不可用(盘符缺失 / 权限不足)才被赋值;不影响磁盘上的 config.json。
// 这样盘符恢复后下次启动会自动回到用户原本选的目录,不会因为一次"临
// 时坏掉"把用户的偏好覆盖掉。
let sessionDataDir = null;
function effectiveDataDir() { return sessionDataDir || appConfig.dataDir; }
function getDbPath() { return path.join(effectiveDataDir(), 'history.db'); }
function getFavDbPath() { return path.join(effectiveDataDir(), 'favorites.db'); }
// ---------- 存储 ----------
let db;
let favDb = null;
const stmt = {};
// favorites 的动态 IN 语句按占位符个数缓存
const favStmtCache = new Map();
let ftsAvailable = null;
function hasFts5(d) {
if (!d) return false;
if (ftsAvailable != null) return ftsAvailable;
try { d.prepare('SELECT 1 FROM clips_fts LIMIT 0').all(); ftsAvailable = true; }
catch { ftsAvailable = false; }
return ftsAvailable;
}
// 列表/搜索不需要 image BLOB —— 单条最大 2MB200 条能到几百 MB。
// 只取渲染需要的列,图片走 clips:get-image 按需拉取。
const listCols = (p = '') =>
`${p}id, ${p}type, ${p}text, ${p}preview, ${p}top_at, ${p}created_at, (${p}image IS NOT NULL) AS has_image`;
/**
* 排序:按最近活跃时间倒序 —— 无论是外部新复制进来created_at = now
* 还是用户点过「复制」movetop 把 created_at 也置为 now最新活跃的
* 一律排在最上面。这是「第一个就是当前剪贴板内容」契约的根基:
*
* 1. 刚被复制进来的内容 → created_at = now → 排到顶
* 2. 用户点过复制的项 → created_at = now被 movetop 同步更新)→ 排到顶
* 3. 其它项按 created_at DESC 自然排列
*
* 用 id DESC 作 tiebreakercreated_at 用 ms 精度,同 ms 内多次变更
* (理论上几乎不可能,但 poll tick + 用户点击落在同一 tick 上的极端
* 场景下能撞上)给一个稳定次序。
*
* top_at 仍然保留 —— 它现在只用于「trim / 清空历史」时保护被用户点过
* 复制的项(见 trimOldest / clearNonTop不再参与排序。
*/
const orderBy = (p = '') => `${p}created_at DESC, ${p}id DESC`;
function openDb() {
fs.mkdirSync(appConfig.dataDir, { recursive: true });
ftsAvailable = null; // 换库后能力可能不同,重新探测
clipCount = null; // 行数缓存绑定旧库,换库后必须回源重查
db = new Database(getDbPath());
db.pragma('journal_mode = WAL');
db.pragma('synchronous = NORMAL'); // WAL 下足够安全,省掉每次写的 fsync
db.exec(`
CREATE TABLE IF NOT EXISTS clips (
id INTEGER PRIMARY KEY AUTOINCREMENT,
type TEXT NOT NULL, -- 'text' | 'image'
text TEXT,
image BLOB,
preview TEXT,
top_at REAL, -- NULL = 自然位置;非空 = 用户点过「复制」trim / 清空历史 跳过)
created_at REAL NOT NULL -- 最近活跃时间:外部新复制 = 入库时间;点过「复制」= 同步更新到 now
);
CREATE INDEX IF NOT EXISTS idx_clips_created_id ON clips(created_at DESC, id DESC);
`);
// 兼容不带 top_at 列的旧 DB早期版本无置顶状态补上
try { db.exec(`ALTER TABLE clips ADD COLUMN top_at REAL`); } catch (e) {}
// 列表排序是 created_at DESC, id DESC见 orderBy 的注释)—— 复合索引
// idx_clips_created_id 正好覆盖 ORDER BY 的两列,扫到 LIMIT 就停,不会
// 因为「id DESC 兜底」触发 TEMP B-TREE FOR LAST TERM OF ORDER BY。
// 早期版本用过的 idx_clips_created_at(created_at DESC)(单列,不够覆盖)
// 和 idx_clips_top_created(top_at DESC, created_at DESC) 都已不再使用,
// 启动时主动 drop 一次,避免无意义的索引拖慢写入。
try { db.exec(`DROP INDEX IF EXISTS idx_clips_top_created`); } catch (_) {}
try { db.exec(`DROP INDEX IF EXISTS idx_clips_created_at`); } catch (_) {}
// 启动时清掉所有 top_at —— "不是长期固定",重启就归零
db.prepare(`UPDATE clips SET top_at = NULL WHERE top_at IS NOT NULL`).run();
migrate(db, 1, appConfig.schemaVersion);
stmt.insertText = db.prepare(`INSERT INTO clips(type, text, preview, top_at, created_at) VALUES('text', ?, ?, ?, ?)`);
stmt.insertImage = db.prepare(`INSERT INTO clips(type, image, preview, top_at, created_at) VALUES('image', ?, ?, ?, ?)`);
stmt.count = db.prepare(`SELECT COUNT(*) AS n FROM clips`);
// 排序created_at DESC, id DESC最近活跃优先见 orderBy 注释)
stmt.listAll = db.prepare(`SELECT ${listCols()} FROM clips ORDER BY ${orderBy()} LIMIT ?`);
stmt.get = db.prepare(`SELECT * FROM clips WHERE id = ?`);
stmt.getMeta = db.prepare(`SELECT ${listCols()} FROM clips WHERE id = ?`);
stmt.getImage = db.prepare(`SELECT image FROM clips WHERE id = ?`);
stmt.moveToTop = db.prepare(`UPDATE clips SET top_at = ?, created_at = ? WHERE id = ?`);
stmt.clearTop = db.prepare(`UPDATE clips SET top_at = NULL WHERE id = ?`);
stmt.delete = db.prepare(`DELETE FROM clips WHERE id = ?`);
stmt.clearNonTop = db.prepare(`DELETE FROM clips WHERE top_at IS NULL`);
// 之前每次入库都现场 prepare 一遍,热路径上白白编译
stmt.trimOldest = db.prepare(`
DELETE FROM clips
WHERE id IN (
SELECT id FROM clips WHERE top_at IS NULL
ORDER BY created_at ASC LIMIT ?
)
`);
if (hasFts5(db)) {
stmt.searchFts = db.prepare(`
SELECT ${listCols('c.')}
FROM clips_fts f JOIN clips c ON c.id = f.rowid
WHERE clips_fts MATCH ?
ORDER BY ${orderBy('c.')}
LIMIT ?
`);
}
}
function openFavDb() {
fs.mkdirSync(appConfig.dataDir, { recursive: true });
favStmtCache.clear(); // 语句绑定在旧连接上,换库后必须丢掉
favDb = favorites.open(getFavDbPath());
}
// 行数在内存里跟踪,避免每次入库都跑一次 COUNT(*) 全表扫描。
// null = 尚未初始化(下次用到时回源查一次)。
let clipCount = null;
function getClipCount() {
if (clipCount == null) clipCount = stmt.count.get().n;
return clipCount;
}
function enforceLimit() {
if (MAX_ITEMS <= 0) return; // 0 = 不限制
const total = getClipCount();
if (total <= MAX_ITEMS) return;
// 优先删除最旧的"自然位置"项top_at IS NULL不动置顶中的
const removed = stmt.trimOldest.run(total - MAX_ITEMS).changes;
clipCount = total - removed;
}
// 多 token 的 LIKE 搜索:每个 token 独立子串、AND 组合(与 FTS MATCH 多短语的
// 语义对齐不要求原文连空格一起逐字出现。patterns 已由 lib/search-plan
// 转义好(% _ \),这里一律带 ESCAPE '\'。语句形状随 token 数变化没法预编译
// 缓存,现 prepare —— SQLite 编译这种简单查询是 µs 级,热路径可接受。
function searchLikeRows(patterns, limit) {
if (!patterns.length) return stmt.listAll.all(limit);
const where = patterns.map(() => `text LIKE ? ESCAPE '\\'`).join(' AND ');
return db.prepare(
`SELECT ${listCols()} FROM clips WHERE ${where} ORDER BY ${orderBy()} LIMIT ?`
).all(...patterns, limit);
}
function makePreview(text, maxLen = 120) {
const flat = String(text || '').replace(/\s+/g, ' ').trim();
return flat.length > maxLen ? flat.slice(0, maxLen - 1) + '…' : flat;
}
// row 来自 listCols()(无 image 列,带 has_image或 SELECT *(有 image 列)。
// 两种都不在这里做 base64 —— 图片由渲染进程按需调 clips:get-image 拉。
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 || '',
topAt: row.top_at, // null = 未被用户点过「复制」;非空 = 用户点过trim / 清空历史 跳过)
createdAt: row.created_at,
};
}
function listWithFavorited(rows) {
if (!favDb || rows.length === 0) return rows;
const ids = rows.map(r => r.id);
// 按占位符个数缓存语句列表长度只有少数几种取值200 / 命中数),
// 之前每次列表都重新 prepare 一条动态 IN 语句。
let st = favStmtCache.get(ids.length);
if (!st) {
st = favDb.prepare(
`SELECT source_clip_id, id FROM favorites WHERE source_clip_id IN (${ids.map(() => '?').join(',')})`
);
if (favStmtCache.size < 64) favStmtCache.set(ids.length, st);
}
const favs = st.all(...ids);
if (favs.length === 0) return rows;
const favMap = new Map(favs.map(f => [f.source_clip_id, f.id]));
return rows.map(r => ({
...r,
favorited: favMap.has(r.id),
favId: favMap.get(r.id),
}));
}
// ---------- 剪贴板轮询 ----------
let pollTimer = null;
// 回声抑制:区分"我们自己写进剪贴板的"和"外部新复制"。两份独立状态机
// (文本 / 图片指纹),规则与踩坑记录见 lib/echo-suppress.js。
const textSuppress = createEchoSuppress();
const imageSuppress = createEchoSuppress();
let pollTick = 0; // 累计轮询次数
/**
* 图片指纹。走 getBitmap()(原始 BGRA一次 memcpy而不是 toPNG()。
*
* toPNG() 要做完整的 PNG 压缩 —— 剪贴板上放一张 5MB 截图时,每个 tick 都要
* 花 ~50ms 重新编码一遍,哪怕图根本没变。改成 hash 原始位图后,同样的判断
* 快了将近一个数量级,而且仍然是精确比较(不会把两张同尺寸的不同图判成一样)。
* toPNG() 现在只在确认图变了之后跑一次。
*/
function imageFingerprint(img) {
const size = img.getSize();
return crypto.createHash('sha1')
.update(`${size.width}x${size.height}:`)
.update(img.getBitmap())
.digest('hex');
}
function pollClipboard() {
pollTick++;
// 读文本
let text = null; // null = 读文本失败(被其他进程独占 / 系统 API 抽风)
try {
text = clipboard.readText() || '';
} catch (e) {
console.error('[poll] readText error:', e.message);
}
// 每 20 次打一次心跳,方便确认轮询是否在跑(间隔 = 20 × pollMs
if (pollTick % 20 === 1) {
devLog(`[poll] tick=${pollTick} textLen=${text == null ? -1 : text.length}`);
}
// 文本变化检测(规则见 lib/echo-suppress.js
// readText 抛错时text === null**不调 observe** —— 否则 last 会被改成
// '' 触发 'empty' 直接退出,但剪贴板上可能有图片;图片分支被一并跳过,
// 用户刚复制的那张图就静默丢失。等下次 tick readText 恢复时再正常观察。
if (text !== null) {
const verdict = textSuppress.observe(text);
if (verdict === 'empty') {
// 剪贴板被清空或原本就空,跳过(不消化回声标记)
return;
}
if (verdict === 'suppressed') {
devLog('[poll] suppressed (was our own paste)');
return;
}
if (verdict === 'new') {
devLog(`[poll] NEW TEXT len=${text.length} preview=${JSON.stringify(text.slice(0, 60))}`);
try {
const now = Date.now() / 1000;
const info = stmt.insertText.run(text, makePreview(text), null, now);
if (clipCount != null) clipCount++;
enforceLimit();
notifyNew(rowToClip(stmt.getMeta.get(info.lastInsertRowid)));
devLog(`[poll] inserted id=${info.lastInsertRowid}`);
} catch (e) {
// 入库失败observe 已经把 last 改成新内容,不回滚下次 tick 会判
// 'unchanged' 把这条内容**永久吞掉**(直到重启重新 seed。abort
// 后下次 tick 再判 'new' 重试一次,符合预期。
console.error('[poll] DB insert error:', e.message, e.stack);
textSuppress.abortLastObserve();
}
return;
}
// verdict === 'unchanged' —— 文本没变,继续看图片分支
} else {
devLog('[poll] readText skipped (last read failed), still probing image branch');
}
// 守卫:剪贴板若没有图片格式,跳过读图避免 ~33MB memcpy + sha1
// (剪贴板只有文本时这是 100% 的常见情况)。
// availableFormats 也用 try 裹住:它只是守卫,抛了(剪贴板被别的进程
// 独占 / 系统 API 抽风)就跳过本 tick 的图片分支 —— 漏一张图的代价
// 远小于未捕获异常打挂主进程Node 默认 uncaughtException = 退出)。
let formats;
try {
formats = clipboard.availableFormats();
} catch (e) {
console.error('[poll] availableFormats error:', e.message);
return;
}
if (!shouldReadImage(formats)) return;
// 读图片
try {
const img = clipboard.readImage();
if (!img || img.isEmpty()) return;
const size = img.getSize();
if (size.width < 2 || size.height < 2) {
// 1×1 透明图,按"没东西"处理:重置基线,下一次任何真图都算"新"
imageSuppress.resetBaseline();
return;
}
const hash = imageFingerprint(img);
// unchanged没变不编码/ suppressed自己的回声都不入库
if (imageSuppress.observe(hash) !== 'new') return;
// 确认是新图,才付 PNG 编码的代价
const buf = img.toPNG();
devLog(`[poll] NEW IMAGE ${size.width}x${size.height} bytes=${buf.length}`);
try {
const now = Date.now() / 1000;
const { buf: resized, preview: capPreview } = normalizeImageForStorage(buf);
const preview = capPreview || '[图片]';
const info = stmt.insertImage.run(resized, preview, null, now);
if (clipCount != null) clipCount++;
enforceLimit();
notifyNew(rowToClip(stmt.getMeta.get(info.lastInsertRowid)));
devLog(`[poll] inserted id=${info.lastInsertRowid}`);
} catch (e) {
// 入库失败:跟文本分支对称地回滚基线,避免 PNG 编码的代价被白付 +
// 下次 tick 把同样的图当 unchanged 静默吞掉。
console.error('[poll] DB insert error:', e.message, e.stack);
imageSuppress.abortLastObserve();
}
} catch (e) {
console.error('[poll] readImage error:', e.message);
}
}
function startPolling() {
stopPolling();
// 立即跑一次,再开定时器
pollClipboard();
const visible = !mainWindow || mainWindow.isDestroyed() ? true : mainWindow.isVisible();
const ms = computePollMs(POLL_MS, visible);
pollTimer = setInterval(pollClipboard, ms);
devLog(`[poll] started, interval=${ms}ms (visible=${visible})`);
}
// 启动时给文本/图片基线喂一次当前剪贴板,**不**入库。
// 之前这里只有 null要是用户开了第二次比如上午开过、下午又开
// 第一次 tick 就会把剪贴板里"上次会话就在的"那条内容当作新内容重入一次。
// seed 只设基线、不标记回声,后面用户的真实外部复制照常入库。
function initializeClipboardState() {
let textLen = 0;
let hasImage = false;
try {
const text = clipboard.readText() || '';
textSuppress.seed(text);
textLen = text.length;
} catch (e) {
devWarn('[poll] init readText failed:', e.message);
}
try {
const img = clipboard.readImage();
if (img && !img.isEmpty()) {
const size = img.getSize();
if (size.width >= 2 && size.height >= 2) {
// 1×1 透明图不值得记指纹(前面 poll 也会按"没东西"处理)
imageSuppress.seed(imageFingerprint(img));
hasImage = true;
}
}
} catch (e) {
devWarn('[poll] init readImage failed:', e.message);
}
devLog(`[poll] init clipboard state seeded: textLen=${textLen} hasImage=${hasImage}`);
}
function attachAdaptivePoll(window) {
if (!window) return;
window.on('show', () => startPolling());
window.on('hide', () => startPolling());
}
function stopPolling() {
if (pollTimer) { clearInterval(pollTimer); pollTimer = null; }
}
// ---------- 主题广播 ----------
function broadcastTheme() {
const sysDark = nativeTheme.shouldUseDarkColors;
const effective = configMod.effectiveTheme(appConfig, sysDark);
const payload = {
// 旧字段保留:渲染端旧版本只读 payload.theme。新代码应优先用 effectiveTheme。
theme: effective,
effectiveTheme: effective,
rawTheme: appConfig.theme,
accent: appConfig.accent,
followSystem: appConfig.followSystem,
systemDark: sysDark,
};
subscribers.notifyTheme('theme:system-changed', payload);
}
nativeTheme.on('updated', () => {
devLog('[theme] system theme changed, broadcasting');
broadcastTheme();
});
function applyMaxItems(n) {
MAX_ITEMS = n;
try { enforceLimit(); } catch (e) { console.error('[cfg] enforceLimit failed:', e.message); }
}
function applyPollMs(ms) {
POLL_MS = ms;
startPolling(); // startPolling 内部会先 stopPolling
}
// 窗口置顶:不落盘,重启回到默认(开启)——与本功能开发前的行为一致
let alwaysOnTop = true;
function applyAlwaysOnTop(on) {
alwaysOnTop = on === true;
if (mainWindow && !mainWindow.isDestroyed()) {
try { mainWindow.setAlwaysOnTop(alwaysOnTop); }
catch (e) { devWarn('[window] setAlwaysOnTop failed:', e.message); }
}
return alwaysOnTop;
}
// ---------- 窗口 ----------
let mainWindow = null;
function createFramelessWindow({ width, height, file, onClose, minimizable = false }) {
const w = new BrowserWindow({
width, height,
show: false,
frame: false,
resizable: true,
minimizable,
maximizable: false,
skipTaskbar: true,
// 跟随当前的置顶开关。写死 true 会让用户关掉置顶、关窗、再开窗之后
// 窗口又变回置顶,而按钮状态仍显示"已关闭"。
alwaysOnTop,
// 背景色跟随主题:之前写死 #FFFFFF5/6 的主题首帧会闪白。
backgroundColor: configMod.themeBgColor(appConfig.theme),
// OS 级窗口图标alt-tab、任务管理器进程图标、Win+Tab 任务视图。
// 工厂被主窗口和设置窗口共用,一行设置两窗生效。
icon: path.join(__dirname, 'icon.ico'),
webPreferences: {
preload: path.join(__dirname, 'preload.js'),
contextIsolation: true,
nodeIntegration: false,
sandbox: false,
},
});
// 外链一律不许在应用内开窗 / 导航:关于页的链接由渲染进程拦下走
// shell.openExternalscheme 白名单在主进程),这里是纵深防御 —— 万一
// 将来有链接漏过了点击拦截(中键点击 / 拖拽 / JS 直跳),也不能把
// frameless 窗口顶成外部网页,或弹一个没有我们 preload 的裸窗口。
w.webContents.setWindowOpenHandler(() => ({ action: 'deny' }));
w.webContents.on('will-navigate', (e, url) => {
e.preventDefault();
devWarn('[window] blocked in-page navigation to', url);
});
w.loadFile(path.join(__dirname, 'renderer', file));
forwardRendererLog(w.webContents);
if (onClose) w.on('closed', onClose);
return w;
}
function createMainWindow() {
mainWindow = createFramelessWindow({
// 默认高度按"刚好显示 5 条单行内容"。每个部件的实测高度(用 headless
// chromium 跑过subpixel 取整后):
// 拖动区 42 #drag-bar = padding 8+7 + icon 26 + border 1
// Tab 条 33 #tabs = padding 0 + .tab 27 + border 2 + .tab margin-bottom -1 + container border 1
// .tab 里 16px 高的 .tab-count 把行盒顶到 33
// 搜索区 50 #search-row = padding 8+10 + input 32
// 状态栏 33 #status = padding 7+7 + 18px kbd + border 1
// <kbd> 18px 高,比 11px×1.4 文字高,把容器撑到 33
// 列表内边距 16 #list-wrap padding 8+8
// 5×卡片 ~69.8 .card = padding 9+10 + head 24 + margin 4 + body 20.8 + border 2
// .action.fav 里 14px SVG 比 11px 文字高,把 head 撑到 24
// 4×间距 24 #list gap: 6px
// 根容器边框 2 #root border-top+border-bottom
// 总和 42+33+50+33+16+5×69.8+4×6+2 = 549.4 → 550 正好够,第 5
// 条底部贴底但不溢出list-wrap 不出现滚动条549 时 5 条全可见,
// 540 时第 5 条已被裁掉)。
// 之前的注释算成 470 / 520 都没考虑到 .tab-count / kbd / SVG 把
// 容器顶高的几个隐式 max-height所以差了几十 px用户反馈"可以
// 高一些,刚好 5 条"就是要 5 条稳稳占满、无需滚动。
// 默认尺寸:
// 宽度 500 —— 460 时 text-preview 一行只能显示 ~28 个汉字,长一点的 URL/
// 路径/代码片段经常被截到第二行500 让单行可显示 ~38 字,多数短文/
// URL 一行内看完。设置窗口是 420这里再宽 80 形成"主宽 / 设窄"的
// 层级,仍能在 1080p 屏幕上居中不显拥挤。
// 高度 550 —— 刚好显示 5 条单行卡片(详见上方注释的逐项实测)。
// 用户拖窗可自由调整,跟旧行为一致。
width: 500, height: 550,
file: 'index.html',
onClose: () => { mainWindow = null; },
// 自动粘贴移除后没有代码会主动最小化它;保留 true 只是不拦截
// Win+M 之类的系统行为toggleWindow() 里有 isMinimized → restore 兜底。
minimizable: true,
});
mainWindow.webContents.on('before-input-event', (_e, input) => {
if (input.key === 'F12' ||
((input.control || input.meta) && input.shift && input.key.toLowerCase() === 'i')) {
mainWindow.webContents.toggleDevTools();
}
});
// 必须在这里挂,不能只在启动时挂一次 —— 用户关掉窗口后 toggleWindow()
// 会重建一个新的 BrowserWindow旧窗口上的监听随之失效自适应轮询就哑了。
attachAdaptivePoll(mainWindow);
return mainWindow;
}
function toggleWindow() {
// 窗口已销毁(用户主动关闭过)→ 重新创建一个并显示
if (!mainWindow || mainWindow.isDestroyed()) {
createMainWindow();
mainWindow.once('ready-to-show', () => {
positionAtTopCenter();
mainWindow.show();
mainWindow.focus();
});
return;
}
if (mainWindow.isVisible()) {
mainWindow.hide();
} else {
if (mainWindow.isMinimized()) mainWindow.restore();
positionAtTopCenter();
mainWindow.show();
mainWindow.focus();
// 之前这里是 mainWindow.reload() —— 每次打开都重新解析全部 CSS、重跑
// preload 和 renderer.js100~300ms 的可感延迟。渲染进程本来就订阅了
// clips:refresh推一条通知让它自己重拉列表就够了。
notifyRefresh();
}
}
let settingsWindow = null;
function openSettingsWindow() {
if (settingsWindow && !settingsWindow.isDestroyed()) { settingsWindow.show(); settingsWindow.focus(); return; }
settingsWindow = createFramelessWindow({
// 480 是初版只有三四个开关时的高度;加了 Tab 页、「更改目录」「清空历史」
// 和快捷键之后,「通用」页内容已有 ~550px而 #settings-body 是
// overflow:hidden设计上设置窗口就该一屏放下、不滚动—— 旧高度下
// 底部整块被裁掉且没有滚动条,「清空历史」按钮根本点不到。
// 690 = 内容 ~545 + 标题栏/Tab 条/状态栏 ~125 + 余量(见 settings-tabs 测试)。
width: 420, height: 690,
file: 'settings.html',
onClose: () => { settingsWindow = null; },
});
settingsWindow.webContents.on('before-input-event', (_e, input) => { if (input.key === 'F12') settingsWindow.webContents.toggleDevTools(); });
settingsWindow.show();
}
function positionAtTopCenter() {
if (!mainWindow) return;
const display = screen.getPrimaryDisplay();
const wa = display.workArea;
const w = mainWindow.getBounds().width;
const x = Math.round(wa.x + (wa.width - w) / 2);
const y = Math.round(wa.y + wa.height * 0.18);
mainWindow.setPosition(x, y);
}
// ---------- 全局快捷键 ----------
// 当前实际注册成功的 accelerator。改键 / 退出时用它精确解绑。
// '' = 此刻没注册任何东西(配置为禁用,或注册失败)。
let registeredHotkey = '';
/**
* 按 appConfig.hotkey 重新注册全局唤起快捷键 → toggleWindow()。
*
* 幂等:先解绑上一次注册成功的键,再注册新键。启动、设置里改键两条路
* 都走这里。返回 true = 生效注册成功或配置就是禁用false = 想注册
* 但没成accelerator 被别的程序占用 / 无效),设置界面据此提示用户。
*
* 必须在 app.whenReady() 之后调用 —— globalShortcut 在这之前不可用。
*/
function applyHotkey() {
// 记下旧的(成功注册过、且这次想换)—— 失败时把它注册回去。
// 不留这个快照的话:从能用的 'A' 改成被占用的 'B' 会让用户
// 暂时完全没有全局快捷键unregister A 已成事实B 又注册不上)。
const prev = registeredHotkey;
if (prev) {
try { globalShortcut.unregister(prev); } catch (_) {}
registeredHotkey = '';
}
const acc = appConfig.hotkey;
if (!acc) return true; // 配置为禁用 —— 不是失败
const tryRegister = (target) => {
try {
// register 同步返回 booleanfalse = 组合键已被占用,回调永远不会触发
return globalShortcut.register(target, () => toggleWindow());
} catch (_) {
// accelerator 格式非法时 register 会抛异常(而不是返回 false
return false;
}
};
if (tryRegister(acc)) {
registeredHotkey = acc;
devLog(`[hotkey] registered ${acc}`);
return true;
}
devWarn(`[hotkey] register failed (in use or invalid): ${acc}`);
if (prev && tryRegister(prev)) {
registeredHotkey = prev;
devLog(`[hotkey] recovered previous ${prev} after failed re-register`);
return false; // 仍然告诉设置界面「这次没注册上」
}
return false;
}
// ---------- 托盘 ----------
let tray = null;
let trayIconCache = null;
function getTrayIcon() {
if (trayIconCache) return trayIconCache;
// 优先用随包发布的 icon.ico多尺寸 16~256缺失或解析失败时回退程序化绘制
try {
const iconPath = path.join(__dirname, 'icon.ico');
if (fs.existsSync(iconPath)) {
const img = nativeImage.createFromPath(iconPath);
if (!img.isEmpty()) {
trayIconCache = img;
return trayIconCache;
}
devWarn('[tray] icon.ico loaded as empty image, falling back to drawn icon');
} else {
devWarn('[tray] icon.ico not found at', iconPath, ', falling back to drawn icon');
}
} catch (e) {
devWarn('[tray] load icon.ico failed:', e.message, ', falling back to drawn icon');
}
trayIconCache = buildTrayIcon();
return trayIconCache;
}
function buildTrayIcon() {
// 64×64 程序化绘制
const size = 64;
const img = nativeImage.createEmpty();
const buf = Buffer.alloc(size * size * 4);
const setPx = (x, y, r, g, b, a = 255) => {
const i = (y * size + x) * 4;
buf[i] = b; buf[i + 1] = g; buf[i + 2] = r; buf[i + 3] = a;
};
// 圆角蓝底
for (let y = 0; y < size; y++) {
for (let x = 0; x < size; x++) {
const dx = Math.max(0, Math.max(4 - x, x - (size - 5)));
const dy = Math.max(0, Math.max(4 - y, y - (size - 5)));
const r2 = dx * dx + dy * dy;
if (r2 <= 14 * 14) setPx(x, y, 59, 130, 246); // #3B82F6
else setPx(x, y, 0, 0, 0, 0);
}
}
// 顶部夹子
for (let y = 8; y < 16; y++)
for (let x = 22; x < 42; x++) setPx(x, y, 255, 255, 255);
// 下方白板
for (let y = 20; y < 52; y++)
for (let x = 14; x < 50; x++) setPx(x, y, 255, 255, 255);
// 内层蓝线
for (let x = 20; x < 44; x++) { setPx(x, 30, 59, 130, 246); setPx(x, 36, 59, 130, 246); }
for (let x = 20; x < 38; x++) setPx(x, 42, 59, 130, 246);
return nativeImage.createFromBuffer(buf, { width: size, height: size });
}
// ---------- 应用图标(标题栏左上角 + OS 窗口图标) ----------
let appIconDataUrlCache = null;
/**
* 把 icon.ico 转成 32×32 PNG data URL给渲染进程当 `<img src>` 用。
*
* 与 getTrayIcon 独立缓存:托盘要的是 nativeImage喂给 Tray.setImage
* 标题栏要的是 data URL浏览器渲染且目标尺寸也不同。
*
* 缓存命中空字符串表示"曾经加载失败",避免每个窗口启动时都重新尝试。
*/
function getAppIconDataUrl() {
if (appIconDataUrlCache !== null) return appIconDataUrlCache;
try {
const iconPath = path.join(__dirname, 'icon.ico');
if (!fs.existsSync(iconPath)) throw new Error('icon.ico not found');
const img = nativeImage.createFromPath(iconPath);
if (img.isEmpty()) throw new Error('empty image');
// 32×32标题栏 CSS 显示 16px2x HiDPI 下仍锐利。
// .ico 内置最大 256×256resize 是高质量降采样。
const resized = img.resize({ width: 32, height: 32, quality: 'best' });
appIconDataUrlCache = resized.toDataURL();
return appIconDataUrlCache;
} catch (e) {
devWarn('[app-icon] failed to load icon.ico:', e.message);
appIconDataUrlCache = '';
return '';
}
}
// ---------- 主题元数据 ----------
function applyThemeChange(patch) {
appConfig = configMod.sanitize({ ...appConfig, ...patch });
// 落盘失败要带给 UI同 settings:set本次会话生效、重启还原得让用户知道。
let saved = true;
try { configMod.saveConfig(appConfig); }
catch (e) { saved = false; console.error('[theme] saveConfig failed:', e.message); }
broadcastTheme();
// 同步返回 effectiveTheme —— settings.js 拿到后要用它设 data-theme
// 不用 raw 主题。返回的 rawTheme 字段给 localStorage / 测试 / 调试用。
const sysDark = nativeTheme.shouldUseDarkColors;
const effective = configMod.effectiveTheme(appConfig, sysDark);
return {
theme: effective,
effectiveTheme: effective,
rawTheme: appConfig.theme,
accent: appConfig.accent,
followSystem: appConfig.followSystem,
systemDark: sysDark,
saved,
};
}
function buildTray() {
if (tray) { try { tray.destroy(); } catch (_) {} tray = null; }
tray = new Tray(getTrayIcon());
tray.setToolTip(APP_NAME);
tray.setContextMenu(Menu.buildFromTemplate(buildTrayMenuTemplate({
onShow: () => toggleWindow(),
onSettings: () => openSettingsWindow(),
onQuit: () => app.quit(),
})));
tray.on('click', () => toggleWindow());
// 不要同时挂 double-click单击触发 click → 再过 ~500ms 又触发 double-click
// 两次 toggleWindow 让用户「双击托盘」变「开一下立刻关掉」。
// 单击是主流用法,需要双击唤起的用户极少;硬要可托 tray.setIgnoreDoubleClickEvents(true)。
}
// ---------- 复制到剪贴板 ----------
// 共用的"载荷 → 系统剪贴板"一步,带回声抑制:记下写进去的内容,
// 让轮询在真正读到它时吞掉,避免自己的写入被当成新内容入库。
function writePayloadToClipboard(payload) {
// markOwnWrite 预置基线 + 回声标记,返回的 undo 供写入失败时回滚:
// 失败时剪贴板上还是旧内容,基线却已是新值,不回滚下个 tick 会把真实
// 旧内容当"新变化"重复入库。写入成功则基线保持预置,下一 tick 直接走
// "无变化"分支,连读图/编码的代价都不付。
if (payload.kind === 'text') {
// 记下"我们写进去的内容"poll 真正读到这串内容时才吞。
// 比占位 flag 更严:中间夹了外部复制也不会误吞。
const undo = textSuppress.markOwnWrite(payload.text);
try { clipboard.writeText(payload.text); }
catch (e) { undo(); throw e; }
} else {
const img = nativeImage.createFromBuffer(payload.buffer);
if (img.isEmpty()) throw new Error('图片解码失败');
// 必须和 pollClipboard 用同一套指纹(原始位图,不是 PNG 字节),
// 否则下一个 tick 会把我们自己写进去的图当成新图。
const undo = imageSuppress.markOwnWrite(imageFingerprint(img));
try { clipboard.writeImage(img); }
catch (e) { undo(); throw e; }
}
}
// 把某条历史记录写入系统剪贴板(不触发重复入库)
function copyClipToClipboard(clipId) {
const row = stmt.get.get(clipId);
if (!row) return false;
try {
writePayloadToClipboard(clipPayload(row));
devLog(`[clipboard] copied id=${clipId} type=${row.type}`);
return true;
} catch (e) {
// 失败时清掉刚记下的 echo 标记,否则会吞掉用户接下来的一次真实复制
textSuppress.clearPending();
imageSuppress.clearPending();
console.error('[clipboard] copy failed:', e.message);
return false;
}
}
// 把收藏自身存下的内容写入系统剪贴板。
// 收藏与历史分库favId 和历史 id 是两套独立自增数 —— 绝不能拿 favId
// 走 copyClipToClipboard那会按同号去 clips 表查到一条无关的历史记录
// (两个库都有 id=1 时必中),把错误内容静默写进剪贴板。
function copyFavToClipboard(favId) {
const row = favorites.getRaw(favDb, favId);
if (!row) return false;
try {
writePayloadToClipboard(clipPayload(row));
devLog(`[clipboard] copied fav id=${favId} type=${row.type}`);
} catch (e) {
textSuppress.clearPending();
imageSuppress.clearPending();
console.error('[clipboard] fav copy failed:', e.message);
return false;
}
// 收藏 Tab 复制也要在历史 Tab 留一份 —— 收藏与历史分库,源记录可能已
// 被清理(那才是用户去收藏 Tab 点复制的原因)。源还在 → 置顶(与历史
// Tab 复制行为对齐),源不在 / 无源 → 当作新内容入库。
try {
bringFavoriteIntoHistory(row);
} catch (e) {
// 入库失败不能把整个复制操作搞砸 —— 剪贴板已经写好了,
// 用户最差就是看到「历史 Tab 没冒新条目」,比「复制失败」轻得多。
console.error('[clipboard] fav copy → history bridge failed:', e.message);
}
return true;
}
// 把收藏的内容带到历史 Tab。源记录还在 → 置顶;不在 → 当新条目入库。
// 入库路径与 pollClipboard 一致clipCount++ / enforceLimit / notifyNew
// 让 poll 缓存、上限清理、横切通知这些只剩一份实现。
function bringFavoriteIntoHistory(row) {
const sourceId = row.source_clip_id;
if (sourceId != null) {
const existing = stmt.get.get(sourceId);
if (existing) {
const now = Date.now() / 1000;
stmt.moveToTop.run(now, now, sourceId);
notifyRefresh();
return;
}
}
const now = Date.now() / 1000;
let info;
if (row.type === 'text') {
const text = row.text || '';
const preview = row.preview || makePreview(text);
info = stmt.insertText.run(text, preview, null, now);
} else {
// 图片:复用 poll 的尺寸/预览判定,超大图压成 800px 宽保证 SOFT_CAP 不爆
const buf = Buffer.isBuffer(row.image) ? row.image : Buffer.from(row.image || []);
const { buf: resized, preview: capPreview } = normalizeImageForStorage(buf);
const preview = capPreview || '[图片]';
info = stmt.insertImage.run(resized, preview, null, now);
}
if (clipCount != null) clipCount++;
enforceLimit();
notifyNew(rowToClip(stmt.getMeta.get(info.lastInsertRowid)));
}
// 通知所有订阅者刷新(用于 clear-top 等场景)
function notifyRefresh() {
subscribers.notifyClip('clips:refresh');
}
// 通知所有订阅者收藏列表已变更
function notifyFavoritesChanged() {
subscribers.notifyFav('favorites:changed');
}
// ---------- IPC ----------
const subscribers = createSubscribers();
function notifyNew(clip) {
subscribers.notifyClip('clips:added', clip);
}
// 把渲染进程的 console 转发到主进程终端(方便调试)
function forwardRendererLog(webContents) {
if (process.env.CB_LOG !== '1') return;
webContents.on('console-message', (_e, level, message) => {
const tag = ['DBG', 'INF', 'WRN', 'ERR'][level] || 'LOG';
devLog(`[renderer ${tag}] ${message}`);
});
}
function registerIpc() {
ipcMain.handle('clips:list', (_e, { query = '', limit = 0 } = {}) => {
// limit = 0 → 用配置的界面显示条数;设置改了之后渲染进程重新拉就生效
const eff = limit > 0 ? limit : (appConfig.displayLimit || 300);
const plan = planSearch(query);
let rows;
if (plan.mode === 'fts' && stmt.searchFts) {
try {
rows = stmt.searchFts.all(plan.match, eff);
} catch (e) {
// FTS 出问题就退回 LIKE不要把搜索整个打死
devWarn('[clips:list] FTS search failed, falling back to LIKE:', e.message);
rows = searchLikeRows(likePatterns(query), eff);
}
} else if (plan.mode === 'fts') {
// 没有 FTS 索引(老库 / 这个 SQLite 不带 FTS5
rows = searchLikeRows(likePatterns(query), eff);
} else if (plan.mode === 'like') {
rows = searchLikeRows(plan.patterns, eff);
} else {
rows = stmt.listAll.all(eff);
}
return listWithFavorited(rows.map(rowToClip));
});
// 图片按需拉取:列表只带 hasImage 标记,渲染进程滚到哪张才取哪张。
// 之前 clips:list 会把 200 条的 image BLOB 全部 base64 后过 IPC
// 几十条图片就是几百 MB 的字符串。
ipcMain.handle('clips:get-image', (_e, { id }) => {
const row = stmt.getImage.get(id);
if (!row || !row.image) return null;
// row.image 是 better-sqlite3 BLOB 列,原生就是 Buffer
// 之前 Buffer.from(row.image) 是一次额外拷贝2MB 图 → 2MB 浪费)。
// Buffer.prototype.toString('base64') 不需要再 wrap一层。
return row.image.toString('base64');
});
ipcMain.handle('clips:count', () => getClipCount());
ipcMain.handle('clips:movetop', async (_e, { id }) => {
const now = Date.now() / 1000;
stmt.moveToTop.run(now, now, id);
// 把该内容写入系统剪贴板,用户切到目标窗口自己按 Ctrl+V 即可。
const copied = copyClipToClipboard(id);
notifyRefresh();
// 渲染到 IPC 之间可能已被自动清理enforceLimit—— row 可能不存在。
// 但剪贴板已经写成功了,渲染端必须能正确报"已写入"
// 而不是把它当失败。让 row 缺失只影响回显字段copied 不动。
const row = stmt.getMeta.get(id);
if (!row) return { copied, pasted: false, clip: null };
// 不做自动粘贴、不隐藏/最小化窗口 —— 界面任何时候都保持可见。
// (自动粘贴需要交出焦点必然要藏窗口,会产生"缩一下"的动画,用户明确不要。)
return { ...listWithFavorited([rowToClip(row)])[0], copied, pasted: false };
});
ipcMain.handle('clips:delete', (_e, { id }) => {
const changes = stmt.delete.run(id).changes;
if (clipCount != null) clipCount -= changes;
if (changes) notifyRefresh();
return true;
});
// clips:clear-natural 已彻底移除 —— 历史 Tab 从不调用它,
// 唯一在用的清空历史入口是 settings:clear-history设置窗口
// 两份路径完全是同实现clearNonTop + clipCount = null + notifyRefresh
// 留一条就够了 —— ipc-wiring 守的是双向一致,一删都删。
ipcMain.handle('clips:subscribe', (e) => {
subscribers.addClip(e.sender);
e.sender.on('destroyed', () => subscribers.drop(e.sender));
return true;
});
ipcMain.handle('settings:open', () => {
openSettingsWindow();
return true;
});
function favGuard() {
if (!favDb) throw new Error('favorites 不可用');
}
ipcMain.handle('favorites:add', (_e, { clipId }) => {
favGuard();
const row = stmt.get.get(clipId);
if (!row) throw new Error('源记录不存在');
// 幂等:重复收藏同一条(双击竞态 / 快速连点)不抛 UNIQUE 约束错,
// 直接返回已有收藏行。
const existFavId = favorites.isFavorited(favDb, row.id);
if (existFavId) return favorites.get(favDb, existFavId);
const result = favorites.add(favDb, {
id: row.id,
type: row.type,
text: row.text,
image: row.image,
preview: row.preview,
createdAt: row.created_at,
});
notifyFavoritesChanged();
return result;
});
ipcMain.handle('favorites:remove', (_e, { favId }) => {
favGuard();
const ok = favorites.remove(favDb, favId);
notifyFavoritesChanged();
return ok;
});
ipcMain.handle('favorites:list', (_e, { query = '', limit = 0 } = {}) => {
favGuard();
// 收藏列表也走 displayLimit —— 渲染层 limit=0 → 用配置档位
const eff = limit > 0 ? limit : (appConfig.displayLimit || 300);
return favorites.list(favDb, query, eff);
});
// 只要一个数字。之前渲染进程为了显示收藏计数会拉全部 5000 条(含图片 base64
// 收藏里有几百张图时就是几百 MB 的无谓开销。
ipcMain.handle('favorites:count', () => (favDb ? favorites.count(favDb) : 0));
ipcMain.handle('favorites:get-image', (_e, { favId }) => {
if (!favDb) return null;
return favorites.getImage(favDb, favId);
});
// 收藏 Tab 的"复制":写收藏自己存的内容(源记录可能早已被清理)。
// 返回形状与 clips:movetop 对齐copied / pasted不自动粘贴恒 false
ipcMain.handle('favorites:copy', (_e, { favId }) => {
favGuard();
const copied = copyFavToClipboard(favId);
return copied ? { copied: true, pasted: false } : null;
});
ipcMain.handle('favorites:subscribe', (e) => {
subscribers.addFav(e.sender);
e.sender.on('destroyed', () => subscribers.drop(e.sender));
return true;
});
ipcMain.handle('window:hide', () => {
if (mainWindow) mainWindow.hide();
});
// on 省略undefined→ 翻转;传 boolean → 直接设置
ipcMain.handle('window:toggle-always-on-top', (_e, { on } = {}) =>
applyAlwaysOnTop(typeof on === 'boolean' ? on : !alwaysOnTop));
ipcMain.handle('window:get-always-on-top', () => alwaysOnTop);
// 标题栏左上角小图标:返回 PNG data URL渲染进程直接喂给 <img src>。
// 命中缓存后基本零成本(一次 IPC 往返 + 一个字符串赋值)。
ipcMain.handle('app:get-icon', () => getAppIconDataUrl());
ipcMain.handle('theme:get', () => {
// 跟 broadcastTheme / applyThemeChange 同形 —— 渲染端拿到时直接用 effectiveTheme
// 设 data-theme不需要自己再判 followSystem之前由渲染端各自判反而因为
// IPC 顺序竞争让设置窗口 / 主窗口在「跟随系统」开着的状态下显示 raw 主题)。
const sysDark = nativeTheme.shouldUseDarkColors;
const effective = configMod.effectiveTheme(appConfig, sysDark);
return {
theme: effective,
effectiveTheme: effective,
rawTheme: appConfig.theme,
accent: appConfig.accent,
followSystem: appConfig.followSystem,
systemDark: sysDark,
};
});
// 没有这个订阅broadcastTheme() 就是在往一个永远空的集合里发 ——
// "跟随系统"改了主题后,已经打开的窗口收不到任何通知。
ipcMain.handle('theme:subscribe', (e) => {
subscribers.addTheme(e.sender);
e.sender.on('destroyed', () => subscribers.drop(e.sender));
return true;
});
ipcMain.handle('theme:set', (_e, { theme }) => {
if (!configMod.ALLOWED_THEMES.includes(theme)) return null;
return applyThemeChange({ theme, followSystem: false });
});
ipcMain.handle('theme:set-accent', (_e, { accent }) => {
if (!configMod.ALLOWED_ACCENTS.includes(accent)) return null;
return applyThemeChange({ accent });
});
ipcMain.handle('theme:set-follow-system', (_e, { followSystem }) => {
return applyThemeChange({ followSystem: followSystem === true });
});
ipcMain.handle('settings:get', () => ({ ...appConfig }));
ipcMain.handle('settings:set', (_e, { key, value }) => {
appConfig = applyPatch(appConfig, { key, value });
let extra = {};
if (key === 'autoLaunch') {
try { app.setLoginItemSettings({ openAtLogin: appConfig.autoLaunch }); }
catch (e) { console.error('[settings] setLoginItem failed:', e.message); }
} else if (key === 'maxItems') {
applyMaxItems(appConfig.maxItems);
} else if (key === 'pollMs') {
applyPollMs(appConfig.pollMs);
} else if (key === 'hotkey') {
// 立刻换键生效。注册失败(被占用)也照存配置 —— 占用是暂时的,
// 重启后可能就空出来了;同时把失败状态带给设置界面提示用户。
extra = { hotkeyRegistered: applyHotkey() };
} else if (key === 'displayLimit') {
// 推到所有 BrowserWindow渲染层订阅后自己 reload()(用新 limit 重拉)。
// 不直接 reload跨窗口设置窗口改、主窗口收需要统一走 IPC 通知。
for (const w of BrowserWindow.getAllWindows()) {
try { w.webContents.send('settings:display-limit-changed', { displayLimit: appConfig.displayLimit }); }
catch (_) {}
}
}
// 落盘失败不能只打日志:运行时状态(轮询间隔/快捷键/登录项)已经改了,
// 但重启会按磁盘旧值还原 —— 必须如实告诉渲染端,由 UI 提示"未保存"。
try { configMod.saveConfig(appConfig); }
catch (e) {
console.error('[settings] saveConfig failed:', e.message);
extra = { ...extra, saved: false, saveError: e.message };
}
return { ...appConfig, ...extra };
});
ipcMain.handle('settings:clear-history', () => {
// 与 clips:clear-natural 同构:行数缓存必须作废,否则下次入库
// enforceLimit 按过期行数算差值,会把刚插入的新记录(乃至所有
// 非置顶项)误删;开着的主窗口也要推一条刷新,不然列表停在已删除的项上。
try {
const changes = stmt.clearNonTop.run().changes;
clipCount = null; // 重新回源计数
if (changes) notifyRefresh();
return changes;
} catch (e) {
console.error('[settings] clear-history failed:', e.message);
return 0;
}
});
ipcMain.handle('settings:open-dir', () => {
// openPath 返回 Promise<string>:失败原因是 resolve 出来的错误串(不是 reject
// 同步 try/catch 接不到任何东西,失败时用户点了没反应、日志里也没痕迹。
// 走 effectiveDataDir() —— 启动兜底后用户看的是真正在用的目录,而不是坏的盘符。
shell.openPath(effectiveDataDir()).then(
(err) => { if (err) console.error('[settings] openPath failed:', err); },
(e) => { console.error('[settings] openPath failed:', e.message); }
);
});
// 外链一律交给系统浏览器:设置窗口直接导航出去会把整个页面拖走。
// scheme 必须卡死在 http/https —— shell.openExternal 接受任意 scheme
// file:、自定义协议),渲染进程递来的字符串不校验就是 RCE 面。
// openExternal 返回 PromiseOS 拒绝时 reject必须 await —— 不 await 的话
// try/catch 接不住Node 20 默认把 unhandled rejection 升级成主进程崩溃。
ipcMain.handle('settings:open-external', async (_e, { url } = {}) => {
if (typeof url !== 'string' || !/^https?:\/\//i.test(url)) return { ok: false };
try {
await shell.openExternal(url);
return { ok: true };
} catch (e) {
console.error('[settings] openExternal failed:', e.message);
return { ok: false };
}
});
function copyIfExists(src, dst) {
if (fs.existsSync(src)) fs.copyFileSync(src, dst);
}
function dbFileSet(dir) {
return [
path.join(dir, 'history.db'),
path.join(dir, 'history.db-wal'),
path.join(dir, 'history.db-shm'),
path.join(dir, 'favorites.db'),
path.join(dir, 'favorites.db-wal'),
path.join(dir, 'favorites.db-shm'),
];
}
// 迁移复制只搬 db + wal。-shm 是 WAL 的临时共享内存索引SQLite 打开时
// 自己重建,搬一个可能与 wal 不同步的快照过去反而更糟(与 config.js
// migrateLegacyData 的处理一致。dbFileSet 保留 -shm 是给「删旧文件 /
// 清理失败目标」用的 —— 删的时候要删干净。
const COPY_NAMES = ['history.db', 'history.db-wal', 'favorites.db', 'favorites.db-wal'];
ipcMain.handle('settings:choose-dir', async () => {
// 1. 选目录
const pick = await dialog.showOpenDialog({
title: '选择数据保存位置',
properties: ['openDirectory', 'createDirectory'],
});
if (pick.canceled || !pick.filePaths[0]) return { ok: false };
const newDir = pick.filePaths[0];
// Windows 大小写不敏感D:\X 与 d:\x 是同一目录,必须按平台语义比较,
// 否则重选当前目录会绕过这个提示、走到下面"目标已有数据"自己迁自己。
// 比较 effectiveDataDir()(含 session 降级)—— 用户在兜底状态下重选
// 同一个 session 目录时不弹迁移对话框自己迁自己。
if (isSameDir(newDir, effectiveDataDir())) {
return { ok: false, message: '已经是当前数据位置' };
}
// 2. 可写校验
try {
fs.mkdirSync(newDir, { recursive: true });
const probe = path.join(newDir, '.write-test');
fs.writeFileSync(probe, 'ok'); fs.unlinkSync(probe);
} catch (e) {
return { ok: false, message: '目标目录不可写:' + e.message };
}
const targetDb = path.join(newDir, 'history.db');
const targetExists = fs.existsSync(targetDb);
// 3. 目标已有 db → 二选一
if (targetExists) {
const r = dialog.showMessageBoxSync({
type: 'question',
buttons: ['使用该目录的现有数据', '取消'],
defaultId: 1,
cancelId: 1,
title: '目标已有数据',
message: '该目录已存在剪贴板数据。是否直接使用它?',
detail: '选择「使用现有数据」将切换到该目录的数据库(不会合并当前数据)。',
});
if (r !== 0) return { ok: false, message: '已取消' };
const oldDir = effectiveDataDir();
stopPolling(); // 马上退出;别让轮询在退出前的间隙再写旧库
appConfig.dataDir = newDir;
sessionDataDir = null; // 用户已正式选定新目录session 兜底退出
try { configMod.saveConfig(appConfig); }
catch (e) {
// 失败后应用留在旧目录继续运行:回滚内存配置 + 恢复轮询,
// 否则剪贴板监听静默死掉,且后续任何 settings:set 会把错误的
// newDir 落盘(内存/落盘分叉,同 choose-dir 迁移分支修过的坑)。
appConfig.dataDir = oldDir;
startPolling();
return { ok: false, message: '保存配置失败:' + e.message };
}
app.relaunch();
app.quit();
return { ok: true };
}
// 4. 迁移:停轮询 → 关闭 db → 复制 → 校验 → 删旧 → 写 config → 重启
// 轮询的 prepared statements 绑在旧连接上:必须先停轮询再关库,否则迁移
// 期间(复制+校验有几十~几百 ms 窗口tick 触发会抛 "database is closed"
// 这一两秒里的外部复制也会被静默丢掉。失败恢复路径里会重新 startPolling()。
stopPolling();
// 旧目录可能是 sessionDataDir 兜底出来的 —— 用 effectiveDataDir()
// 不要再用裸 appConfig.dataDir否则 session 状态下会从用户原本选的
// (不可用的)目录复制,而当前在用的兜底目录里的数据被丢在原地。
const oldDir = effectiveDataDir();
try {
if (db) { const d = db; db = null; d.close(); }
if (favDb) { const f = favDb; favDb = null; favorites.close(f); }
} catch (e) {
// db.close 抛错时把句柄置空再恢复轮询:留着旧句柄会让下一个 tick
// 在已关闭的连接上 prepare抛 "database is closed" 打到控制台。
db = null; favDb = null;
startPolling(); // 关库失败也要恢复轮询,应用还留在旧目录运行
return { ok: false, message: '关闭数据库失败:' + e.message };
}
try {
const oldFavDbExists = fs.existsSync(path.join(oldDir, 'favorites.db'));
for (const name of COPY_NAMES) {
copyIfExists(path.join(oldDir, name), path.join(newDir, name));
}
const testH = new Database(path.join(newDir, 'history.db'));
testH.prepare('SELECT COUNT(*) AS n FROM clips').get();
testH.close();
// 旧目录没有 favorites.db升级自旧版本→ 用 favorites.open() 初始化空库
if (!oldFavDbExists) {
const initFav = favorites.open(path.join(newDir, 'favorites.db'));
favorites.close(initFav);
}
const testF = new Database(path.join(newDir, 'favorites.db'));
testF.prepare('SELECT COUNT(*) AS n FROM favorites').get();
testF.close();
} catch (e) {
try { for (const f of dbFileSet(newDir)) if (fs.existsSync(f)) fs.unlinkSync(f); } catch (_) {}
try { openDb(); } catch (_) {}
try { openFavDb(); } catch (_) {}
startPolling(); // 迁移失败、留在旧目录:必须恢复剪贴板监听
return { ok: false, message: '迁移失败,已保留原数据:' + e.message };
}
// 先写 config + 重开新库(指向新位置),再删旧文件 —— 避免崩溃窗口遗留孤儿数据。
// 失败分支语义:新文件已复制成功但配置未落盘 → 不删旧文件(两边都在,下次启动
// 仍能从旧目录恢复),内存里切到新目录让会话内可用,重启后由 config 决定位置。
appConfig.dataDir = newDir;
sessionDataDir = null; // 用户已正式选定新目录session 兜底退出
try { configMod.saveConfig(appConfig); }
catch (e) {
try { openDb(); } catch (_) {}
try { openFavDb(); } catch (_) {}
startPolling(); // 应用继续运行(已切到新库):恢复监听
return { ok: false, message: '数据已迁移但配置未保存:' + e.message };
}
try { for (const f of dbFileSet(oldDir)) if (fs.existsSync(f)) fs.unlinkSync(f); } catch (_) {}
app.relaunch();
app.quit();
return { ok: true };
});
}
// ---------- 单实例 + 启动 ----------
const gotLock = app.requestSingleInstanceLock();
if (!gotLock) {
app.quit();
} else {
// 老版本把数据放在 %USERPROFILE%\.clipboard-app现在默认固定在
// D:\Clipboard Data。第一次跑到新目录时把老数据搬过来复制老文件保留
// 放在单实例锁之后:否则被忽略的第二个实例会去复制正在被写的 WAL。
try {
const mig = configMod.migrateLegacyData();
if (mig.migrated) {
devLog(`[main] 数据已从 ${mig.from} 迁移到 ${mig.to}${mig.files.join(', ') || '(仅配置)'}`);
appConfig = configMod.loadConfig();
MAX_ITEMS = appConfig.maxItems;
POLL_MS = appConfig.pollMs;
}
} catch (e) {
console.error('[main] 老数据迁移失败(继续用新目录启动):', e.message);
}
app.on('second-instance', () => toggleWindow());
app.whenReady().then(() => {
try {
devLog('[main] whenReady, opening db...');
// 用户在设置里改过 dataDir、之后那块盘符断电 / 被拔 / 改权限了,
// openDb 的 mkdirSync 会抛 ENOENT —— 之前会一路冒到外层 catch
// 弹「启动失败」对话框。这里主动兜一次:本会话切到 defaultDataDir()
// (它本身已经做了 D 盘 → home 的二级降级),配置页也能打开。
//
// 注意:这里只动 sessionDataDir不改 appConfig.dataDir也不写盘 —
// 用户原本选的目录可能只是临时坏掉(外接硬盘拔了、临时网盘断连),
// 盘符恢复后下次启动会回到原配置;现在覆盖掉的话用户得手动重选。
if (!configMod.isDirUsable(appConfig.dataDir)) {
const fallback = configMod.defaultDataDir();
console.warn(`[main] dataDir ${appConfig.dataDir} 不可用,本会话切到 ${fallback}`);
sessionDataDir = fallback;
}
openDb();
try {
openFavDb();
devLog('[main] favorites db opened');
} catch (e) {
// favorites DB 损坏/不可用 —— 不让整个应用挂掉;收藏功能降级为不可用
console.error('[main] openFavDb failed (favorites disabled):', e.message, e.stack);
favDb = null;
}
devLog('[main] db opened');
registerIpc();
devLog('[main] ipc registered');
// 启动时把模块级 alwaysOnTop 同步到磁盘配置 —— 之前硬编码 true
// 用户在主窗口点了图钉关闭置顶之后重启,配置里若有 ever-set 的
// false 状态也会被无视。应用层只暴露主窗口图钉一个入口,
// 所以这个字段大多停留在默认值;只是把"读 config、推模块变量"
// 的链路补上,避免和磁盘分叉。
// 必须在 createMainWindow 之前调用:模块级 alwaysOnTop 默认 true
// 窗口构造时会读它,晚了就要先建后改(多一次 setAlwaysOnTop 调用)。
applyAlwaysOnTop(appConfig.alwaysOnTop);
createMainWindow(); // 内部已经挂好自适应轮询的 show/hide 监听
devLog('[main] window created');
// 启动时窗口默认隐藏 —— 靠托盘图标唤出。createFramelessWindow 已经
// 用 show:false 创建,这里不再触发 ready-to-show 弹窗,否则每次开机
// 都会抢焦点,和"剪贴板工具"的预期行为不一致。
//
// 不在此时广播主题渲染窗口还没起来、没人订阅setImmediate 这条只
// 是对着空 Set 发了一次消息;初始状态由 theme:get 主动拉。
buildTray();
devLog('[main] tray built');
// 全局快捷键必须在 whenReady 之后注册。被占用时不致命:
// 托盘唤出仍然可用只是少了键盘入口applyHotkey 内部有日志)。
applyHotkey();
try {
app.setLoginItemSettings({ openAtLogin: appConfig.autoLaunch });
} catch (e) { console.error('[main] setLoginItem failed:', e.message); }
// 先采样当前剪贴板作为"上次状态",再开 poll。否则第一个 tick
// 就会把当前剪贴板里的内容当成"新内容"再入一次库。
initializeClipboardState();
startPolling();
devLog(`[main] ready, polling every ${POLL_MS}ms; db = ${getDbPath()}`);
} catch (e) {
console.error('[main] startup FAILED:', e.message, e.stack);
// ENOENT/EACCES/EPERM数据目录出问题最常见的三种 —— 引导用户去设置里改。
const code = e && e.code;
const hint = (code === 'ENOENT' || code === 'EACCES' || code === 'EPERM')
? '\n\n可能是数据目录不可用盘符缺失、权限不足等。\n请到「设置 → 通用 → 数据保存位置」换一个位置,或检查该盘符/文件夹是否可访问。'
: '';
dialog.showErrorBox(APP_NAME, '启动失败:' + e.message + hint);
}
});
app.on('window-all-closed', () => {
// 不退出,靠托盘
});
app.on('will-quit', () => {
stopPolling();
// 退出时释放全局快捷键。进程正常退出时系统本来也会回收,但崩溃窗口
// 里残留注册会影响下次启动抢占同名组合键 —— 显式清掉最干净。
try { globalShortcut.unregisterAll(); } catch (_) {}
if (db) db.close();
if (favDb) favorites.close(favDb);
});
}