diff --git a/changelog.md b/changelog.md index 98de8297..1f87b587 100644 --- a/changelog.md +++ b/changelog.md @@ -2,6 +2,7 @@ ## 新功能 (Feat) +- 新增订阅 DNS 覆写保护:避免覆盖自定义解析配置,手动开启需确认风险 - 新增按 WiFi SSID 自动切换订阅:为指定 SSID 绑定订阅,连上对应 WiFi 时自动切换,可选离开时切回原订阅(暂停 SSID 优先);macOS 采用事件驱动检测,不再轮询 - 新增自定义 GitHub 下载代理(#2096) - 覆写页面新增全局开关的快捷切换(#1280) diff --git a/src/main/config/app.ts b/src/main/config/app.ts index e9ca9ef2..cd8bec4e 100644 --- a/src/main/config/app.ts +++ b/src/main/config/app.ts @@ -50,7 +50,18 @@ export async function getAppConfig(force = false): Promise { return appConfig } -export async function patchAppConfig(patch: Partial): Promise { +function commitAppConfig(nextConfig: IAppConfig): void { + appConfig = nextConfig + setGlobalMaxLogFileSizeMB(nextConfig.maxLogFileSize) + setCoreLogDisabled(nextConfig.disableCoreLog === true) + setAppLogDisabled(nextConfig.disableAppLog === true) + notifyAppConfigListeners() +} + +async function writeAppConfig( + patch: Partial, + commitOnWriteError: boolean +): Promise { await appConfigWriteQueue.run(async () => { const replaceNameserverPolicy = Object.prototype.hasOwnProperty.call(patch, 'nameserverPolicy') const nextConfig = deepMerge( @@ -61,11 +72,21 @@ export async function patchAppConfig(patch: Partial): Promise nextConfig.nameserverPolicy = patch.nameserverPolicy ?? {} } nextConfig.maxLogFileSize = normalizeMaxLogFileSizeMB(nextConfig.maxLogFileSize) - await atomicWriteFile(appConfigPath(), stringify(nextConfig)) - appConfig = nextConfig - setGlobalMaxLogFileSizeMB(nextConfig.maxLogFileSize) - setCoreLogDisabled(nextConfig.disableCoreLog === true) - setAppLogDisabled(nextConfig.disableAppLog === true) - notifyAppConfigListeners() + try { + await atomicWriteFile(appConfigPath(), stringify(nextConfig)) + } catch (error) { + if (commitOnWriteError) commitAppConfig(nextConfig) + throw error + } + commitAppConfig(nextConfig) }) } + +export async function patchAppConfig(patch: Partial): Promise { + await writeAppConfig(patch, false) +} + +// 内核应用后同步:落盘失败仍更新内存,并抛错供调用方记录。 +export async function syncAppConfigAfterApply(patch: Partial): Promise { + await writeAppConfig(patch, true) +} diff --git a/src/main/config/index.ts b/src/main/config/index.ts index 08be947c..54d9ae44 100644 --- a/src/main/config/index.ts +++ b/src/main/config/index.ts @@ -1,4 +1,4 @@ -export { getAppConfig, patchAppConfig, subscribeAppConfig } from './app' +export { getAppConfig, patchAppConfig, subscribeAppConfig, syncAppConfigAfterApply } from './app' export { getControledMihomoConfig, patchControledMihomoConfig } from './controledMihomo' export { getProfile, diff --git a/src/main/core/dnsOverrideGuard.ts b/src/main/core/dnsOverrideGuard.ts new file mode 100644 index 00000000..14ebb481 --- /dev/null +++ b/src/main/core/dnsOverrideGuard.ts @@ -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> + +function isPlainObject(value: unknown): value is Record { + 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 { + 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 { + 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 { + if ( + !mainWindow || + mainWindow.isDestroyed() || + !mainWindow.isVisible() || + mainWindow.isMinimized() + ) { + return false + } + // 读取与清除之间不可插入 await,避免重复领取。 + const pending = pendingAutoDisabledNotice + pendingAutoDisabledNotice = false + return pending +} + +async function inspectCurrentProfileDns(): Promise { + 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 { + 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' } +} diff --git a/src/main/core/factory.ts b/src/main/core/factory.ts index e8d6d48c..1147cfb5 100644 --- a/src/main/core/factory.ts +++ b/src/main/core/factory.ts @@ -26,6 +26,7 @@ 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' +import { evaluateDnsOverrideGuard, type DnsOverrideGuardResult } from './dnsOverrideGuard' const factoryLogger = createLogger('Factory') const SMART_OVERRIDE_ID = 'smart-core-override' @@ -45,6 +46,12 @@ interface GenerateProfileOptions { updateRuntimeConfig?: boolean } +export interface GenerateProfileResult { + profileId: string | undefined + // 随本次配置成功应用后同步。 + dnsGuard: DnsOverrideGuardResult +} + export async function globalOverrideIdsNow(): Promise { const { items = [] } = (await getOverrideConfig()) || {} return items.filter((item) => item.global).map((item) => item.id) @@ -127,7 +134,7 @@ function ensureSmartProxyServerTunExclude(profile: IMihomoConfig, enabled: boole export async function generateProfile( pendingControledMihomoConfig?: Partial, options: GenerateProfileOptions = {} -): Promise { +): Promise { // 第一阶段:并行读取互不依赖的配置(强制重读 profileConfig 完成后再进入第二阶段,保证缓存一致)。 const [profileConfig, appConfig] = await Promise.all([getProfileConfig(true), getAppConfig()]) const { current } = profileConfig @@ -142,6 +149,20 @@ export async function generateProfile( ]) const ageSecretKey = options.ageSecretKey ?? currentProfileItem?.ageSecretKey ?? '' 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( baseProfile, overrideIds.normal, @@ -154,12 +175,6 @@ export async function generateProfile( ageSecretKey ) - const { - diffWorkDir = false, - controlDns = DEFAULT_CONTROL_DNS, - controlSniff = DEFAULT_CONTROL_SNIFF, - useNameserverPolicy - } = appConfig // 根据开关状态过滤控制配置 controledMihomoConfig = { ...controledMihomoConfig } if (!controlDns) { @@ -223,7 +238,7 @@ export async function generateProfile( runtimeConfig = profile runtimeConfigStr = nextRuntimeConfigStr } - return profileId + return { profileId, dnsGuard } } async function applyRuleOverride( diff --git a/src/main/core/manager.ts b/src/main/core/manager.ts index 579427a7..2d82690e 100644 --- a/src/main/core/manager.ts +++ b/src/main/core/manager.ts @@ -46,6 +46,7 @@ import { getAxios } from './mihomoApi' import { generateProfile } from './factory' +import { syncControlDnsAfterApply, type DnsOverrideGuardResult } from './dnsOverrideGuard' import { syncSmartModelToTestDir } from './smartModel' import { checkAdminRestartForTun as checkAdminRestartForTunWithRestart, @@ -422,6 +423,7 @@ interface CoreConfig { detached: boolean startupMode: CoreStartupMode startupHook?: CoreStartupHook + dnsGuard: DnsOverrideGuardResult } function buildCoreEnv(safePath?: string, ageSecretKey?: string): NodeJS.ProcessEnv { @@ -463,7 +465,7 @@ async function prepareCore(detached: boolean, skipStop = false): Promise[]>((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 readiness.then( diff --git a/src/main/core/mihomoApi.ts b/src/main/core/mihomoApi.ts index 027d6751..182abceb 100644 --- a/src/main/core/mihomoApi.ts +++ b/src/main/core/mihomoApi.ts @@ -11,6 +11,7 @@ import { recordTrafficUsage } from '../traffic/recorder' import { createLogger } from '../utils/logger' import { mihomoWorkConfigPath } from '../utils/dirs' import { generateProfile, getRuntimeConfig } from './factory' +import { syncControlDnsAfterApply } from './dnsOverrideGuard' import { getMihomoIpcPath, hasCoreProcess, restartCore } from './manager' const mihomoApiLogger = createLogger('MihomoApi') @@ -438,7 +439,7 @@ export const mihomoHotReloadConfig = async (): Promise => { // Smart 覆写脚本由应用配置生成,必须先同步再生成配置, // 否则界面上改动的 Smart 选项会沿用旧脚本,要等到下次重启内核才生效 await manageSmartOverride() - const current = await generateProfile() + const { profileId: current, dnsGuard } = await generateProfile() const { diffWorkDir = false } = await getAppConfig() const configPath = diffWorkDir ? mihomoWorkConfigPath(current) : mihomoWorkConfigPath('work') mihomoApiLogger.info(`hot reload config path: ${configPath}`) @@ -452,6 +453,11 @@ export const mihomoHotReloadConfig = async (): Promise => { return } 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 { const { scheduleRuntimeConfigUpload } = await import('../resolve/gistApi') scheduleRuntimeConfigUpload() diff --git a/src/main/utils/ipc.ts b/src/main/utils/ipc.ts index ea992b23..84d8b6e0 100644 --- a/src/main/utils/ipc.ts +++ b/src/main/utils/ipc.ts @@ -91,6 +91,7 @@ import { setupFirewall } from '../sys/misc' import { getRuntimeConfig, getRuntimeConfigStr } from '../core/factory' +import { setControlDns, takeDnsOverrideAutoDisabledNotice } from '../core/dnsOverrideGuard' import { listWebdavBackups, webdavBackup, @@ -271,6 +272,8 @@ const asyncHandlers: Record = { patchAppConfig, getControledMihomoConfig, patchControledMihomoConfig, + setControlDns, + takeDnsOverrideAutoDisabledNotice, // Profile getProfileConfig, setProfileConfig, diff --git a/src/preload/index.ts b/src/preload/index.ts index 20b4a0b9..6ddf9922 100644 --- a/src/preload/index.ts +++ b/src/preload/index.ts @@ -37,6 +37,8 @@ const validInvokeChannels = [ 'patchAppConfig', 'getControledMihomoConfig', 'patchControledMihomoConfig', + 'setControlDns', + 'takeDnsOverrideAutoDisabledNotice', 'resetAppConfig', // Profile 'getProfileConfig', @@ -183,7 +185,8 @@ const validListenChannels = [ 'rulesUpdated', 'updateDownloadProgress', 'pluginConfigUpdated', - 'openPluginFile' + 'openPluginFile', + 'dnsOverrideAutoDisabled' ] as const // 允许的 send channels 白名单 diff --git a/src/renderer/src/App.tsx b/src/renderer/src/App.tsx index 6a4f9d86..1797a876 100644 --- a/src/renderer/src/App.tsx +++ b/src/renderer/src/App.tsx @@ -11,6 +11,7 @@ import { applyTheme, setNativeTheme, setTitleBarOverlay } from '@renderer/utils/ import { platform } from '@renderer/utils/init' import { TitleBarOverlayOptions } from 'electron' 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 { hasPendingPluginFile, subscribePluginFile } from '@renderer/utils/plugin-file-open' import 'driver.js/dist/driver.css' @@ -54,6 +55,7 @@ const App: React.FC = () => { rememberSelectedSiderCard = false } = appConfig || {} useTrafficLogger(enableTrafficLogger) + useDnsOverrideAutoDisabledNotice() const narrowWidth = platform === 'darwin' ? 70 : 60 const [siderWidthValue, setSiderWidthValue] = useState(siderWidth) const siderWidthValueRef = useRef(siderWidthValue) diff --git a/src/renderer/src/components/base/base-confirm-modal.tsx b/src/renderer/src/components/base/base-confirm-modal.tsx index 592e887f..e557a8f3 100644 --- a/src/renderer/src/components/base/base-confirm-modal.tsx +++ b/src/renderer/src/components/base/base-confirm-modal.tsx @@ -8,11 +8,14 @@ interface Props { onCancel: () => void onConfirm: () => void isOpen: boolean + cancelText?: string + confirmText?: string + isLoading?: boolean } const BaseConfirmModal: React.FC = (props) => { const { t } = useTranslation() - const { title, content, onCancel, onConfirm, isOpen } = props + const { title, content, onCancel, onConfirm, isOpen, cancelText, confirmText, isLoading } = props return ( @@ -22,11 +25,11 @@ const BaseConfirmModal: React.FC = (props) => {

