247 lines
11 KiB
JavaScript
247 lines
11 KiB
JavaScript
'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,
|
||
}; |