From 4cae1ff132bb64c94b25731c55bb54faf97a2c93 Mon Sep 17 00:00:00 2001 From: BadCat1428 <18322004021@163.com> Date: Mon, 5 Oct 2026 13:12:38 +0000 Subject: [PATCH 1/5] =?UTF-8?q?fix=20a=20wrong=20in=20README.md=EF=BC=88?= =?UTF-8?q?=E5=BC=BA=E8=BF=AB=E7=97=87=E7=8A=AF=E4=BA=86=EF=BC=89=20(#8176?= =?UTF-8?q?)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * Update README.md * Update README.md --- README.md | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/README.md b/README.md index f76d4138c..db4bdd51c 100644 --- a/README.md +++ b/README.md @@ -50,14 +50,14 @@ Supports Windows (x64/x86), Linux (x64/arm64) and macOS 11+ (intel/apple). ### ✈️ [AI云边 -- 全新架构机场 ClaudeBorder](https://cruise.54678999.xyz/#/register?code=58q5UJZc) -🔥热销中使用本链接注册即送 3 天免费试用**,每日 **1GB 流量**:👉 [点此注册](https://cruise.54678999.xyz/#/register?code=58q5UJZc) +🔥热销中使用本链接注册即送 **3 天免费试用**,每日 **1GB 流量**:👉 [点此注册](https://cruise.54678999.xyz/#/register?code=58q5UJZc) #### AI云边 -- 全新架构机场。 - 💻 多次**技术迭代后**全新亮相。 - 🗺 全**高速稳定**正价节点。 - 🌏 **海外团队**,不跑路 -- 🚀 线路**冗余**设计,自动化运维**对抗各类封锁 +- 🚀 线路**冗余**设计,**自动化运维**对抗各类封锁 - 👨‍🦲 团队架构师为**大厂**网络架构师 - 💰 极致**稳定**,亲民价**价格** - 🌐 全面支持**流媒体及各AI访问** From c7cee50fc187ff3d955a29fdf2b584660f6d556f Mon Sep 17 00:00:00 2001 From: wangentongee-cmyk Date: Mon, 5 Oct 2026 13:31:19 +0000 Subject: [PATCH 2/5] =?UTF-8?q?docs:=20=E8=A1=A5=E5=85=85=E4=B8=AD?= =?UTF-8?q?=E6=96=87=20README=20=E7=9A=84=E7=B3=BB=E7=BB=9F=E6=94=AF?= =?UTF-8?q?=E6=8C=81=E8=AF=B4=E6=98=8E=20(#8158)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 中文部分缺少支持的系统说明,已对照英文补充。 --- README.md | 1 + 1 file changed, 1 insertion(+) diff --git a/README.md b/README.md index db4bdd51c..2a65c2e6b 100644 --- a/README.md +++ b/README.md @@ -31,6 +31,7 @@ A Clash Meta GUI based on Tauri Go to the [Release page](https://github.com/clash-verge-rev/clash-verge-rev/releases) to download the corresponding installation package
Supports Windows (x64/x86), Linux (x64/arm64) and macOS 11+ (intel/apple). +支持 Windows (x64/x86)、Linux (x64/arm64) 和 macOS 11+ (intel/apple)。 #### 我应当怎样选择发行版 From 7cc6c1ed95961e1b57ba6410c067012b4bf04ef0 Mon Sep 17 00:00:00 2001 From: wonfen Date: Mon, 5 Oct 2026 20:28:25 +0800 Subject: [PATCH 3/5] fix(port): edit the saved mixed port, not the session fallback Closes #8149 --- Changelog.md | 1 + .../setting/mods/clash-port-viewer.tsx | 43 +++++++++++-------- src/locales/ar/settings.json | 3 ++ src/locales/de/settings.json | 3 ++ src/locales/en/settings.json | 3 ++ src/locales/es/settings.json | 3 ++ src/locales/fa/settings.json | 3 ++ src/locales/id/settings.json | 3 ++ src/locales/jp/settings.json | 3 ++ src/locales/ko/settings.json | 3 ++ src/locales/ru/settings.json | 3 ++ src/locales/tr/settings.json | 3 ++ src/locales/tt/settings.json | 3 ++ src/locales/zh/settings.json | 3 ++ src/locales/zhtw/settings.json | 3 ++ src/types/generated/i18n-keys.ts | 3 ++ src/types/generated/i18n-resources.ts | 3 ++ 17 files changed, 72 insertions(+), 17 deletions(-) diff --git a/Changelog.md b/Changelog.md index 563cbc13d..742d404d1 100644 --- a/Changelog.md +++ b/Changelog.md @@ -3,6 +3,7 @@
🐞 修复问题 +- 修复端口设置混淆保存值与运行值、输入无效时缺少提示的问题 - 修复从开始菜单再次打开应用后,窗口反复弹出并提示启动失败的问题 - 修复多个代理集合包含同名节点时,代理组中的节点变灰并显示 ambiguous、无法选择的问题 diff --git a/src/components/setting/mods/clash-port-viewer.tsx b/src/components/setting/mods/clash-port-viewer.tsx index d45d7ff9a..19a452ad9 100644 --- a/src/components/setting/mods/clash-port-viewer.tsx +++ b/src/components/setting/mods/clash-port-viewer.tsx @@ -13,6 +13,7 @@ import { forwardRef, useImperativeHandle, useState } from 'react' import { useTranslation } from 'react-i18next' import { BaseDialog, Switch } from '@/components/base' +import { useClashInfo } from '@/hooks/use-clash' import { useDisplayedMixedPort } from '@/hooks/use-displayed-mixed-port' import { useVerge } from '@/hooks/use-verge' import { saveProxyPorts } from '@/services/cmds' @@ -32,11 +33,14 @@ const generateRandomPort = () => export const ClashPortViewer = forwardRef((_, ref) => { const { t } = useTranslation() const { verge } = useVerge() + const { clashInfo } = useClashInfo() + const configuredMixedPort = + verge?.verge_mixed_port ?? clashInfo?.mixed_port ?? 7897 const displayedMixedPort = useDisplayedMixedPort() const [open, setOpen] = useState(false) // Mixed Port - const [mixedPort, setMixedPort] = useState(displayedMixedPort) + const [mixedPort, setMixedPort] = useState(configuredMixedPort) // 其他端口状态 const [socksPort, setSocksPort] = useState(verge?.verge_socks_port ?? 7898) @@ -76,7 +80,7 @@ export const ClashPortViewer = forwardRef((_, ref) => { useImperativeHandle(ref, () => ({ open: () => { - setMixedPort(displayedMixedPort) + setMixedPort(configuredMixedPort) setSocksPort(verge?.verge_socks_port ?? 7898) setSocksEnabled(verge?.verge_socks_enabled ?? false) setHttpPort(verge?.verge_port ?? 7899) @@ -92,6 +96,14 @@ export const ClashPortViewer = forwardRef((_, ref) => { // TODO 减少代码复杂度,性能开支 const onSave = useLockFn(async () => { + // The backend also rejects out-of-range ports on disabled listeners. + const isValidPort = (port: number) => port >= 1 && port <= 65535 + const allPorts = [mixedPort, socksPort, httpPort, redirPort, tproxyPort] + if (!allPorts.every(isValidPort)) { + showNotice.error('settings.modals.clashPort.messages.invalidPort') + return + } + // 端口冲突检测 const portList = [ mixedPort, @@ -102,20 +114,7 @@ export const ClashPortViewer = forwardRef((_, ref) => { ].filter((p) => p !== -1) if (new Set(portList).size !== portList.length) { - return - } - - // 验证端口范围 - const isValidPort = (port: number) => port >= 1 && port <= 65535 - const allPortsValid = [ - mixedPort, - socksEnabled ? socksPort : 0, - httpEnabled ? httpPort : 0, - redirEnabled ? redirPort : 0, - tproxyEnabled ? tproxyPort : 0, - ].every((port) => port === 0 || isValidPort(port)) - - if (!allPortsValid) { + showNotice.error('settings.modals.clashPort.messages.duplicatePort') return } @@ -154,7 +153,17 @@ export const ClashPortViewer = forwardRef((_, ref) => {
Date: Mon, 5 Oct 2026 22:00:28 +0800 Subject: [PATCH 4/5] refactor(port): build the port dialog rows from one listener table --- Changelog.md | 1 + .../setting/mods/clash-port-viewer.tsx | 388 +++++++----------- 2 files changed, 160 insertions(+), 229 deletions(-) diff --git a/Changelog.md b/Changelog.md index 742d404d1..1d0b13ffb 100644 --- a/Changelog.md +++ b/Changelog.md @@ -23,6 +23,7 @@
🚀 优化改进 +- 优化端口设置窗口的实现,精简重复代码 - 优化侧边栏流量图表的 CPU 占用 **🖥️ Windows** diff --git a/src/components/setting/mods/clash-port-viewer.tsx b/src/components/setting/mods/clash-port-viewer.tsx index 19a452ad9..e0ffbf6f7 100644 --- a/src/components/setting/mods/clash-port-viewer.tsx +++ b/src/components/setting/mods/clash-port-viewer.tsx @@ -16,8 +16,9 @@ import { BaseDialog, Switch } from '@/components/base' import { useClashInfo } from '@/hooks/use-clash' import { useDisplayedMixedPort } from '@/hooks/use-displayed-mixed-port' import { useVerge } from '@/hooks/use-verge' -import { saveProxyPorts } from '@/services/cmds' +import { type ProxyPortSettings, saveProxyPorts } from '@/services/cmds' import { showNotice } from '@/services/notice-service' +import type { TranslationKey } from '@/types/generated/i18n-keys' import getSystem from '@/utils/get-system' const OS = getSystem() @@ -27,9 +28,125 @@ interface ClashPortViewerRef { close: () => void } +type ListenerKey = Exclude + +const LISTENERS: { + key: ListenerKey + label: TranslationKey + visible: boolean +}[] = [ + { + key: 'socks', + label: 'settings.modals.clashPort.fields.socks', + visible: true, + }, + { + key: 'http', + label: 'settings.modals.clashPort.fields.http', + visible: true, + }, + { + key: 'redir', + label: 'settings.modals.clashPort.fields.redir', + visible: OS !== 'windows', + }, + { + key: 'tproxy', + label: 'settings.modals.clashPort.fields.tproxy', + visible: OS === 'linux', + }, +] + const generateRandomPort = () => Math.floor(Math.random() * (65535 - 1025 + 1)) + 1025 +const isValidPort = (port: number) => port >= 1 && port <= 65535 + +const readPortSettings = ( + verge: IVergeConfig | undefined, + mixedPort: number, +): ProxyPortSettings => ({ + mixedPort, + socks: { + enabled: verge?.verge_socks_enabled ?? false, + port: verge?.verge_socks_port ?? 7898, + }, + http: { + enabled: verge?.verge_http_enabled ?? false, + port: verge?.verge_port ?? 7899, + }, + redir: { + enabled: verge?.verge_redir_enabled ?? false, + port: verge?.verge_redir_port ?? 7895, + }, + tproxy: { + enabled: verge?.verge_tproxy_enabled ?? false, + port: verge?.verge_tproxy_port ?? 7896, + }, +}) + +interface PortRowProps { + label: string + secondary?: string + port: number + enabled: boolean + onPortChange: (port: number) => void + // Without it the switch stays locked on, as the mixed port cannot be disabled. + onEnabledChange?: (enabled: boolean) => void +} + +const PortRow = ({ + label, + secondary, + port, + enabled, + onPortChange, + onEnabledChange, +}: PortRowProps) => { + const { t } = useTranslation() + + return ( + + +
+ + onPortChange(+e.target.value?.replace(/\D+/, '').slice(0, 5)) + } + disabled={!enabled} + slotProps={{ htmlInput: { style: { fontSize: 12 } } }} + /> + onPortChange(generateRandomPort())} + title={t('settings.modals.clashPort.actions.random')} + disabled={!enabled} + sx={{ mr: 0.5 }} + > + + + onEnabledChange?.(c)} + sx={{ ml: 0.5, opacity: onEnabledChange ? undefined : 0.7 }} + /> +
+
+ ) +} + export const ClashPortViewer = forwardRef((_, ref) => { const { t } = useTranslation() const { verge } = useVerge() @@ -38,27 +155,14 @@ export const ClashPortViewer = forwardRef((_, ref) => { verge?.verge_mixed_port ?? clashInfo?.mixed_port ?? 7897 const displayedMixedPort = useDisplayedMixedPort() const [open, setOpen] = useState(false) + const [ports, setPorts] = useState(() => + readPortSettings(verge, configuredMixedPort), + ) - // Mixed Port - const [mixedPort, setMixedPort] = useState(configuredMixedPort) - - // 其他端口状态 - const [socksPort, setSocksPort] = useState(verge?.verge_socks_port ?? 7898) - const [socksEnabled, setSocksEnabled] = useState( - verge?.verge_socks_enabled ?? false, - ) - const [httpPort, setHttpPort] = useState(verge?.verge_port ?? 7899) - const [httpEnabled, setHttpEnabled] = useState( - verge?.verge_http_enabled ?? false, - ) - const [redirPort, setRedirPort] = useState(verge?.verge_redir_port ?? 7895) - const [redirEnabled, setRedirEnabled] = useState( - verge?.verge_redir_enabled ?? false, - ) - const [tproxyPort, setTproxyPort] = useState(verge?.verge_tproxy_port ?? 7896) - const [tproxyEnabled, setTproxyEnabled] = useState( - verge?.verge_tproxy_enabled ?? false, - ) + const updateListener = ( + key: ListenerKey, + patch: Partial, + ) => setPorts((prev) => ({ ...prev, [key]: { ...prev[key], ...patch } })) // 添加保存请求,防止GUI卡死 const { loading, runAsync: saveSettings } = useRequest(saveProxyPorts, { @@ -80,51 +184,33 @@ export const ClashPortViewer = forwardRef((_, ref) => { useImperativeHandle(ref, () => ({ open: () => { - setMixedPort(configuredMixedPort) - setSocksPort(verge?.verge_socks_port ?? 7898) - setSocksEnabled(verge?.verge_socks_enabled ?? false) - setHttpPort(verge?.verge_port ?? 7899) - setHttpEnabled(verge?.verge_http_enabled ?? false) - setRedirPort(verge?.verge_redir_port ?? 7895) - setRedirEnabled(verge?.verge_redir_enabled ?? false) - setTproxyPort(verge?.verge_tproxy_port ?? 7896) - setTproxyEnabled(verge?.verge_tproxy_enabled ?? false) + setPorts(readPortSettings(verge, configuredMixedPort)) setOpen(true) }, close: () => setOpen(false), })) - // TODO 减少代码复杂度,性能开支 const onSave = useLockFn(async () => { + const listeners = LISTENERS.map(({ key }) => ports[key]) + // The backend also rejects out-of-range ports on disabled listeners. - const isValidPort = (port: number) => port >= 1 && port <= 65535 - const allPorts = [mixedPort, socksPort, httpPort, redirPort, tproxyPort] + const allPorts = [ports.mixedPort, ...listeners.map(({ port }) => port)] if (!allPorts.every(isValidPort)) { showNotice.error('settings.modals.clashPort.messages.invalidPort') return } // 端口冲突检测 - const portList = [ - mixedPort, - socksEnabled ? socksPort : -1, - httpEnabled ? httpPort : -1, - redirEnabled ? redirPort : -1, - tproxyEnabled ? tproxyPort : -1, - ].filter((p) => p !== -1) - - if (new Set(portList).size !== portList.length) { + const enabledPorts = [ + ports.mixedPort, + ...listeners.filter(({ enabled }) => enabled).map(({ port }) => port), + ] + if (new Set(enabledPorts).size !== enabledPorts.length) { showNotice.error('settings.modals.clashPort.messages.duplicatePort') return } - await saveSettings({ - mixedPort, - socks: { enabled: socksEnabled, port: socksPort }, - http: { enabled: httpEnabled, port: httpPort }, - redir: { enabled: redirEnabled, port: redirPort }, - tproxy: { enabled: tproxyEnabled, port: tproxyPort }, - }) + await saveSettings(ports) }) return ( @@ -150,187 +236,31 @@ export const ClashPortViewer = forwardRef((_, ref) => { onOk={onSave} > - - + setPorts((prev) => ({ ...prev, mixedPort })) + } + /> + {LISTENERS.filter(({ visible }) => visible).map(({ key, label }) => ( + updateListener(key, { port })} + onEnabledChange={(enabled) => updateListener(key, { enabled })} /> -
- - setMixedPort(+e.target.value?.replace(/\D+/, '').slice(0, 5)) - } - slotProps={{ htmlInput: { style: { fontSize: 12 } } }} - /> - setMixedPort(generateRandomPort())} - title={t('settings.modals.clashPort.actions.random')} - sx={{ mr: 0.5 }} - > - - - -
-
- - - -
- - setSocksPort(+e.target.value?.replace(/\D+/, '').slice(0, 5)) - } - disabled={!socksEnabled} - slotProps={{ htmlInput: { style: { fontSize: 12 } } }} - /> - setSocksPort(generateRandomPort())} - title={t('settings.modals.clashPort.actions.random')} - disabled={!socksEnabled} - sx={{ mr: 0.5 }} - > - - - setSocksEnabled(c)} - sx={{ ml: 0.5 }} - /> -
-
- - - -
- - setHttpPort(+e.target.value?.replace(/\D+/, '').slice(0, 5)) - } - disabled={!httpEnabled} - slotProps={{ htmlInput: { style: { fontSize: 12 } } }} - /> - setHttpPort(generateRandomPort())} - title={t('settings.modals.clashPort.actions.random')} - disabled={!httpEnabled} - sx={{ mr: 0.5 }} - > - - - setHttpEnabled(c)} - sx={{ ml: 0.5 }} - /> -
-
- - {OS !== 'windows' && ( - - -
- - setRedirPort(+e.target.value?.replace(/\D+/, '').slice(0, 5)) - } - disabled={!redirEnabled} - slotProps={{ htmlInput: { style: { fontSize: 12 } } }} - /> - setRedirPort(generateRandomPort())} - title={t('settings.modals.clashPort.actions.random')} - disabled={!redirEnabled} - sx={{ mr: 0.5 }} - > - - - setRedirEnabled(c)} - sx={{ ml: 0.5 }} - /> -
-
- )} - - {OS === 'linux' && ( - - -
- - setTproxyPort(+e.target.value?.replace(/\D+/, '').slice(0, 5)) - } - disabled={!tproxyEnabled} - slotProps={{ htmlInput: { style: { fontSize: 12 } } }} - /> - setTproxyPort(generateRandomPort())} - title={t('settings.modals.clashPort.actions.random')} - disabled={!tproxyEnabled} - sx={{ mr: 0.5 }} - > - - - setTproxyEnabled(c)} - sx={{ ml: 0.5 }} - /> -
-
- )} + ))}
) From 6ec35c8195f6a98208ea0d481ccd533ec5d5f013 Mon Sep 17 00:00:00 2001 From: wonfen Date: Tue, 6 Oct 2026 11:01:34 +0800 Subject: [PATCH 5/5] fix(macos): restore system DNS reliably after TUN Detached tasks spawned on every config generation set and restored the placeholder DNS without ordering, so a set could back up the placeholder itself and a later restore reported success while leaving it in place. Reconcile from the applied config under the lifecycle lock, and restore whenever the core stops. Keep one backup per network service, never back up the placeholder, and restore only while the service still holds it. Scripts verify every read and write and are killed after 5 s. Refs #8070, #8168 --- Changelog.md | 4 + scripts/set_dns.sh | 60 ++++--- scripts/unset_dns.sh | 65 ++++++- src-tauri/src/core/manager/config.rs | 6 + src-tauri/src/core/manager/lifecycle.rs | 22 ++- src-tauri/src/core/manager/state.rs | 2 + src-tauri/src/core/service.rs | 8 +- src-tauri/src/enhance/tun.rs | 16 -- src-tauri/src/feat/clash.rs | 13 ++ src-tauri/src/feat/window.rs | 25 ++- src-tauri/src/utils/resolve/dns.rs | 218 ++++++++++++++---------- src-tauri/src/utils/resolve/mod.rs | 3 +- 12 files changed, 299 insertions(+), 143 deletions(-) diff --git a/Changelog.md b/Changelog.md index 1d0b13ffb..a7add527d 100644 --- a/Changelog.md +++ b/Changelog.md @@ -12,6 +12,10 @@ - 修复 Windows 升级后因文件权限异常无法启动、需要手动清理配置的问题 - 修复 Windows 服务启动类型被改为手动后,软件启动卡住约两分钟的问题,并提供一键修复 +**🍎 macOS** + +- 修复 macOS 关闭 TUN 或退出后,系统 DNS 仍停留在 `114.114.114.114` 导致无法解析域名的问题 +
diff --git a/scripts/set_dns.sh b/scripts/set_dns.sh index c3e03e209..2b3469fcc 100644 --- a/scripts/set_dns.sh +++ b/scripts/set_dns.sh @@ -30,29 +30,51 @@ function is_valid_ip() { is_valid_ipv4 "$1" || is_valid_ipv6 "$1" } +# Prints one value of a SystemConfiguration dictionary, or nothing when the key is absent. +function sc_value() { + printf 'show %s\nquit\n' "$1" | scutil | sed -n "s/^ *$2 : //p" +} + +# Prints the service's DNS servers, or "empty" when none is set; fails on any other answer. +function dns_of() { + local dns line ip='^[0-9A-Fa-f]*[.:][0-9A-Fa-f.:]*(%[[:alnum:]_.-]+)?$' + dns=$(networksetup -getdnsservers "$1") || return 1 + if [ "$dns" = "There aren't any DNS Servers set on $1." ]; then + echo "empty" + return + fi + [ -n "$dns" ] || return 1 + while read -r line; do + [[ $line =~ $ip ]] || return 1 + done <<<"$dns" + echo "$dns" +} + [ $# -lt 1 ] && echo "Usage: $0 " && exit 1 ! is_valid_ip "$1" && echo "$1 is not a valid IP address." && exit 1 -nic=$(route -n get default | grep "interface" | awk '{print $2}') -hardware_port=$(networksetup -listnetworkserviceorder | awk -v dev="$nic" ' - /^\([0-9]+\) /{port=$0; sub(/^\([0-9]+\) /, "", port)} - /\(Hardware Port:/{interface=$NF;sub(/\)/, "", interface); if (interface == dev) {print port; exit}} -') +# Backups are keyed by service ID, which survives renaming the service and renumbering its interface. +service_id=$(sc_value State:/Network/Global/IPv4 PrimaryService) +hardware_port=$(sc_value "Setup:/Network/Service/$service_id" UserDefinedName) +[ -z "$hardware_port" ] && echo "No primary network service." && exit 1 -original_dns=$(networksetup -getdnsservers "$hardware_port") +original_dns=$(dns_of "$hardware_port") || { echo "Cannot read DNS servers of $hardware_port." >&2; exit 1; } -is_valid_dns=false -for ip in $original_dns; do - ip=$(echo "$ip" | tr -d '[:space:]') - if [ -n "$ip" ] && (is_valid_ipv4 "$ip" || is_valid_ipv6 "$ip"); then - is_valid_dns=true - break +backup=".original_dns/$service_id" +mkdir -p .original_dns || exit 1 +# Older releases kept a single backup for whichever service was primary. +if [ -f .original_dns.txt ]; then + if [ -f "$backup" ]; then + rm -f .original_dns.txt || exit 1 + else + mv .original_dns.txt "$backup" || exit 1 fi -done - -if [ "$is_valid_dns" = false ]; then - echo "empty" >.original_dns.txt -else - echo "$original_dns" >.original_dns.txt fi -networksetup -setdnsservers "$hardware_port" "$1" +# The placeholder is ours, not the user's: keep the backup taken before it was applied. +if [ "$original_dns" != "$1" ]; then + echo "$original_dns" >"$backup" || exit 1 + networksetup -setdnsservers "$hardware_port" "$1" + [ "$(dns_of "$hardware_port")" = "$1" ] || { echo "Failed to set DNS for $hardware_port." >&2; exit 1; } +elif [ ! -f "$backup" ]; then + echo "empty" >"$backup" || exit 1 +fi diff --git a/scripts/unset_dns.sh b/scripts/unset_dns.sh index 405809f80..03f925e7d 100644 --- a/scripts/unset_dns.sh +++ b/scripts/unset_dns.sh @@ -1,13 +1,62 @@ #!/bin/bash -nic=$(route -n get default | grep "interface" | awk '{print $2}') +[ $# -lt 1 ] && echo "Usage: $0 " && exit 1 -hardware_port=$(networksetup -listnetworkserviceorder | awk -v dev="$nic" ' - /^\([0-9]+\) /{port=$0; sub(/^\([0-9]+\) /, "", port)} - /\(Hardware Port:/{interface=$NF;sub(/\)/, "", interface); if (interface == dev) {print port; exit}} -') +# Prints one value of a SystemConfiguration dictionary, or nothing when the key is absent. +function sc_value() { + printf 'show %s\nquit\n' "$1" | scutil | sed -n "s/^ *$2 : //p" +} +# Prints the service's DNS servers, or "empty" when none is set; fails on any other answer. +function dns_of() { + local dns line ip='^[0-9A-Fa-f]*[.:][0-9A-Fa-f.:]*(%[[:alnum:]_.-]+)?$' + dns=$(networksetup -getdnsservers "$1") || return 1 + if [ "$dns" = "There aren't any DNS Servers set on $1." ]; then + echo "empty" + return + fi + [ -n "$dns" ] || return 1 + while read -r line; do + [[ $line =~ $ip ]] || return 1 + done <<<"$dns" + echo "$dns" +} + +result=0 +# Older releases kept a single backup for whichever service was primary. if [ -f .original_dns.txt ]; then - original_dns=$(cat .original_dns.txt) - networksetup -setdnsservers "$hardware_port" $original_dns - rm -rf .original_dns.txt + service_id=$(sc_value State:/Network/Global/IPv4 PrimaryService) + if [ -z "$service_id" ] || ! mkdir -p .original_dns; then + result=1 + elif [ -f ".original_dns/$service_id" ]; then + rm -f .original_dns.txt || result=1 + elif ! mv .original_dns.txt ".original_dns/$service_id"; then + result=1 + fi fi + +for backup in .original_dns/*; do + [ -f "$backup" ] || continue + hardware_port=$(sc_value "Setup:/Network/Service/${backup##*/}" UserDefinedName) + # Kept for a service that is gone: there is nothing to write it back to. + [ -n "$hardware_port" ] || continue + if ! current_dns=$(dns_of "$hardware_port"); then + echo "Cannot read DNS servers of $hardware_port." >&2 + result=1 + continue + fi + # A service no longer on the placeholder was changed by someone else, and that change stands. + if [ "$current_dns" = "$1" ]; then + original_dns=$(cat "$backup") || { result=1; continue; } + # Older releases could back up the placeholder itself. + [ "$original_dns" = "$1" ] && original_dns=empty + networksetup -setdnsservers "$hardware_port" $original_dns + if [ "$(dns_of "$hardware_port")" != "$original_dns" ]; then + echo "Failed to restore DNS for $hardware_port." >&2 + result=1 + continue + fi + fi + rm -f "$backup" || result=1 +done +rmdir .original_dns 2>/dev/null +exit $result diff --git a/src-tauri/src/core/manager/config.rs b/src-tauri/src/core/manager/config.rs index 2f3e7f7f0..50f56f03d 100644 --- a/src-tauri/src/core/manager/config.rs +++ b/src-tauri/src/core/manager/config.rs @@ -311,6 +311,12 @@ impl CoreManager { let run_path = Config::write_runtime_file(&yaml).await?; self.apply_config(run_path).await?; transaction.commit(); + // Under the lifecycle lock, so a stop for exit waits for this write instead of outrunning it. + #[cfg(target_os = "macos")] + { + let _lifecycle = self.lifecycle_lock.lock().await; + crate::utils::resolve::dns::sync_public_dns().await; + } Ok(ValidationOutcome::Valid) } diff --git a/src-tauri/src/core/manager/lifecycle.rs b/src-tauri/src/core/manager/lifecycle.rs index 30cc45ede..4ea262b64 100644 --- a/src-tauri/src/core/manager/lifecycle.rs +++ b/src-tauri/src/core/manager/lifecycle.rs @@ -515,7 +515,13 @@ impl CoreManager { } async fn stop_sidecar_after_proxy_clear(&self) -> Result<()> { - run_sidecar_termination_transition(proxy_control::clear, || self.stop_core_by_sidecar_unprepared()).await + let result = + run_sidecar_termination_transition(proxy_control::clear, || self.stop_core_by_sidecar_unprepared()).await; + #[cfg(target_os = "macos")] + if matches!(*self.get_running_mode(), RunningMode::NotRunning) { + crate::utils::resolve::dns::sync_public_dns().await; + } + result } pub(super) async fn replace_service_core_with_config(&self, config_file: &Path) -> Result<()> { @@ -544,6 +550,8 @@ impl CoreManager { .ok_or_else(|| { anyhow::anyhow!("cannot apply system proxy before core readiness").context(SysproxyFailure::CoreNotReady) })?; + #[cfg(target_os = "macos")] + crate::utils::resolve::dns::sync_public_dns().await; // At login the app usually beats the network. Leave the write to the network watcher // rather than fail — only if the watcher is actually live; a user toggling while // offline still fails fast. @@ -615,6 +623,8 @@ impl CoreManager { tracing::Span::current().record("decision", tracing::field::debug(&startup)); if matches!(startup, StartupDecision::Wait) { self.rollback_failed_start().await; + #[cfg(target_os = "macos")] + crate::utils::resolve::dns::sync_public_dns().await; #[cfg(target_os = "windows")] self.try_sidecar_after_service_unavailable().await?; return Ok(()); @@ -649,6 +659,8 @@ impl CoreManager { // A failed start must not leave a locally reported running mode. if result.is_err() { self.rollback_failed_start().await; + #[cfg(target_os = "macos")] + crate::utils::resolve::dns::sync_public_dns().await; return result; } @@ -737,11 +749,17 @@ impl CoreManager { async fn stop_core_unprepared_inner(&self) -> Result<()> { CLASH_LOGGER.clear_logs(); - match *self.get_running_mode() { + let result = match *self.get_running_mode() { RunningMode::Service => self.stop_core_by_service().await, RunningMode::Sidecar => self.stop_core_by_sidecar_unprepared().await, RunningMode::NotRunning => Ok(()), + }; + // A Sidecar whose exit wait failed is already marked stopped; a core still running keeps its DNS. + #[cfg(target_os = "macos")] + if matches!(*self.get_running_mode(), RunningMode::NotRunning) { + crate::utils::resolve::dns::sync_public_dns().await; } + result } #[tracing::instrument(skip_all, level = "info")] diff --git a/src-tauri/src/core/manager/state.rs b/src-tauri/src/core/manager/state.rs index 346485ac7..733c6b1d9 100644 --- a/src-tauri/src/core/manager/state.rs +++ b/src-tauri/src/core/manager/state.rs @@ -394,6 +394,8 @@ impl CoreManager { self.set_job_handle(None); proxy_control::stop_guard().await; self.core_stopped(); + #[cfg(target_os = "macos")] + crate::utils::resolve::dns::sync_public_dns().await; } } diff --git a/src-tauri/src/core/service.rs b/src-tauri/src/core/service.rs index 2205e787a..2d90890dd 100644 --- a/src-tauri/src/core/service.rs +++ b/src-tauri/src/core/service.rs @@ -1761,12 +1761,18 @@ async fn recover_after_owner_loss_while_locked(reason: OwnerRecoveryReason) { ); mark_service_unavailable_after_owner_loss(&RUN_STATE, reason); proxy_control::stop_guard().await; + let policy = owner_recovery_policy(reason, cfg!(target_os = "macos")); // Clear while still in Service mode with the session: on macOS it routes through the helper. - if owner_recovery_policy(reason, cfg!(target_os = "macos")).reset_system_proxy { + if policy.reset_system_proxy { clear_proxy_after_owner_loss().await; } clear_active_service_session(); CoreManager::global().core_stopped(); + // A displaced Service may still run TUN for its new owner, and DNS settings are system-wide. + #[cfg(target_os = "macos")] + if policy.reset_system_proxy { + crate::utils::resolve::dns::sync_public_dns().await; + } } async fn clear_proxy_after_owner_loss() { diff --git a/src-tauri/src/enhance/tun.rs b/src-tauri/src/enhance/tun.rs index ad7c7183f..f5d1af7ab 100644 --- a/src-tauri/src/enhance/tun.rs +++ b/src-tauri/src/enhance/tun.rs @@ -1,8 +1,5 @@ use serde_yaml_ng::{Mapping, Value}; -#[cfg(target_os = "macos")] -use crate::process::AsyncHandler; - macro_rules! revise { ($map: expr, $key: expr, $val: expr) => { let ret_key = Value::String($key.into()); @@ -46,22 +43,9 @@ pub fn use_tun(mut config: Mapping, enable: bool) -> Mapping { if ipv6_val && !dns_val.contains_key(Value::from("fake-ip-range6")) { revise!(dns_val, "fake-ip-range6", "2001:2::0/64"); } - - #[cfg(target_os = "macos")] - { - AsyncHandler::spawn(move || async move { - crate::utils::resolve::dns::restore_public_dns().await; - crate::utils::resolve::dns::set_public_dns("114.114.114.114".to_string()).await; - }); - } } revise!(config, "dns", dns_val); - } else { - #[cfg(target_os = "macos")] - AsyncHandler::spawn(move || async move { - crate::utils::resolve::dns::restore_public_dns().await; - }); } revise!(tun_val, "enable", enable); diff --git a/src-tauri/src/feat/clash.rs b/src-tauri/src/feat/clash.rs index 1bd8da356..c01f799af 100644 --- a/src-tauri/src/feat/clash.rs +++ b/src-tauri/src/feat/clash.rs @@ -53,6 +53,19 @@ pub async fn restart_app() { if !cleanup_result.core_stopped { handle::Handle::global().clear_is_exiting(); + // A failed stop may still have marked the core stopped, and exit cleanup skipped the DNS restore. + // A core that kept running keeps its DNS, whatever an unapplied config draft says. + #[cfg(target_os = "macos")] + { + let manager = CoreManager::global(); + let _lifecycle = manager.lifecycle_lock.lock().await; + if matches!( + *manager.get_running_mode(), + crate::core::manager::RunningMode::NotRunning + ) { + crate::utils::resolve::dns::sync_public_dns().await; + } + } handle::Handle::notice_message( "app_restart::core_stop_failed", cleanup_result.stop_error.unwrap_or_default(), diff --git a/src-tauri/src/feat/window.rs b/src-tauri/src/feat/window.rs index 0c13b3d1b..d07fd0971 100644 --- a/src-tauri/src/feat/window.rs +++ b/src-tauri/src/feat/window.rs @@ -81,16 +81,21 @@ where async fn restore_dns_after_core_stop() -> bool { #[cfg(target_os = "macos")] + // Outlasts the script's own deadline, so a hung restore is killed with its process group before exit. match timeout( - Duration::from_millis(1000), + crate::utils::resolve::dns::DNS_SCRIPT_TIMEOUT + Duration::from_secs(1), crate::utils::resolve::dns::restore_public_dns(), ) .await { - Ok(_) => { + Ok(Ok(())) => { logging!(debug, Type::Window, "DNS设置已恢复"); true } + Ok(Err(err)) => { + logging!(warn, Type::Window, "恢复DNS设置失败: {err:#}"); + false + } Err(_) => { logging!(warn, Type::Window, "恢复DNS设置超时"); false @@ -128,6 +133,19 @@ pub async fn quit() -> clash_verge_signal::ShutdownOutcome { if should_abort_exit_after_cleanup(cleanup_result.core_stopped) { handle::Handle::global().clear_is_exiting(); + // A failed stop may still have marked the core stopped, and exit cleanup skipped the DNS restore. + // A core that kept running keeps its DNS, whatever an unapplied config draft says. + #[cfg(target_os = "macos")] + { + let manager = CoreManager::global(); + let _lifecycle = manager.lifecycle_lock.lock().await; + if matches!( + *manager.get_running_mode(), + crate::core::manager::RunningMode::NotRunning + ) { + crate::utils::resolve::dns::sync_public_dns().await; + } + } handle::Handle::notice_message( "app_quit::core_stop_failed", cleanup_result.stop_error.unwrap_or_default(), @@ -178,6 +196,9 @@ pub async fn clean_async() -> CleanupResult { pub async fn clean_session_ending_best_effort() -> CleanupResult { #[cfg(target_os = "windows")] let stop_timeout = Duration::from_secs(2); + // TODO(macOS): a DNS script already blocked when the session ends holds the lifecycle lock for up to + // DNS_SCRIPT_TIMEOUT, past this deadline; the stop is cancelled, the DNS restore skipped, and the + // script can still write after the app exits. #[cfg(not(target_os = "windows"))] let stop_timeout = Duration::from_secs(3); diff --git a/src-tauri/src/utils/resolve/dns.rs b/src-tauri/src/utils/resolve/dns.rs index 35e53be1e..0a27acfaa 100644 --- a/src-tauri/src/utils/resolve/dns.rs +++ b/src-tauri/src/utils/resolve/dns.rs @@ -1,123 +1,149 @@ -use anyhow::Context as _; +use anyhow::{Context as _, bail, ensure}; use clash_verge_logging::{Type, logging}; -#[cfg(target_os = "macos")] -use std::path::Path; -use std::path::PathBuf; +use serde_yaml_ng::{Mapping, Value}; +use std::{ + io::Read as _, + os::unix::process::CommandExt as _, + path::{Path, PathBuf}, + process::{Command, Stdio}, + time::{Duration, Instant}, +}; +use tokio::sync::Mutex; -#[cfg(target_os = "macos")] const DNS_STATE_FILE: &str = ".original_dns.txt"; +const DNS_BACKUP_DIR: &str = ".original_dns"; +// Any address routed into TUN works, since dns-hijack answers it; the value only matters if left behind. +const PLACEHOLDER_DNS: &str = "114.114.114.114"; +// Scripts run under lifecycle_lock, and networksetup waits for other preference writers without a limit. +pub(crate) const DNS_SCRIPT_TIMEOUT: Duration = Duration::from_secs(5); + +static DNS_LOCK: Mutex<()> = Mutex::const_new(()); fn dns_state_dir() -> anyhow::Result { - // The DNS scripts persist .original_dns.txt relative to their working directory. + // The DNS scripts persist their backups relative to their working directory. let dir = crate::utils::dirs::app_home_dir()?; std::fs::create_dir_all(&dir).with_context(|| format!("failed to create DNS state directory {}", dir.display()))?; Ok(dir) } -#[cfg(target_os = "macos")] fn restore_dns_state_dir(resource_dir: &Path, state_dir: PathBuf) -> PathBuf { - if resource_dir.join(DNS_STATE_FILE).exists() { + if resource_dir.join(DNS_STATE_FILE).exists() || resource_dir.join(DNS_BACKUP_DIR).exists() { resource_dir.to_path_buf() } else { state_dir } } -pub async fn set_public_dns(dns_server: String) { - use crate::{core::handle, utils::dirs}; - use tauri_plugin_shell::ShellExt as _; - let app_handle = handle::Handle::app_handle(); - - logging!(debug, Type::Config, "try to set system dns"); - let resource_dir = match dirs::app_resources_dir() { - Ok(dir) => dir, - Err(e) => { - logging!(error, Type::Config, "Failed to get resource directory: {}", e); - return; - } +// Without the placeholder, a LAN resolver is reached outside TUN and fake-ip is bypassed. +fn needs_public_dns(config: &Mapping) -> bool { + let section = |name: &str| config.get(name).and_then(Value::as_mapping); + let enabled = |name: &str| { + section(name) + .and_then(|map| map.get("enable")) + .and_then(Value::as_bool) + .unwrap_or(false) }; - let script = resource_dir.join("set_dns.sh"); - if !script.exists() { - logging!(error, Type::Config, "DNS script not found: {}", script.display()); + enabled("tun") + && enabled("dns") + && section("dns") + .and_then(|dns| dns.get("enhanced-mode")) + .and_then(Value::as_str) + == Some("fake-ip") +} + +/// Sets the placeholder DNS while the running config needs it, and restores the system DNS otherwise. +pub async fn sync_public_dns() { + use crate::{ + config::Config, + core::{CoreManager, handle::Handle, manager::RunningMode}, + }; + + let _guard = DNS_LOCK.lock().await; + // Exit cleanup restores DNS on its own budget; a sync queued behind it must not undo that. + if Handle::global().is_exiting() { return; } - let script = script.to_string_lossy().into_owned(); - let state_dir = match dns_state_dir() { - Ok(dir) => dir, - Err(e) => { - logging!(error, Type::Config, "Failed to get DNS state directory: {e:#}"); - return; - } - }; - match app_handle - .shell() - .command("bash") - .args([script, dns_server]) - .current_dir(state_dir) - .status() - .await - { - Ok(status) => { - if status.success() { - logging!(info, Type::Config, "set system dns successfully"); - } else { - let code = status.code().unwrap_or(-1); - logging!(error, Type::Config, "set system dns failed: {code}"); - } - } - Err(err) => { - logging!(error, Type::Config, "set system dns failed: {err}"); - } + let core_running = !matches!(*CoreManager::global().get_running_mode(), RunningMode::NotRunning); + let runtime = Config::runtime().await.latest_arc(); + if core_running && runtime.config.as_ref().is_some_and(needs_public_dns) { + set_public_dns().await; + } else { + let _ = unset_public_dns().await; } } -#[cfg(target_os = "macos")] -pub async fn restore_public_dns() { - use crate::{core::handle, utils::dirs}; - use tauri_plugin_shell::ShellExt as _; - let app_handle = handle::Handle::app_handle(); +pub async fn restore_public_dns() -> anyhow::Result<()> { + let _guard = DNS_LOCK.lock().await; + unset_public_dns().await +} + +async fn set_public_dns() { + logging!(debug, Type::Config, "try to set system dns"); + let result = async { + let script = crate::utils::dirs::app_resources_dir()?.join("set_dns.sh"); + run_dns_script(&script, dns_state_dir()?).await + } + .await; + match result { + Ok(()) => logging!(info, Type::Config, "set system dns successfully"), + Err(err) => logging!(error, Type::Config, "set system dns failed: {err:#}"), + } +} + +async fn unset_public_dns() -> anyhow::Result<()> { logging!(debug, Type::Config, "try to unset system dns"); - let resource_dir = match dirs::app_resources_dir() { - Ok(dir) => dir, - Err(e) => { - logging!(error, Type::Config, "Failed to get resource directory: {}", e); - return; - } - }; - let script = resource_dir.join("unset_dns.sh"); - if !script.exists() { - logging!(error, Type::Config, "DNS script not found: {}", script.display()); - return; + let result = async { + let resource_dir = crate::utils::dirs::app_resources_dir()?; + let state_dir = restore_dns_state_dir(&resource_dir, dns_state_dir()?); + run_dns_script(&resource_dir.join("unset_dns.sh"), state_dir).await } - let script = script.to_string_lossy().into_owned(); - let state_dir = match dns_state_dir() { - Ok(dir) => dir, - Err(e) => { - logging!(error, Type::Config, "Failed to get DNS state directory: {e:#}"); - return; - } - }; - let state_dir = restore_dns_state_dir(&resource_dir, state_dir); - match app_handle - .shell() - .command("bash") - .args([script]) - .current_dir(state_dir) - .status() - .await - { - Ok(status) => { - if status.success() { - logging!(info, Type::Config, "unset system dns successfully"); - } else { - let code = status.code().unwrap_or(-1); - logging!(error, Type::Config, "unset system dns failed: {code}"); + .await; + match &result { + Ok(()) => logging!(info, Type::Config, "unset system dns successfully"), + Err(err) => logging!(error, Type::Config, "unset system dns failed: {err:#}"), + } + result +} + +/// On timeout the script's whole process group is killed, so no networksetup it started writes late. +async fn run_dns_script(script: &Path, dir: PathBuf) -> anyhow::Result<()> { + ensure!(script.exists(), "DNS script not found: {}", script.display()); + let mut child = Command::new("bash") + .arg(script) + .arg(PLACEHOLDER_DNS) + .current_dir(dir) + .stdout(Stdio::null()) + .stderr(Stdio::piped()) + .process_group(0) + .spawn() + .with_context(|| format!("failed to run {}", script.display()))?; + crate::process::AsyncHandler::spawn_blocking(move || { + let deadline = Instant::now() + DNS_SCRIPT_TIMEOUT; + let status = loop { + if let Some(status) = child.try_wait()? { + break status; } + if Instant::now() >= deadline { + unsafe { libc::killpg(libc::pid_t::try_from(child.id())?, libc::SIGKILL) }; + child.wait()?; + bail!("timed out after {} s", DNS_SCRIPT_TIMEOUT.as_secs()); + } + std::thread::sleep(Duration::from_millis(20)); + }; + let mut stderr = String::new(); + if let Some(mut pipe) = child.stderr.take() { + pipe.read_to_string(&mut stderr)?; } - Err(err) => { - logging!(error, Type::Config, "unset system dns failed: {err}"); - } - } + ensure!( + status.success(), + "exit code {}: {}", + status.code().unwrap_or(-1), + stderr.trim() + ); + Ok(()) + }) + .await + .context("DNS script task failed")? } #[cfg(test)] @@ -125,7 +151,6 @@ mod tests { #[test] #[allow(clippy::expect_used)] - #[cfg(target_os = "macos")] fn restore_dns_state_dir_defaults_to_app_data_but_honors_legacy_file() { use super::restore_dns_state_dir; @@ -137,6 +162,11 @@ mod tests { assert_eq!(restore_dns_state_dir(&resource_dir, state_dir.clone()), state_dir); std::fs::write(resource_dir.join(".original_dns.txt"), "empty").expect("create legacy DNS state"); + assert_eq!(restore_dns_state_dir(&resource_dir, state_dir.clone()), resource_dir); + + // A migrated legacy backup whose restore failed must still be retried there. + std::fs::remove_file(resource_dir.join(".original_dns.txt")).expect("remove legacy DNS state"); + std::fs::create_dir(resource_dir.join(".original_dns")).expect("create migrated DNS state"); assert_eq!(restore_dns_state_dir(&resource_dir, state_dir), resource_dir); std::fs::remove_dir_all(root).expect("remove test directory"); diff --git a/src-tauri/src/utils/resolve/mod.rs b/src-tauri/src/utils/resolve/mod.rs index 49cd25edb..5a1a78ea5 100644 --- a/src-tauri/src/utils/resolve/mod.rs +++ b/src-tauri/src/utils/resolve/mod.rs @@ -21,6 +21,7 @@ use crate::{ use clash_verge_logging::{Type, logging, logging_error}; use clash_verge_signal; +#[cfg(target_os = "macos")] pub mod dns; mod scheme; pub(crate) mod window; @@ -102,7 +103,7 @@ pub async fn resolve_reset_async() -> Result<(), anyhow::Error> { #[cfg(target_os = "macos")] { use dns::restore_public_dns; - restore_public_dns().await; + restore_public_dns().await?; } Ok(())