diff --git a/.gitignore b/.gitignore index 04c01ba..db32ad2 100644 --- a/.gitignore +++ b/.gitignore @@ -1,2 +1,10 @@ node_modules/ -dist/ \ No newline at end of file +dist/ +data/ +*.log +.superpowers/ +docs/superpowers/ +.playwright-mcp/ +.remember/ +final-hover-card.png +*.pyc diff --git a/README.md b/README.md index 277d86d..7e9945a 100644 --- a/README.md +++ b/README.md @@ -1,4 +1,4 @@ -# 剪贴板 +# Clipboard 基于 Electron 的轻量剪贴板管理器,灵感来自 [Ditto](https://github.com/sabrogden/Ditto)。 diff --git a/favorites.js b/favorites.js index 7df5d58..92423a5 100644 --- a/favorites.js +++ b/favorites.js @@ -102,7 +102,9 @@ function get(db, favId) { function getImage(db, favId) { const row = stmts(db).getImage.get(favId); if (!row || !row.image) return null; - return Buffer.from(row.image).toString('base64'); + // row.image 原生就是 Buffer(better-sqlite3 BLOB 列)—— 与 main.js + // clips:get-image 同一优化:Buffer.from 是一次无谓的 2MB 拷贝。 + return row.image.toString('base64'); } // 原始行(含 image BLOB),仅供主进程"复制收藏到系统剪贴板"用。 diff --git a/keyhelper.js b/keyhelper.js index 2e8c679..603dbe8 100644 --- a/keyhelper.js +++ b/keyhelper.js @@ -70,6 +70,15 @@ class KeyHelper { this.ps.stderr.on('data', (chunk) => { console.error('[keyhelper stderr]', chunk.toString()); }); + // spawn 失败(powershell.exe 缺失 / 被策略禁用)走 'error' 事件而不是 + // 抛异常 —— 不挂监听的话 Node 把它升级成 uncaughtException 打挂主进程。 + // 'error' 之后 'exit' 可能不再触发,重连也要在这里做; + // _reconnect 有 _reconnectTimer 去重,两个事件都到也只重连一次。 + this.ps.on('error', (err) => { + console.error('[keyhelper] powershell spawn error:', err.message); + this._rejectAll(err); + this._reconnect(); + }); this.ps.on('exit', (code) => { console.error('[keyhelper] powershell exited with code', code); this._rejectAll(new Error('keyhelper exited')); diff --git a/lib/image-cap.js b/lib/image-cap.js index 8e9bc53..af9974e 100644 --- a/lib/image-cap.js +++ b/lib/image-cap.js @@ -21,7 +21,7 @@ function capImage(buf) { /** * 把图片 buffer 按 SOFT_CAP_BYTES 收纳:超出的图压成 RESIZE_WIDTH 宽。 - * 三处共用:pollClipboard / bringFavoriteIntoHistory。 + * 两处共用:pollClipboard / bringFavoriteIntoHistory(见 main.js)。 * * 返回 { buf, preview },preview 由 capImage 给出(无图/小图 = null), * 给数据库的 preview 列用。 diff --git a/main.js b/main.js index 31fdbe5..3582a02 100644 --- a/main.js +++ b/main.js @@ -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(); } }); diff --git a/package.json b/package.json index 1b1dd22..6cd165a 100644 --- a/package.json +++ b/package.json @@ -1,7 +1,7 @@ { "name": "clipboard-app", "version": "1.0.0", - "description": "剪贴板 — 简洁的剪贴板管理器(Electron 实现)", + "description": "Clipboard — 简洁的剪贴板管理器(Electron 实现)", "main": "main.js", "scripts": { "start": "set \"ELECTRON_RUN_AS_NODE=\" && electron .", @@ -23,7 +23,7 @@ }, "build": { "appId": "com.clipboard.app", - "productName": "剪贴板", + "productName": "Clipboard", "files": [ "main.js", "config.js", diff --git a/renderer/index.html b/renderer/index.html index a674029..f1a694e 100644 --- a/renderer/index.html +++ b/renderer/index.html @@ -3,9 +3,13 @@ - 剪贴板历史 - + Clipboard + + @@ -16,7 +20,7 @@
-

剪贴板历史

+

Clipboard