1454 lines
61 KiB
JavaScript
1454 lines
61 KiB
JavaScript
/**
|
||
* 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
|
||
// (<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.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,给渲染进程当 `<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 显示 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,渲染进程直接喂给 <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 返回 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);
|
||
});
|
||
} |