This commit is contained in:
2026-09-12 13:59:12 +08:00
commit 941ce4baab
71 changed files with 13037 additions and 0 deletions

247
config.js Normal file
View File

@@ -0,0 +1,247 @@
'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 而不是 APPDATAAPPDATA 是 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]; // 默认 1000500/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.themesettings.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,
};