From 2a04cafee5dcf206477e1fc85c68a9c468c832c0 Mon Sep 17 00:00:00 2001 From: zjdndjf <186369260+zjdndjf@users.noreply.github.com> Date: Tue, 14 Apr 2026 17:29:41 +0800 Subject: [PATCH] feat: add per-log-file size limit with truncation --- src/main/config/app.ts | 5 + src/main/core/manager.ts | 8 +- src/main/resolve/server.ts | 7 +- src/main/utils/logFile.ts | 165 ++++++++++++++++++++++++++++ src/main/utils/logger.ts | 4 +- src/main/utils/template.ts | 1 + src/renderer/src/locales/en-US.json | 1 + src/renderer/src/locales/fa-IR.json | 1 + src/renderer/src/locales/ru-RU.json | 1 + src/renderer/src/locales/zh-CN.json | 1 + src/renderer/src/locales/zh-TW.json | 1 + src/renderer/src/pages/mihomo.tsx | 21 ++++ src/shared/types.d.ts | 1 + 13 files changed, 208 insertions(+), 9 deletions(-) create mode 100644 src/main/utils/logFile.ts diff --git a/src/main/config/app.ts b/src/main/config/app.ts index 54189326..8c4de562 100644 --- a/src/main/config/app.ts +++ b/src/main/config/app.ts @@ -3,6 +3,7 @@ import { appConfigPath } from '../utils/dirs' import { parse, stringify } from '../utils/yaml' import { deepMerge } from '../utils/merge' import { defaultConfig } from '../utils/template' +import { normalizeMaxLogFileSizeMB, setGlobalMaxLogFileSizeMB } from '../utils/logFile' let appConfig: IAppConfig // config.yaml let appConfigWriteQueue: Promise = Promise.resolve() @@ -13,9 +14,11 @@ export async function getAppConfig(force = false): Promise { const data = await readFile(appConfigPath(), 'utf-8') const parsedConfig = parse(data) const mergedConfig = deepMerge({ ...defaultConfig }, parsedConfig || {}) + mergedConfig.maxLogFileSize = normalizeMaxLogFileSizeMB(mergedConfig.maxLogFileSize) if (JSON.stringify(mergedConfig) !== JSON.stringify(parsedConfig)) { await writeFile(appConfigPath(), stringify(mergedConfig)) } + setGlobalMaxLogFileSizeMB(mergedConfig.maxLogFileSize) appConfig = mergedConfig }) await appConfigWriteQueue @@ -30,6 +33,8 @@ export async function patchAppConfig(patch: Partial): Promise appConfig.nameserverPolicy = patch.nameserverPolicy } appConfig = deepMerge(appConfig, patch) + appConfig.maxLogFileSize = normalizeMaxLogFileSizeMB(appConfig.maxLogFileSize) + setGlobalMaxLogFileSizeMB(appConfig.maxLogFileSize) await writeFile(appConfigPath(), stringify(appConfig)) }) await appConfigWriteQueue diff --git a/src/main/core/manager.ts b/src/main/core/manager.ts index e9c8edfd..6173ebdb 100644 --- a/src/main/core/manager.ts +++ b/src/main/core/manager.ts @@ -3,7 +3,7 @@ import { readFile, rm, writeFile } from 'fs/promises' import { promisify } from 'util' import path from 'path' import os from 'os' -import { createWriteStream, existsSync } from 'fs' +import { existsSync } from 'fs' import chokidar, { FSWatcher } from 'chokidar' import { app, ipcMain } from 'electron' import { mainWindow } from '../window' @@ -28,6 +28,7 @@ import { startMonitor } from '../resolve/trafficMonitor' import { safeShowErrorBox } from '../utils/init' import i18next from '../../shared/i18n' import { managerLogger } from '../utils/logger' +import { createCappedLogWritableStream } from '../utils/logFile' import { startMihomoTraffic, startMihomoConnections, @@ -210,9 +211,6 @@ async function prepareCore(detached: boolean, skipStop = false): Promise { subStorePort = await findAvailablePort(38324) const icon = nativeImage.createFromPath(subStoreIcon) icon.toDataURL() - const stdout = createWriteStream(substoreLogPath(), { flags: 'a' }) - const stderr = createWriteStream(substoreLogPath(), { flags: 'a' }) + const stdout = createCappedLogWritableStream(substoreLogPath()) + const stderr = createCappedLogWritableStream(substoreLogPath()) const env = { SUB_STORE_BACKEND_API_PORT: subStorePort.toString(), SUB_STORE_BACKEND_API_HOST: subStoreHost, diff --git a/src/main/utils/logFile.ts b/src/main/utils/logFile.ts new file mode 100644 index 00000000..c0e2aa39 --- /dev/null +++ b/src/main/utils/logFile.ts @@ -0,0 +1,165 @@ +import { appendFile, open, stat, writeFile } from 'fs/promises' +import { Writable } from 'stream' + +const MB = 1024 * 1024 +const DEFAULT_MAX_LOG_FILE_SIZE_MB = 10 +const MIN_MAX_LOG_FILE_SIZE_MB = 1 +const TRUNCATE_MARKER = Buffer.from('\n[LOG] File truncated because size limit reached.\n') + +interface LogFileState { + queue: Promise + size: number | null +} + +const logFileStates = new Map() + +let globalMaxLogFileSizeBytes = DEFAULT_MAX_LOG_FILE_SIZE_MB * MB + +function getLogFileState(filePath: string): LogFileState { + const existing = logFileStates.get(filePath) + if (existing) return existing + + const created: LogFileState = { + queue: Promise.resolve(), + size: null + } + logFileStates.set(filePath, created) + return created +} + +export function normalizeMaxLogFileSizeMB(value: unknown): number { + const num = Number(value) + if (!Number.isFinite(num)) return DEFAULT_MAX_LOG_FILE_SIZE_MB + return Math.max(MIN_MAX_LOG_FILE_SIZE_MB, Math.floor(num)) +} + +export function setGlobalMaxLogFileSizeMB(value: unknown): void { + const normalized = normalizeMaxLogFileSizeMB(value) + globalMaxLogFileSizeBytes = normalized * MB +} + +export function getGlobalMaxLogFileSizeBytes(): number { + return globalMaxLogFileSizeBytes +} + +async function readTail(filePath: string, bytes: number): Promise { + if (bytes <= 0) return Buffer.alloc(0) + + try { + const file = await open(filePath, 'r') + try { + const fileStat = await file.stat() + const readSize = Math.min(bytes, fileStat.size) + if (readSize <= 0) return Buffer.alloc(0) + + const buffer = Buffer.alloc(readSize) + await file.read(buffer, 0, readSize, fileStat.size - readSize) + return buffer + } finally { + await file.close() + } + } catch { + return Buffer.alloc(0) + } +} + +async function getCurrentSize(filePath: string, state: LogFileState): Promise { + if (state.size !== null) return state.size + + try { + const fileStat = await stat(filePath) + state.size = fileStat.size + } catch { + state.size = 0 + } + + return state.size +} + +async function appendToFileWithLimitInternal( + filePath: string, + data: Buffer, + state: LogFileState, + maxBytes: number +): Promise { + if (data.length === 0) return + + if (maxBytes <= 0) { + await appendFile(filePath, data) + const size = await getCurrentSize(filePath, state) + state.size = size + data.length + return + } + + if (data.length >= maxBytes) { + const sliced = data.subarray(data.length - maxBytes) + await writeFile(filePath, sliced) + state.size = sliced.length + return + } + + const size = await getCurrentSize(filePath, state) + if (size + data.length <= maxBytes) { + await appendFile(filePath, data) + state.size = size + data.length + return + } + + const keepBytes = Math.max(0, maxBytes - data.length - TRUNCATE_MARKER.length) + const tail = await readTail(filePath, keepBytes) + let rewritten = Buffer.concat([tail, TRUNCATE_MARKER, data]) + + if (rewritten.length > maxBytes) { + rewritten = rewritten.subarray(rewritten.length - maxBytes) + } + + await writeFile(filePath, rewritten) + state.size = rewritten.length +} + +export async function appendToFileWithLimit( + filePath: string, + data: string | Buffer, + maxBytes = getGlobalMaxLogFileSizeBytes() +): Promise { + const buffer = Buffer.isBuffer(data) ? data : Buffer.from(data) + const state = getLogFileState(filePath) + + state.queue = state.queue + .catch(() => { + // Keep queue alive after previous failures. + }) + .then(() => appendToFileWithLimitInternal(filePath, buffer, state, maxBytes)) + + await state.queue +} + +class CappedLogWritable extends Writable { + private readonly filePath: string + private readonly maxBytes: number + + constructor(filePath: string, maxBytes: number) { + super() + this.filePath = filePath + this.maxBytes = maxBytes + } + + _write( + chunk: Buffer | string, + encoding: BufferEncoding, + callback: (error?: Error | null) => void + ): void { + const buffer = Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk, encoding) + appendToFileWithLimit(this.filePath, buffer, this.maxBytes).then( + () => callback(), + (error) => callback(error as Error) + ) + } +} + +export function createCappedLogWritableStream( + filePath: string, + maxBytes = getGlobalMaxLogFileSizeBytes() +): Writable { + return new CappedLogWritable(filePath, maxBytes) +} diff --git a/src/main/utils/logger.ts b/src/main/utils/logger.ts index 2b9de3bd..0c800630 100644 --- a/src/main/utils/logger.ts +++ b/src/main/utils/logger.ts @@ -1,5 +1,5 @@ -import { writeFile } from 'fs/promises' import { logPath } from './dirs' +import { appendToFileWithLimit } from './logFile' export type LogLevel = 'debug' | 'info' | 'warn' | 'error' @@ -24,7 +24,7 @@ class Logger { try { const appLogPath = logPath() const logMessage = this.formatLogMessage(level, message, error) - await writeFile(appLogPath, logMessage, { flag: 'a' }) + await appendToFileWithLimit(appLogPath, logMessage) } catch (logError) { // 如果写入日志文件失败,仍然输出到控制台 console.error(`[Logger] Failed to write to log file:`, logError) diff --git a/src/main/utils/template.ts b/src/main/utils/template.ts index 077890d2..66df0569 100644 --- a/src/main/utils/template.ts +++ b/src/main/utils/template.ts @@ -13,6 +13,7 @@ export const defaultConfig: IAppConfig = { trayProxyGroupStyle: 'default', disableTrayIconColor: false, maxLogDays: 7, + maxLogFileSize: 10, proxyCols: 'auto', connectionDirection: 'asc', connectionOrderBy: 'time', diff --git a/src/renderer/src/locales/en-US.json b/src/renderer/src/locales/en-US.json index 76c320de..23ac33ba 100644 --- a/src/renderer/src/locales/en-US.json +++ b/src/renderer/src/locales/en-US.json @@ -222,6 +222,7 @@ "mihomo.disableEmbedCA": "Disable Embed CA", "mihomo.disableSystemCA": "Disable System CA", "mihomo.logRetentionDays": "Log Retention Days", + "mihomo.logFileSizeLimit": "Single Log File Limit (MB)", "mihomo.logLevel": "Log Level", "mihomo.selectLogLevel": "Select Log Level", "mihomo.silent": "Silent", diff --git a/src/renderer/src/locales/fa-IR.json b/src/renderer/src/locales/fa-IR.json index 861f3656..7fe7ed7d 100644 --- a/src/renderer/src/locales/fa-IR.json +++ b/src/renderer/src/locales/fa-IR.json @@ -197,6 +197,7 @@ "mihomo.disableEmbedCA": "عدم استفاده از گواهی CA داخلی", "mihomo.disableSystemCA": "عدم استفاده از گواهی CA سیستم", "mihomo.logRetentionDays": "روزهای نگهداری لاگ", + "mihomo.logFileSizeLimit": "حداکثر حجم هر فایل لاگ (MB)", "mihomo.logLevel": "سطح لاگ", "mihomo.selectLogLevel": "انتخاب سطح لاگ", "mihomo.silent": "بی‌صدا", diff --git a/src/renderer/src/locales/ru-RU.json b/src/renderer/src/locales/ru-RU.json index 6365e953..64d457a7 100644 --- a/src/renderer/src/locales/ru-RU.json +++ b/src/renderer/src/locales/ru-RU.json @@ -199,6 +199,7 @@ "mihomo.disableEmbedCA": "Отключить встроенный CA", "mihomo.disableSystemCA": "Отключить системный CA", "mihomo.logRetentionDays": "Дни хранения логов", + "mihomo.logFileSizeLimit": "Лимит одного лог-файла (MB)", "mihomo.logLevel": "Уровень логирования", "mihomo.selectLogLevel": "Выберите уровень логирования", "mihomo.silent": "Тихий", diff --git a/src/renderer/src/locales/zh-CN.json b/src/renderer/src/locales/zh-CN.json index 0a17913e..28367cea 100644 --- a/src/renderer/src/locales/zh-CN.json +++ b/src/renderer/src/locales/zh-CN.json @@ -222,6 +222,7 @@ "mihomo.disableEmbedCA": "不使用内置 CA 证书", "mihomo.disableSystemCA": "不使用系统 CA 证书", "mihomo.logRetentionDays": "日志保留天数", + "mihomo.logFileSizeLimit": "日志上限(MB)", "mihomo.logLevel": "日志等级", "mihomo.selectLogLevel": "选择日志等级", "mihomo.silent": "静默", diff --git a/src/renderer/src/locales/zh-TW.json b/src/renderer/src/locales/zh-TW.json index d485bfbf..a44ffc02 100644 --- a/src/renderer/src/locales/zh-TW.json +++ b/src/renderer/src/locales/zh-TW.json @@ -222,6 +222,7 @@ "mihomo.disableEmbedCA": "不使用內置 CA 證書", "mihomo.disableSystemCA": "不使用系統 CA 證書", "mihomo.logRetentionDays": "日誌保留天數", + "mihomo.logFileSizeLimit": "日誌上限(MB)", "mihomo.logLevel": "日誌等級", "mihomo.selectLogLevel": "選擇日誌等級", "mihomo.silent": "靜默", diff --git a/src/renderer/src/pages/mihomo.tsx b/src/renderer/src/pages/mihomo.tsx index a8b9391f..e97c60a2 100644 --- a/src/renderer/src/pages/mihomo.tsx +++ b/src/renderer/src/pages/mihomo.tsx @@ -96,6 +96,7 @@ const Mihomo: React.FC = () => { smartCoreStrategy = 'sticky-sessions', smartCollectorSize = 100, maxLogDays = 7, + maxLogFileSize = 10, sysProxy, showMixedPort, enableMixedPort = true, @@ -1431,6 +1432,26 @@ const Mihomo: React.FC = () => { }} /> + + { + const num = parseInt(v) + if (!isNaN(num)) { + patchAppConfig({ maxLogFileSize: num }) + } + }} + onBlur={(e) => { + const num = parseInt(e.target.value) + if (isNaN(num) || num < 1) { + patchAppConfig({ maxLogFileSize: 1 }) + } + }} + /> +