mirror of
https://hubproxy.babadafafafafa.cn/https://github.com/mihomo-party-org/clash-party.git
synced 2026-09-20 08:03:39 +08:00
feat: protect critical data writes
This commit is contained in:
@@ -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
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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' })
|
||||
}
|
||||
|
||||
@@ -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> {
|
||||
|
||||
@@ -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' }
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
|
||||
@@ -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> {
|
||||
|
||||
@@ -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))
|
||||
}
|
||||
})
|
||||
)
|
||||
|
||||
@@ -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> {
|
||||
|
||||
86
src/main/utils/safeFile.ts
Normal file
86
src/main/utils/safeFile.ts
Normal 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
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user