/** * Electron 主进程 * * 职责: * - 应用生命周期 + 单实例锁 * - 系统托盘(含右键菜单,单击打开历史窗口) * - 主窗口(无边框、置顶、Frameless) * - 轮询剪贴板(默认 1000ms,可在设置里改)→ 去重 → 写 SQLite * - IPC:list / 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 —— 单条最大 2MB,200 条能到几百 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 作 tiebreaker:created_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, // 背景色跟随主题:之前写死 #FFFFFF,5/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.openExternal(scheme 白名单在主进程),这里是纵深防御 —— 万一 // 将来有链接漏过了点击拦截(中键点击 / 拖拽 / 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 // ( 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.js,100~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 同步返回 boolean:false = 组合键已被占用,回调永远不会触发 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,给渲染进程当 `` 用。 * * 与 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 显示 16px,2x HiDPI 下仍锐利。 // .ico 内置最大 256×256,resize 是高质量降采样。 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,渲染进程直接喂给 。 // 命中缓存后基本零成本(一次 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:失败原因是 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 返回 Promise:OS 拒绝时 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); }); }