'use strict'; const fs = require('node:fs'); const os = require('node:os'); const path = require('node:path'); const { isSameDir } = require('./lib/same-dir'); const APP_DIRNAME = '.clipboard-app'; const DEFAULT_DATA_DIR = 'D:\\Clipboard Data'; // 默认目录不可用(D 盘不存在 / 只读)时降级到的目录。 // 走 home 而不是 APPDATA:APPDATA 是 Roaming,重装系统 / 换账号会丢; // home 下的文件夹随账号走,更符合「剪贴板历史跟着用户走」的预期。 const FALLBACK_DATA_DIRNAME = 'Clipboard Data'; // v1.0 之前的数据目录:%USERPROFILE%\.clipboard-app // 现在只作为"一次性迁移"的来源,不再是默认值。 function legacyDataDir() { return path.join(os.homedir(), APP_DIRNAME); } /** * 探测某个目录能不能真正写入。 * * `mkdirSync(p, { recursive: true })` 只能创建中间文件夹,盘符本身不存在 * (如没 D 盘的机器)会直接抛 ENOENT —— 这正是启动失败的原因。盘符存在 * 但只读(CD-ROM、网络盘、权限不足)时 mkdir 反而会过,必须再写一个探针 * 文件才能拦住。任一步抛错都判为不可用。 * * 探针文件在 mkdir 成功后立即 unlink —— 不在用户目录下留垃圾。 * 抛错时已经半创建的祖先目录无伤大雅,留着下次还能复用。 * * @param {string} dir * @returns {boolean} */ function isDirUsable(dir) { if (!dir) return false; try { fs.mkdirSync(dir, { recursive: true }); const probe = path.join(dir, '.clipboard-write-test'); fs.writeFileSync(probe, ''); fs.unlinkSync(probe); return true; } catch (_) { return false; } } // 默认数据目录固定在 D:\Clipboard Data。 // 选固定路径而不是「项目根/data」是为了: // 1. 装/卸/重装项目时数据不丢; // 2. 项目目录可以放进云同步 / 临时盘而不被数据库占空间; // 3. 重装系统前备份一个文件夹就够了。 // D 盘不一定每台机器都有(笔记本、无光驱、D: 是不可写设备)—— // 不可用时降级到 home 下的 Clipboard Data,保证首次启动一定成功。 // 想用别的位置:设置 → 「更换目录」,或在启动前设环境变量 // CLIPBOARD_APP_DATA_DIR(测试也用它隔离)。 let _defaultDataDirCache = null; function defaultDataDir() { if (_defaultDataDirCache !== null) return _defaultDataDirCache; const env = process.env.CLIPBOARD_APP_DATA_DIR; if (typeof env === 'string' && env.trim()) { _defaultDataDirCache = path.resolve(env.trim()); return _defaultDataDirCache; } _defaultDataDirCache = isDirUsable(DEFAULT_DATA_DIR) ? DEFAULT_DATA_DIR : path.join(os.homedir(), FALLBACK_DATA_DIRNAME); return _defaultDataDirCache; } const DEFAULTS = { schemaVersion: 3, autoLaunch: false, maxItems: 0, // 0 = 不限制(数据库最大保留条数) displayLimit: 300, // 界面(历史 / 收藏)一次渲染的条数上限 pollMs: 1000, dataDir: defaultDataDir(), theme: 'linear', accent: 'purple', followSystem: false, alwaysOnTop: true, // 窗口置顶开关,默认开启 hotkey: 'Ctrl+Alt+V', // 全局唤起面板快捷键;'' = 禁用 }; const ALLOWED_MAX = [0, 200, 500, 1000]; // 0 = 不限制 // 界面显示条数:只作用于渲染层,不裁数据库。不给「不限制」档, // 避免一次建几千个 DOM 节点导致首屏卡顿。 const ALLOWED_DISPLAY = [100, 300, 500, 1000]; const ALLOWED_POLL = [500, 1000, 2000]; // 默认 1000,500/1000/2000 三档 const ALLOWED_THEMES = ['linear', 'midnight', 'carbon', 'solarized', 'github', 'light']; const ALLOWED_ACCENTS = ['purple', 'blue', 'green', 'orange', 'red']; // 全局唤起快捷键预设。'' = 禁用;其余必须是 Electron globalShortcut // 能解析的 accelerator。给白名单而不是自由输入:非法组合键 register // 会抛异常,被别的程序占用的永远注册不上 —— 不如只给确定能用的。 // 默认 Ctrl+Alt+V 而不是 Ctrl+Shift+V —— 后者是浏览器 / VS Code 的 // 「粘贴为纯文本」,全局劫持掉会惹骂。 const ALLOWED_HOTKEYS = ['', 'Ctrl+Alt+V', 'Ctrl+Shift+V', 'Ctrl+Alt+C', 'Alt+Shift+V']; // config.json 固定在默认目录,不随 dataDir 迁移 function getConfigPath() { return path.join(defaultDataDir(), 'config.json'); } function sanitize(raw) { const r = raw && typeof raw === 'object' ? raw : {}; return { autoLaunch: r.autoLaunch === true, maxItems: ALLOWED_MAX.includes(r.maxItems) ? r.maxItems : DEFAULTS.maxItems, displayLimit: ALLOWED_DISPLAY.includes(r.displayLimit) ? r.displayLimit : DEFAULTS.displayLimit, pollMs: ALLOWED_POLL.includes(r.pollMs) ? r.pollMs : DEFAULTS.pollMs, dataDir: (typeof r.dataDir === 'string' && r.dataDir.trim()) ? r.dataDir : defaultDataDir(), theme: ALLOWED_THEMES.includes(r.theme) ? r.theme : DEFAULTS.theme, accent: ALLOWED_ACCENTS.includes(r.accent) ? r.accent : DEFAULTS.accent, followSystem: r.followSystem === true, // 缺省视为开启 —— 老用户首次升上来也是"置顶" alwaysOnTop: r.alwaysOnTop === undefined ? DEFAULTS.alwaysOnTop : r.alwaysOnTop === true, // 老配置没有 hotkey 字段 → 回落默认(等于老用户升上来自动获得快捷键) hotkey: ALLOWED_HOTKEYS.includes(r.hotkey) ? r.hotkey : DEFAULTS.hotkey, schemaVersion: 3, // always forward-fill to current }; } function loadConfig() { try { const txt = fs.readFileSync(getConfigPath(), 'utf8'); return sanitize(JSON.parse(txt)); } catch (e) { return sanitize({}); } } function saveConfig(cfg) { const clean = sanitize(cfg); const p = getConfigPath(); fs.mkdirSync(path.dirname(p), { recursive: true }); const tmp = p + '.tmp'; fs.writeFileSync(tmp, JSON.stringify(clean, null, 2), 'utf8'); fs.renameSync(tmp, p); } // 迁移时要搬的数据文件。 // -shm 不搬:它是 WAL 的共享内存索引,SQLite 打开时会自己重建, // 搬一个不匹配的过去反而更糟。db + -wal 一起搬,SQLite 能自行恢复。 const DATA_FILES = ['history.db', 'history.db-wal', 'favorites.db', 'favorites.db-wal']; /** * 一次性迁移:把老目录(%USERPROFILE%\.clipboard-app,或老 config.json 里 * 指向的自定义目录)的数据复制到新的默认目录(D:\Clipboard Data)。 * * 只在新目录还是空的时候做,所以是幂等的;老文件保留不删,出问题可以回退。 * 复制而不是移动 —— 万一新位置有权限问题,老数据还在。 * * 必须在拿到单实例锁之后调用:否则第二个实例会去复制正在被写的 WAL。 * * @returns {{migrated: boolean, reason?: string, from?: string, to?: string, files?: string[]}} */ function migrateLegacyData() { const target = defaultDataDir(); const legacy = legacyDataDir(); // Windows 大小写不敏感:同目录不同写法必须判为相同,否则会自己迁自己 if (isSameDir(target, legacy)) return { migrated: false, reason: 'same-dir' }; // 新目录已经在用了 → 不碰(只迁一次) if (fs.existsSync(path.join(target, 'config.json')) || fs.existsSync(path.join(target, 'history.db'))) { return { migrated: false, reason: 'target-in-use' }; } let rawCfg = null; try { rawCfg = JSON.parse(fs.readFileSync(path.join(legacy, 'config.json'), 'utf8')); } catch (_) { rawCfg = null; } const legacyCfg = rawCfg && typeof rawCfg === 'object' && !Array.isArray(rawCfg) ? sanitize(rawCfg) : null; // 老数据实际在哪:老 config 指到哪就从哪搬(用户可能手动换过目录), // 没有 config 就按老默认目录找。dataDir 必须取**原始**值:sanitize 会把 // 缺省字段补成新默认目录(= target),拿它当 src 就成了"从 target 搬到 // target"—— 什么也没搬却报 migrated: true,老数据被永久跳过。 let src = legacy; if (legacyCfg && typeof rawCfg.dataDir === 'string' && rawCfg.dataDir.trim()) { src = path.resolve(rawCfg.dataDir.trim()); } const hasDb = DATA_FILES.some((n) => fs.existsSync(path.join(src, n))); if (!hasDb && !legacyCfg) return { migrated: false, reason: 'nothing-to-migrate' }; fs.mkdirSync(target, { recursive: true }); const files = []; for (const name of DATA_FILES) { const from = path.join(src, name); if (!fs.existsSync(from)) continue; try { fs.copyFileSync(from, path.join(target, name), fs.constants.COPYFILE_EXCL); files.push(name); } catch (_) { /* 单个文件搬不动不该拦住启动 */ } } // 设置也跟着搬,但 dataDir 必须落到新目录 —— 否则原样写回去, // 下次启动又跑回老地方,等于没迁。 saveConfig({ ...(legacyCfg || {}), dataDir: target }); return { migrated: true, from: src, to: target, files }; } /** * 在「跟随系统」开启时,主题按系统明暗二选一;关闭时直接用用户选的 theme。 * * 这个判断在主进程(broadcastTheme / theme:get / applyThemeChange)和潜在的 * 测试里都会用到,集中到这里避免重复实现又跑偏。注意:raw 字段 cfg.theme * 仍然要保留 —— 用户关掉「跟随系统」后要回到他之前选的那个主题。 * * 旧版把这段逻辑直接内联在 main.js 的 broadcastTheme / IPC 处理器里, * 跟「跟随系统」开关的 IPC 顺序竞争:applyThemeChange 在 broadcast 之后 * 把 raw theme 写回 res.theme,settings.js 拿到后用 res.theme 设 * data-theme —— 覆盖了刚刚广播的 effective 主题,开关「跟随系统」后 * 设置窗口主题卡的高亮和 DOM 不一致。修复方式见 main.js / settings.js。 * * @param {{theme: string, followSystem: boolean}} cfg * @param {boolean} sysDark 当前系统是否处于深色模式 * @returns {string} */ function effectiveTheme(cfg, sysDark) { if (!cfg || !cfg.followSystem) return cfg && cfg.theme ? cfg.theme : DEFAULTS.theme; return sysDark ? 'linear' : 'light'; } /** * 把每个 theme 映射到对应的窗口首帧背景色。 * 用法:createFramelessWindow({ backgroundColor: themeBgColor(appConfig.theme) }) * backgroundColor 只影响首帧;切主题后整个渲染进程重绘,无需重新设置。 * 未知 theme 兜底到 light 背景(与 tokens.css 的 :root --bg-base 一致)。 */ function themeBgColor(theme) { const map = { linear: '#0E0E12', midnight: '#08090B', carbon: '#1A1D23', solarized: '#002B36', github: '#0D1117', light: '#FAFAFA', }; return map[theme] || '#FAFAFA'; } module.exports = { DEFAULTS, ALLOWED_MAX, ALLOWED_DISPLAY, ALLOWED_POLL, ALLOWED_THEMES, ALLOWED_ACCENTS, ALLOWED_HOTKEYS, isDirUsable, defaultDataDir, legacyDataDir, getConfigPath, sanitize, loadConfig, saveConfig, migrateLegacyData, themeBgColor, effectiveTheme, };