feat: protect subscription DNS settings from unconfirmed overrides

- Detect custom DNS in raw subscriptions and require confirmation
- Bind confirmation to the subscription and DNS content for the current session
- Sync the override switch after successful core application
- Retain auto-disable notices until the window becomes visible
This commit is contained in:
ezequielnick
2026-09-10 14:05:26 +08:00
parent 0cd5d86193
commit eb0498b33d
21 changed files with 403 additions and 39 deletions

View File

@@ -2,6 +2,7 @@
## 新功能 (Feat) ## 新功能 (Feat)
- 新增订阅 DNS 覆写保护:避免覆盖自定义解析配置,手动开启需确认风险
- 新增按 WiFi SSID 自动切换订阅:为指定 SSID 绑定订阅,连上对应 WiFi 时自动切换,可选离开时切回原订阅(暂停 SSID 优先macOS 采用事件驱动检测,不再轮询 - 新增按 WiFi SSID 自动切换订阅:为指定 SSID 绑定订阅,连上对应 WiFi 时自动切换,可选离开时切回原订阅(暂停 SSID 优先macOS 采用事件驱动检测,不再轮询
- 新增自定义 GitHub 下载代理(#2096 - 新增自定义 GitHub 下载代理(#2096
- 覆写页面新增全局开关的快捷切换(#1280 - 覆写页面新增全局开关的快捷切换(#1280

View File

@@ -50,7 +50,18 @@ export async function getAppConfig(force = false): Promise<IAppConfig> {
return appConfig return appConfig
} }
export async function patchAppConfig(patch: Partial<IAppConfig>): Promise<void> { function commitAppConfig(nextConfig: IAppConfig): void {
appConfig = nextConfig
setGlobalMaxLogFileSizeMB(nextConfig.maxLogFileSize)
setCoreLogDisabled(nextConfig.disableCoreLog === true)
setAppLogDisabled(nextConfig.disableAppLog === true)
notifyAppConfigListeners()
}
async function writeAppConfig(
patch: Partial<IAppConfig>,
commitOnWriteError: boolean
): Promise<void> {
await appConfigWriteQueue.run(async () => { await appConfigWriteQueue.run(async () => {
const replaceNameserverPolicy = Object.prototype.hasOwnProperty.call(patch, 'nameserverPolicy') const replaceNameserverPolicy = Object.prototype.hasOwnProperty.call(patch, 'nameserverPolicy')
const nextConfig = deepMerge( const nextConfig = deepMerge(
@@ -61,11 +72,21 @@ export async function patchAppConfig(patch: Partial<IAppConfig>): Promise<void>
nextConfig.nameserverPolicy = patch.nameserverPolicy ?? {} nextConfig.nameserverPolicy = patch.nameserverPolicy ?? {}
} }
nextConfig.maxLogFileSize = normalizeMaxLogFileSizeMB(nextConfig.maxLogFileSize) nextConfig.maxLogFileSize = normalizeMaxLogFileSizeMB(nextConfig.maxLogFileSize)
await atomicWriteFile(appConfigPath(), stringify(nextConfig)) try {
appConfig = nextConfig await atomicWriteFile(appConfigPath(), stringify(nextConfig))
setGlobalMaxLogFileSizeMB(nextConfig.maxLogFileSize) } catch (error) {
setCoreLogDisabled(nextConfig.disableCoreLog === true) if (commitOnWriteError) commitAppConfig(nextConfig)
setAppLogDisabled(nextConfig.disableAppLog === true) throw error
notifyAppConfigListeners() }
commitAppConfig(nextConfig)
}) })
} }
export async function patchAppConfig(patch: Partial<IAppConfig>): Promise<void> {
await writeAppConfig(patch, false)
}
// 内核应用后同步:落盘失败仍更新内存,并抛错供调用方记录。
export async function syncAppConfigAfterApply(patch: Partial<IAppConfig>): Promise<void> {
await writeAppConfig(patch, true)
}

View File

@@ -1,4 +1,4 @@
export { getAppConfig, patchAppConfig, subscribeAppConfig } from './app' export { getAppConfig, patchAppConfig, subscribeAppConfig, syncAppConfigAfterApply } from './app'
export { getControledMihomoConfig, patchControledMihomoConfig } from './controledMihomo' export { getControledMihomoConfig, patchControledMihomoConfig } from './controledMihomo'
export { export {
getProfile, getProfile,

View File

@@ -0,0 +1,196 @@
import { createHash } from 'crypto'
import { getAppConfig, getProfile, getProfileConfig, syncAppConfigAfterApply } from '../config'
import { mainWindow } from '../window'
import { createLogger } from '../utils/logger'
import { DEFAULT_CONTROL_DNS } from '../../shared/appConfig'
const guardLogger = createLogger('DnsOverrideGuard')
const PROFILE_DNS_FIELDS = [
'proxy-server-nameserver',
'proxy-server-nameserver-policy',
'nameserver-policy'
] as const
type ProfileDnsField = (typeof PROFILE_DNS_FIELDS)[number]
export type ProfileDnsFields = Partial<Record<ProfileDnsField, unknown>>
function isPlainObject(value: unknown): value is Record<string, unknown> {
return typeof value === 'object' && value !== null && !Array.isArray(value)
}
function hasContent(value: unknown): boolean {
if (typeof value === 'string') return value.trim().length > 0
if (Array.isArray(value)) return value.length > 0
if (isPlainObject(value)) return Object.keys(value).length > 0
return false
}
// 仅探测原始订阅 dns 下的受保护字段。
export function detectProfileDns(profile: unknown): ProfileDnsFields | null {
if (!isPlainObject(profile) || !isPlainObject(profile.dns)) return null
const fields: ProfileDnsFields = {}
for (const key of PROFILE_DNS_FIELDS) {
const value = profile.dns[key]
if (hasContent(value)) fields[key] = value
}
return Object.keys(fields).length > 0 ? fields : null
}
// 指纹忽略映射键顺序,保留数组顺序。
function canonicalize(value: unknown): unknown {
if (Array.isArray(value)) return value.map(canonicalize)
if (isPlainObject(value)) {
return Object.fromEntries(
Object.keys(value)
.sort()
.map((key) => [key, canonicalize(value[key])])
)
}
return value
}
// 确认指纹:订阅标识 + 受保护 DNS 内容。
export function profileDnsFingerprint(profileId: string, fields: ProfileDnsFields): string {
return createHash('sha256')
.update(JSON.stringify({ profileId, fields: canonicalize(fields) }))
.digest('hex')
}
// 手动请求仅在内核应用成功后提交。
interface ControlDnsRequest {
controlDns: boolean
confirmation: string | null
applied?: DnsOverrideGuardResult
}
// 确认、请求和待通知状态仅保留在本次进程内。
let confirmedFingerprint: string | null = null
let pendingRequest: ControlDnsRequest | null = null
let pendingAutoDisabledNotice = false
export interface DnsOverrideGuardResult {
controlDns: boolean
autoDisabled: boolean
fingerprint: string | null
request: ControlDnsRequest | null
}
// 生成保护判定候选校验runtime=false不修改确认也不使用暂存请求。
export function evaluateDnsOverrideGuard(
profileId: string,
profile: unknown,
controlDns: boolean,
runtime: boolean
): DnsOverrideGuardResult {
const fields = detectProfileDns(profile)
const fingerprint = fields ? profileDnsFingerprint(profileId, fields) : null
if (runtime && confirmedFingerprint !== null && confirmedFingerprint !== fingerprint) {
confirmedFingerprint = null
}
const request = runtime ? pendingRequest : null
const enabled = request ? request.controlDns : controlDns
const confirmed =
fingerprint !== null &&
(confirmedFingerprint === fingerprint || request?.confirmation === fingerprint)
const autoDisabled = enabled && fingerprint !== null && !confirmed
return { controlDns: enabled && !autoDisabled, autoDisabled, fingerprint, request }
}
// 应用后的保存失败只记日志,不影响已完成的操作。
async function persistControlDns(controlDns: boolean): Promise<void> {
try {
await syncAppConfigAfterApply({ controlDns })
} catch (error) {
guardLogger.error('Failed to persist DNS override state after apply', error)
}
}
function notifyRenderer(autoDisabled: boolean): void {
mainWindow?.webContents.send('appConfigUpdated')
if (autoDisabled) mainWindow?.webContents.send('dnsOverrideAutoDisabled')
}
// 按实际成功应用的配置提交开关、确认及通知。
export async function syncControlDnsAfterApply(applied: DnsOverrideGuardResult): Promise<void> {
const { request } = applied
if (request && pendingRequest === request) {
pendingRequest = null
request.applied = applied
confirmedFingerprint = applied.controlDns ? applied.fingerprint : null
if (applied.autoDisabled) {
guardLogger.info('Profile changed while enabling DNS override, kept disabled')
pendingAutoDisabledNotice = true
}
await persistControlDns(applied.controlDns)
notifyRenderer(applied.autoDisabled)
return
}
// 并发更新可能晚于确认提交完成,仍需按实际来源使旧确认失效。
if (confirmedFingerprint !== null && applied.fingerprint !== confirmedFingerprint) {
confirmedFingerprint = null
}
if (!applied.autoDisabled) return
const { controlDns = DEFAULT_CONTROL_DNS } = await getAppConfig()
if (!controlDns) return
if (applied.fingerprint !== null && applied.fingerprint === confirmedFingerprint) return
guardLogger.info('Current profile carries custom DNS fields, DNS override disabled')
pendingAutoDisabledNotice = true
await persistControlDns(false)
notifyRenderer(true)
}
// 窗口可见时领取通知;隐藏或最小化时保留。
export async function takeDnsOverrideAutoDisabledNotice(): Promise<boolean> {
if (
!mainWindow ||
mainWindow.isDestroyed() ||
!mainWindow.isVisible() ||
mainWindow.isMinimized()
) {
return false
}
// 读取与清除之间不可插入 await避免重复领取。
const pending = pendingAutoDisabledNotice
pendingAutoDisabledNotice = false
return pending
}
async function inspectCurrentProfileDns(): Promise<string | null> {
const { current } = await getProfileConfig(true)
const fields = detectProfileDns(await getProfile(current))
return fields ? profileDnsFingerprint(current ?? 'default', fields) : null
}
// 手动切换:校验当前来源的确认,暂存请求并等待内核应用。
export async function setControlDns(
enabled: boolean,
confirmation?: string
): Promise<IControlDnsApplyResult> {
let request: ControlDnsRequest
if (enabled) {
const source = await inspectCurrentProfileDns()
if (source !== null && confirmedFingerprint !== source && confirmation !== source) {
return { status: 'confirm-required', confirmation: source }
}
request = { controlDns: true, confirmation: source }
} else {
request = { controlDns: false, confirmation: null }
}
pendingRequest = request
try {
const { mihomoHotReloadConfig } = await import('./mihomoApi')
await mihomoHotReloadConfig()
} finally {
if (pendingRequest === request) pendingRequest = null
}
const applied = request.applied
// 合并到既有重启时,本请求可能未参与配置生成。
if (!applied) throw new Error('Core is busy, DNS override change was not applied')
if (enabled && applied.autoDisabled && applied.fingerprint !== null) {
// 应用期间来源变化,需重新确认。
return { status: 'confirm-required', confirmation: applied.fingerprint }
}
return { status: 'applied' }
}

View File

@@ -26,6 +26,7 @@ import { createLogger } from '../utils/logger'
import { decryptAgeContent } from '../utils/age' import { decryptAgeContent } from '../utils/age'
import { DEFAULT_CONTROL_DNS, DEFAULT_CONTROL_SNIFF } from '../../shared/appConfig' import { DEFAULT_CONTROL_DNS, DEFAULT_CONTROL_SNIFF } from '../../shared/appConfig'
import { atomicWriteFile } from '../utils/safeFile' import { atomicWriteFile } from '../utils/safeFile'
import { evaluateDnsOverrideGuard, type DnsOverrideGuardResult } from './dnsOverrideGuard'
const factoryLogger = createLogger('Factory') const factoryLogger = createLogger('Factory')
const SMART_OVERRIDE_ID = 'smart-core-override' const SMART_OVERRIDE_ID = 'smart-core-override'
@@ -45,6 +46,12 @@ interface GenerateProfileOptions {
updateRuntimeConfig?: boolean updateRuntimeConfig?: boolean
} }
export interface GenerateProfileResult {
profileId: string | undefined
// 随本次配置成功应用后同步。
dnsGuard: DnsOverrideGuardResult
}
export async function globalOverrideIdsNow(): Promise<string[]> { export async function globalOverrideIdsNow(): Promise<string[]> {
const { items = [] } = (await getOverrideConfig()) || {} const { items = [] } = (await getOverrideConfig()) || {}
return items.filter((item) => item.global).map((item) => item.id) return items.filter((item) => item.global).map((item) => item.id)
@@ -127,7 +134,7 @@ function ensureSmartProxyServerTunExclude(profile: IMihomoConfig, enabled: boole
export async function generateProfile( export async function generateProfile(
pendingControledMihomoConfig?: Partial<IMihomoConfig>, pendingControledMihomoConfig?: Partial<IMihomoConfig>,
options: GenerateProfileOptions = {} options: GenerateProfileOptions = {}
): Promise<string | undefined> { ): Promise<GenerateProfileResult> {
// 第一阶段:并行读取互不依赖的配置(强制重读 profileConfig 完成后再进入第二阶段,保证缓存一致)。 // 第一阶段:并行读取互不依赖的配置(强制重读 profileConfig 完成后再进入第二阶段,保证缓存一致)。
const [profileConfig, appConfig] = await Promise.all([getProfileConfig(true), getAppConfig()]) const [profileConfig, appConfig] = await Promise.all([getProfileConfig(true), getAppConfig()])
const { current } = profileConfig const { current } = profileConfig
@@ -142,6 +149,20 @@ export async function generateProfile(
]) ])
const ageSecretKey = options.ageSecretKey ?? currentProfileItem?.ageSecretKey ?? '' const ageSecretKey = options.ageSecretKey ?? currentProfileItem?.ageSecretKey ?? ''
let controledMihomoConfig = pendingControledMihomoConfig ?? fetchedControledMihomoConfig let controledMihomoConfig = pendingControledMihomoConfig ?? fetchedControledMihomoConfig
const {
diffWorkDir = false,
controlDns: controlDnsSetting = DEFAULT_CONTROL_DNS,
controlSniff = DEFAULT_CONTROL_SNIFF,
useNameserverPolicy
} = appConfig
// DNS 保护先于覆写和脚本处理,开关在内核应用成功后同步。
const dnsGuard = evaluateDnsOverrideGuard(
profileId ?? 'default',
baseProfile,
controlDnsSetting,
options.updateRuntimeConfig !== false
)
const { controlDns } = dnsGuard
const profileWithNormalOverride = await applyOverrides( const profileWithNormalOverride = await applyOverrides(
baseProfile, baseProfile,
overrideIds.normal, overrideIds.normal,
@@ -154,12 +175,6 @@ export async function generateProfile(
ageSecretKey ageSecretKey
) )
const {
diffWorkDir = false,
controlDns = DEFAULT_CONTROL_DNS,
controlSniff = DEFAULT_CONTROL_SNIFF,
useNameserverPolicy
} = appConfig
// 根据开关状态过滤控制配置 // 根据开关状态过滤控制配置
controledMihomoConfig = { ...controledMihomoConfig } controledMihomoConfig = { ...controledMihomoConfig }
if (!controlDns) { if (!controlDns) {
@@ -223,7 +238,7 @@ export async function generateProfile(
runtimeConfig = profile runtimeConfig = profile
runtimeConfigStr = nextRuntimeConfigStr runtimeConfigStr = nextRuntimeConfigStr
} }
return profileId return { profileId, dnsGuard }
} }
async function applyRuleOverride( async function applyRuleOverride(

View File

@@ -46,6 +46,7 @@ import {
getAxios getAxios
} from './mihomoApi' } from './mihomoApi'
import { generateProfile } from './factory' import { generateProfile } from './factory'
import { syncControlDnsAfterApply, type DnsOverrideGuardResult } from './dnsOverrideGuard'
import { syncSmartModelToTestDir } from './smartModel' import { syncSmartModelToTestDir } from './smartModel'
import { import {
checkAdminRestartForTun as checkAdminRestartForTunWithRestart, checkAdminRestartForTun as checkAdminRestartForTunWithRestart,
@@ -422,6 +423,7 @@ interface CoreConfig {
detached: boolean detached: boolean
startupMode: CoreStartupMode startupMode: CoreStartupMode
startupHook?: CoreStartupHook startupHook?: CoreStartupHook
dnsGuard: DnsOverrideGuardResult
} }
function buildCoreEnv(safePath?: string, ageSecretKey?: string): NodeJS.ProcessEnv { function buildCoreEnv(safePath?: string, ageSecretKey?: string): NodeJS.ProcessEnv {
@@ -463,7 +465,7 @@ async function prepareCore(detached: boolean, skipStop = false): Promise<CoreCon
await manageSmartOverride() await manageSmartOverride()
// generateProfile 返回实际使用的 current // generateProfile 返回实际使用的 current
const current = await generateProfile() const { profileId: current, dnsGuard } = await generateProfile()
const ageSecretKey = (await getProfileItem(current))?.ageSecretKey || '' const ageSecretKey = (await getProfileItem(current))?.ageSecretKey || ''
if (testProfileOnStart) { if (testProfileOnStart) {
await checkProfile(current, core, diffWorkDir, ageSecretKey) await checkProfile(current, core, diffWorkDir, ageSecretKey)
@@ -508,7 +510,8 @@ async function prepareCore(detached: boolean, skipStop = false): Promise<CoreCon
ageSecretKey, ageSecretKey,
detached, detached,
startupMode, startupMode,
startupHook startupHook,
dnsGuard
} }
} }
@@ -754,6 +757,14 @@ async function startCoreInternal(detached = false, skipStop = false): Promise<Co
const readiness = new Promise<Promise<void>[]>((resolve, reject) => { const readiness = new Promise<Promise<void>[]>((resolve, reject) => {
setupCoreListeners(proc, config, hookWaiter, resolve, reject) setupCoreListeners(proc, config, hookWaiter, resolve, reject)
}).then(async (value) => {
// API 就绪后同步本次 DNS 保护结果。
try {
await syncControlDnsAfterApply(config.dnsGuard)
} catch (error) {
managerLogger.warn('Failed to sync DNS override state after core start', error)
}
return value
}) })
const activeCancel = cancelActiveStartup const activeCancel = cancelActiveStartup
readiness.then( readiness.then(

View File

@@ -11,6 +11,7 @@ import { recordTrafficUsage } from '../traffic/recorder'
import { createLogger } from '../utils/logger' import { createLogger } from '../utils/logger'
import { mihomoWorkConfigPath } from '../utils/dirs' import { mihomoWorkConfigPath } from '../utils/dirs'
import { generateProfile, getRuntimeConfig } from './factory' import { generateProfile, getRuntimeConfig } from './factory'
import { syncControlDnsAfterApply } from './dnsOverrideGuard'
import { getMihomoIpcPath, hasCoreProcess, restartCore } from './manager' import { getMihomoIpcPath, hasCoreProcess, restartCore } from './manager'
const mihomoApiLogger = createLogger('MihomoApi') const mihomoApiLogger = createLogger('MihomoApi')
@@ -438,7 +439,7 @@ export const mihomoHotReloadConfig = async (): Promise<void> => {
// Smart 覆写脚本由应用配置生成,必须先同步再生成配置, // Smart 覆写脚本由应用配置生成,必须先同步再生成配置,
// 否则界面上改动的 Smart 选项会沿用旧脚本,要等到下次重启内核才生效 // 否则界面上改动的 Smart 选项会沿用旧脚本,要等到下次重启内核才生效
await manageSmartOverride() await manageSmartOverride()
const current = await generateProfile() const { profileId: current, dnsGuard } = await generateProfile()
const { diffWorkDir = false } = await getAppConfig() const { diffWorkDir = false } = await getAppConfig()
const configPath = diffWorkDir ? mihomoWorkConfigPath(current) : mihomoWorkConfigPath('work') const configPath = diffWorkDir ? mihomoWorkConfigPath(current) : mihomoWorkConfigPath('work')
mihomoApiLogger.info(`hot reload config path: ${configPath}`) mihomoApiLogger.info(`hot reload config path: ${configPath}`)
@@ -452,6 +453,11 @@ export const mihomoHotReloadConfig = async (): Promise<void> => {
return return
} }
mihomoApiLogger.info('hot reload config completed') mihomoApiLogger.info('hot reload config completed')
try {
await syncControlDnsAfterApply(dnsGuard)
} catch (error) {
mihomoApiLogger.warn('Failed to sync DNS override state after hot reload', error)
}
try { try {
const { scheduleRuntimeConfigUpload } = await import('../resolve/gistApi') const { scheduleRuntimeConfigUpload } = await import('../resolve/gistApi')
scheduleRuntimeConfigUpload() scheduleRuntimeConfigUpload()

View File

@@ -91,6 +91,7 @@ import {
setupFirewall setupFirewall
} from '../sys/misc' } from '../sys/misc'
import { getRuntimeConfig, getRuntimeConfigStr } from '../core/factory' import { getRuntimeConfig, getRuntimeConfigStr } from '../core/factory'
import { setControlDns, takeDnsOverrideAutoDisabledNotice } from '../core/dnsOverrideGuard'
import { import {
listWebdavBackups, listWebdavBackups,
webdavBackup, webdavBackup,
@@ -271,6 +272,8 @@ const asyncHandlers: Record<string, AsyncFn> = {
patchAppConfig, patchAppConfig,
getControledMihomoConfig, getControledMihomoConfig,
patchControledMihomoConfig, patchControledMihomoConfig,
setControlDns,
takeDnsOverrideAutoDisabledNotice,
// Profile // Profile
getProfileConfig, getProfileConfig,
setProfileConfig, setProfileConfig,

View File

@@ -37,6 +37,8 @@ const validInvokeChannels = [
'patchAppConfig', 'patchAppConfig',
'getControledMihomoConfig', 'getControledMihomoConfig',
'patchControledMihomoConfig', 'patchControledMihomoConfig',
'setControlDns',
'takeDnsOverrideAutoDisabledNotice',
'resetAppConfig', 'resetAppConfig',
// Profile // Profile
'getProfileConfig', 'getProfileConfig',
@@ -183,7 +185,8 @@ const validListenChannels = [
'rulesUpdated', 'rulesUpdated',
'updateDownloadProgress', 'updateDownloadProgress',
'pluginConfigUpdated', 'pluginConfigUpdated',
'openPluginFile' 'openPluginFile',
'dnsOverrideAutoDisabled'
] as const ] as const
// 允许的 send channels 白名单 // 允许的 send channels 白名单

View File

@@ -11,6 +11,7 @@ import { applyTheme, setNativeTheme, setTitleBarOverlay } from '@renderer/utils/
import { platform } from '@renderer/utils/init' import { platform } from '@renderer/utils/init'
import { TitleBarOverlayOptions } from 'electron' import { TitleBarOverlayOptions } from 'electron'
import { useTrafficLogger } from '@renderer/hooks/use-traffic-logger' import { useTrafficLogger } from '@renderer/hooks/use-traffic-logger'
import { useDnsOverrideAutoDisabledNotice } from '@renderer/hooks/use-dns-override-notice'
import { createTourDriver, getDriver, startTourIfNeeded } from '@renderer/utils/tour' import { createTourDriver, getDriver, startTourIfNeeded } from '@renderer/utils/tour'
import { hasPendingPluginFile, subscribePluginFile } from '@renderer/utils/plugin-file-open' import { hasPendingPluginFile, subscribePluginFile } from '@renderer/utils/plugin-file-open'
import 'driver.js/dist/driver.css' import 'driver.js/dist/driver.css'
@@ -54,6 +55,7 @@ const App: React.FC = () => {
rememberSelectedSiderCard = false rememberSelectedSiderCard = false
} = appConfig || {} } = appConfig || {}
useTrafficLogger(enableTrafficLogger) useTrafficLogger(enableTrafficLogger)
useDnsOverrideAutoDisabledNotice()
const narrowWidth = platform === 'darwin' ? 70 : 60 const narrowWidth = platform === 'darwin' ? 70 : 60
const [siderWidthValue, setSiderWidthValue] = useState(siderWidth) const [siderWidthValue, setSiderWidthValue] = useState(siderWidth)
const siderWidthValueRef = useRef(siderWidthValue) const siderWidthValueRef = useRef(siderWidthValue)

View File

@@ -8,11 +8,14 @@ interface Props {
onCancel: () => void onCancel: () => void
onConfirm: () => void onConfirm: () => void
isOpen: boolean isOpen: boolean
cancelText?: string
confirmText?: string
isLoading?: boolean
} }
const BaseConfirmModal: React.FC<Props> = (props) => { const BaseConfirmModal: React.FC<Props> = (props) => {
const { t } = useTranslation() const { t } = useTranslation()
const { title, content, onCancel, onConfirm, isOpen } = props const { title, content, onCancel, onConfirm, isOpen, cancelText, confirmText, isLoading } = props
return ( return (
<Modal backdrop="blur" classNames={{ backdrop: 'top-[48px]' }} hideCloseButton isOpen={isOpen}> <Modal backdrop="blur" classNames={{ backdrop: 'top-[48px]' }} hideCloseButton isOpen={isOpen}>
@@ -22,11 +25,11 @@ const BaseConfirmModal: React.FC<Props> = (props) => {
<p>{content}</p> <p>{content}</p>
</ModalBody> </ModalBody>
<ModalFooter> <ModalFooter>
<Button size="sm" variant="light" onPress={onCancel}> <Button size="sm" variant="light" isDisabled={isLoading} onPress={onCancel}>
{t('common.cancel')} {cancelText ?? t('common.cancel')}
</Button> </Button>
<Button size="sm" color="danger" onPress={onConfirm}> <Button size="sm" color="danger" isLoading={isLoading} onPress={onConfirm}>
{t('common.confirm')} {confirmText ?? t('common.confirm')}
</Button> </Button>
</ModalFooter> </ModalFooter>
</ModalContent> </ModalContent>

View File

@@ -49,7 +49,8 @@ const addDetailedToast = (type: ToastType, message: string, title?: string): voi
export const toast = { export const toast = {
success: (message: string, title?: string): void => addToast('success', message, title), success: (message: string, title?: string): void => addToast('success', message, title),
error: (message: string, title?: string): void => addToast('error', message, title, 1800), error: (message: string, title?: string): void => addToast('error', message, title, 1800),
warning: (message: string, title?: string): void => addToast('warning', message, title), warning: (message: string, title?: string, duration?: number): void =>
addToast('warning', message, title, duration),
info: (message: string, title?: string): void => addToast('info', message, title), info: (message: string, title?: string): void => addToast('info', message, title),
detailedError: (message: string, title?: string): void => detailedError: (message: string, title?: string): void =>
addDetailedToast('error', message, title) addDetailedToast('error', message, title)

View File

@@ -1,13 +1,14 @@
import { Button, Card, CardBody, CardFooter, Tooltip } from '@heroui/react' import { Button, Card, CardBody, CardFooter, Spinner, Tooltip } from '@heroui/react'
import { toast } from '@renderer/components/base/toast' import { toast } from '@renderer/components/base/toast'
import BorderSwitch from '@renderer/components/base/border-switch' import BorderSwitch from '@renderer/components/base/border-switch'
import BaseConfirmModal from '@renderer/components/base/base-confirm-modal'
import { LuServer } from 'react-icons/lu' import { LuServer } from 'react-icons/lu'
import { useLocation, useNavigate } from 'react-router-dom' import { useLocation, useNavigate } from 'react-router-dom'
import { mihomoHotReloadConfig } from '@renderer/utils/ipc' import { setControlDns } from '@renderer/utils/ipc'
import { useSortable } from '@dnd-kit/sortable' import { useSortable } from '@dnd-kit/sortable'
import { CSS } from '@dnd-kit/utilities' import { CSS } from '@dnd-kit/utilities'
import { useAppConfig } from '@renderer/hooks/use-app-config' import { useAppConfig } from '@renderer/hooks/use-app-config'
import React from 'react' import React, { useState } from 'react'
import { useTranslation } from 'react-i18next' import { useTranslation } from 'react-i18next'
import { DEFAULT_CONTROL_DNS } from '../../../../shared/appConfig' import { DEFAULT_CONTROL_DNS } from '../../../../shared/appConfig'
@@ -16,8 +17,11 @@ interface Props {
} }
const DNSCard: React.FC<Props> = (props) => { const DNSCard: React.FC<Props> = (props) => {
const { t } = useTranslation() const { t } = useTranslation()
const { appConfig, patchAppConfig } = useAppConfig() const { appConfig, mutateAppConfig } = useAppConfig()
const { iconOnly } = props const { iconOnly } = props
const [applying, setApplying] = useState(false)
// 弹窗保存待确认指纹,开关以后端状态为准。
const [confirmation, setConfirmation] = useState<string | null>(null)
const { const {
dnsCardStatus = 'col-span-1', dnsCardStatus = 'col-span-1',
controlDns = DEFAULT_CONTROL_DNS, controlDns = DEFAULT_CONTROL_DNS,
@@ -37,14 +41,23 @@ const DNSCard: React.FC<Props> = (props) => {
id: 'dns' id: 'dns'
}) })
const transform = tf ? { x: tf.x, y: tf.y, scaleX: 1, scaleY: 1 } : null const transform = tf ? { x: tf.x, y: tf.y, scaleX: 1, scaleY: 1 } : null
const onChange = async (controlDns: boolean): Promise<void> => { const apply = async (enabled: boolean, confirmed?: string): Promise<void> => {
if (applying) return
setApplying(true)
try { try {
await patchAppConfig({ controlDns }) const result = await setControlDns(enabled, confirmed)
await mihomoHotReloadConfig() // 来源变化时保留弹窗,换用新指纹。
setConfirmation(result.status === 'confirm-required' ? result.confirmation : null)
} catch (e) { } catch (e) {
toast.error(String(e)) toast.error(String(e))
} finally {
setApplying(false)
mutateAppConfig()
} }
} }
const onChange = (controlDns: boolean): void => {
void apply(controlDns)
}
if (iconOnly) { if (iconOnly) {
return ( return (
@@ -95,12 +108,15 @@ const DNSCard: React.FC<Props> = (props) => {
className={`${match ? 'text-primary-foreground' : 'text-foreground'} text-[24px] font-bold`} className={`${match ? 'text-primary-foreground' : 'text-foreground'} text-[24px] font-bold`}
/> />
</Button> </Button>
<BorderSwitch <div className="flex items-center">
isShowBorder={match && controlDns} {applying && <Spinner size="sm" color={match ? 'white' : 'primary'} />}
isSelected={controlDns} <BorderSwitch
isDisabled={false} isShowBorder={match && controlDns}
onValueChange={onChange} isSelected={controlDns}
/> isDisabled={applying}
onValueChange={onChange}
/>
</div>
</div> </div>
</CardBody> </CardBody>
<CardFooter className="pt-1"> <CardFooter className="pt-1">
@@ -111,6 +127,20 @@ const DNSCard: React.FC<Props> = (props) => {
</h3> </h3>
</CardFooter> </CardFooter>
</Card> </Card>
<BaseConfirmModal
isOpen={confirmation !== null}
title={t('dns.overrideGuard.confirmTitle')}
content={t('dns.overrideGuard.confirmContent')}
cancelText={t('dns.overrideGuard.keepOff')}
confirmText={t('dns.overrideGuard.enableAnyway')}
isLoading={applying}
onCancel={() => {
if (!applying) setConfirmation(null)
}}
onConfirm={() => {
if (confirmation) void apply(true, confirmation)
}}
/>
</div> </div>
) )
} }

View File

@@ -0,0 +1,39 @@
import { useEffect } from 'react'
import { useTranslation } from 'react-i18next'
import { toast } from '@renderer/components/base/toast'
import { takeDnsOverrideAutoDisabledNotice } from '@renderer/utils/ipc'
import { useAppConfig } from './use-app-config'
const NOTICE_DURATION_MS = 6000
// 挂载、窗口显示和实时事件统一补取通知,由主进程检查可见性并去重。
export function useDnsOverrideAutoDisabledNotice(): void {
const { t } = useTranslation()
const { mutateAppConfig } = useAppConfig()
useEffect(() => {
const claim = async (): Promise<void> => {
let pending = false
try {
pending = await takeDnsOverrideAutoDisabledNotice()
} catch {
return
}
if (!pending) return
mutateAppConfig()
toast.warning(t('dns.overrideGuard.autoDisabled'), undefined, NOTICE_DURATION_MS)
}
const onShown = (): void => {
void claim()
}
const unsubscribe = window.electron.ipcRenderer.on('dnsOverrideAutoDisabled', onShown)
window.addEventListener('focus', onShown)
document.addEventListener('visibilitychange', onShown)
void claim()
return () => {
unsubscribe()
window.removeEventListener('focus', onShown)
document.removeEventListener('visibilitychange', onShown)
}
}, [t, mutateAppConfig])
}

View File

@@ -556,6 +556,11 @@
"dns.customHosts.domainPlaceholder": "Domain", "dns.customHosts.domainPlaceholder": "Domain",
"dns.customHosts.valuePlaceholder": "Domain or IP, separate multiple with commas", "dns.customHosts.valuePlaceholder": "Domain or IP, separate multiple with commas",
"dns.saveOnly": "Save Only", "dns.saveOnly": "Save Only",
"dns.overrideGuard.confirmTitle": "Enable DNS Override?",
"dns.overrideGuard.confirmContent": "The current profile contains custom DNS settings. Enabling DNS override may overwrite them, which can break node connectivity or access to some websites. Enable anyway?",
"dns.overrideGuard.keepOff": "Keep Off",
"dns.overrideGuard.enableAnyway": "Enable Anyway",
"dns.overrideGuard.autoDisabled": "The current profile contains custom DNS settings, so DNS override has been turned off automatically.",
"profiles.title": "Profile Management", "profiles.title": "Profile Management",
"profiles.input.placeholder": "Enter your subscription URL", "profiles.input.placeholder": "Enter your subscription URL",
"profiles.updateAll": "Update All Profiles", "profiles.updateAll": "Update All Profiles",

View File

@@ -542,6 +542,11 @@
"dns.customHosts.domainPlaceholder": "دامنه", "dns.customHosts.domainPlaceholder": "دامنه",
"dns.customHosts.valuePlaceholder": "دامنه یا IP، چندین مورد با کاما جدا کنید", "dns.customHosts.valuePlaceholder": "دامنه یا IP، چندین مورد با کاما جدا کنید",
"dns.saveOnly": "فقط ذخیره", "dns.saveOnly": "فقط ذخیره",
"dns.overrideGuard.confirmTitle": "بازنویسی DNS فعال شود؟",
"dns.overrideGuard.confirmContent": "اشتراک فعلی شامل تنظیمات DNS سفارشی است. فعال کردن بازنویسی DNS ممکن است این تنظیمات را بازنویسی کند و باعث قطع اتصال نودها یا در دسترس نبودن برخی سایت‌ها شود. با این حال فعال شود؟",
"dns.overrideGuard.keepOff": "خاموش بماند",
"dns.overrideGuard.enableAnyway": "با این حال فعال شود",
"dns.overrideGuard.autoDisabled": "اشتراک فعلی شامل تنظیمات DNS سفارشی است؛ بازنویسی DNS به‌طور خودکار خاموش شد.",
"profiles.title": "مدیریت پروفایل", "profiles.title": "مدیریت پروفایل",
"profiles.input.placeholder": "آدرس اشتراک خود را وارد کنید", "profiles.input.placeholder": "آدرس اشتراک خود را وارد کنید",
"profiles.updateAll": "به‌روزرسانی همه پروفایل‌ها", "profiles.updateAll": "به‌روزرسانی همه پروفایل‌ها",

View File

@@ -544,6 +544,11 @@
"dns.customHosts.domainPlaceholder": "Домен", "dns.customHosts.domainPlaceholder": "Домен",
"dns.customHosts.valuePlaceholder": "Домен или IP, несколько через запятую", "dns.customHosts.valuePlaceholder": "Домен или IP, несколько через запятую",
"dns.saveOnly": "Только сохранить", "dns.saveOnly": "Только сохранить",
"dns.overrideGuard.confirmTitle": "Включить переопределение DNS?",
"dns.overrideGuard.confirmContent": "Текущий профиль содержит собственные настройки DNS. Включение переопределения DNS может перезаписать их, из-за чего узлы перестанут подключаться или часть сайтов станет недоступна. Всё равно включить?",
"dns.overrideGuard.keepOff": "Оставить выключенным",
"dns.overrideGuard.enableAnyway": "Всё равно включить",
"dns.overrideGuard.autoDisabled": "Текущий профиль содержит собственные настройки DNS, поэтому переопределение DNS отключено автоматически.",
"profiles.title": "Управление профилями", "profiles.title": "Управление профилями",
"profiles.input.placeholder": "Введите URL вашей подписки", "profiles.input.placeholder": "Введите URL вашей подписки",
"profiles.updateAll": "Обновить все профили", "profiles.updateAll": "Обновить все профили",

View File

@@ -556,6 +556,11 @@
"dns.fallbackFilter.domain": "回退域名", "dns.fallbackFilter.domain": "回退域名",
"dns.fallbackFilter.domainPlaceholder": "例:+.google.com", "dns.fallbackFilter.domainPlaceholder": "例:+.google.com",
"dns.saveOnly": "仅保存", "dns.saveOnly": "仅保存",
"dns.overrideGuard.confirmTitle": "是否开启 DNS 覆写?",
"dns.overrideGuard.confirmContent": "当前订阅包含自定义 DNS 配置。开启 DNS 覆写可能覆盖这些配置,导致节点无法连接或部分网站无法访问。是否仍要开启?",
"dns.overrideGuard.keepOff": "保持关闭",
"dns.overrideGuard.enableAnyway": "仍然开启",
"dns.overrideGuard.autoDisabled": "检测到当前订阅包含自定义 DNS 配置,已自动关闭 DNS 覆写。",
"profiles.title": "订阅管理", "profiles.title": "订阅管理",
"profiles.input.placeholder": "请输入您的订阅网址", "profiles.input.placeholder": "请输入您的订阅网址",
"profiles.updateAll": "更新全部订阅", "profiles.updateAll": "更新全部订阅",

View File

@@ -556,6 +556,11 @@
"dns.fallbackFilter.domain": "回退域名", "dns.fallbackFilter.domain": "回退域名",
"dns.fallbackFilter.domainPlaceholder": "例:+.google.com", "dns.fallbackFilter.domainPlaceholder": "例:+.google.com",
"dns.saveOnly": "僅保存", "dns.saveOnly": "僅保存",
"dns.overrideGuard.confirmTitle": "是否開啟 DNS 覆寫?",
"dns.overrideGuard.confirmContent": "目前訂閱包含自訂 DNS 設定。開啟 DNS 覆寫可能覆蓋這些設定,導致節點無法連線或部分網站無法存取。是否仍要開啟?",
"dns.overrideGuard.keepOff": "保持關閉",
"dns.overrideGuard.enableAnyway": "仍然開啟",
"dns.overrideGuard.autoDisabled": "偵測到目前訂閱包含自訂 DNS 設定,已自動關閉 DNS 覆寫。",
"profiles.title": "訂閱管理", "profiles.title": "訂閱管理",
"profiles.input.placeholder": "請輸入您的訂閱網址", "profiles.input.placeholder": "請輸入您的訂閱網址",
"profiles.updateAll": "更新全部訂閱", "profiles.updateAll": "更新全部訂閱",

View File

@@ -62,6 +62,8 @@ interface IpcApi {
// Config // Config
getAppConfig: (force?: boolean) => Promise<IAppConfig> getAppConfig: (force?: boolean) => Promise<IAppConfig>
patchAppConfig: (patch: Partial<IAppConfig>) => Promise<void> patchAppConfig: (patch: Partial<IAppConfig>) => Promise<void>
setControlDns: (enabled: boolean, confirmation?: string) => Promise<IControlDnsApplyResult>
takeDnsOverrideAutoDisabledNotice: () => Promise<boolean>
getControledMihomoConfig: (force?: boolean) => Promise<Partial<IMihomoConfig>> getControledMihomoConfig: (force?: boolean) => Promise<Partial<IMihomoConfig>>
patchControledMihomoConfig: (patch: Partial<IMihomoConfig>) => Promise<void> patchControledMihomoConfig: (patch: Partial<IMihomoConfig>) => Promise<void>
resetAppConfig: () => Promise<void> resetAppConfig: () => Promise<void>
@@ -238,6 +240,8 @@ export const {
// Config // Config
getAppConfig, getAppConfig,
patchAppConfig, patchAppConfig,
setControlDns,
takeDnsOverrideAutoDisabledNotice,
getControledMihomoConfig, getControledMihomoConfig,
patchControledMihomoConfig, patchControledMihomoConfig,
resetAppConfig, resetAppConfig,

View File

@@ -541,6 +541,10 @@ interface IMihomoConfig {
profile: IMihomoProfileConfig profile: IMihomoProfileConfig
} }
// DNS 覆写切换结果;用户确认后原样回传来源指纹。
type IControlDnsApplyResult =
{ status: 'applied' } | { status: 'confirm-required'; confirmation: string }
interface IProfileConfig { interface IProfileConfig {
current?: string current?: string
items: IProfileItem[] items: IProfileItem[]