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 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:
@@ -2,6 +2,7 @@
|
||||
|
||||
## 新功能 (Feat)
|
||||
|
||||
- 新增订阅 DNS 覆写保护:避免覆盖自定义解析配置,手动开启需确认风险
|
||||
- 新增按 WiFi SSID 自动切换订阅:为指定 SSID 绑定订阅,连上对应 WiFi 时自动切换,可选离开时切回原订阅(暂停 SSID 优先);macOS 采用事件驱动检测,不再轮询
|
||||
- 新增自定义 GitHub 下载代理(#2096)
|
||||
- 覆写页面新增全局开关的快捷切换(#1280)
|
||||
|
||||
@@ -50,7 +50,18 @@ export async function getAppConfig(force = false): Promise<IAppConfig> {
|
||||
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 () => {
|
||||
const replaceNameserverPolicy = Object.prototype.hasOwnProperty.call(patch, 'nameserverPolicy')
|
||||
const nextConfig = deepMerge(
|
||||
@@ -61,11 +72,21 @@ export async function patchAppConfig(patch: Partial<IAppConfig>): Promise<void>
|
||||
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<IAppConfig>): Promise<void> {
|
||||
await writeAppConfig(patch, false)
|
||||
}
|
||||
|
||||
// 内核应用后同步:落盘失败仍更新内存,并抛错供调用方记录。
|
||||
export async function syncAppConfigAfterApply(patch: Partial<IAppConfig>): Promise<void> {
|
||||
await writeAppConfig(patch, true)
|
||||
}
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
export { getAppConfig, patchAppConfig, subscribeAppConfig } from './app'
|
||||
export { getAppConfig, patchAppConfig, subscribeAppConfig, syncAppConfigAfterApply } from './app'
|
||||
export { getControledMihomoConfig, patchControledMihomoConfig } from './controledMihomo'
|
||||
export {
|
||||
getProfile,
|
||||
|
||||
196
src/main/core/dnsOverrideGuard.ts
Normal file
196
src/main/core/dnsOverrideGuard.ts
Normal 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' }
|
||||
}
|
||||
@@ -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<string[]> {
|
||||
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<IMihomoConfig>,
|
||||
options: GenerateProfileOptions = {}
|
||||
): Promise<string | undefined> {
|
||||
): Promise<GenerateProfileResult> {
|
||||
// 第一阶段:并行读取互不依赖的配置(强制重读 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(
|
||||
|
||||
@@ -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<CoreCon
|
||||
await manageSmartOverride()
|
||||
|
||||
// generateProfile 返回实际使用的 current
|
||||
const current = await generateProfile()
|
||||
const { profileId: current, dnsGuard } = await generateProfile()
|
||||
const ageSecretKey = (await getProfileItem(current))?.ageSecretKey || ''
|
||||
if (testProfileOnStart) {
|
||||
await checkProfile(current, core, diffWorkDir, ageSecretKey)
|
||||
@@ -508,7 +510,8 @@ async function prepareCore(detached: boolean, skipStop = false): Promise<CoreCon
|
||||
ageSecretKey,
|
||||
detached,
|
||||
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) => {
|
||||
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(
|
||||
|
||||
@@ -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<void> => {
|
||||
// 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<void> => {
|
||||
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()
|
||||
|
||||
@@ -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<string, AsyncFn> = {
|
||||
patchAppConfig,
|
||||
getControledMihomoConfig,
|
||||
patchControledMihomoConfig,
|
||||
setControlDns,
|
||||
takeDnsOverrideAutoDisabledNotice,
|
||||
// Profile
|
||||
getProfileConfig,
|
||||
setProfileConfig,
|
||||
|
||||
@@ -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 白名单
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -8,11 +8,14 @@ interface Props {
|
||||
onCancel: () => void
|
||||
onConfirm: () => void
|
||||
isOpen: boolean
|
||||
cancelText?: string
|
||||
confirmText?: string
|
||||
isLoading?: boolean
|
||||
}
|
||||
|
||||
const BaseConfirmModal: React.FC<Props> = (props) => {
|
||||
const { t } = useTranslation()
|
||||
const { title, content, onCancel, onConfirm, isOpen } = props
|
||||
const { title, content, onCancel, onConfirm, isOpen, cancelText, confirmText, isLoading } = props
|
||||
|
||||
return (
|
||||
<Modal backdrop="blur" classNames={{ backdrop: 'top-[48px]' }} hideCloseButton isOpen={isOpen}>
|
||||
@@ -22,11 +25,11 @@ const BaseConfirmModal: React.FC<Props> = (props) => {
|
||||
<p>{content}</p>
|
||||
</ModalBody>
|
||||
<ModalFooter>
|
||||
<Button size="sm" variant="light" onPress={onCancel}>
|
||||
{t('common.cancel')}
|
||||
<Button size="sm" variant="light" isDisabled={isLoading} onPress={onCancel}>
|
||||
{cancelText ?? t('common.cancel')}
|
||||
</Button>
|
||||
<Button size="sm" color="danger" onPress={onConfirm}>
|
||||
{t('common.confirm')}
|
||||
<Button size="sm" color="danger" isLoading={isLoading} onPress={onConfirm}>
|
||||
{confirmText ?? t('common.confirm')}
|
||||
</Button>
|
||||
</ModalFooter>
|
||||
</ModalContent>
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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> = (props) => {
|
||||
const { t } = useTranslation()
|
||||
const { appConfig, patchAppConfig } = useAppConfig()
|
||||
const { appConfig, mutateAppConfig } = useAppConfig()
|
||||
const { iconOnly } = props
|
||||
const [applying, setApplying] = useState(false)
|
||||
// 弹窗保存待确认指纹,开关以后端状态为准。
|
||||
const [confirmation, setConfirmation] = useState<string | null>(null)
|
||||
const {
|
||||
dnsCardStatus = 'col-span-1',
|
||||
controlDns = DEFAULT_CONTROL_DNS,
|
||||
@@ -37,14 +41,23 @@ const DNSCard: React.FC<Props> = (props) => {
|
||||
id: 'dns'
|
||||
})
|
||||
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 {
|
||||
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> = (props) => {
|
||||
className={`${match ? 'text-primary-foreground' : 'text-foreground'} text-[24px] font-bold`}
|
||||
/>
|
||||
</Button>
|
||||
<BorderSwitch
|
||||
isShowBorder={match && controlDns}
|
||||
isSelected={controlDns}
|
||||
isDisabled={false}
|
||||
onValueChange={onChange}
|
||||
/>
|
||||
<div className="flex items-center">
|
||||
{applying && <Spinner size="sm" color={match ? 'white' : 'primary'} />}
|
||||
<BorderSwitch
|
||||
isShowBorder={match && controlDns}
|
||||
isSelected={controlDns}
|
||||
isDisabled={applying}
|
||||
onValueChange={onChange}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</CardBody>
|
||||
<CardFooter className="pt-1">
|
||||
@@ -111,6 +127,20 @@ const DNSCard: React.FC<Props> = (props) => {
|
||||
</h3>
|
||||
</CardFooter>
|
||||
</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>
|
||||
)
|
||||
}
|
||||
|
||||
39
src/renderer/src/hooks/use-dns-override-notice.tsx
Normal file
39
src/renderer/src/hooks/use-dns-override-notice.tsx
Normal 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])
|
||||
}
|
||||
@@ -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",
|
||||
|
||||
@@ -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": "بهروزرسانی همه پروفایلها",
|
||||
|
||||
@@ -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": "Обновить все профили",
|
||||
|
||||
@@ -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": "更新全部订阅",
|
||||
|
||||
@@ -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": "更新全部訂閱",
|
||||
|
||||
@@ -62,6 +62,8 @@ interface IpcApi {
|
||||
// Config
|
||||
getAppConfig: (force?: boolean) => Promise<IAppConfig>
|
||||
patchAppConfig: (patch: Partial<IAppConfig>) => Promise<void>
|
||||
setControlDns: (enabled: boolean, confirmation?: string) => Promise<IControlDnsApplyResult>
|
||||
takeDnsOverrideAutoDisabledNotice: () => Promise<boolean>
|
||||
getControledMihomoConfig: (force?: boolean) => Promise<Partial<IMihomoConfig>>
|
||||
patchControledMihomoConfig: (patch: Partial<IMihomoConfig>) => Promise<void>
|
||||
resetAppConfig: () => Promise<void>
|
||||
@@ -238,6 +240,8 @@ export const {
|
||||
// Config
|
||||
getAppConfig,
|
||||
patchAppConfig,
|
||||
setControlDns,
|
||||
takeDnsOverrideAutoDisabledNotice,
|
||||
getControledMihomoConfig,
|
||||
patchControledMihomoConfig,
|
||||
resetAppConfig,
|
||||
|
||||
4
src/shared/types.d.ts
vendored
4
src/shared/types.d.ts
vendored
@@ -541,6 +541,10 @@ interface IMihomoConfig {
|
||||
profile: IMihomoProfileConfig
|
||||
}
|
||||
|
||||
// DNS 覆写切换结果;用户确认后原样回传来源指纹。
|
||||
type IControlDnsApplyResult =
|
||||
{ status: 'applied' } | { status: 'confirm-required'; confirmation: string }
|
||||
|
||||
interface IProfileConfig {
|
||||
current?: string
|
||||
items: IProfileItem[]
|
||||
|
||||
Reference in New Issue
Block a user