This commit is contained in:
2026-09-12 14:15:26 +08:00
commit 9c06d3f4be
99 changed files with 41853 additions and 0 deletions

566
shared/settings-schema.js Normal file
View File

@@ -0,0 +1,566 @@
// 设置 schema —— 单一事实源
// ============================================================================
//
// 这是 main / preload / renderer 三处设置定义的唯一权威来源。
// 之前四处漂移main.js 的 DEFAULT_CONFIG + save-settings 80 行校验链 + renderer
// DEFAULT_SETTINGS + settings-dialog 的选项表)统一收敛到本文件:
//
// • DEFAULT_SETTINGS — 各键的默认值
// • SETTINGS_SCHEMA — 每键的 type / enum / min / max / custom validator
// • validateAndSanitize() — 用 schema 校验+clamp+enumeration
// • SETTINGS_UI_OPTIONS — 对话框用的枚举列表readerFontSize / palette / sort
//
// 加载方式:
// • main.js / preload.jsCJSconst schema = require('../shared/settings-schema.js')
// • renderer经 preload contextBridge 过桥window.api.settingsSchema / coerceLoadedSettings
// —— renderer 跑在 Chromium 原生 ESM不能直接 import CJS无 CJS 互操作),
// .cjs / .js 扩展名都不能解决这个问题,文件 *格式* 决定有没有 named export。
// 所以 renderer 拿到的始终是经 preload 包装过的纯数据 / 函数schema 内部
// 结构不暴露。
//
// 新增/修改设置项流程:
// 1. 在 DEFAULT_SETTINGS 加默认值
// 2. 在 SETTINGS_SCHEMA 加类型/范围/枚举/自定义校验
// 3. 若需要在 UI 显示,在 SETTINGS_UI_OPTIONS 加选项
// 4. 跑 npm run check 验证 — validateAndSanitize 会用 schema 拒绝非法值
// ============================================================================
'use strict';
/**
* 默认值(单一事实源)。所有键都在这里定义,缺一不可。
* @type {Readonly<Record<string, *>>}
*/
const DEFAULT_SETTINGS = Object.freeze({
dataDir: null, // null = 用主进程默认目录home/Notes
theme: 'dark', // 'dark' | 'light'
themePalette: 'default', // 调色板 ID见 SETTINGS_UI_OPTIONS.palettes
alwaysOnTop: false,
editorMode: 'split', // 'preview' | 'edit' | 'split' — 默认双栏
readerFontSize: 17, // 阅读字号 (px),范围 12..24
readerLineHeight: 1.85, // 阅读行距,范围 1.4..2.2
fileListSort: 'name', // 'name' | 'mtime-desc'
autoSaveDebounceMs: 500, // 自动保存防抖延迟ms0 = 关闭;默认 500ms = 「停打后立刻存」
splitRatio: 0.5, // 双栏模式左侧占比 0.2..0.8
sidebarWidth: null, // 侧栏拖拽后的宽度pxnull = 使用 CSS 默认 --w-sidebar
aiWidth: null, // AI 中间面板拖拽后的宽度pxnull = 使用 CSS 默认 --w-ai
focusMode: false, // 聚焦模式:隐藏工具栏/侧栏/状态栏Ctrl+Shift+F 切换
// AI 修改功能(用户自填 API Key / BaseURL / Model / System Prompt
// 详见 main/ai.js。空值时 AI 入口点击会提示去设置。
aiProvider: 'openai', // 'openai' | 'anthropic' —— 决定 main/ai.js 走哪条协议
aiBaseUrl: '', // baseURLOpenAI 含 /v1如 https://api.openai.com/v1Anthropic 不含(如 https://api.anthropic.com
aiApiKey: '', // API Key敏感数据只在主进程内存里使用不写日志
aiModel: '', // 模型名OpenAI 如 gpt-4o-miniAnthropic 如 claude-opus-5
aiSystemPrompt: '', // 自定义系统提示词;空 = 用 main/ai.js 内置中文 prompt
});
/**
* 每个键的校验规则。
* type: 'string' | 'number' | 'boolean' | 'enum' | 'nullable-string' | 'nullable-path'
* enum?: 仅 enum 类型:允许的字面量数组
* min?: number / integer / string(长度)
* max?: number / integer / string(长度)
* clamp?: number: 是否 clamp 到 [min,max]
* integer?:boolean number 是否取整
* round?: number: 保留几位小数
* choices?:Array<{value, label, hint?}> UI 选项(仅供 dialog 渲染,不参与校验)
*
* 自定义校验(如 dataDir 必须存在)放在 validateAndSanitize() 里集中处理,
* 因为它需要 fs 调用,不能纯声明式表达。
*
* @type {Readonly<Record<string, object>>}
*/
const SETTINGS_SCHEMA = Object.freeze({
dataDir: {
type: 'nullable-path',
description: '数据目录null = 用默认目录',
},
theme: {
type: 'enum',
enum: ['dark', 'light'],
description: 'UI 主题',
},
themePalette: {
type: 'enum',
enum: ['default', 'ocean', 'forest', 'lavender', 'sunset'],
description: '调色板',
},
alwaysOnTop: {
type: 'boolean',
description: '窗口置顶',
},
editorMode: {
type: 'enum',
enum: ['preview', 'edit', 'split'],
description: '视图模式',
},
readerFontSize: {
type: 'number',
min: 12,
max: 24,
integer: true,
clamp: true,
description: '阅读字号 (px)',
},
readerLineHeight: {
type: 'number',
min: 1.4,
max: 2.2,
// fix(audit 2026-08)round:1 会把 1.85 静默四舍五入到 1.9。UI 选项列出
// 的是 [1.5, 1.7, 1.85, 2.0] 两位小数round:2 才能保留用户的选择。
round: 2,
description: '阅读行距',
},
fileListSort: {
type: 'enum',
enum: ['name', 'mtime-desc'],
description: '侧栏文件列表排序',
},
autoSaveDebounceMs: {
type: 'number',
min: 0,
max: 60000,
clamp: true,
integer: true,
description: '编辑停止后多少毫秒触发自动保存0 = 关闭',
},
splitRatio: {
type: 'number',
min: 0.2,
max: 0.8,
round: 3,
clamp: true,
description: '双栏模式左侧占比',
},
sidebarWidth: {
type: 'nullable-number',
min: 120,
max: 480,
integer: true,
description: '侧栏宽度 (px)null = 用 CSS 默认',
},
aiWidth: {
type: 'nullable-number',
min: 220,
max: 720,
integer: true,
description: 'AI 中间面板宽度 (px)null = 用 CSS 默认 --w-ai (360)',
},
focusMode: {
type: 'boolean',
description: '聚焦模式(隐藏工具栏/侧栏/状态栏)',
},
aiProvider: {
type: 'enum',
enum: ['openai', 'anthropic'],
description: 'AI 服务提供方openai = /chat/completionsanthropic = /v1/messages',
},
aiBaseUrl: {
type: 'string',
// URL 不会超过几 KB留 4 KB 足够;防止有人塞几 MB 把请求体打爆
max: 4_096,
// audit fix (#9 shared):格式校验。空串放行(用户主动清空 = 关闭 AI
// 非空必须是可解析的 http(s) URL避免「abc / www.foo.com / file:///xxx」
// 这类带空格 / 漏 scheme / 协议错误的值被静默接受,最后在主进程 fetch 时
// 才抛 TypeError错误信息很难定位到 settings。trim 后用 URL 解析,
// 协议限定 http: 或 https:(不接 ftp / file / data 等)。
format: 'url-https',
description: 'baseURLOpenAI 含 /v1如 https://api.openai.com/v1Anthropic 不含(如 https://api.anthropic.com',
},
aiApiKey: {
// 密码字段renderer 通过 IPC 传给主进程,不在 schema 校验链里打印
type: 'string',
// API key 通常 50~200 字符;留 4 KB 上限足够
max: 4_096,
description: 'API Key',
},
aiModel: {
type: 'string',
max: 256,
description: '模型名',
},
aiSystemPrompt: {
type: 'string',
// 系统提示词较长是合理的,但单个几十 MB 的 prompt 会拖慢 JSON.stringify
// 且让 AI 计费爆炸 —— 200 KB 对应约 5 万中文字,足够绝大多数场景
max: 200_000,
description: '自定义系统提示词;空 = 用内置默认',
},
});
/**
* UI 选项表 —— 仅供对话框渲染使用。
* key 与 SETTINGS_SCHEMA 的 enum 对齐,但额外带 label / hint。
* @type {Readonly<Record<string, ReadonlyArray<{value: *, label: string, hint?: string}>>>}
*/
const SETTINGS_UI_OPTIONS = Object.freeze({
themePalette: Object.freeze([
{ value: 'default', label: '默认' },
{ value: 'ocean', label: '海洋' },
{ value: 'forest', label: '森林' },
{ value: 'lavender', label: '薰衣草' },
{ value: 'sunset', label: '夕阳' },
]),
fileListSort: Object.freeze([
{ value: 'name', label: '按名称', hint: 'A → ZlocaleCompare(zh-CN)' },
{ value: 'mtime-desc', label: '按修改时间', hint: '最近修改排在最前' },
]),
// 自动保存不再需要枚举选项debounce 延迟是连续数值0..60000 ms
// 由 toolbar 按钮直接 toggle 0 ↔ 500UI 也不再有「选几秒」的下拉。
readerFontSize: Object.freeze([14, 15, 17, 19, 22]),
readerLineHeight: Object.freeze([1.5, 1.7, 1.85, 2.0]),
// 两个选项都标"兼容":突出是「按这个协议实现的兼容 API」而不是特定厂商
// 用户可填任意走该协议的 baseURL中转、自部署、官方 API 都行)。
aiProvider: Object.freeze([
{ value: 'openai', label: 'OpenAI 兼容', hint: '/chat/completions · 含 DeepSeek / Moonshot / Azure 等' },
{ value: 'anthropic', label: 'Anthropic 兼容', hint: '/v1/messages · Claude 系列 · 含第三方中转' },
]),
});
/**
* 校验 + sanitize 单个键。
* 返回 { ok:true, value } 或 { ok:false, error }。
*
* @param {string} key
* @param {*} raw
* @param {object} [opts] - { resolveDir?: async (path) => { ok, error? } }
* 注入目录存在性校验(默认走 fs调用方可传 mock
* @returns {Promise<{ok: true, value: *} | {ok: false, error: string}>}
*/
async function validateKey(key, raw, opts = {}) {
const rule = SETTINGS_SCHEMA[key];
if (!rule) {
// 未知键直接拒绝(防止 renderer 误传)
return { ok: false, error: `未知设置项: ${key}` };
}
// nullable 类型null 一律放行
if ((rule.type === 'nullable-string' || rule.type === 'nullable-path' || rule.type === 'nullable-number') && raw === null) {
return { ok: true, value: null };
}
// 类型分发
switch (rule.type) {
case 'nullable-path': {
if (typeof raw !== 'string') return { ok: false, error: `${key} 必须是字符串或 null` };
const trimmed = raw.trim();
if (!trimmed) return { ok: true, value: null };
if (opts.resolveDir) {
const r = await opts.resolveDir(trimmed);
if (!r.ok) return { ok: false, error: r.error };
}
return { ok: true, value: trimmed };
}
case 'nullable-string': {
if (typeof raw !== 'string') return { ok: false, error: `${key} 必须是字符串或 null` };
if (typeof rule.max === 'number' && raw.length > rule.max) {
return { ok: false, error: `${key} 过长(超过 ${rule.max} 字符)` };
}
return { ok: true, value: raw };
}
case 'string': {
if (typeof raw !== 'string') return { ok: false, error: `${key} 必须是字符串` };
// P2 fixtrim 首尾空白 —— 用户复制粘贴 AI Key / Model 经常带回车 / 空格,
// 不 trim 会让 main/ai.js 把它当字面量放进 Authorization header / 请求体,
// 服务端校验「key 不匹配」但错误信息没有「多打了空格」的提示,用户无从下手。
// 用户手动改 settings.json 也经常留尾空格。
const trimmed = raw.trim();
// rule.max 限定字符串长度 —— 防止几 MB 的值把请求体撑爆 / 拖慢序列化。
// 用户看到的错误直接说"过长",避免报"非法 JSON"。
// audit fix之前误写成 opts.max导致 aiApiKey/aiBaseUrl/aiModel/
// aiSystemPrompt 声明的上限从未生效。)
if (typeof rule.max === 'number' && trimmed.length > rule.max) {
return { ok: false, error: `${key} 过长(超过 ${rule.max} 字符)` };
}
// audit fix (#9 shared):可选 format 校验。空串放行(清空合法);
// 非空按 format 规则走,失败给中文错误。
if (trimmed && rule.format === 'url-https') {
let parsed;
try {
parsed = new URL(trimmed);
} catch {
return { ok: false, error: `${key} 不是合法的 URL需以 http:// 或 https:// 开头)` };
}
if (parsed.protocol !== 'http:' && parsed.protocol !== 'https:') {
return { ok: false, error: `${key} 必须是 http(s) URL当前协议 ${parsed.protocol}` };
}
}
return { ok: true, value: trimmed };
}
case 'boolean': {
if (typeof raw !== 'boolean') return { ok: false, error: `${key} 必须是布尔` };
return { ok: true, value: raw };
}
case 'number': {
// audit C3拒绝 null / undefined / 空串 / 非数字 —— Number('') === 0 会
// 让 `readerFontSize: ''` 静默通过 clamp 到 min这是 bug不是「合法 0」。
// 合法输入:实际数字 或 非空数字字符串。
if (raw === null || raw === undefined) {
return { ok: false, error: `${key} 必须是数字` };
}
if (typeof raw === 'string' && raw.trim() === '') {
return { ok: false, error: `${key} 必须是数字` };
}
const n = Number(raw);
if (!Number.isFinite(n)) return { ok: false, error: `${key} 必须是数字` };
let v = n;
if (rule.clamp && (v < rule.min || v > rule.max)) {
v = Math.min(Math.max(v, rule.min), rule.max);
}
if (rule.integer) v = Math.round(v);
if (typeof rule.round === 'number') {
const k = 10 ** rule.round;
v = Math.round(v * k) / k;
}
if (v < rule.min || v > rule.max) {
return { ok: false, error: `${key} 超出范围 [${rule.min}, ${rule.max}]` };
}
return { ok: true, value: v };
}
case 'nullable-number': {
// audit fix (Round 9):与 'number' 分支对齐显式拒绝 null / undefined /
// 空串 / 非数字。Number('') === 0 会让 nullable-number 字段把空串静默
// 转成 0 再 clamp 到 min —— 与上面 'number' 分支同款 bug。null 是
// 合法值(保留为 null让 UI 显示「未设置」),但空串 / 非数字必须拒绝。
//
// 审计修复 (Round 11 deep-fix P2-3)undefined 在 nullable 字段应等价于 null。
// 旧版异步路径拒绝 undefined → 同步 sanitizeSync 路径却把 undefined 当作 null
// 手改 config.json 时如果字段被删JSON.stringify 会序列化成 undefined → 字段缺失,
// 但 settings-dialog applySetting 走异步路径)会出现 update() reject / load() accept 的不对称。
// 现在 undefined 走「视为 null」分支。
if (raw === null || raw === undefined) return { ok: true, value: null };
if (typeof raw === 'string' && raw.trim() === '') {
return { ok: false, error: `${key} 必须是数字或 null` };
}
const n = Number(raw);
if (!Number.isFinite(n)) return { ok: false, error: `${key} 必须是数字或 null` };
let v = n;
if (rule.integer) v = Math.round(v);
if (v < rule.min || v > rule.max) {
return { ok: false, error: `${key} 超出范围 [${rule.min}, ${rule.max}]` };
}
return { ok: true, value: v };
}
case 'enum': {
// enum值在列表内即放行兼容 string / number 字面量;如 0 也要命中)
const allowed = rule.enum.some((e) => e === raw) ||
(typeof raw === 'string' && rule.enum.includes(raw));
if (!allowed) return { ok: false, error: `${key} 取值非法: ${raw}` };
return { ok: true, value: raw };
}
default:
return { ok: false, error: `${key} 类型未定义: ${rule.type}` };
}
}
/**
* 批量校验 + sanitize。
* 跳过未在 partial 里出现的键(局部更新)。
*
* @param {object} partial
* @param {object} [opts] - 同 validateKey
* @returns {Promise<{ok: true, sanitized: object} | {ok: false, error: string}>}
*/
async function validateAndSanitize(partial, opts = {}) {
if (!partial || typeof partial !== 'object' || Array.isArray(partial)) {
return { ok: false, error: '请求体必须是对象' };
}
/** @type {Record<string, *>} */
const sanitized = {};
for (const key of Object.keys(partial)) {
const r = await validateKey(key, partial[key], opts);
if (!r.ok) return r;
sanitized[key] = r.value;
}
return { ok: true, sanitized };
}
/**
* 与默认合并 + 校验完整 settings 对象(启动 / 读取配置文件时用)。
*
* 同步校验 —— 故意不 await fs 检查 dataDir 存在性load 阶段不阻塞);
* dataDir 存在性推迟到 settings-store.update 时再走 validateAndSanitize。
* 但同步可校验的部分enum / 范围 / 类型)必须现在就做,否则手改的
* config.json 会把整个 UI 弄坏audit #8
*
* @param {*} raw
* @returns {Record<string, *>}
*/
function coerceLoadedSettings(raw) {
if (!raw || typeof raw !== 'object' || Array.isArray(raw)) {
return { ...DEFAULT_SETTINGS };
}
// 旧版 autoSaveIntervalSec (enum: 0/3/10 秒 轮询) → autoSaveDebounceMs (ms 防抖)
// 语义变了:「每隔 X 秒轮询」变成「停打 X ms 后保存」。所有「开启」档位
// (旧 3 秒、10 秒)一律映射到新的 500ms 默认值,避免老用户升级后自动保存
// 突然变得非常激进1s 内反复触发或太迟10s 才一次)。
if ('autoSaveIntervalSec' in raw) {
const old = raw.autoSaveIntervalSec;
if (!('autoSaveDebounceMs' in raw)) {
const offish = old === 0 || old === '0' || old === false;
raw.autoSaveDebounceMs = offish ? 0 : 500;
}
delete raw.autoSaveIntervalSec;
}
/** @type {Record<string, *>} */
const out = { ...DEFAULT_SETTINGS };
for (const key of Object.keys(DEFAULT_SETTINGS)) {
if (!(key in raw)) continue;
const sanitized = sanitizeSync(key, raw[key]);
// sanitizeSync 返回 undefined 表示「未知键 / 不可修复」,跳过即可
if (sanitized !== undefined) out[key] = sanitized;
}
// Phase N 修复:保留白名单内的 `_`-前缀元数据键(当前只有 `_hasAiKey`)。
// 之前只迭代 Object.keys(DEFAULT_SETTINGS) 把未声明的键全 drop —— get-settings
// 返回的 `_hasAiKey: !!cfg.aiApiKey` 在 coerce 阶段被吃掉renderer 永远拿不到
// "已配置 API Key" 信号,"显示已填 key" + reveal 流程全失效。
//
// 防御:必须用白名单而不是"所有下划线前缀键都过"。否则攻击者 / 误用方可用
// `_xxx` 形式把任意字段塞进内存 settings虽然不会写盘但能在内存里残留
// 白名单维护成本低(已知元数据键只有少数几个),但放行成本高。
const METADATA_KEYS = new Set(['_hasAiKey']);
for (const key of Object.keys(raw)) {
if (METADATA_KEYS.has(key) && !(key in out)) {
out[key] = raw[key];
}
}
return out;
}
/**
* 同步版本的单键 sanitize —— 只做不依赖 IO 的检查。
* 与 validateKey 共享规则,但把 fs 检查nullable-path推迟到 update 阶段。
*
* @param {string} key
* @param {*} raw
* @returns {*} 合法值;不可修复时返回 undefined调用方应忽略这个键
*/
function sanitizeSync(key, raw) {
const rule = SETTINGS_SCHEMA[key];
if (!rule) return undefined; // 未知键:丢弃
// nullablenull 合法
if ((rule.type === 'nullable-string' || rule.type === 'nullable-path' || rule.type === 'nullable-number') && raw === null) {
return null;
}
switch (rule.type) {
case 'nullable-path':
case 'string': {
if (typeof raw !== 'string') return undefined;
// audit fix (Round 4 P1-1)nullable-path 与 validateKey 异步路径对齐——空串归一为 null。
// 之前 sanitizeSync 直接返回 ''coerceLoadedSettings 把磁盘上残留的
// `"dataDir": ""` 保留为 '',但 validateKey 异步路径会归一为 null两条路径
// 语义不同步 → 任何依赖 dataDir === null 判断的代码失配resolveDataDir
// 靠 .trim() 兜底不崩但漏检 null 路径)。
if (rule.type === 'nullable-path' && raw.trim() === '') return null;
// 同步路径coerceLoadedSettings / sanitizeSync也要尊重 max —— 用户
// 手工改坏 settings.json 时同样不能让几 MB 的字符串进入运行时。
if (typeof rule.max === 'number' && raw.length > rule.max) return undefined;
// 审计修复 (Round 11 deep-fix P2-3):同步路径也 trim前后空白不再让 URL 校验
// 失败。async validateKey 早就 trimRound 8 fix同步路径遗漏导致
// load(): 不 trim 直接 new URL(' https://api.example.com ')
// update(): trim 后校验
// 两次读同一字段返回不同值UI 看着值变了(实际上是同一字符串的展示差异)。
// 注意trim 后可能变空字符串,与 max > 0 但被 trim 成空的 case 区分;
// 这里把 trim 后空串仍走原 length 检查(空串会让 url-https 校验短路,
// 但保留 nullable-path 上面已经拦截过的场景)。
const trimmed = (rule.type === 'string' || rule.type === 'nullable-string' || rule.type === 'path') ? raw.trim() : raw;
if (typeof rule.max === 'number' && trimmed.length > rule.max) return undefined;
// fix(audit 2026-08):同步路径也要校验 format。旧版只 validateKeyasync 路径)
// 校验 formatcoerceLoadedSettings 直接放行 → 手改 settings.json 把 aiBaseUrl
// 写成 "not-a-url" / "ftp://xxx" 也会被加载,渲染端拿到的值是无效 URL
// 真正 fetch 时才报 TypeError: fetch failed错误链很难定位到 settings。
if (trimmed.length > 0 && rule.format === 'url-https') {
// 必须前缀严格是 http:// 或 https://,避免 'http:/missing-slash' 这种
// URL 构造器能解析但实际 fetch 行为不一致的 case。
if (!/^https?:\/\//i.test(trimmed)) return undefined;
let parsed;
try {
parsed = new URL(trimmed);
} catch {
return undefined;
}
if (parsed.protocol !== 'http:' && parsed.protocol !== 'https:') return undefined;
}
return trimmed;
}
case 'nullable-string': {
if (typeof raw !== 'string') return undefined;
if (typeof rule.max === 'number' && raw.length > rule.max) return undefined;
// fix(audit 2026-08)nullable-string 与 string 一样需要 format 校验,
// 否则未来 schema 加 nullable-string + format 字段会静默失效。
if (raw.length > 0 && rule.format === 'url-https') {
if (!/^https?:\/\//i.test(raw)) return undefined;
let parsed;
try {
parsed = new URL(raw);
} catch {
return undefined;
}
if (parsed.protocol !== 'http:' && parsed.protocol !== 'https:') return undefined;
}
return raw;
}
case 'boolean':
return typeof raw === 'boolean' ? raw : undefined;
case 'number': {
// fix(audit 2026-08):拒绝 Boolean 输入。Number(true) === 1,
// Number(false) === 0 —— 旧版会让 autoSaveDebounceMs: true 静默变 1ms
// (激进到每个 keystroke 都存splitRatio: false 变 0无预览面板
// 合法输入限定为 number 或非空数字字符串。
if (raw === null || raw === undefined) return undefined;
if (typeof raw !== 'number' && typeof raw !== 'string') return undefined;
if (typeof raw === 'string' && raw.trim() === '') return undefined;
const n = Number(raw);
if (!Number.isFinite(n)) return undefined;
let v = n;
if (rule.clamp && (v < rule.min || v > rule.max)) {
v = Math.min(Math.max(v, rule.min), rule.max);
}
if (rule.integer) v = Math.round(v);
if (typeof rule.round === 'number') {
const k = 10 ** rule.round;
v = Math.round(v * k) / k;
}
// clamp 后还在范围外(例如 raw 是 NaN / Infinity→ 拒收
if (v < rule.min || v > rule.max) return undefined;
return v;
}
case 'nullable-number': {
// audit fix (Round 4 P2-3):与 number 分支对称——拒绝 Boolean 输入。
// 原版无 typeof 守卫Number(true) === 1 隐式通过 isFinite目前 schema
// 用 nullable-number 的字段sidebarWidth / aiWidthmin 检查会拦下 1
// 但语义上与 number 不一致,且未来加更宽 min 范围的字段会绕过。复制上方
// number 分支的 typeof 守卫保持两条路径对称。
if (raw === null || raw === undefined) return null;
if (typeof raw !== 'number' && typeof raw !== 'string') return undefined;
if (typeof raw === 'string' && raw.trim() === '') return undefined;
const n = Number(raw);
if (!Number.isFinite(n)) return undefined;
let v = n;
if (rule.integer) v = Math.round(v);
if (v < rule.min || v > rule.max) return undefined;
return v;
}
case 'enum': {
// 数字 enum 接受 number字符串 enum 接受 string 字面量
const ok = rule.enum.some((e) => e === raw)
|| (typeof raw === 'string' && rule.enum.includes(raw));
return ok ? raw : undefined;
}
default:
return undefined;
}
}
module.exports = {
DEFAULT_SETTINGS,
SETTINGS_SCHEMA,
SETTINGS_UI_OPTIONS,
validateKey,
validateAndSanitize,
coerceLoadedSettings,
};