This commit is contained in:
2026-09-20 23:35:03 +08:00
parent 941ce4baab
commit 388c700996
19 changed files with 393 additions and 71 deletions

63
main.js
View File

@@ -41,7 +41,7 @@ const { createEchoSuppress } = require('./lib/echo-suppress');
// ---------- 配置 ----------
const APP_NAME = '剪贴板';
const APP_NAME = 'Clipboard';
let appConfig = configMod.loadConfig();
let MAX_ITEMS = appConfig.maxItems; // 运行时可变
@@ -102,7 +102,10 @@ const listCols = (p = '') =>
const orderBy = (p = '') => `${p}created_at DESC, ${p}id DESC`;
function openDb() {
fs.mkdirSync(appConfig.dataDir, { recursive: true });
// 必须 mkdir effectiveDataDir()(含 session 兜底),不是 appConfig.dataDir:
// 用户配置的目录不可用(盘符缺失)时启动已切到 sessionDataDir,这里再去
// mkdir 那个坏目录会直接抛 ENOENT —— 兜底逻辑被这一行废掉,照样弹"启动失败"。
fs.mkdirSync(effectiveDataDir(), { recursive: true });
ftsAvailable = null; // 换库后能力可能不同,重新探测
clipCount = null; // 行数缓存绑定旧库,换库后必须回源重查
db = new Database(getDbPath());
@@ -144,7 +147,6 @@ function openDb() {
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 一遍,热路径上白白编译
@@ -167,7 +169,7 @@ function openDb() {
}
function openFavDb() {
fs.mkdirSync(appConfig.dataDir, { recursive: true });
fs.mkdirSync(effectiveDataDir(), { recursive: true }); // 同 openDb:兜底目录
favStmtCache.clear(); // 语句绑定在旧连接上,换库后必须丢掉
favDb = favorites.open(getFavDbPath());
}
@@ -287,15 +289,12 @@ function pollClipboard() {
}
// 文本变化检测(规则见 lib/echo-suppress.js)。
// readText 抛错时(text === null)**不调 observe** —— 否则 last 会被改成
// '' 触发 'empty' 直接退出,但剪贴板上可能有图片;图片分支被一并跳过,
// 用户刚复制的那张图就静默丢失。等下次 tick readText 恢复时再正常观察。
// readText 抛错时(text === null)**不调 observe** —— 一次读取失败不等于
// 剪贴板被清空:把基线 last 改成 '' 后,下个 tick 读回原来的文本会判
// 'new' 把旧内容重复入一次库。跳过本次观察,等 readText 恢复再正常分发;
// 图片分支不受影响,照常探测。
if (text !== null) {
const verdict = textSuppress.observe(text);
if (verdict === 'empty') {
// 剪贴板被清空或原本就空,跳过(不消化回声标记)
return;
}
if (verdict === 'suppressed') {
devLog('[poll] suppressed (was our own paste)');
return;
@@ -318,6 +317,12 @@ function pollClipboard() {
}
return;
}
// verdict === 'empty':剪贴板上没有文本 —— 可能被清空,也可能上面是
// 一张图(readText 读不到图片)。之前这里直接 return:从「复制文本」
// 切到「复制图片」时,图片要晚一个 tick 才入库(等下一次 observe('')
// 判 unchanged 才轮到图片分支)。落空继续走图片分支即可 —— 真为空时
// shouldReadImage 守卫零开销拦掉。「empty 不消化回声标记」的规则不变
// (由 lib/echo-suppress.js 内部保证),这里只是不再提前退出。
// verdict === 'unchanged' —— 文本没变,继续看图片分支
} else {
devLog('[poll] readText skipped (last read failed), still probing image branch');
@@ -455,7 +460,7 @@ function applyPollMs(ms) {
startPolling(); // startPolling 内部会先 stopPolling
}
// 窗口置顶:不落盘,重启回到默认(开启)——与本功能开发前的行为一致
// 窗口置顶:落盘持久化,重启后保持用户的选择(与其他设置项一致)。
let alwaysOnTop = true;
function applyAlwaysOnTop(on) {
@@ -464,6 +469,14 @@ function applyAlwaysOnTop(on) {
try { mainWindow.setAlwaysOnTop(alwaysOnTop); }
catch (e) { devWarn('[window] setAlwaysOnTop failed:', e.message); }
}
// 值真的变了才写盘:启动时的同步调用(applyAlwaysOnTop(appConfig.alwaysOnTop))
// 值与磁盘一致,靠这个判断避免每次启动都白写一次 config.json。
// 写盘失败只打日志 —— 置顶本会话已生效,重启还原成旧值,比设置项失败轻。
if (appConfig.alwaysOnTop !== alwaysOnTop) {
appConfig.alwaysOnTop = alwaysOnTop;
try { configMod.saveConfig(appConfig); }
catch (e) { console.error('[window] save alwaysOnTop failed:', e.message); }
}
return alwaysOnTop;
}
@@ -485,7 +498,10 @@ function createFramelessWindow({ width, height, file, onClose, minimizable = fal
// 窗口又变回置顶,而按钮状态仍显示"已关闭"。
alwaysOnTop,
// 背景色跟随主题:之前写死 #FFFFFF,5/6 的主题首帧会闪白。
backgroundColor: configMod.themeBgColor(appConfig.theme),
// 必须用 effectiveTheme 而不是 raw theme —— 「跟随系统」开着时 raw 主题
// 和实际显示的不一致(如 raw 是暗色但系统是浅色),首帧会闪一下错色。
backgroundColor: configMod.themeBgColor(
configMod.effectiveTheme(appConfig, nativeTheme.shouldUseDarkColors)),
// OS 级窗口图标:alt-tab、任务管理器进程图标、Win+Tab 任务视图。
// 工厂被主窗口和设置窗口共用,一行设置两窗生效。
icon: path.join(__dirname, 'icon.ico'),
@@ -1116,7 +1132,13 @@ function registerIpc() {
return applyThemeChange({ followSystem: followSystem === true });
});
ipcMain.handle('settings:get', () => ({ ...appConfig }));
ipcMain.handle('settings:get', () => ({
...appConfig,
// 实际在用的数据目录(含 session 兜底)。settings:open-dir 开的就是它;
// 设置页只显示 appConfig.dataDir 的话,兜底状态下用户看到的目录和
// 「打开数据目录」进去的目录对不上。
dataDirEffective: effectiveDataDir(),
}));
ipcMain.handle('settings:set', (_e, { key, value }) => {
appConfig = applyPatch(appConfig, { key, value });
@@ -1393,13 +1415,9 @@ if (!gotLock) {
registerIpc();
devLog('[main] ipc registered');
// 启动时把模块级 alwaysOnTop 同步到磁盘配置 —— 之前硬编码 true,
// 用户在主窗口点了图钉关闭置顶之后重启,配置里若有 ever-set 的
// false 状态也会被无视。应用层只暴露主窗口图钉一个入口,
// 所以这个字段大多停留在默认值;只是把"读 config、推模块变量"
// 的链路补上,避免和磁盘分叉。
// 必须在 createMainWindow 之前调用:模块级 alwaysOnTop 默认 true,
// 窗口构造时会读它,晚了就要先建后改(多一次 setAlwaysOnTop 调用)。
// 启动时从磁盘配置恢复置顶开关(applyAlwaysOnTop 在切换时已落盘,
// 见该函数注释)。必须在 createMainWindow 之前调用:窗口构造时会
// 读模块级 alwaysOnTop,晚了就要先建后改(多一次 setAlwaysOnTop)。
applyAlwaysOnTop(appConfig.alwaysOnTop);
createMainWindow(); // 内部已经挂好自适应轮询的 show/hide 监听
@@ -1436,6 +1454,9 @@ if (!gotLock) {
? '\n\n可能是数据目录不可用(盘符缺失、权限不足等)。\n请到「设置 → 通用 → 数据保存位置」换一个位置,或检查该盘符/文件夹是否可访问。'
: '';
dialog.showErrorBox(APP_NAME, '启动失败:' + e.message + hint);
// 启动失败时窗口 / 托盘 / 轮询全没起来,window-all-closed 又拦着不退
// —— 不主动退出的话进程变成无界面僵尸,只能去任务管理器里杀。
app.quit();
}
});