feat: protect critical data writes

This commit is contained in:
zjdndjf
2026-07-14 06:19:31 +08:00
parent 6d26c69511
commit 1f85bbef8e
13 changed files with 206 additions and 99 deletions

View File

@@ -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<void> = 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<IAppConfig> {
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<IAppConfig>): Promise<void> {
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
}

View File

@@ -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<IMihomoConfig> // mihomo.yaml
let controledMihomoWriteQueue: Promise<void> = Promise.resolve()
const controledMihomoWriteQueue = new WriteQueue()
function cloneDefaultControledMihomoConfig(): Partial<IMihomoConfig> {
return JSON.parse(JSON.stringify(defaultControledMihomoConfig)) as Partial<IMihomoConfig>
@@ -27,7 +28,9 @@ export async function getControledMihomoConfig(force = false): Promise<Partial<I
} else {
controledMihomoConfig = cloneDefaultControledMihomoConfig()
try {
await writeFile(controledMihomoConfigPath(), stringify(controledMihomoConfig), 'utf-8')
await atomicWriteFile(controledMihomoConfigPath(), stringify(controledMihomoConfig), {
encoding: 'utf8'
})
} catch (error) {
controledMihomoLogger.error('Failed to create mihomo.yaml file', error)
}
@@ -53,66 +56,77 @@ export async function getControledMihomoConfig(force = false): Promise<Partial<I
}
export async function patchControledMihomoConfig(patch: Partial<IMihomoConfig>): Promise<void> {
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<IMihomoConfig>
const nextPatch = JSON.parse(JSON.stringify(patch)) as Partial<IMihomoConfig>
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<IMihomoConfig>):
}
// 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<IMihomoConfig>):
controledMihomoLogger.warn('Failed to schedule runtime config Gist sync', error)
}
})
await controledMihomoWriteQueue
}

View File

@@ -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<void> = Promise.resolve()
const overrideConfigWriteQueue = new WriteQueue()
export async function getOverrideConfig(force = false): Promise<IOverrideConfig> {
if (force || !overrideConfig) {
@@ -16,15 +17,15 @@ export async function getOverrideConfig(force = false): Promise<IOverrideConfig>
}
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<void> {
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<IOverrideItem | undefined> {
@@ -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<void> {
await writeFile(overridePath(id, ext), content, 'utf-8')
await atomicWriteFile(overridePath(id, ext), content, { encoding: 'utf8' })
}

View File

@@ -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<void> = Promise.resolve()
const writeQueue = new WriteQueue()
export async function getPluginConfig(force = false): Promise<IPluginConfig> {
if (force || !pluginConfig) {
@@ -21,16 +22,12 @@ export async function getPluginConfig(force = false): Promise<IPluginConfig> {
}
async function update(updater: (c: IPluginConfig) => IPluginConfig): Promise<void> {
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<IPluginItem | undefined> {

View File

@@ -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<void> = Promise.resolve()
const profileConfigWriteQueue = new WriteQueue()
let changeProfileQueue: Promise<void> = Promise.resolve()
// 并发去重
const inflightRemoteFetches = new Map<string, Promise<IProfileItem>>()
@@ -98,28 +99,28 @@ export async function getProfileConfig(force = false): Promise<IProfileConfig> {
}
export async function setProfileConfig(config: IProfileConfig): Promise<void> {
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<IProfileConfig>
): Promise<IProfileConfig> {
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<IProfileItem | undefined> {
@@ -555,7 +556,7 @@ export async function getProfileStr(id: string | undefined): Promise<string> {
export async function setProfileStr(id: string, content: string): Promise<void> {
// 读取最新的配置
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<void> {
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' }
)
}
}

View File

@@ -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<string | undefined> {
export async function generateProfile(
pendingControledMihomoConfig?: Partial<IMihomoConfig>
): Promise<string | undefined> {
// 读取最新的配置
const { current } = await getProfileConfig(true)
const {
@@ -130,7 +133,7 @@ export async function generateProfile(): Promise<string | undefined> {
overrideIds.smart,
ageSecretKey
)
let controledMihomoConfig = await getControledMihomoConfig()
let controledMihomoConfig = pendingControledMihomoConfig ?? (await getControledMihomoConfig())
// 根据开关状态过滤控制配置
controledMihomoConfig = { ...controledMihomoConfig }
@@ -181,15 +184,16 @@ export async function generateProfile(): Promise<string | undefined> {
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
}

View File

@@ -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<boolean> {
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
}

View File

@@ -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<void>
}
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)
}

View File

@@ -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<string> {
}
export async function writeTheme(theme: string, css: string): Promise<void> {
await writeFile(path.join(themesDir(), theme), css)
await atomicWriteFile(path.join(themesDir(), theme), css)
}
export async function applyTheme(theme: string): Promise<void> {

View File

@@ -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<void> {
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))
}
})
)

View File

@@ -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<any>
@@ -188,7 +189,7 @@ async function getRuleStr(id: string): Promise<string> {
}
async function setRuleStr(id: string, str: string): Promise<void> {
await writeFile(rulePath(id), str, 'utf-8')
await atomicWriteFile(rulePath(id), str, { encoding: 'utf8' })
}
async function getSmartOverrideContent(): Promise<string | null> {

View File

@@ -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<void> {
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<void> = Promise.resolve()
run<T>(task: () => Promise<T>): Promise<T> {
const current = this.tail.then(task, task)
this.tail = current.then(
() => undefined,
() => undefined
)
return current
}
}

View File

@@ -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)
}
}