From 1f85bbef8e6fd11288795cf2bed0ff1898677271 Mon Sep 17 00:00:00 2001 From: zjdndjf <186369260+zjdndjf@users.noreply.github.com> Date: Tue, 14 Jul 2026 06:19:31 +0800 Subject: [PATCH] feat: protect critical data writes --- src/main/config/app.ts | 31 ++++++----- src/main/config/controledMihomo.ts | 63 +++++++++++++--------- src/main/config/override.ts | 17 +++--- src/main/config/plugin.ts | 13 ++--- src/main/config/profile.ts | 41 +++++++------- src/main/core/factory.ts | 18 ++++--- src/main/resolve/gistApi.ts | 4 +- src/main/resolve/plugin/vault.ts | 8 ++- src/main/resolve/theme.ts | 5 +- src/main/utils/init.ts | 5 +- src/main/utils/ipc.ts | 5 +- src/main/utils/safeFile.ts | 86 ++++++++++++++++++++++++++++++ src/main/window.ts | 9 ++-- 13 files changed, 206 insertions(+), 99 deletions(-) create mode 100644 src/main/utils/safeFile.ts diff --git a/src/main/config/app.ts b/src/main/config/app.ts index c6fb927a..655d7f9c 100644 --- a/src/main/config/app.ts +++ b/src/main/config/app.ts @@ -1,5 +1,6 @@ -import { readFile, writeFile } from 'fs/promises' +import { readFile } from 'fs/promises' import { appConfigPath } from '../utils/dirs' +import { atomicWriteFile, WriteQueue } from '../utils/safeFile' import { parse, stringify } from '../utils/yaml' import { deepMerge } from '../utils/merge' import { defaultConfig } from '../utils/template' @@ -7,7 +8,7 @@ import { normalizeMaxLogFileSizeMB, setGlobalMaxLogFileSizeMB } from '../utils/l import { setAppLogDisabled } from '../utils/logger' let appConfig: IAppConfig // config.yaml -let appConfigWriteQueue: Promise = Promise.resolve() +const appConfigWriteQueue = new WriteQueue() function cloneDefaultConfig(): IAppConfig { return JSON.parse(JSON.stringify(defaultConfig)) as IAppConfig @@ -15,35 +16,37 @@ function cloneDefaultConfig(): IAppConfig { export async function getAppConfig(force = false): Promise { if (force || !appConfig) { - appConfigWriteQueue = appConfigWriteQueue.then(async () => { + await appConfigWriteQueue.run(async () => { const data = await readFile(appConfigPath(), 'utf-8') const parsedConfig = parse(data) const mergedConfig = deepMerge(cloneDefaultConfig(), parsedConfig || {}) mergedConfig.maxLogFileSize = normalizeMaxLogFileSizeMB(mergedConfig.maxLogFileSize) if (JSON.stringify(mergedConfig) !== JSON.stringify(parsedConfig)) { - await writeFile(appConfigPath(), stringify(mergedConfig)) + await atomicWriteFile(appConfigPath(), stringify(mergedConfig)) } setGlobalMaxLogFileSizeMB(mergedConfig.maxLogFileSize) setAppLogDisabled(mergedConfig.disableAppLog === true) appConfig = mergedConfig }) - await appConfigWriteQueue } - if (typeof appConfig !== 'object') appConfig = defaultConfig + if (typeof appConfig !== 'object') appConfig = cloneDefaultConfig() return appConfig } export async function patchAppConfig(patch: Partial): Promise { - appConfigWriteQueue = appConfigWriteQueue.then(async () => { + await appConfigWriteQueue.run(async () => { const replaceNameserverPolicy = Object.prototype.hasOwnProperty.call(patch, 'nameserverPolicy') - appConfig = deepMerge(appConfig, patch) + const nextConfig = deepMerge( + JSON.parse(JSON.stringify(appConfig ?? cloneDefaultConfig())) as IAppConfig, + patch + ) if (replaceNameserverPolicy) { - appConfig.nameserverPolicy = patch.nameserverPolicy ?? {} + nextConfig.nameserverPolicy = patch.nameserverPolicy ?? {} } - appConfig.maxLogFileSize = normalizeMaxLogFileSizeMB(appConfig.maxLogFileSize) - setGlobalMaxLogFileSizeMB(appConfig.maxLogFileSize) - setAppLogDisabled(appConfig.disableAppLog === true) - await writeFile(appConfigPath(), stringify(appConfig)) + nextConfig.maxLogFileSize = normalizeMaxLogFileSizeMB(nextConfig.maxLogFileSize) + await atomicWriteFile(appConfigPath(), stringify(nextConfig)) + appConfig = nextConfig + setGlobalMaxLogFileSizeMB(nextConfig.maxLogFileSize) + setAppLogDisabled(nextConfig.disableAppLog === true) }) - await appConfigWriteQueue } diff --git a/src/main/config/controledMihomo.ts b/src/main/config/controledMihomo.ts index 34453158..66559fe6 100644 --- a/src/main/config/controledMihomo.ts +++ b/src/main/config/controledMihomo.ts @@ -1,4 +1,4 @@ -import { readFile, writeFile } from 'fs/promises' +import { readFile } from 'fs/promises' import { existsSync } from 'fs' import { controledMihomoConfigPath } from '../utils/dirs' import { parse, stringify } from '../utils/yaml' @@ -7,13 +7,14 @@ import { patchMihomoConfig, startMihomoLogs } from '../core/mihomoApi' import { defaultControledMihomoConfig } from '../utils/template' import { deepMerge } from '../utils/merge' import { createLogger } from '../utils/logger' +import { atomicWriteFile, WriteQueue } from '../utils/safeFile' import { DEFAULT_CONTROL_DNS, DEFAULT_CONTROL_SNIFF } from '../../shared/appConfig' import { getAppConfig, patchAppConfig } from './app' const controledMihomoLogger = createLogger('ControledMihomo') let controledMihomoConfig: Partial // mihomo.yaml -let controledMihomoWriteQueue: Promise = Promise.resolve() +const controledMihomoWriteQueue = new WriteQueue() function cloneDefaultControledMihomoConfig(): Partial { return JSON.parse(JSON.stringify(defaultControledMihomoConfig)) as Partial @@ -27,7 +28,9 @@ export async function getControledMihomoConfig(force = false): Promise): Promise { - controledMihomoWriteQueue = controledMihomoWriteQueue.then(async () => { + await controledMihomoWriteQueue.run(async () => { const appConfig = await getAppConfig() const { controlDns = DEFAULT_CONTROL_DNS, controlSniff = DEFAULT_CONTROL_SNIFF, controlDnsBeforePause } = appConfig + const nextConfig = JSON.parse( + JSON.stringify(controledMihomoConfig || cloneDefaultControledMihomoConfig()) + ) as Partial + const nextPatch = JSON.parse(JSON.stringify(patch)) as Partial + let restoreDnsState = false // 当模式从 direct 切换到 rule/global 时,恢复之前保存的 DNS 状态 - const currentMode = controledMihomoConfig?.mode - const newMode = patch.mode + const currentMode = nextConfig.mode + const newMode = nextPatch.mode if ( currentMode === 'direct' && newMode && newMode !== 'direct' && controlDnsBeforePause !== undefined ) { - // 恢复 DNS 状态并清除保存的状态 - await patchAppConfig({ controlDns: controlDnsBeforePause, controlDnsBeforePause: undefined }) + restoreDnsState = true } // 过滤端口字段中的 NaN 值,防止写入无效配置 const portFields = ['mixed-port', 'socks-port', 'port', 'redir-port', 'tproxy-port'] as const for (const field of portFields) { - if (field in patch && (typeof patch[field] !== 'number' || Number.isNaN(patch[field]))) { - delete patch[field] + if ( + field in nextPatch && + (typeof nextPatch[field] !== 'number' || Number.isNaN(nextPatch[field])) + ) { + delete nextPatch[field] } } - if (patch.hosts) { - controledMihomoConfig.hosts = patch.hosts + if (nextPatch.hosts) { + nextConfig.hosts = nextPatch.hosts } const replaceNameserverPolicy = Object.prototype.hasOwnProperty.call( - patch.dns || {}, + nextPatch.dns || {}, 'nameserver-policy' ) - controledMihomoConfig = deepMerge(controledMihomoConfig, patch) + deepMerge(nextConfig, nextPatch) if (replaceNameserverPolicy) { - controledMihomoConfig.dns = controledMihomoConfig.dns || {} - controledMihomoConfig.dns['nameserver-policy'] = patch.dns?.['nameserver-policy'] ?? {} + nextConfig.dns = nextConfig.dns || {} + nextConfig.dns['nameserver-policy'] = nextPatch.dns?.['nameserver-policy'] ?? {} } // 从不接管状态恢复 if (controlDns) { // 确保 DNS 配置包含所有必要的默认字段,特别是新增的 fallback 等 - controledMihomoConfig.dns = deepMerge( + nextConfig.dns = deepMerge( cloneDefaultControledMihomoConfig().dns || {}, - controledMihomoConfig.dns || {} + nextConfig.dns || {} ) } - if (controlSniff && !controledMihomoConfig.sniffer) { - controledMihomoConfig.sniffer = cloneDefaultControledMihomoConfig().sniffer + if (controlSniff && !nextConfig.sniffer) { + nextConfig.sniffer = cloneDefaultControledMihomoConfig().sniffer } - await generateProfile() - await writeFile(controledMihomoConfigPath(), stringify(controledMihomoConfig), 'utf-8') + await generateProfile(nextConfig) + await atomicWriteFile(controledMihomoConfigPath(), stringify(nextConfig), { encoding: 'utf8' }) + controledMihomoConfig = nextConfig + if (restoreDnsState) { + await patchAppConfig({ controlDns: controlDnsBeforePause, controlDnsBeforePause: undefined }) + } // 优先对运行中内核进行热更新,避免无意义重启 try { - await patchMihomoConfig(patch) + await patchMihomoConfig(nextPatch) } catch (error) { controledMihomoLogger.warn( 'Hot patch /configs failed, changes will apply on next restart', @@ -121,7 +135,7 @@ export async function patchControledMihomoConfig(patch: Partial): } // log-level 改变时重连日志 WebSocket,使新等级立刻生效 - if (patch['log-level']) { + if (nextPatch['log-level']) { try { await startMihomoLogs() } catch (error) { @@ -136,5 +150,4 @@ export async function patchControledMihomoConfig(patch: Partial): controledMihomoLogger.warn('Failed to schedule runtime config Gist sync', error) } }) - await controledMihomoWriteQueue } diff --git a/src/main/config/override.ts b/src/main/config/override.ts index 0d7feafc..3bb6ec67 100644 --- a/src/main/config/override.ts +++ b/src/main/config/override.ts @@ -1,13 +1,14 @@ -import { readFile, writeFile, rm } from 'fs/promises' +import { readFile, rm } from 'fs/promises' import { existsSync } from 'fs' import { overrideConfigPath, overridePath } from '../utils/dirs' import * as chromeRequest from '../utils/chromeRequest' import { parse, stringify } from '../utils/yaml' +import { atomicWriteFile, WriteQueue } from '../utils/safeFile' import { DEFAULT_MIHOMO_PORTS } from '../../shared/appConfig' import { getControledMihomoConfig } from './controledMihomo' let overrideConfig: IOverrideConfig // override.yaml -let overrideConfigWriteQueue: Promise = Promise.resolve() +const overrideConfigWriteQueue = new WriteQueue() export async function getOverrideConfig(force = false): Promise { if (force || !overrideConfig) { @@ -16,15 +17,15 @@ export async function getOverrideConfig(force = false): Promise } if (typeof overrideConfig !== 'object') overrideConfig = { items: [] } if (!Array.isArray(overrideConfig.items)) overrideConfig.items = [] - return overrideConfig + return JSON.parse(JSON.stringify(overrideConfig)) as IOverrideConfig } export async function setOverrideConfig(config: IOverrideConfig): Promise { - overrideConfigWriteQueue = overrideConfigWriteQueue.then(async () => { - overrideConfig = config - await writeFile(overrideConfigPath(), stringify(overrideConfig), 'utf-8') + await overrideConfigWriteQueue.run(async () => { + const nextConfig = JSON.parse(JSON.stringify(config)) as IOverrideConfig + await atomicWriteFile(overrideConfigPath(), stringify(nextConfig), { encoding: 'utf8' }) + overrideConfig = nextConfig }) - await overrideConfigWriteQueue } export async function getOverrideItem(id: string | undefined): Promise { @@ -110,5 +111,5 @@ export async function getOverride(id: string, ext: 'js' | 'yaml' | 'log'): Promi } export async function setOverride(id: string, ext: 'js' | 'yaml', content: string): Promise { - await writeFile(overridePath(id, ext), content, 'utf-8') + await atomicWriteFile(overridePath(id, ext), content, { encoding: 'utf8' }) } diff --git a/src/main/config/plugin.ts b/src/main/config/plugin.ts index 6db0804b..7701774f 100644 --- a/src/main/config/plugin.ts +++ b/src/main/config/plugin.ts @@ -1,10 +1,11 @@ -import { readFile, writeFile } from 'fs/promises' +import { readFile } from 'fs/promises' import { existsSync } from 'fs' import { pluginConfigPath } from '../utils/dirs' +import { atomicWriteFile, WriteQueue } from '../utils/safeFile' import { parse, stringify } from '../utils/yaml' let pluginConfig: IPluginConfig | undefined -let writeQueue: Promise = Promise.resolve() +const writeQueue = new WriteQueue() export async function getPluginConfig(force = false): Promise { if (force || !pluginConfig) { @@ -21,16 +22,12 @@ export async function getPluginConfig(force = false): Promise { } async function update(updater: (c: IPluginConfig) => IPluginConfig): Promise { - const run = writeQueue.then(async () => { + await writeQueue.run(async () => { const current = await getPluginConfig(true) const next = updater(current) + await atomicWriteFile(pluginConfigPath(), stringify(next), { encoding: 'utf8' }) pluginConfig = next - await writeFile(pluginConfigPath(), stringify(next), 'utf-8') }) - // Keep the queue chain settled so a rejected op doesn't poison later writes, - // but still surface this op's error/result to the caller via `run`. - writeQueue = run.catch(() => {}) - await run } export async function getPluginItem(id: string): Promise { diff --git a/src/main/config/profile.ts b/src/main/config/profile.ts index 32a93879..5da86cd9 100644 --- a/src/main/config/profile.ts +++ b/src/main/config/profile.ts @@ -1,4 +1,4 @@ -import { access, readFile, rm, unlink, writeFile } from 'fs/promises' +import { access, readFile, rm, unlink } from 'fs/promises' import { constants, existsSync } from 'fs' import { exec, execFile } from 'child_process' import { isAbsolute, join, relative, resolve } from 'path' @@ -25,6 +25,7 @@ import { profilePath } from '../utils/dirs' import { createLogger } from '../utils/logger' +import { atomicWriteFile, WriteQueue } from '../utils/safeFile' import { getAppConfig } from './app' import { getControledMihomoConfig } from './controledMihomo' @@ -32,7 +33,7 @@ const profileLogger = createLogger('Profile') const execFilePromise = promisify(execFile) let profileConfig: IProfileConfig -let profileConfigWriteQueue: Promise = Promise.resolve() +const profileConfigWriteQueue = new WriteQueue() let changeProfileQueue: Promise = Promise.resolve() // 并发去重 const inflightRemoteFetches = new Map>() @@ -98,28 +99,28 @@ export async function getProfileConfig(force = false): Promise { } export async function setProfileConfig(config: IProfileConfig): Promise { - profileConfigWriteQueue = profileConfigWriteQueue.then(async () => { - profileConfig = config - await writeFile(profileConfigPath(), stringify(config), 'utf-8') + await profileConfigWriteQueue.run(async () => { + const nextConfig = JSON.parse(JSON.stringify(config)) as IProfileConfig + await atomicWriteFile(profileConfigPath(), stringify(nextConfig), { encoding: 'utf8' }) + profileConfig = nextConfig }) - await profileConfigWriteQueue } export async function updateProfileConfig( updater: (config: IProfileConfig) => IProfileConfig | Promise ): Promise { - let result: IProfileConfig | undefined - profileConfigWriteQueue = profileConfigWriteQueue.then(async () => { + return await profileConfigWriteQueue.run(async () => { const data = await readFile(profileConfigPath(), 'utf-8') - profileConfig = parse(data) || { items: [] } - if (typeof profileConfig !== 'object') profileConfig = { items: [] } - if (!Array.isArray(profileConfig.items)) profileConfig.items = [] - profileConfig = await updater(JSON.parse(JSON.stringify(profileConfig))) - result = profileConfig - await writeFile(profileConfigPath(), stringify(profileConfig), 'utf-8') + const currentConfig = (parse(data) || { items: [] }) as IProfileConfig + if (typeof currentConfig !== 'object') { + throw new Error('Profile config is invalid') + } + if (!Array.isArray(currentConfig.items)) currentConfig.items = [] + const nextConfig = await updater(JSON.parse(JSON.stringify(currentConfig))) + await atomicWriteFile(profileConfigPath(), stringify(nextConfig), { encoding: 'utf8' }) + profileConfig = nextConfig + return JSON.parse(JSON.stringify(nextConfig)) as IProfileConfig }) - await profileConfigWriteQueue - return JSON.parse(JSON.stringify(result ?? profileConfig)) } export async function getProfileItem(id: string | undefined): Promise { @@ -555,7 +556,7 @@ export async function getProfileStr(id: string | undefined): Promise { export async function setProfileStr(id: string, content: string): Promise { // 读取最新的配置 const { current } = await getProfileConfig(true) - await writeFile(profilePath(id), content, 'utf-8') + await atomicWriteFile(profilePath(id), content, { encoding: 'utf8' }) if (current === id) { try { await mihomoHotReloadConfig() @@ -652,12 +653,12 @@ export async function setFileStr(path: string, content: string): Promise { const { diffWorkDir = false } = await getAppConfig() const { current } = await getProfileConfig() if (isAbsolutePath(path)) { - await writeFile(path, content, 'utf-8') + await atomicWriteFile(path, content, { encoding: 'utf8' }) } else { - await writeFile( + await atomicWriteFile( join(diffWorkDir ? mihomoProfileWorkDir(current) : mihomoWorkDir(), path), content, - 'utf-8' + { encoding: 'utf8' } ) } } diff --git a/src/main/core/factory.ts b/src/main/core/factory.ts index 9273d474..eb442096 100644 --- a/src/main/core/factory.ts +++ b/src/main/core/factory.ts @@ -1,4 +1,4 @@ -import { copyFile, mkdir, writeFile, readFile, stat } from 'fs/promises' +import { copyFile, mkdir, readFile, stat } from 'fs/promises' import vm from 'vm' import { existsSync, writeFileSync } from 'fs' import path from 'path' @@ -25,6 +25,7 @@ import { deepMerge } from '../utils/merge' import { createLogger } from '../utils/logger' import { decryptAgeContent } from '../utils/age' import { DEFAULT_CONTROL_DNS, DEFAULT_CONTROL_SNIFF } from '../../shared/appConfig' +import { atomicWriteFile } from '../utils/safeFile' const factoryLogger = createLogger('Factory') const SMART_OVERRIDE_ID = 'smart-core-override' @@ -106,7 +107,9 @@ function ensureSmartProxyServerTunExclude(profile: IMihomoConfig, enabled: boole return added } -export async function generateProfile(): Promise { +export async function generateProfile( + pendingControledMihomoConfig?: Partial +): Promise { // 读取最新的配置 const { current } = await getProfileConfig(true) const { @@ -130,7 +133,7 @@ export async function generateProfile(): Promise { overrideIds.smart, ageSecretKey ) - let controledMihomoConfig = await getControledMihomoConfig() + let controledMihomoConfig = pendingControledMihomoConfig ?? (await getControledMihomoConfig()) // 根据开关状态过滤控制配置 controledMihomoConfig = { ...controledMihomoConfig } @@ -181,15 +184,16 @@ export async function generateProfile(): Promise { delete partialProfile['external-ui'] delete partialProfile['external-ui-url'] } - runtimeConfig = profile - runtimeConfigStr = stringify(profile) + const nextRuntimeConfigStr = stringify(profile) if (diffWorkDir) { await prepareProfileWorkDir(current) } - await writeFile( + await atomicWriteFile( diffWorkDir ? mihomoWorkConfigPath(current) : mihomoWorkConfigPath('work'), - runtimeConfigStr + nextRuntimeConfigStr ) + runtimeConfig = profile + runtimeConfigStr = nextRuntimeConfigStr return current } diff --git a/src/main/resolve/gistApi.ts b/src/main/resolve/gistApi.ts index bb245753..e7d190a6 100644 --- a/src/main/resolve/gistApi.ts +++ b/src/main/resolve/gistApi.ts @@ -1,4 +1,3 @@ -import { writeFile } from 'fs/promises' import { createHash } from 'crypto' import { dialog } from 'electron' import * as chromeRequest from '../utils/chromeRequest' @@ -8,6 +7,7 @@ import { DEFAULT_MIHOMO_PORTS } from '../../shared/appConfig' import { getRuntimeConfigStr } from '../core/factory' import { encryptAgeContent, generateAgeKeyPair } from '../utils/age' import { createLogger } from '../utils/logger' +import { atomicWriteFile } from '../utils/safeFile' interface GistInfo { id: string @@ -203,6 +203,6 @@ export async function exportGistAgeSecretKey(): Promise { if (canceled || !filePath) return false - await writeFile(filePath, `${gistAgeSecretKey.trim()}\n`, 'utf-8') + await atomicWriteFile(filePath, `${gistAgeSecretKey.trim()}\n`, { encoding: 'utf8', mode: 0o600 }) return true } diff --git a/src/main/resolve/plugin/vault.ts b/src/main/resolve/plugin/vault.ts index 329ce8ed..60f7189b 100644 --- a/src/main/resolve/plugin/vault.ts +++ b/src/main/resolve/plugin/vault.ts @@ -1,7 +1,8 @@ -import { mkdir, writeFile, readFile, rm, rename } from 'fs/promises' +import { mkdir, readFile, rm } from 'fs/promises' import { existsSync } from 'fs' import { safeStorage } from 'electron' import { pluginVaultDir, pluginVaultPath } from '../../utils/dirs' +import { atomicWriteFile } from '../../utils/safeFile' import { parseGatewayOrigin, isValidEndpointPath } from './gateway-url' // safeStorage 不可用时的会话内内存兜底(重启即丢) @@ -40,10 +41,7 @@ export async function writeVault(id: string, vault: IPluginVault): Promise } await mkdir(pluginVaultDir(), { recursive: true }) const enc = safeStorage.encryptString(JSON.stringify(vault)) - const finalPath = pluginVaultPath(id) - const tmpPath = `${finalPath}.tmp` - await writeFile(tmpPath, enc, { mode: 0o600 }) - await rename(tmpPath, finalPath) // 原子替换 + await atomicWriteFile(pluginVaultPath(id), enc, { mode: 0o600 }) memoryVaults.set(id, vault) } diff --git a/src/main/resolve/theme.ts b/src/main/resolve/theme.ts index b85748e7..f0afad25 100644 --- a/src/main/resolve/theme.ts +++ b/src/main/resolve/theme.ts @@ -1,4 +1,4 @@ -import { copyFile, readdir, readFile, writeFile } from 'fs/promises' +import { copyFile, readdir, readFile } from 'fs/promises' import path from 'path' import { existsSync } from 'fs' import AdmZip from 'adm-zip' @@ -8,6 +8,7 @@ import * as chromeRequest from '../utils/chromeRequest' import { getControledMihomoConfig } from '../config' import { DEFAULT_MIHOMO_PORTS } from '../../shared/appConfig' import { mainWindow } from '../window' +import { atomicWriteFile } from '../utils/safeFile' import { floatingWindow } from './floatingWindow' let insertedCSSKeyMain: string | undefined = undefined @@ -66,7 +67,7 @@ export async function readTheme(theme: string): Promise { } export async function writeTheme(theme: string, css: string): Promise { - await writeFile(path.join(themesDir(), theme), css) + await atomicWriteFile(path.join(themesDir(), theme), css) } export async function applyTheme(theme: string): Promise { diff --git a/src/main/utils/init.ts b/src/main/utils/init.ts index f132b306..e5cb354e 100644 --- a/src/main/utils/init.ts +++ b/src/main/utils/init.ts @@ -1,4 +1,4 @@ -import { mkdir, writeFile, rm, readdir, cp, stat, rename } from 'fs/promises' +import { mkdir, rm, readdir, cp, stat, rename } from 'fs/promises' import { existsSync } from 'fs' import { exec, execFile } from 'child_process' import { promisify } from 'util' @@ -49,6 +49,7 @@ import { themesDir } from './dirs' import { initLogger } from './logger' +import { atomicWriteFile } from './safeFile' let isInitBasicCompleted = false let isRuntimeFilesCompleted = false @@ -140,7 +141,7 @@ async function initConfig(): Promise { await Promise.all( configs.map(async (config) => { if (!existsSync(config.path)) { - await writeFile(config.path, stringify(config.content)) + await atomicWriteFile(config.path, stringify(config.content)) } }) ) diff --git a/src/main/utils/ipc.ts b/src/main/utils/ipc.ts index 3ec94dce..b99c22f0 100644 --- a/src/main/utils/ipc.ts +++ b/src/main/utils/ipc.ts @@ -1,6 +1,6 @@ import path from 'path' import v8 from 'v8' -import { readFile, writeFile } from 'fs/promises' +import { readFile } from 'fs/promises' import { app, ipcMain } from 'electron' import i18next from 'i18next' import { @@ -137,6 +137,7 @@ import { getIconDataURL } from './icon' import { getAppName } from './appName' import { logDir, rulePath } from './dirs' import { installMihomoCore, getGitHubTags, clearVersionCache } from './github' +import { atomicWriteFile } from './safeFile' // eslint-disable-next-line @typescript-eslint/no-explicit-any type AsyncFn = (...args: any[]) => Promise @@ -188,7 +189,7 @@ async function getRuleStr(id: string): Promise { } async function setRuleStr(id: string, str: string): Promise { - await writeFile(rulePath(id), str, 'utf-8') + await atomicWriteFile(rulePath(id), str, { encoding: 'utf8' }) } async function getSmartOverrideContent(): Promise { diff --git a/src/main/utils/safeFile.ts b/src/main/utils/safeFile.ts new file mode 100644 index 00000000..1b4a2184 --- /dev/null +++ b/src/main/utils/safeFile.ts @@ -0,0 +1,86 @@ +import { randomBytes } from 'crypto' +import { closeSync, fsyncSync, openSync, renameSync, rmSync, writeFileSync } from 'fs' +import { open, rename, rm, type FileHandle } from 'fs/promises' +import { basename, dirname, join } from 'path' + +export interface AtomicWriteOptions { + encoding?: BufferEncoding + mode?: number +} + +function temporaryPath(filePath: string): string { + return join( + dirname(filePath), + `.${basename(filePath)}.${process.pid}.${randomBytes(8).toString('hex')}.tmp` + ) +} + +/** + * Write a complete replacement beside the existing file, then atomically rename it into place. + * A failed write never truncates the previous file. + */ +export async function atomicWriteFile( + filePath: string, + data: string | Uint8Array, + options: AtomicWriteOptions = {} +): Promise { + const tempPath = temporaryPath(filePath) + let handle: FileHandle | undefined + + try { + handle = await open(tempPath, 'wx', options.mode) + await handle.writeFile(data, options.encoding ?? 'utf8') + await handle.sync() + await handle.close() + handle = undefined + await rename(tempPath, filePath) + } finally { + if (handle) await handle.close().catch(() => {}) + await rm(tempPath, { force: true }).catch(() => {}) + } +} + +export function atomicWriteFileSync( + filePath: string, + data: string | Uint8Array, + options: AtomicWriteOptions = {} +): void { + const tempPath = temporaryPath(filePath) + let fd: number | undefined + + try { + fd = openSync(tempPath, 'wx', options.mode) + writeFileSync(fd, data, options.encoding ?? 'utf8') + fsyncSync(fd) + closeSync(fd) + fd = undefined + renameSync(tempPath, filePath) + } finally { + if (fd !== undefined) { + try { + closeSync(fd) + } catch { + // Best effort cleanup after the original write error. + } + } + try { + rmSync(tempPath, { force: true }) + } catch { + // Best effort cleanup after the original write error. + } + } +} + +/** Keeps writes serialized without allowing one failed write to block later retries. */ +export class WriteQueue { + private tail: Promise = Promise.resolve() + + run(task: () => Promise): Promise { + const current = this.tail.then(task, task) + this.tail = current.then( + () => undefined, + () => undefined + ) + return current + } +} diff --git a/src/main/window.ts b/src/main/window.ts index e0e62684..b9ae5b02 100644 --- a/src/main/window.ts +++ b/src/main/window.ts @@ -1,5 +1,5 @@ import { join } from 'path' -import { readFileSync, writeFileSync } from 'fs' +import { readFileSync } from 'fs' import { BrowserWindow, Menu, screen, shell } from 'electron' import { is } from '@electron-toolkit/utils' import icon from '../../resources/icon.png?asset' @@ -9,6 +9,7 @@ import { triggerSysProxy } from './sys/sysproxy' import { hideDockIcon, showDockIcon } from './resolve/tray' import { dataDir } from './utils/dirs' import { mainWindowLogger } from './utils/logger' +import { atomicWriteFileSync } from './utils/safeFile' interface WindowState { width: number @@ -76,9 +77,9 @@ function updateWindowState(window: BrowserWindow, trackBounds = true): void { function persistWindowState(): void { try { - writeFileSync(windowStateFile(), JSON.stringify(windowState)) - } catch { - // 忽略 + atomicWriteFileSync(windowStateFile(), JSON.stringify(windowState)) + } catch (error) { + void mainWindowLogger.error('Failed to persist window state', error) } }