Files
Clipboard/config.js
2026-09-12 13:59:12 +08:00

247 lines
11 KiB
JavaScript
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
'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,
};