Dev Build / cleanup (push) Waiting to run
Dev Build / typecheck (push) Waiting to run
Publish MCP to npm / Build, test, and publish MCP (push) Waiting to run
Publish SDK to npm / Build, test, and publish SDK (push) Waiting to run
Sync action catalog to docs / sync (push) Waiting to run
Publish UI to npm / Build and publish UI (push) Waiting to run
Build and Release / preflight (push) Waiting to run
408 lines
12 KiB
TypeScript
408 lines
12 KiB
TypeScript
import fs from 'fs';
|
|
import path from 'path';
|
|
import { isObject } from './coerce';
|
|
import { normalizeUpdateSourceConfig, type UpdateSourceConfig } from './update-source';
|
|
|
|
export interface RuntimeConfig {
|
|
updateSource?: UpdateSourceConfig;
|
|
webuiPort?: number;
|
|
/** When true, every newly-discovered QQ process gets auto-injected by
|
|
* the HookManager. Also overridable at runtime via SNOWLUMA_HOOK_AUTOLOAD.
|
|
* Defaults to false; the Docker image flips it on in supervisord.conf. */
|
|
hookAutoLoad?: boolean;
|
|
/** WebUI listener bind address. '127.0.0.1' = localhost-only (default),
|
|
* '0.0.0.0' = all IPv4 interfaces. Listener-level: change needs a restart. */
|
|
webuiHost?: string;
|
|
/** WebUI TLS. Cert/key live in config/cert.pem + config/key.pem; this
|
|
* only flips serving on/off. Listener-level: change needs a restart. */
|
|
webuiTls?: { enabled?: boolean };
|
|
/** Raw trust-proxy directive (same domain as SNOWLUMA_WEBUI_TRUST_PROXY),
|
|
* consumed by the WebUI's client-ip resolver. '' = trust nobody. */
|
|
trustProxy?: string;
|
|
/** Aggregate cap for every managed file below the log root. */
|
|
logMaxTotalMb?: number;
|
|
/** Calendar-day retention. Zero disables date-based deletion. */
|
|
logRetainDays?: number;
|
|
/** Duplicate UIN-scoped lines into logs/<uin>/ in addition to the shared log. */
|
|
logPerUin?: boolean;
|
|
}
|
|
|
|
const CONFIG_DIR = 'config';
|
|
const RUNTIME_CONFIG_PATH = path.join(CONFIG_DIR, 'runtime.json');
|
|
|
|
const DEFAULT_WEBUI_PORT = 5099;
|
|
const DEFAULT_WEBUI_HOST = '127.0.0.1';
|
|
export const DEFAULT_LOG_MAX_TOTAL_MB = 1024;
|
|
export const DEFAULT_LOG_RETAIN_DAYS = 7;
|
|
export const DEFAULT_LOG_PER_UIN = false;
|
|
export const MAX_LOG_TOTAL_MB = Math.floor(Number.MAX_SAFE_INTEGER / (1024 * 1024));
|
|
export const MAX_LOG_RETAIN_DAYS = Math.floor(Number.MAX_SAFE_INTEGER / (24 * 60 * 60 * 1000));
|
|
|
|
/**
|
|
* Pure on-disk-object → typed config normalization (defaults + validation,
|
|
* no fs / no env). Exported for testing; `loadRuntimeConfig` wraps it.
|
|
*/
|
|
export function normalizeRuntimeConfig(parsed: unknown): RuntimeConfig {
|
|
const obj = isObject(parsed) ? parsed : {};
|
|
return {
|
|
...(obj.updateSource === undefined
|
|
? {}
|
|
: { updateSource: normalizeUpdateSourceConfig(obj.updateSource) }),
|
|
webuiPort: normalizePort(obj.webuiPort ?? DEFAULT_WEBUI_PORT, DEFAULT_WEBUI_PORT),
|
|
hookAutoLoad: normalizeBool(obj.hookAutoLoad, false),
|
|
webuiHost: normalizeHost(obj.webuiHost),
|
|
webuiTls: {
|
|
enabled: isObject(obj.webuiTls) ? normalizeBool(obj.webuiTls.enabled, false) : false,
|
|
},
|
|
trustProxy: typeof obj.trustProxy === 'string' ? obj.trustProxy : '',
|
|
logMaxTotalMb: normalizeRequiredInteger(
|
|
obj.logMaxTotalMb,
|
|
DEFAULT_LOG_MAX_TOTAL_MB,
|
|
1,
|
|
MAX_LOG_TOTAL_MB,
|
|
'logMaxTotalMb',
|
|
),
|
|
logRetainDays: normalizeRequiredInteger(
|
|
obj.logRetainDays,
|
|
DEFAULT_LOG_RETAIN_DAYS,
|
|
0,
|
|
MAX_LOG_RETAIN_DAYS,
|
|
'logRetainDays',
|
|
),
|
|
logPerUin: normalizeRequiredBool(obj.logPerUin, DEFAULT_LOG_PER_UIN, 'logPerUin'),
|
|
};
|
|
}
|
|
|
|
/**
|
|
* Pure SNOWLUMA_* env → override patch (no fs). Env wins over runtime.json
|
|
* (a trusted launcher like SnowLumaDesktop pins these per-launch without
|
|
* rewriting the file). Absent vars produce no key.
|
|
*/
|
|
export function resolveRuntimeEnvOverrides(env: NodeJS.ProcessEnv): Partial<RuntimeConfig> {
|
|
const out: Partial<RuntimeConfig> = {};
|
|
|
|
const port = parsePortString(env.SNOWLUMA_WEBUI_PORT);
|
|
|
|
if (port !== undefined) {
|
|
out.webuiPort = port;
|
|
}
|
|
|
|
const host = env.SNOWLUMA_WEBUI_HOST;
|
|
|
|
if (typeof host === 'string' && host.trim()) {
|
|
out.webuiHost = host.trim();
|
|
}
|
|
|
|
// Present-but-"0"/"off" is a deliberate override (trust nobody), distinct
|
|
// from "absent" — so gate on key presence, not truthiness.
|
|
const tp = env.SNOWLUMA_WEBUI_TRUST_PROXY;
|
|
|
|
if (typeof tp === 'string') {
|
|
out.trustProxy = tp;
|
|
}
|
|
|
|
const logMaxTotalMb = parseRequiredIntegerEnv(
|
|
env.SNOWLUMA_LOG_MAX_TOTAL_MB,
|
|
1,
|
|
MAX_LOG_TOTAL_MB,
|
|
'SNOWLUMA_LOG_MAX_TOTAL_MB',
|
|
);
|
|
|
|
if (logMaxTotalMb !== undefined) {
|
|
out.logMaxTotalMb = logMaxTotalMb;
|
|
}
|
|
|
|
const logRetainDays = parseRequiredIntegerEnv(
|
|
env.SNOWLUMA_LOG_RETAIN_DAYS,
|
|
0,
|
|
MAX_LOG_RETAIN_DAYS,
|
|
'SNOWLUMA_LOG_RETAIN_DAYS',
|
|
);
|
|
|
|
if (logRetainDays !== undefined) {
|
|
out.logRetainDays = logRetainDays;
|
|
}
|
|
|
|
const logPerUin = parseRequiredBoolEnv(env.SNOWLUMA_LOG_PER_UIN, 'SNOWLUMA_LOG_PER_UIN');
|
|
|
|
if (logPerUin !== undefined) {
|
|
out.logPerUin = logPerUin;
|
|
}
|
|
|
|
const webuiTls = parseRequiredBoolEnv(env.SNOWLUMA_WEBUI_TLS, 'SNOWLUMA_WEBUI_TLS');
|
|
|
|
if (webuiTls !== undefined) {
|
|
out.webuiTls = { enabled: webuiTls };
|
|
}
|
|
|
|
return out;
|
|
}
|
|
|
|
export function loadRuntimeConfig(): RuntimeConfig {
|
|
fs.mkdirSync(CONFIG_DIR, { recursive: true });
|
|
|
|
const parsed = tryLoadRuntimeConfig();
|
|
const normalized = normalizeRuntimeConfig(parsed ?? {});
|
|
|
|
// Persist when the file is absent or when normalization changed/backfilled
|
|
// anything (e.g. an older runtime.json lacking webuiHost/webuiTls).
|
|
if (parsed === null || !sameRuntimeConfig(parsed, normalized)) {
|
|
saveRuntimeConfig(normalized);
|
|
}
|
|
|
|
// Env overrides apply in-memory only — never written back to disk.
|
|
return { ...normalized, ...resolveRuntimeEnvOverrides(process.env) };
|
|
}
|
|
|
|
/**
|
|
* Read the persisted config (normalized, no env overrides, no write). For the
|
|
* settings panel's GET — shows what's actually saved/editable on disk.
|
|
*/
|
|
export function readRuntimeConfig(): RuntimeConfig {
|
|
return normalizeRuntimeConfig(tryLoadRuntimeConfig() ?? {});
|
|
}
|
|
|
|
/**
|
|
* Persist a partial update. Merges onto the ON-DISK config (not the env-merged
|
|
* runtime view) so an env override (e.g. SNOWLUMA_WEBUI_PORT) is never baked
|
|
* into runtime.json. Returns the new persisted config (without env overrides).
|
|
*/
|
|
export function updateRuntimeConfig(patch: Partial<RuntimeConfig>): RuntimeConfig {
|
|
fs.mkdirSync(CONFIG_DIR, { recursive: true });
|
|
const onDisk = normalizeRuntimeConfig(tryLoadRuntimeConfig() ?? {});
|
|
const next = normalizeRuntimeConfig({ ...onDisk, ...patch });
|
|
saveRuntimeConfig(next);
|
|
return next;
|
|
}
|
|
|
|
function tryLoadRuntimeConfig(): Record<string, unknown> | null {
|
|
if (!fs.existsSync(RUNTIME_CONFIG_PATH)) {
|
|
return null;
|
|
}
|
|
|
|
try {
|
|
const parsed = JSON.parse(fs.readFileSync(RUNTIME_CONFIG_PATH, 'utf8')) as unknown;
|
|
return isObject(parsed) ? parsed : null;
|
|
} catch {
|
|
return null;
|
|
}
|
|
}
|
|
|
|
function saveRuntimeConfig(config: RuntimeConfig): void {
|
|
const temporaryPath = `${RUNTIME_CONFIG_PATH}.tmp-${String(process.pid)}`;
|
|
|
|
try {
|
|
fs.writeFileSync(temporaryPath, JSON.stringify(config, null, 2), 'utf8');
|
|
fs.renameSync(temporaryPath, RUNTIME_CONFIG_PATH);
|
|
} catch (error) {
|
|
try {
|
|
fs.unlinkSync(temporaryPath);
|
|
} catch (cleanupError) {
|
|
if (!isErrnoCode(cleanupError, 'ENOENT')) {
|
|
throw new AggregateError(
|
|
[error, cleanupError],
|
|
'failed to persist runtime config and remove its temporary file',
|
|
);
|
|
}
|
|
}
|
|
|
|
throw error;
|
|
}
|
|
}
|
|
|
|
/** True when the raw on-disk object already matches the normalized config
|
|
* on every known field (so we can skip a needless rewrite). */
|
|
function sameRuntimeConfig(parsed: Record<string, unknown>, n: RuntimeConfig): boolean {
|
|
const parsedTls = isObject(parsed.webuiTls) ? parsed.webuiTls.enabled : undefined;
|
|
return (
|
|
parsed.webuiPort === n.webuiPort &&
|
|
parsed.hookAutoLoad === n.hookAutoLoad &&
|
|
parsed.webuiHost === n.webuiHost &&
|
|
parsedTls === n.webuiTls?.enabled &&
|
|
parsed.trustProxy === n.trustProxy &&
|
|
parsed.logMaxTotalMb === n.logMaxTotalMb &&
|
|
parsed.logRetainDays === n.logRetainDays &&
|
|
parsed.logPerUin === n.logPerUin &&
|
|
JSON.stringify(parsed.updateSource) === JSON.stringify(n.updateSource)
|
|
);
|
|
}
|
|
|
|
function parsePortString(raw: unknown): number | undefined {
|
|
if (typeof raw !== 'string' || !raw.trim()) {
|
|
return undefined;
|
|
}
|
|
|
|
const n = Number(raw.trim());
|
|
|
|
if (!Number.isFinite(n)) {
|
|
return undefined;
|
|
}
|
|
|
|
const port = Math.trunc(n);
|
|
|
|
if (port <= 0 || port > 65535) {
|
|
return undefined;
|
|
}
|
|
|
|
return port;
|
|
}
|
|
|
|
function normalizeHost(value: unknown): string {
|
|
if (typeof value === 'string' && value.trim()) {
|
|
return value.trim();
|
|
}
|
|
|
|
return DEFAULT_WEBUI_HOST;
|
|
}
|
|
|
|
function normalizePort(value: unknown, fallback: number): number {
|
|
if (typeof value === 'number' && Number.isFinite(value)) {
|
|
const n = Math.trunc(value);
|
|
|
|
if (n > 0 && n <= 65535) {
|
|
return n;
|
|
}
|
|
|
|
return fallback;
|
|
}
|
|
|
|
if (typeof value === 'string' && value.trim()) {
|
|
const n = Number(value);
|
|
|
|
if (Number.isFinite(n)) {
|
|
const port = Math.trunc(n);
|
|
|
|
if (port > 0 && port <= 65535) {
|
|
return port;
|
|
}
|
|
}
|
|
}
|
|
|
|
return fallback;
|
|
}
|
|
|
|
function normalizeRequiredInteger(
|
|
value: unknown,
|
|
fallback: number,
|
|
min: number,
|
|
max: number,
|
|
field: string,
|
|
): number {
|
|
if (value === undefined) {
|
|
return fallback;
|
|
}
|
|
|
|
const n = typeof value === 'string' && value.trim() ? Number(value.trim()) : value;
|
|
|
|
if (typeof n === 'number' && Number.isSafeInteger(n) && n >= min && n <= max) {
|
|
return n;
|
|
}
|
|
|
|
throw new RangeError(`${field} must be an integer in ${String(min)}..${String(max)}`);
|
|
}
|
|
|
|
function normalizeRequiredBool(value: unknown, fallback: boolean, field: string): boolean {
|
|
if (value === undefined) {
|
|
return fallback;
|
|
}
|
|
|
|
const parsed = parseBoolValue(value);
|
|
|
|
if (parsed !== undefined) {
|
|
return parsed;
|
|
}
|
|
|
|
throw new TypeError(`${field} must be a boolean`);
|
|
}
|
|
|
|
function normalizeBool(value: unknown, fallback: boolean): boolean {
|
|
if (typeof value === 'boolean') {
|
|
return value;
|
|
}
|
|
|
|
if (typeof value === 'number') {
|
|
return value !== 0;
|
|
}
|
|
|
|
if (typeof value === 'string') {
|
|
const v = value.trim().toLowerCase();
|
|
|
|
if (v === 'true' || v === '1' || v === 'yes' || v === 'on') {
|
|
return true;
|
|
}
|
|
|
|
if (v === 'false' || v === '0' || v === 'no' || v === 'off' || v === '') {
|
|
return false;
|
|
}
|
|
}
|
|
|
|
return fallback;
|
|
}
|
|
|
|
function parseRequiredIntegerEnv(
|
|
raw: unknown,
|
|
min: number,
|
|
max: number,
|
|
field: string,
|
|
): number | undefined {
|
|
if (typeof raw !== 'string' || !raw.trim()) {
|
|
return undefined;
|
|
}
|
|
|
|
const n = Number(raw.trim());
|
|
|
|
if (Number.isSafeInteger(n) && n >= min && n <= max) {
|
|
return n;
|
|
}
|
|
|
|
throw new RangeError(`${field} must be an integer in ${String(min)}..${String(max)}`);
|
|
}
|
|
|
|
function parseRequiredBoolEnv(raw: unknown, field: string): boolean | undefined {
|
|
if (typeof raw !== 'string' || !raw.trim()) {
|
|
return undefined;
|
|
}
|
|
|
|
const parsed = parseBoolValue(raw);
|
|
|
|
if (parsed !== undefined) {
|
|
return parsed;
|
|
}
|
|
|
|
throw new TypeError(`${field} must be a boolean`);
|
|
}
|
|
|
|
function parseBoolValue(raw: unknown): boolean | undefined {
|
|
if (typeof raw === 'boolean') {
|
|
return raw;
|
|
}
|
|
|
|
if (raw === 1) {
|
|
return true;
|
|
}
|
|
|
|
if (raw === 0) {
|
|
return false;
|
|
}
|
|
|
|
if (typeof raw !== 'string') {
|
|
return undefined;
|
|
}
|
|
|
|
const value = raw.trim().toLowerCase();
|
|
|
|
if (value === 'true' || value === '1' || value === 'yes' || value === 'on') {
|
|
return true;
|
|
}
|
|
|
|
if (value === 'false' || value === '0' || value === 'no' || value === 'off') {
|
|
return false;
|
|
}
|
|
|
|
return undefined;
|
|
}
|
|
|
|
function isErrnoCode(error: unknown, code: string): boolean {
|
|
return error instanceof Error && 'code' in error && error.code === code;
|
|
}
|