'use strict' const fs = require('fs') const path = require('path') const { APP_DIR_NAME, xdgDataHome } = require('./platform') // 这三样以前随安装包落在安装目录里,但都属于「用户改出来的东西」,必须跟数据目录一样 // 挪到安装目录之外:卸载器结尾是 `RMDir /r $INSTDIR`,覆盖安装又会先静默跑一遍旧卸载器, // 放在安装目录里等于用户改一次丢一次。 // console\config.json → <用户根>\console\config.json // config.yaml → <用户根>\config.yaml // rules\ → <用户根>\rules\ // 用户根与数据目录同一基座,只是基座本身按平台不同: // Windows %LOCALAPPDATA%\ScoreboardConsole\ // Linux $XDG_DATA_HOME/ScoreboardConsole/(缺省 ~/.local/share/ScoreboardConsole/) const CONSOLE_SUBDIR = 'console' const CONSOLE_CONFIG_FILE = 'config.json' const SERVER_CONFIG_FILE = 'config.yaml' const RULES_SUBDIR = 'rules' const ITEM_NAMES = ['consoleConfig', 'serverConfig', 'rulesDir'] const ITEM_LABEL = { consoleConfig: 'Console 配置文件', serverConfig: '服务端配置文件', rulesDir: '规则目录' } // 拿不到用户数据基座时的提示:两边缺的不是同一个变量,文案各说各的, // 免得现场照着去设一个本平台不存在的环境变量 const NO_ROOT_WARNING = { 'no-localappdata': '警告:拿不到 %LOCALAPPDATA%,config.json / config.yaml / rules 继续用安装目录里的那份,' + '覆盖安装会连同它们一起丢失', 'no-user-root': '警告:拿不到 $XDG_DATA_HOME,也没有可用的 $HOME,config.json / config.yaml / rules ' + '继续用安装目录里的那份,覆盖安装会连同它们一起丢失' } // 用户根目录;拿不到用户数据基座时返回 null,由调用方回落到安装目录。 // platform 缺省按 Windows 解析(改动前的口径),生产调用点传 process.platform。 function resolveUserRoot(env, platform = 'win32') { if (platform === 'win32') { const localAppData = env ? env.LOCALAPPDATA : '' const value = typeof localAppData === 'string' ? localAppData.trim() : '' return value ? path.join(value, APP_DIR_NAME) : null } const xdg = xdgDataHome(env) return xdg ? path.join(xdg, APP_DIR_NAME) : null } // 解析落点,纯函数不碰磁盘。mode='user' 才接管到用户目录; // 其余情况(开发态、拿不到用户数据基座)三项一律用安装目录里随包的那份, // 行为与以前完全一致 —— 开发态不能动仓库里的文件。 function planUserPaths({ installDir, userRoot, isPackaged, platform = 'win32' }) { const install = path.resolve(installDir || '.') const templates = { consoleConfig: path.join(install, CONSOLE_CONFIG_FILE), serverConfig: path.join(install, SERVER_CONFIG_FILE), rulesDir: path.join(install, RULES_SUBDIR) } const items = {} const targets = { consoleConfig: path.join(install, CONSOLE_CONFIG_FILE), serverConfig: path.join(install, SERVER_CONFIG_FILE), rulesDir: path.join(install, RULES_SUBDIR) } // 缺的基座两边不同名:Windows 是 %LOCALAPPDATA%,Linux 是 $XDG_DATA_HOME / $HOME。 // 文案按这个 reason 分(见 NO_ROOT_WARNING),reason 本身也留着给调用方判断 const reason = !isPackaged ? 'dev' : userRoot ? null : platform === 'win32' ? 'no-localappdata' : 'no-user-root' if (!reason) { targets.consoleConfig = path.join(userRoot, CONSOLE_SUBDIR, CONSOLE_CONFIG_FILE) targets.serverConfig = path.join(userRoot, SERVER_CONFIG_FILE) targets.rulesDir = path.join(userRoot, RULES_SUBDIR) } for (const name of ITEM_NAMES) { items[name] = { target: path.resolve(targets[name]), template: path.resolve(templates[name]) } } return { mode: reason ? 'install' : 'user', reason, root: userRoot ? path.resolve(userRoot) : null, installDir: install, items } } // 目标目录必须真的能写:mkdir 只保证目录建出来,权限不足或路径被占要在这一步就暴露, // 好让调用方回落到安装目录,而不是等到写配置文件时才失败 function ensureDir(dir) { fs.mkdirSync(dir, { recursive: true }) fs.accessSync(dir, fs.constants.W_OK) } function tryRemove(dir) { try { fs.rmSync(dir, { recursive: true, force: true }) } catch { // 清不掉就留着:只是本次拷贝留下的临时目录,下次会原地重建 } } // 规则目录先拷到临时目录再整体改名到位:中途失败不会留下半个 rules\, // 否则用户目录里一旦存在同名目录,下次启动就按「已存在」跳过、再也不补拷 function copyDirOnce(template, target) { const tmp = `${target}.tmp-${process.pid}` try { tryRemove(tmp) fs.cpSync(template, tmp, { recursive: true }) fs.renameSync(tmp, target) } catch (err) { tryRemove(tmp) throw err } } // 按 plan 落盘,返回最终生效的路径。判定只有两条: // 目标不存在 + 安装目录里有 → 复制过去(安装目录那份留作模板,反正卸载器会删); // 目标已存在 → 一律用用户目录那份,绝不覆盖用户改过的东西。 // 任一目录建不出来 / 拷不动都只记日志,该项继续用安装目录里的那份,绝不删用户文件、 // 也绝不因此启动失败。 function prepareUserPaths({ plan, log }) { const notify = typeof log === 'function' ? log : () => {} const result = { mode: plan.mode, reason: plan.reason, root: plan.mode === 'user' ? plan.root : null, consoleConfig: plan.items.consoleConfig.template, rulesDir: plan.items.rulesDir.template, // null = 不接管:拉起服务端与 passwd 时沿用 config.json 里 -config 的原值 serverConfig: null } if (plan.mode !== 'user') { // 两个 reason 对应两个平台缺的那个变量,文案不同但处理完全一致 const warning = NO_ROOT_WARNING[plan.reason] if (warning) notify(warning) return result } for (const name of ITEM_NAMES) { const { target, template } = plan.items[name] const label = ITEM_LABEL[name] if (path.resolve(target) === path.resolve(template)) continue try { ensureDir(path.dirname(target)) if (!fs.existsSync(target)) { if (!fs.existsSync(template)) { notify(`警告:${label}在用户目录与安装目录里都不存在(${template}),本次跳过复制`) } else { if (name === 'rulesDir') copyDirOnce(template, target) else fs.copyFileSync(template, target) notify(`已把 ${label} 复制到 ${target}(安装目录里那份留作模板)`) } } } catch (err) { notify( `警告:准备 ${label} 失败(${err.message}),继续用安装目录里的 ${template};` + '覆盖安装会丢失对它的改动' ) continue } if (name === 'serverConfig') result.serverConfig = target else result[name] = target } return result } // 服务端读哪份 config.yaml 由 Console 定:config.json 里的 server.args 只声明「有个 -config 参数」, // 它的值在拉起时换成上面解析出的绝对路径(不传 configPath 时原样返回,开发态就走这条路) function withConfigArg(args, configPath) { const list = Array.isArray(args) ? [...args] : [] if (typeof configPath !== 'string' || configPath === '') return list const index = list.indexOf('-config') if (index !== -1 && typeof list[index + 1] === 'string') list[index + 1] = configPath else list.push('-config', configPath) return list } module.exports = { resolveUserRoot, planUserPaths, prepareUserPaths, withConfigArg }