{content}

- - diff --git a/src/renderer/src/components/base/toast.tsx b/src/renderer/src/components/base/toast.tsx index f83cc5f3..97460a4d 100644 --- a/src/renderer/src/components/base/toast.tsx +++ b/src/renderer/src/components/base/toast.tsx @@ -49,7 +49,8 @@ const addDetailedToast = (type: ToastType, message: string, title?: string): voi export const toast = { success: (message: string, title?: string): void => addToast('success', message, title), 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), detailedError: (message: string, title?: string): void => addDetailedToast('error', message, title) diff --git a/src/renderer/src/components/sider/dns-card.tsx b/src/renderer/src/components/sider/dns-card.tsx index e37744ea..5dfcfdb9 100644 --- a/src/renderer/src/components/sider/dns-card.tsx +++ b/src/renderer/src/components/sider/dns-card.tsx @@ -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 BorderSwitch from '@renderer/components/base/border-switch' +import BaseConfirmModal from '@renderer/components/base/base-confirm-modal' import { LuServer } from 'react-icons/lu' 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 { CSS } from '@dnd-kit/utilities' import { useAppConfig } from '@renderer/hooks/use-app-config' -import React from 'react' +import React, { useState } from 'react' import { useTranslation } from 'react-i18next' import { DEFAULT_CONTROL_DNS } from '../../../../shared/appConfig' @@ -16,8 +17,11 @@ interface Props { } const DNSCard: React.FC = (props) => { const { t } = useTranslation() - const { appConfig, patchAppConfig } = useAppConfig() + const { appConfig, mutateAppConfig } = useAppConfig() const { iconOnly } = props + const [applying, setApplying] = useState(false) + // 弹窗保存待确认指纹,开关以后端状态为准。 + const [confirmation, setConfirmation] = useState(null) const { dnsCardStatus = 'col-span-1', controlDns = DEFAULT_CONTROL_DNS, @@ -37,14 +41,23 @@ const DNSCard: React.FC = (props) => { id: 'dns' }) const transform = tf ? { x: tf.x, y: tf.y, scaleX: 1, scaleY: 1 } : null - const onChange = async (controlDns: boolean): Promise => { + const apply = async (enabled: boolean, confirmed?: string): Promise => { + if (applying) return + setApplying(true) try { - await patchAppConfig({ controlDns }) - await mihomoHotReloadConfig() + const result = await setControlDns(enabled, confirmed) + // 来源变化时保留弹窗,换用新指纹。 + setConfirmation(result.status === 'confirm-required' ? result.confirmation : null) } catch (e) { toast.error(String(e)) + } finally { + setApplying(false) + mutateAppConfig() } } + const onChange = (controlDns: boolean): void => { + void apply(controlDns) + } if (iconOnly) { return ( @@ -95,12 +108,15 @@ const DNSCard: React.FC = (props) => { className={`${match ? 'text-primary-foreground' : 'text-foreground'} text-[24px] font-bold`} /> - +
+ {applying && } + +
@@ -111,6 +127,20 @@ const DNSCard: React.FC = (props) => { + { + if (!applying) setConfirmation(null) + }} + onConfirm={() => { + if (confirmation) void apply(true, confirmation) + }} + /> ) } diff --git a/src/renderer/src/hooks/use-dns-override-notice.tsx b/src/renderer/src/hooks/use-dns-override-notice.tsx new file mode 100644 index 00000000..b0c57b6f --- /dev/null +++ b/src/renderer/src/hooks/use-dns-override-notice.tsx @@ -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 => { + 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]) +} diff --git a/src/renderer/src/locales/en-US.json b/src/renderer/src/locales/en-US.json index 3ca09a8d..d1a26039 100644 --- a/src/renderer/src/locales/en-US.json +++ b/src/renderer/src/locales/en-US.json @@ -556,6 +556,11 @@ "dns.customHosts.domainPlaceholder": "Domain", "dns.customHosts.valuePlaceholder": "Domain or IP, separate multiple with commas", "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.input.placeholder": "Enter your subscription URL", "profiles.updateAll": "Update All Profiles", diff --git a/src/renderer/src/locales/fa-IR.json b/src/renderer/src/locales/fa-IR.json index f04e109d..039a2f59 100644 --- a/src/renderer/src/locales/fa-IR.json +++ b/src/renderer/src/locales/fa-IR.json @@ -542,6 +542,11 @@ "dns.customHosts.domainPlaceholder": "دامنه", "dns.customHosts.valuePlaceholder": "دامنه یا IP، چندین مورد با کاما جدا کنید", "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.input.placeholder": "آدرس اشتراک خود را وارد کنید", "profiles.updateAll": "به‌روزرسانی همه پروفایل‌ها", diff --git a/src/renderer/src/locales/ru-RU.json b/src/renderer/src/locales/ru-RU.json index ea8b1a4d..0bedf7ad 100644 --- a/src/renderer/src/locales/ru-RU.json +++ b/src/renderer/src/locales/ru-RU.json @@ -544,6 +544,11 @@ "dns.customHosts.domainPlaceholder": "Домен", "dns.customHosts.valuePlaceholder": "Домен или IP, несколько через запятую", "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.input.placeholder": "Введите URL вашей подписки", "profiles.updateAll": "Обновить все профили", diff --git a/src/renderer/src/locales/zh-CN.json b/src/renderer/src/locales/zh-CN.json index c5f855c4..a1551671 100644 --- a/src/renderer/src/locales/zh-CN.json +++ b/src/renderer/src/locales/zh-CN.json @@ -556,6 +556,11 @@ "dns.fallbackFilter.domain": "回退域名", "dns.fallbackFilter.domainPlaceholder": "例:+.google.com", "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.input.placeholder": "请输入您的订阅网址", "profiles.updateAll": "更新全部订阅", diff --git a/src/renderer/src/locales/zh-TW.json b/src/renderer/src/locales/zh-TW.json index e9692e8f..c2c38df7 100644 --- a/src/renderer/src/locales/zh-TW.json +++ b/src/renderer/src/locales/zh-TW.json @@ -556,6 +556,11 @@ "dns.fallbackFilter.domain": "回退域名", "dns.fallbackFilter.domainPlaceholder": "例:+.google.com", "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.input.placeholder": "請輸入您的訂閱網址", "profiles.updateAll": "更新全部訂閱", diff --git a/src/renderer/src/utils/ipc.ts b/src/renderer/src/utils/ipc.ts index 7455301f..7a32c24b 100644 --- a/src/renderer/src/utils/ipc.ts +++ b/src/renderer/src/utils/ipc.ts @@ -62,6 +62,8 @@ interface IpcApi { // Config getAppConfig: (force?: boolean) => Promise patchAppConfig: (patch: Partial) => Promise + setControlDns: (enabled: boolean, confirmation?: string) => Promise + takeDnsOverrideAutoDisabledNotice: () => Promise getControledMihomoConfig: (force?: boolean) => Promise> patchControledMihomoConfig: (patch: Partial) => Promise resetAppConfig: () => Promise @@ -238,6 +240,8 @@ export const { // Config getAppConfig, patchAppConfig, + setControlDns, + takeDnsOverrideAutoDisabledNotice, getControledMihomoConfig, patchControledMihomoConfig, resetAppConfig, diff --git a/src/shared/types.d.ts b/src/shared/types.d.ts index 1ea58aaf..45700f29 100644 --- a/src/shared/types.d.ts +++ b/src/shared/types.d.ts @@ -541,6 +541,10 @@ interface IMihomoConfig { profile: IMihomoProfileConfig } +// DNS 覆写切换结果;用户确认后原样回传来源指纹。 +type IControlDnsApplyResult = + { status: 'applied' } | { status: 'confirm-required'; confirmation: string } + interface IProfileConfig { current?: string items: IProfileItem[]