diff --git a/doc/tech/dns-mode-e2e-recipe.md b/doc/tech/dns-mode-e2e-recipe.md index 4158ef0..954643b 100644 --- a/doc/tech/dns-mode-e2e-recipe.md +++ b/doc/tech/dns-mode-e2e-recipe.md @@ -210,6 +210,113 @@ cp /tmp/manifest-backup.json "$MANIFEST" --- +## 5.6 Scenario F — 系统 DNS 被外部改动(issue #153 不一致探测) + +`probe_system_dns` 是前端**独立于 Rust 内存态**的第二个数据源。`get_dns_mode` +只返回 `state.dns_enabled` 这个 `AtomicBool`("mHost 认为自己是开是关"), +而本场景验证的是 OS 侧的实际配置("系统实际上指向哪里")。两者分歧时 +Settings 页 DNS 卡片顶部出现 `data-testid="dns-discrepancy-banner"` 横幅。 + +### 5.6.1 not_pointing 方向(显示 Running,系统没指向 mHost) + +```bash +# 1. UI 启用 DNS 模式,确认 Status = Running +# 2. UI 切到别的 app(或直接改 DNS 再切回 mHost 触发 window focus 探测) +# 切回 mHost 窗口本身就会触发探测(App.tsx focus listener,1s 冷却) + +# 3. 外部把系统 DNS 改成 8.8.8.8(模拟用户/别的工具动了网络配置) +IFACE=$(networksetup -listnetworkserviceorder | grep -A2 "Hardware Port: Wi-Fi" | grep "Device" | awk '{print $2}') +sudo networksetup -setdnsservers "Wi-Fi" 8.8.8.8 + +# 4. 切回 mHost 窗口 → focus 触发探测 +``` + +期望: + +- Settings 页 DNS 卡片出现横幅,`data-discrepancy="not_pointing"` +- 标题 `System DNS does not point at mHost` +- 正文显示实际读到的 servers(`8.8.8.8`)和接口名(`Wi-Fi`) +- **没有** Restore 按钮 —— 这个方向不危险(DNS 还能用),修它要重启 + enable 流程(两次 sudo + 重建 DnsServer) +- 此时 mHost 的 DNS 规则**确实不生效**(`dig` 打到 8.8.8.8 而不是 1053) + +```bash +# 5. 验证规则确实没生效(用一个被 hosts profile 覆盖的域名) +dig +short <被覆盖的域名> @8.8.8.8 +# 期望: 上游返回的真实 IP,不是 profile 里配的 IP +``` + +**恢复方式(UI)**:关闭再开启 DNS 模式。横幅应在 toggle 完成后自动消失 +(`toggleDnsModeAtom` 成功分支会重新探测)。 + +### 5.6.2 stuck_at_loopback 方向(显示 Stopped,系统卡在 127.0.0.1) + +这是 **#152 那个 bug 的样子** —— 用户完全无计可施的原因,当时前端只有内存态 +这一个数据源。**危险**:DNS 指向一个没人监听的地址,解析直接失败。 + +```bash +# 1. UI 禁用 DNS 模式,确认 Status = Stopped + +# 2. 外部把系统 DNS 指向 127.0.0.1(模拟 disable 部分失败 / proxy 崩溃残留) +sudo networksetup -setdnsservers "Wi-Fi" 127.0.0.1 + +# 3. 切走再切回 mHost 窗口 → focus 触发探测 +# (也可以重启 mHost,启动时的 truth-fetch 会并行探测) +``` + +期望: + +- 横幅出现,`data-discrepancy="stuck_at_loopback"` +- 标题 `System DNS still points at mHost`(标题不写死 127.0.0.1:IPv6-only 环境正文会显示 `::1`) +- 正文提示 `Domain resolution may be broken right now` +- **有** `data-testid="dns-restore-button"`(`Restore system DNS`) +- 此时 `dig any-domain` 应该失败/超时 + +```bash +# 4. 验证网络解析确实坏了 +dig +short example.com +# 期望: 超时 / SERVFAIL(127.0.0.1:53 上没有监听者) + +# 5. 点「Restore system DNS」→ 弹 sudo(走既有的 disable 事务) +``` + +期望: + +- 系统 DNS 被还原(DhcpEmpty 场景写 `Empty`,Manual 场景写回原始 servers) +- 横幅自动消失 +- `dig example.com` 恢复正常 + +```bash +# 6. 验证还原结果 +networksetup -getdnsservers "Wi-Fi" +# 期望: "There aren't any DNS Servers set on Wi-Fi"(或用户原始手动配置) +``` + +**关键点**:此时 Rust 内存态 `dns_enabled` 已经是 `false`,但 +`set_dns_mode_disable` **没有**「已禁用就短路」的分支 —— 它会真的执行 +`disable_dns_mode()` 走完整个还原事务。这正是这条恢复路径成立的前提, +**不要**给它加短路优化。 + +### 5.6.3 探测不可用(不应显示横幅) + +```bash +# 断开 Wi-Fi / 拔网线,然后切回 mHost 窗口 +``` + +期望: + +- `route -n get default` 失败 → 后端返回 `Err` +- 前端**静默**吞掉(不弹 toast、不写 `dnsErrorAtom`),`systemDnsAtom` 置 null +- **不显示横幅** —— 无从判断就不报警 + +日志里可见(`tracing::debug`,需 RUST_LOG=debug): + +``` +probe_system_dns: interface=Wi-Fi servers=["127.0.0.1"] points_at_loopback=true +``` + +--- + ## 6. 日志 grep 一览表 | 期望日志 | 含义 | @@ -226,6 +333,7 @@ cp /tmp/manifest-backup.json "$MANIFEST" | `try_recover_dns: disable recovery marker found at ...` | 下次启动兜底命中 | | `force restore failed` | sudo 兜底也失败 | | `recovery marker left at ...` | marker 保留,下次启动 retry | +| `probe_system_dns: interface=... points_at_loopback=...` | issue #153 探测结果(需 `RUST_LOG=debug`) | | 不期望日志 | 含义 | |------------|------| @@ -233,6 +341,7 @@ cp /tmp/manifest-backup.json "$MANIFEST" | `Failed to enable DNS mode` | enable 失败(一般配合 osascript 超时) | | `dns-proxy failed to become ready within 5s` | ready 文件超时(proxy 启动失败) | | `recovery marker found` 在 successful disable 后 | 误报(说明 marker 没被清) | +| `probe system dns failed` 的前端错误提示 | issue #153 的探测失败应该静默,出现说明契约被破坏 | --- @@ -260,6 +369,7 @@ ls -la "$(find . -path '*/MacOS/mhost-dns-proxy' -type f | head -1)" - **Wi-Fi 切换**: Scenario A 假设用户稳定连接 Wi-Fi;如果中途断网 / 切到有线,networksetup 输出会改变,建议在稳定的 home Wi-Fi 环境下测。 - **macOS 版本差异**: 早期 macOS(< 12)的 networksetup 输出格式略有差异,但只要是 DHCP-empty 状态,输出都是 `There aren't any DNS Servers set on Wi-Fi`。 - **TCC 缓存**: 第一次跑 Scenario A 之前用户可能需要授权一次 mhost(系统弹窗)。授权后 5 分钟内不再弹(macOS 默认缓存策略)。 +- **Scenario F 的 focus 触发**: 探测依赖 `window` 的 `focus` 事件(1s 冷却)。如果用自动化手段(AppleScript / XCTest)切窗口,事件可能不触发 —— 此时改用重启 mHost 验证,启动时的 truth-fetch 会并行探测。 --- @@ -271,3 +381,5 @@ ls -la "$(find . -path '*/MacOS/mhost-dns-proxy' -type f | head -1)" - Issue #152 讨论历史 — 完整 regression diff + 候选 root cause 分析 - `src-tauri/crates/mhost-dns/src/platform.rs` — `verify_dns_restored_against_loopback` 实现 + tests - `src-tauri/crates/mhost-core/src/models.rs` — `OriginalDns::restore_argv` 防御层 + tests +- Issue #153 — 系统 DNS 不一致探测(`probe_system_dns` IPC + Settings 横幅) +- `src-tauri/crates/mhost-dns/src/platform.rs` — `probe_system_dns_state` / `probe_snapshot_from`(注意与 `networksetup_get_dns` 的过滤语义**故意相反**) diff --git a/src-tauri/crates/mhost-core/src/error.rs b/src-tauri/crates/mhost-core/src/error.rs index 6f33248..55c335c 100644 --- a/src-tauri/crates/mhost-core/src/error.rs +++ b/src-tauri/crates/mhost-core/src/error.rs @@ -30,6 +30,20 @@ pub enum MhostError { #[error("invalid input: {0}")] InvalidInput(String), + /// The requested OS-level capability doesn't exist on this platform. + /// + /// Distinct from [`MhostError::InvalidInput`] because the caller did + /// nothing wrong: `probe_system_dns` (issue #153) returns this on + /// non-macOS, where DNS mode itself isn't available yet (#67 tracks + /// Windows / Linux). Reporting a platform limitation as "invalid input" + /// would be a lie that surfaces verbatim if any future code path ever + /// renders the message to the user. + /// + /// Serializes as `{ Unsupported: "" }`; `extractErrorMessage` + /// renders it as `unsupported on this platform: `. + #[error("unsupported on this platform: {0}")] + Unsupported(String), + /// A quick apply requested with `require_safe` was rejected because the /// change is destructive (conflicts, would disable another profile, or a /// bulk change over the threshold). The caller must fall back to the diff --git a/src-tauri/crates/mhost-core/src/models.rs b/src-tauri/crates/mhost-core/src/models.rs index 280eed5..4b52871 100644 --- a/src-tauri/crates/mhost-core/src/models.rs +++ b/src-tauri/crates/mhost-core/src/models.rs @@ -669,6 +669,59 @@ pub struct DnsStatus { pub cache_capacity: usize, } +// --------------------------------------------------------------------------- +// SystemDnsSnapshot (issue #153) +// --------------------------------------------------------------------------- + +/// **issue #153**:系统 DNS 的**只读真相快照**,由 `probe_system_dns` IPC +/// 直接从 OS 读出(`networksetup -getdnsservers`),不经过任何 Rust 内存 +/// 状态。 +/// +/// 与 [`DnsStatus`] 的区别是本 issue 的核心: +/// +/// - `DnsStatus` 是 **mHost 自己视角**的运行态(`DnsServer` 是否在跑、 +/// `state.dns_enabled` 捕获的 original…)。它只反映「mHost 认为自己是 +/// 什么状态」。 +/// - `SystemDnsSnapshot` 是 **OS 视角**的实际配置。两者可能不一致 —— +/// #152(DNS 关掉后卡在 127.0.0.1)就是这类分歧的实例,而当时前端 +/// 根本没有第二个数据源能发现它。 +/// +/// `servers` **保留原始未过滤内容**(含 `127.0.0.1`),仅供 UI 展示; +/// 判定 `points_at_loopback` 一律走 [`is_local_resolver`]。 +/// +/// **不要用这个类型替换 `capture_dns_state()` 的返回类型**:后者服务于 +/// 「用户的原始 DNS 是什么」并**必须**过滤掉 loopback(#152 root cause 2: +/// 把 mHost 自己注入的 `127.0.0.1` 当成用户原始值持久化,会永久污染后续 +/// 还原)。两者语义相反,任何「顺手合并」都会重新引入那个 bug。 +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] +pub struct SystemDnsSnapshot { + /// 默认路由对应的 hardware port(如 `Wi-Fi`、`USB 10/100/1000 LAN`)。 + pub interface: String, + /// `networksetup -getdnsservers` 的原始条目,**未过滤**。 + /// 空 = 用户没手动配(DHCP 默认)。 + pub servers: Vec, + /// 任一条目指向 loopback / unspecified。 + pub points_at_loopback: bool, +} + +impl SystemDnsSnapshot { + /// 从接口名 + 原始 server 列表构造,`points_at_loopback` 由 + /// [`is_local_resolver`] 推导。 + /// + /// 语义是 **any**(不是 all):enable 路径把系统 DNS 设成单个 + /// `127.0.0.1`,所以 any 命中即代表「mHost 在接管」。混排 + /// (`127.0.0.1, 8.8.8.8`)按接管处理 —— 保守方向,与 + /// `platform::any_local_resolver`(#152 hardening Step 3)一致。 + pub fn new(interface: impl Into, servers: Vec) -> Self { + let points_at_loopback = servers.iter().any(|s| is_local_resolver(s)); + Self { + interface: interface.into(), + servers, + points_at_loopback, + } + } +} + // --------------------------------------------------------------------------- // Tests // --------------------------------------------------------------------------- @@ -1455,4 +1508,132 @@ mod tests { assert_eq!(q, ApplyMode::QuickApply); assert_eq!(r, ApplyMode::RequirePreview); } + + // ----------------------------------------------------------------------- + // SystemDnsSnapshot tests (issue #153) + // ----------------------------------------------------------------------- + + /// `points_at_loopback` 判定是 issue #153 整个特性的地基:前端拿它和 + /// `dnsEnabledAtom` 对比来决定是否报不一致。判定错 = 用户要么被假警报 + /// 骚扰,要么在真卡住时看不到横幅。 + /// + /// 表格驱动覆盖: + /// - `127.0.0.1` / `::1` / `0.0.0.0` → true(loopback + unspecified) + /// - `host:port` / `[v6]:port` 形式也走 is_local_resolver 的解析 + /// - 公共 DNS → false + /// - 空列表(DHCP 默认)→ false + /// - **混排 any 语义**:`127.0.0.1, 8.8.8.8` → true + #[test] + fn test_system_dns_snapshot_points_at_loopback() { + struct Case { + name: &'static str, + servers: Vec<&'static str>, + expected: bool, + } + let cases = vec![ + Case { + name: "single_ipv4_loopback", + servers: vec!["127.0.0.1"], + expected: true, + }, + Case { + name: "ipv6_loopback", + servers: vec!["::1"], + expected: true, + }, + Case { + name: "ipv4_unspecified", + servers: vec!["0.0.0.0"], + expected: true, + }, + Case { + name: "loopback_with_port", + servers: vec!["127.0.0.1:53"], + expected: true, + }, + Case { + name: "bracketed_ipv6_loopback_with_port", + servers: vec!["[::1]:53"], + expected: true, + }, + Case { + name: "public_resolvers", + servers: vec!["8.8.8.8", "1.1.1.1"], + expected: false, + }, + Case { + name: "empty_dhcp_default", + servers: vec![], + expected: false, + }, + // any 语义:只要有一条指向 loopback 就算接管。enable 路径把 + // 系统 DNS 设成单个 127.0.0.1,混排是异常状态但仍要按接管 + // 判定(保守方向,与 platform::any_local_resolver 一致)。 + Case { + name: "mixed_loopback_first_is_any_semantics", + servers: vec!["127.0.0.1", "8.8.8.8"], + expected: true, + }, + // 畸形字符串按「非本地」处理(is_local_resolver 的既有契约)。 + Case { + name: "malformed_entry_is_not_loopback", + servers: vec!["not-an-ip"], + expected: false, + }, + ]; + + for c in cases { + let snapshot = + SystemDnsSnapshot::new("Wi-Fi", c.servers.iter().map(|s| s.to_string()).collect()); + assert_eq!(snapshot.points_at_loopback, c.expected, "case: {}", c.name); + assert_eq!(snapshot.interface, "Wi-Fi", "case: {}", c.name); + assert_eq!(snapshot.servers.len(), c.servers.len(), "case: {}", c.name); + } + } + + /// `servers` 必须**原样保留**(含 loopback),只用于 UI 展示。 + /// + /// 如果这里有人「顺手」加个 filter(因为 #152 在 `capture_dns_state` + /// 路径上加过一次),探测会永远返回空列表,`points_at_loopback` 恒为 + /// false,issue #153 静默退化成 no-op —— 且不会有任何测试失败。 + /// 这个断言就是那堵墙。 + #[test] + fn test_system_dns_snapshot_preserves_loopback_in_servers() { + let snapshot = SystemDnsSnapshot::new("Wi-Fi", vec!["127.0.0.1".to_string()]); + assert_eq!( + snapshot.servers, + vec!["127.0.0.1".to_string()], + "servers must be the RAW networksetup output — filtering here silently \ + disables issue #153's probe" + ); + assert!(snapshot.points_at_loopback); + } + + #[test] + fn test_system_dns_snapshot_serde_roundtrip() { + let cases = vec![ + ( + "loopback", + SystemDnsSnapshot::new("Wi-Fi", vec!["127.0.0.1".into()]), + ), + ( + "public", + SystemDnsSnapshot::new("Ethernet", vec!["1.1.1.1".into()]), + ), + ("empty", SystemDnsSnapshot::new("Wi-Fi", vec![])), + ]; + + for (name, snapshot) in cases { + let json = serde_json::to_string(&snapshot).unwrap(); + let restored: SystemDnsSnapshot = serde_json::from_str(&json).unwrap(); + assert_eq!(snapshot, restored, "case: {}", name); + // points_at_loopback 必须在线上(前端直接读这个字段决定横幅)。 + let parsed: serde_json::Value = serde_json::from_str(&json).unwrap(); + assert!( + parsed.get("points_at_loopback").is_some(), + "case {}: points_at_loopback missing from wire format", + name + ); + } + } } diff --git a/src-tauri/crates/mhost-dns/src/platform.rs b/src-tauri/crates/mhost-dns/src/platform.rs index 3cc8b32..a778968 100644 --- a/src-tauri/crates/mhost-dns/src/platform.rs +++ b/src-tauri/crates/mhost-dns/src/platform.rs @@ -3,7 +3,7 @@ use std::path::{Path, PathBuf}; use std::process::Command; use std::sync::atomic::{AtomicU64, Ordering}; -use mhost_core::OriginalDns; +use mhost_core::{OriginalDns, SystemDnsSnapshot}; // --------------------------------------------------------------------------- // Runtime directory + signal/state file paths @@ -122,6 +122,11 @@ pub enum PlatformError { TempScript(String), #[error("interface name is empty")] EmptyInterfaceName, + /// **issue #153**:该平台没有对应的系统 DNS 原语(DNS mode 目前 + /// macOS-only)。消息直接面向用户,会经 `probe_system_dns` IPC + /// 冒泡到 Settings 页的错误路径。 + #[error("unsupported platform: {0}")] + UnsupportedPlatform(&'static str), } /// 接口名白名单:只允许字母、数字、空格、点、下划线、连字符、斜杠。 @@ -621,6 +626,111 @@ fn networksetup_get_dns(port: &str) -> Result, PlatformError> { Ok(filtered) } +// --------------------------------------------------------------------------- +// System DNS probe (issue #153) +// --------------------------------------------------------------------------- +// +// **不要把下面这条读数路径和 `networksetup_get_dns` 合并。** 两者对 +// `127.0.0.1` 的处理**故意相反**: +// +// - `networksetup_get_dns`(#152 root cause 2)—— **过滤掉** loopback。 +// 它服务 `capture_dns_state()`,即「用户的原始 DNS 是什么」。若把 +// mHost 自己注入的 `127.0.0.1` 读回来当原始值持久化到 +// `mhost-dns-original.txt`,后续每次 restore 都会把用户系统 DNS 写成 +// 127.0.0.1 —— 永久污染。 +// - `networksetup_get_dns_stdout`(本函数,#153)—— **保留** loopback。 +// 它服务「OS 现在到底指向哪」的探测,答案**必须**能表达 +// 「指向 127.0.0.1」。过滤掉之后 `points_at_loopback` 恒为 false, +// issue #153 静默退化成 no-op,而且不会有任何测试失败。 + +/// `networksetup -getdnsservers ` 的 **raw stdout**,不过滤任何条目。 +/// +/// 刻意返回原始字符串而不是 `Vec`:解析只发生在纯函数 +/// [`probe_snapshot_from`] 里一处,让「IO 与判定分离」,issue #153 的 +/// parsing 单测才能在 CI 上跑(不需要 macOS 实机)。 +/// +/// `port` 必须已通过 [`validate_interface_name`](调用方 +/// `get_active_network_interface` 在返回前会校验,安全边界自动继承)。 +#[cfg(target_os = "macos")] +fn networksetup_get_dns_stdout(port: &str) -> Result { + let output = Command::new("networksetup") + .args(["-getdnsservers", port]) + .output() + .map_err(|e| PlatformError::GetDns(format!("networksetup command failed: {}", e)))?; + if !output.status.success() { + let stderr = String::from_utf8_lossy(&output.stderr); + return Err(PlatformError::GetDns(format!( + "networksetup failed: {}", + stderr + ))); + } + // 关键:**不过滤**。raw stdout 直接交给 probe_snapshot_from 解析。 + Ok(String::from_utf8_lossy(&output.stdout).into_owned()) +} + +/// 从 `networksetup -getdnsservers` 的 **raw stdout** 组装 +/// [`SystemDnsSnapshot`]。 +/// +/// 纯函数(不 spawn 任何进程),所以 issue #153 的验收标准「parsing + +/// loopback detection 有单测覆盖」可以完全跑在 CI 上,不需要 macOS 实机。 +#[cfg(target_os = "macos")] +pub(crate) fn probe_snapshot_from( + interface: &str, + raw_stdout: &str, +) -> Result { + let servers = parse_dns_servers(raw_stdout)?; + Ok(SystemDnsSnapshot::new(interface, servers)) +} + +/// **issue #153**:从 OS 读出系统 DNS 的当前实际状态。 +/// +/// 这是前端**唯一独立于 Rust 内存态**的数据源 —— `get_dns_mode` 只返回 +/// `state.dns_enabled` 这个 `AtomicBool`,当内存态和现实分歧时 +/// (#152:`disable` 声称成功但系统 DNS 卡在 127.0.0.1)前端无从发现。 +/// +/// 纯只读:不写任何文件、不调 `networksetup -setdnsservers`、不弹 sudo。 +/// 因此可以在任意时机(启动 / 每次 toggle / 窗口重新聚焦)放心调用。 +/// +/// 非 macOS 直接返回 [`PlatformError::UnsupportedPlatform`] —— DNS mode +/// 本身目前是 macOS-only(#67 跟踪 Windows / Linux)。IPC 仍然注册, +/// 这样前端 TS 类型在所有平台保持一致。 +#[cfg(target_os = "macos")] +pub fn probe_system_dns_state() -> Result { + probe_system_dns_state_with(get_active_network_interface, networksetup_get_dns_stdout) +} + +/// 组合层核心,两个 IO 步骤都注入 —— 纯逻辑(除了注入的 IO 本身), +/// 所以组合层也能在 CI 上单测。 +/// +/// **review #231 跟进**:上一版 `probe_system_dns_state` 把 +/// `get_active_network_interface` → 读数 → `probe_snapshot_from` 三步 +/// 写死在函数体里,护栏测试只覆盖了 `probe_snapshot_from`(纯函数层)。 +/// 于是「有人把组合层改成直接调**带过滤**的 `networksetup_get_dns` +/// 再拼 snapshot」这条最现实的破坏路径**能让所有测试全绿** —— +/// 它绕开的就是唯一被测的那一层。 +/// +/// 现在 IO 可注入,组合层本身有测试。 +/// +/// 注意这**不能**替代 `test_probe_read_path_wires_unfiltered_reader`: +/// 注入的 reader 是测试自己给的,测不到真实 wiring 指向哪个 reader。 +/// 那条 source-grep 护栏才是钉死真实组合关系的,两条一起才完整。 +#[cfg(target_os = "macos")] +fn probe_system_dns_state_with( + resolve_interface: impl Fn() -> Result, + read_dns: impl Fn(&str) -> Result, +) -> Result { + let interface = resolve_interface()?; + let raw_stdout = read_dns(&interface)?; + probe_snapshot_from(&interface, &raw_stdout) +} + +#[cfg(not(target_os = "macos"))] +pub fn probe_system_dns_state() -> Result { + Err(PlatformError::UnsupportedPlatform( + "system DNS probe is only supported on macOS", + )) +} + /// `ipconfig getoption domain_name_server` —— DHCP 推的 DNS。 /// 每行一个 IP(legacy 版本可能空格分隔),由 `parse_dns_servers` 统一解析。 fn ipconfig_get_dns(device: &str) -> Result, PlatformError> { @@ -2801,6 +2911,242 @@ Ethernet Address: aa:bb:cc:dd:ee:ff assert_eq!(filtered, vec!["8.8.8.8".to_string(), "1.1.1.1".to_string()]); } + // ----------------------------------------------------------------------- + // System DNS probe tests (issue #153) + // ----------------------------------------------------------------------- + + /// `probe_snapshot_from` 是纯函数,覆盖 issue #153 要求的 + /// 「parsing + loopback detection」两半。 + /// + /// 关键用例是 `dhcp_empty`(networksetup 对「没手动配 DNS」的固定 + /// 输出)和 `single_loopback_is_NOT_filtered` —— 后者是整个特性的 + /// 地基:如果这里把 `127.0.0.1` 过滤掉,`points_at_loopback` 恒为 + /// false,探测退化成永远「一致」,横幅永不出现,且不会有别的测试 + /// 失败。 + #[test] + #[cfg(target_os = "macos")] + fn test_probe_snapshot_from_parsing_and_loopback_detection() { + struct Case { + name: &'static str, + raw: &'static str, + expected_servers: Vec<&'static str>, + expected_loopback: bool, + } + let cases = vec![ + Case { + name: "dhcp_empty", + raw: "There aren't any DNS Servers set on Wi-Fi.\n", + expected_servers: vec![], + expected_loopback: false, + }, + Case { + name: "single_loopback_is_NOT_filtered", + raw: "127.0.0.1\n", + expected_servers: vec!["127.0.0.1"], + expected_loopback: true, + }, + Case { + name: "ipv6_loopback", + raw: "::1\n", + expected_servers: vec!["::1"], + expected_loopback: true, + }, + Case { + name: "public_resolvers", + raw: "8.8.8.8\n1.1.1.1\n", + expected_servers: vec!["8.8.8.8", "1.1.1.1"], + expected_loopback: false, + }, + Case { + // 用户手动配了上游,但 mHost 的 127.0.0.1 也还在列表里 + // (半完成状态)。any 语义 → 判定为接管。 + name: "mixed_any_semantics", + raw: "127.0.0.1\n8.8.8.8\n", + expected_servers: vec!["127.0.0.1", "8.8.8.8"], + expected_loopback: true, + }, + Case { + name: "extra_whitespace_and_crlf", + raw: " 127.0.0.1 \r\n", + expected_servers: vec!["127.0.0.1"], + expected_loopback: true, + }, + Case { + name: "malformed_entry_is_preserved_verbatim", + raw: "not-an-ip\n", + expected_servers: vec!["not-an-ip"], + expected_loopback: false, + }, + Case { + name: "empty_output", + raw: "", + expected_servers: vec![], + expected_loopback: false, + }, + ]; + + for c in cases { + let snapshot = probe_snapshot_from("Wi-Fi", c.raw) + .unwrap_or_else(|e| panic!("case {}: unexpected err {}", c.name, e)); + assert_eq!( + snapshot.servers, c.expected_servers, + "case {}: servers must be the RAW unfiltered read", + c.name + ); + assert_eq!( + snapshot.points_at_loopback, c.expected_loopback, + "case {}", + c.name + ); + assert_eq!(snapshot.interface, "Wi-Fi", "case: {}", c.name); + } + } + + /// **组合层护栏(issue #153,review #231 跟进)**:真实的 + /// `probe_system_dns_state` 必须把 IO 接到**不过滤**的 + /// `networksetup_get_dns_stdout` 上。 + /// + /// 上一版护栏(`..._is_not_merged_with_capture_read_path`)只钉住了 + /// 纯函数层,于是「组合层直接改调带过滤的 `networksetup_get_dns`」 + /// 这条最现实的破坏路径能让全部测试全绿。注入式测试也测不到这一点 + /// —— 注入的 reader 是测试自己给的。 + /// + /// 所以这里用 source-grep 把真实 wiring 钉死(本仓库同类问题的既有 + /// 手法,见 `test_try_recover_dns_reads_canonical_marker_path`): + /// 匹配 `networksetup_get_dns(`(带左括号)而不是 + /// `networksetup_get_dns`(不带)—— 后者是前者的前缀,会误伤 + /// `networksetup_get_dns_stdout(`。 + #[test] + #[cfg(target_os = "macos")] + fn test_probe_read_path_wires_unfiltered_reader() { + let src = include_str!("platform.rs"); + + // 只看 probe_system_dns_state 的函数体(到下一个独立的 `}` 为止)。 + let start = src + .find("pub fn probe_system_dns_state()") + .expect("probe_system_dns_state must exist"); + let body_end = src[start..].find("\n}").expect("function must have a body"); + let body = &src[start..start + body_end]; + + assert!( + body.contains("networksetup_get_dns_stdout"), + "probe_system_dns_state must wire the UNFILTERED reader \ + (networksetup_get_dns_stdout). Wiring it to the capture-path \ + reader silently disables issue #153 — points_at_loopback would \ + be permanently false. body: {}", + body + ); + assert!( + !body.contains("networksetup_get_dns("), + "probe_system_dns_state must NOT use the capture-path reader \ + `networksetup_get_dns(` — it filters loopback (issue #152 root \ + cause 2) and would make points_at_loopback permanently false. \ + body: {}", + body + ); + } + + /// **组合层行为测试(issue #153,review #231 跟进)**:注入假的接口 + /// 解析 + 假的读数函数,验证 `probe_system_dns_state_with` 的三步 + /// 编排 —— 尤其是**读数函数收到的是解析出来的接口名**(而不是 + /// 硬编码的 "Wi-Fi"),以及 raw stdout 原样透传给解析层(未被过滤)。 + #[test] + #[cfg(target_os = "macos")] + fn test_probe_system_dns_state_with_composition() { + // 1. happy path:假 reader 返回 127.0.0.1,必须原样到达判定层。 + let snapshot = probe_system_dns_state_with( + || Ok("USB 10/100/1000 LAN".to_string()), + |iface| { + assert_eq!( + iface, "USB 10/100/1000 LAN", + "reader must receive the resolved interface" + ); + Ok("127.0.0.1 +" + .to_string()) + }, + ) + .expect("probe should succeed"); + assert_eq!(snapshot.interface, "USB 10/100/1000 LAN"); + assert_eq!(snapshot.servers, vec!["127.0.0.1".to_string()]); + assert!( + snapshot.points_at_loopback, + "127.0.0.1 must survive the composition layer" + ); + + // 2. 读数是公网 DNS → 不判定为接管。 + let snapshot = probe_system_dns_state_with( + || Ok("Wi-Fi".to_string()), + |_| Ok("8.8.8.8\n1.1.1.1\n".to_string()), + ) + .unwrap(); + assert!(!snapshot.points_at_loopback); + + // 3. 接口解析失败 → 短路,**不**调用读数函数。 + // 用 Cell 而不是 `mut bool`:闭包是 `Fn`,不能改捕获的可变变量。 + let reader_called = std::cell::Cell::new(false); + let err = probe_system_dns_state_with( + || Err(PlatformError::DetectInterface("no default route".into())), + |_| { + reader_called.set(true); + Ok("127.0.0.1\n".to_string()) + }, + ) + .unwrap_err(); + assert!(matches!(err, PlatformError::DetectInterface(_))); + assert!( + !reader_called.get(), + "reader must not run when the interface is unknown" + ); + + // 4. 读数失败 → 向上传播。 + let err = probe_system_dns_state_with( + || Ok("Wi-Fi".to_string()), + |_| Err(PlatformError::GetDns("networksetup exploded".into())), + ) + .unwrap_err(); + assert!(matches!(err, PlatformError::GetDns(_))); + } + + /// **回归护栏(issue #153)**:`networksetup_get_dns_stdout`(探测用, + /// 不过滤)与 `networksetup_get_dns`(capture 用,过滤 loopback) + /// 对同一份 stdout **必须**给出相反结果。 + /// + /// 这两个函数同在一个文件、几乎逐行相同,未来极容易被「顺手统一」 + /// 成一个。而那个统一**不会让任何现存测试变红** —— 它只会让 #153 + /// 的探测静默失效(`points_at_loopback` 恒 false)。 + /// 本测试把这个分界线钉死:谁把两条路径合并,这里立刻失败。 + #[test] + #[cfg(target_os = "macos")] + fn test_probe_read_path_is_not_merged_with_capture_read_path() { + let raw = "127.0.0.1\n1.1.1.1\n"; + + // capture 路径(#152):loopback 被过滤掉。 + let capture_raw = parse_dns_servers(raw).unwrap(); + let capture_filtered: Vec = capture_raw + .into_iter() + .filter(|s| !is_local_resolver(s)) + .collect(); + assert_eq!( + capture_filtered, + vec!["1.1.1.1".to_string()], + "capture path must keep filtering loopback (#152 root cause 2)" + ); + + // probe 路径(#153):loopback 保留。 + let probe = probe_snapshot_from("Wi-Fi", raw).unwrap(); + assert_eq!( + probe.servers, + vec!["127.0.0.1".to_string(), "1.1.1.1".to_string()], + "probe path must NOT filter — merging it with the capture path silently \ + disables issue #153" + ); + assert!( + probe.points_at_loopback, + "issue #153 depends on this being true when system DNS is 127.0.0.1" + ); + } + /// **fix (issue #152, root cause 1)**: `try_recover_dns` must read the /// recovery marker via `disable_recovery_marker_file()`, NOT from a /// hard-coded `/tmp/...` path. The disable path writes to the former; diff --git a/src-tauri/src/commands/dns.rs b/src-tauri/src/commands/dns.rs index 3be01d4..b45879a 100644 --- a/src-tauri/src/commands/dns.rs +++ b/src-tauri/src/commands/dns.rs @@ -2,7 +2,7 @@ use std::sync::atomic::Ordering; use std::sync::{Arc, Mutex}; use crate::state::{lock_or_recover, AppState}; -use mhost_core::{MhostError, OriginalDns, ProfileMode}; +use mhost_core::{MhostError, OriginalDns, ProfileMode, SystemDnsSnapshot}; use tauri::State; use tokio::task::JoinHandle; use tokio_util::sync::CancellationToken; @@ -1051,6 +1051,67 @@ mod tests { "tick must follow the NEW interval, not the spawn-time 168h one; elapsed={elapsed:?}" ); } + + /// **issue #153(review #231 跟进)**:探测失败的错误分类。 + /// + /// 原来所有失败一律 `InvalidInput`。这不是分类,是偷懒 —— 而且 + /// 会骗到人:非 macOS 上的 + /// 「invalid input: system DNS probe is only supported on macOS」 + /// 暗示用户输入了什么东西,而用户根本没输入。 + #[test] + fn test_map_probe_error_classification() { + use mhost_dns::platform::PlatformError; + + // 平台限制 → Unsupported(不是 InvalidInput)。 + let mapped = map_probe_error(PlatformError::UnsupportedPlatform( + "system DNS probe is only supported on macOS", + )); + match &mapped { + MhostError::Unsupported(msg) => { + assert_eq!(msg, "system DNS probe is only supported on macOS") + } + other => panic!("expected Unsupported, got {:?}", other), + } + // Display 必须读成平台限制,不能出现 "invalid input"。 + let display = mapped.to_string(); + assert!( + display.starts_with("unsupported on this platform:"), + "unexpected display: {}", + display + ); + assert!( + !display.contains("invalid input"), + "a platform limitation must never read as invalid input: {}", + display + ); + + // 读 OS 失败 → Io + 固定 kind(前端据此识别来源)。 + // PlatformError 不 Clone,所以每种情况各自构造一次。 + let cases: Vec<(&str, PlatformError)> = vec![ + ( + "detect_interface", + PlatformError::DetectInterface("route failed".into()), + ), + ( + "get_dns", + PlatformError::GetDns("networksetup failed".into()), + ), + ( + "invalid_interface_name", + PlatformError::InvalidInterfaceName("bad; name".into()), + ), + ("empty_interface_name", PlatformError::EmptyInterfaceName), + ]; + for (name, e) in cases { + match map_probe_error(e) { + MhostError::Io { kind, message } => { + assert_eq!(kind, "system-dns-probe", "case: {}", name); + assert!(!message.is_empty(), "case: {}", name); + } + other => panic!("case {}: expected Io, got {:?}", name, other), + } + } + } } /// 获取 DNS 服务运行状态。 @@ -1086,6 +1147,72 @@ pub async fn get_dns_status( let status = build(lock_or_recover(&state.dns_server).as_ref(), original_dns); Ok(status) } + +/// **issue #153**:探测 OS 上系统 DNS 的**实际**配置。 +/// +/// 与 `get_dns_mode` 的关系是刻意的互补而非重复: +/// +/// - `get_dns_mode` 返回 `state.dns_enabled`(in-memory `AtomicBool`) +/// —— 「mHost **认为** DNS 模式是开还是关」。 +/// - 本命令直接读 `networksetup` —— 「**系统实际上**指向哪里」。 +/// +/// #152 的 bug(`disable` 报告成功、系统 DNS 仍卡在 127.0.0.1)之所以让 +/// 用户完全无计可施,正是因为当时前端**只有前一个数据源**:Rust 说关了, +/// 而真相在另一个进程手里,没人去读。 +/// +/// 纯只读 —— 不写文件、不改系统 DNS、不需要 sudo,因此可以在启动 / +/// 每次 toggle / 窗口重新聚焦这些时机无副作用地调用。 +/// +/// 签名**不带** `State<'_, AppState>`:探测不依赖任何应用状态,这正是 +/// 它的价值所在(内存态已经不可信时,探测仍要能给出真值)。 +/// +/// 非 macOS(DNS mode 尚未支持,见 #67)返回 +/// `UnsupportedPlatform` 错误;前端会静默吞掉,不影响其它功能。 +/// **issue #153(review #231 跟进)**:探测错误 → IPC 错误的映射。 +/// +/// 原来所有失败一律 `InvalidInput`,但探测失败里没有一类是「输入无效」: +/// +/// - `UnsupportedPlatform` 是**平台限制**(DNS mode 目前 macOS-only,#67) +/// → `MhostError::Unsupported`。当前前端两边都静默吞掉,没有行为差异; +/// 但一旦有人把这个 message 渲染给用户,写成「invalid input: system +/// DNS probe is only supported on macOS」是在骗人。 +/// - 其余(`route` 找不到默认路由、`networksetup` 失败、接口名非法)都是 +/// **读 OS 失败** → `MhostError::Io` + `kind: "system-dns-probe"`, +/// `extractErrorMessage` 渲染成 ` (system-dns-probe)`。 +/// +/// 抽成独立函数而不是内联在闭包里:这样这段「为什么不是 InvalidInput」 +/// 的理由不会随着闭包一起被压扁掉。 +fn map_probe_error(e: mhost_dns::platform::PlatformError) -> MhostError { + use mhost_dns::platform::PlatformError; + match e { + PlatformError::UnsupportedPlatform(msg) => MhostError::Unsupported(msg.to_string()), + other => MhostError::Io { + kind: "system-dns-probe".to_string(), + message: other.to_string(), + }, + } +} + +#[tauri::command] +pub async fn probe_system_dns() -> Result { + // `route` / `networksetup` 是同步 syscall,可能因 wedged configd + // 阻塞调用线程。包 spawn_blocking 防止占死 tokio worker —— + // 与 set_dns_mode_enable 里 capture_dns_state 的处理同款(issue #214)。 + let snapshot = tokio::task::spawn_blocking(mhost_dns::platform::probe_system_dns_state) + .await + .map_err(|e| MhostError::Io { + kind: "system-dns-probe".to_string(), + message: format!("probe blocking task join failed: {}", e), + })? + .map_err(map_probe_error)?; + tracing::debug!( + "probe_system_dns: interface={} servers={:?} points_at_loopback={}", + snapshot.interface, + snapshot.servers, + snapshot.points_at_loopback + ); + Ok(snapshot) +} /// Abort the periodic ad-block refresh task if one is registered. /// /// The disable path (issue #130) is the only legitimate caller: diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index 1c7cb3a..1058998 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -159,6 +159,8 @@ pub fn run() { get_dns_mode, reload_dns_rules, get_dns_status, + // issue #153: OS 侧系统 DNS 独立探测(不依赖内存态) + probe_system_dns, list_dns_profiles, cancel_dns_mode, // 广告屏蔽(issue #130) diff --git a/src/App.test.tsx b/src/App.test.tsx index f2fedaf..6589bdd 100644 --- a/src/App.test.tsx +++ b/src/App.test.tsx @@ -30,6 +30,10 @@ vi.mock("./lib/tauri", () => ({ auto_refresh_enabled: true, refresh_interval_hours: 24, }), + // DNS truth-fetch + 系统 DNS 探测(issue #153)— App 在 mount 时并发调用。 + getDnsMode: vi.fn().mockResolvedValue(false), + getDnsStatus: vi.fn().mockResolvedValue(null), + probeSystemDns: vi.fn().mockResolvedValue(null), })); vi.mock("@tauri-apps/plugin-dialog", () => ({ diff --git a/src/App.tsx b/src/App.tsx index 686fade..23bdc2b 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -13,6 +13,7 @@ import { fetchDnsProfilesAtom, fetchDnsModeAtom, fetchAdBlockStateAtom, + probeSystemDnsAtom, } from "./stores/profiles"; function App() { @@ -20,6 +21,7 @@ function App() { const fetchDnsProfiles = useSetAtom(fetchDnsProfilesAtom); const fetchDnsMode = useSetAtom(fetchDnsModeAtom); const fetchAdBlock = useSetAtom(fetchAdBlockStateAtom); + const probeSystemDns = useSetAtom(probeSystemDnsAtom); const navigate = useNavigate(); useEffect(() => { @@ -66,6 +68,39 @@ function App() { }; }, [fetchProfiles, fetchDnsProfiles, fetchDnsMode, fetchAdBlock, navigate]); + /** + * Issue #153: 窗口重新获得焦点时重新探测系统 DNS。 + * + * 覆盖「用户在 mHost 开着的时候,在 System Settings 或别的工具里改了 + * 系统 DNS」这个场景 —— 启动时的探测看不到它,但用户切回 app 的瞬间 + * 就是天然的复检时机(而且比任何定时器都便宜:只有真的切回来才跑)。 + * + * 两道闸门防止来回切窗口时刷 IPC: + * - in-flight 标志:上一次探测还没回来就不再发一次 + * - 1s 冷却:macOS 上 focus 事件会成簇触发(点标题栏、Cmd-Tab + * 回弹、点通知中心再点回来……) + * + * 探测失败由 `probeSystemDnsAtom` 自己吞掉,这里不处理。 + */ + useEffect(() => { + let inFlight = false; + let lastProbeAt = 0; + const COOLDOWN_MS = 1000; + + const onFocus = () => { + const now = Date.now(); + if (inFlight || now - lastProbeAt < COOLDOWN_MS) return; + inFlight = true; + lastProbeAt = now; + probeSystemDns().finally(() => { + inFlight = false; + }); + }; + + window.addEventListener("focus", onFocus); + return () => window.removeEventListener("focus", onFocus); + }, [probeSystemDns]); + return ( }> diff --git a/src/lib/__tests__/error.test.ts b/src/lib/__tests__/error.test.ts index cf9ce4c..256b2e6 100644 --- a/src/lib/__tests__/error.test.ts +++ b/src/lib/__tests__/error.test.ts @@ -23,6 +23,36 @@ describe("extractErrorMessage", () => { ); }); + /** + * Issue #153: `probe_system_dns` 在非 macOS 返回 `Unsupported`。 + * + * 关键断言是「不读成 invalid input」—— 平台限制和用户输入错误是两件事, + * 一旦被归错类,将来任何把这个 message 显示给用户的代码都会告诉用户 + * 「你输入无效」,而他根本没有输入任何东西。 + */ + it("renders MhostError::Unsupported as a platform limitation, not invalid input", () => { + const msg = extractErrorMessage({ + Unsupported: "system DNS probe is only supported on macOS", + }); + expect(msg).toBe( + "unsupported on this platform: system DNS probe is only supported on macOS", + ); + expect(msg).not.toMatch(/invalid input/i); + }); + + /** Issue #153: 探测的读 OS 失败走 `Io { kind: "system-dns-probe" }`。 */ + it("renders the system-dns-probe Io kind", () => { + const msg = extractErrorMessage({ + Io: { + kind: "system-dns-probe", + message: "failed to detect active network interface: route failed", + }, + }); + expect(msg).toBe( + "failed to detect active network interface: route failed (system-dns-probe)", + ); + }); + it("returns string-payload Network variant (no raw JSON)", () => { const result = extractErrorMessage({ Network: "connection refused" }); expect(result).not.toContain("{"); diff --git a/src/lib/error.ts b/src/lib/error.ts index 6fa27ce..389dec1 100644 --- a/src/lib/error.ts +++ b/src/lib/error.ts @@ -8,6 +8,7 @@ * MhostError::Io { kind, message } → { Io: { kind, message } } * MhostError::InvalidInput(String) → { InvalidInput: "…" } * MhostError::Network(String) → { Network: "…" } + * MhostError::Unsupported(String) → { Unsupported: "…" } * MhostError::ExternalApi(String) → { ExternalApi: "…" } * MhostError::Parse(ParseError) → { Parse: { : } } * MhostError::Apply(ApplyError) → { Apply: { : } } @@ -51,6 +52,13 @@ export function extractErrorMessage(err: unknown): string { return `preview required: ${obj.PreviewRequired}`; } + // MhostError::Unsupported(String) — issue #153. A platform limitation, + // NOT a user input problem: "probe_system_dns only works on macOS" + // must never read as "invalid input: ...". + if (typeof obj.Unsupported === "string") { + return `unsupported on this platform: ${obj.Unsupported}`; + } + // MhostError::Network(String) if (typeof obj.Network === "string") { return `network error: ${obj.Network}`; diff --git a/src/lib/tauri.ts b/src/lib/tauri.ts index ccc5a97..38fec81 100644 --- a/src/lib/tauri.ts +++ b/src/lib/tauri.ts @@ -7,6 +7,7 @@ import type { ExportFormat, SnapshotMeta, DnsStatus, + SystemDnsSnapshot, ProfileMode, AdBlockState, AdBlockLimits, @@ -252,6 +253,22 @@ export async function getDnsStatus(): Promise { return invoke("get_dns_status"); } +/** + * Issue #153: 读出系统 DNS 的**实际**配置(独立于 Rust 内存态)。 + * + * 与 `getDnsMode` 互补而非重复:`getDnsMode` 只返回 `state.dns_enabled` + * 这个 AtomicBool("mHost 认为自己开着"),本函数直接问 `networksetup` + * ("系统实际上指向哪里")。#152 之所以让用户无计可施,就是因为当时 + * 前端只有前一个数据源。 + * + * 纯只读、无副作用、不需要 sudo,因此可以在启动 / 每次 toggle / + * 窗口重新聚焦时放心调用。非 macOS 会被后端拒掉(DNS mode 目前 + * macOS-only,见 #67),调用方应把 reject 当作「探测不可用」静默处理。 + */ +export async function probeSystemDns(): Promise { + return invoke("probe_system_dns"); +} + export async function listDnsProfiles(): Promise { return invoke("list_dns_profiles"); } diff --git a/src/pages/Settings.module.css b/src/pages/Settings.module.css index e45367c..aea130b 100644 --- a/src/pages/Settings.module.css +++ b/src/pages/Settings.module.css @@ -167,6 +167,49 @@ /* ---- DNS Mode Card ---- */ +/* ---- System DNS discrepancy banner (issue #153) ---- + 视觉沿用 AdBlock.module.css 的 .banner(warning-soft 底 + 右侧按钮), + 但用更重的边框/底色:这里报告的是「你的 DNS 现在是坏的」,比 + 「编辑暂不生效」严重一个量级。 */ + +.dnsDiscrepancyBanner { + display: flex; + align-items: center; + gap: 10px; + margin-bottom: 12px; + padding: 10px 14px; + border-radius: var(--radius-md, 6px); + border: 1px solid var(--color-warning, var(--warning)); + background: var(--color-warning-soft, #fff5e0); + color: var(--color-text, var(--ink)); + font-size: 13px; +} + +.dnsDiscrepancyText { + display: flex; + flex-direction: column; + gap: 2px; + min-width: 0; +} + +.dnsDiscrepancyTitle { + font-weight: 600; + color: var(--color-text, var(--ink)); +} + +.dnsDiscrepancyDetail { + color: var(--color-text-secondary); + font-size: 12px; + /* servers 列表可能很长(用户手动配了一串),别把按钮挤出卡片 */ + overflow-wrap: anywhere; +} + +.dnsDiscrepancyBanner button { + margin-left: auto; + flex-shrink: 0; + white-space: nowrap; +} + .dnsStatusRow { display: flex; align-items: center; diff --git a/src/pages/Settings.tsx b/src/pages/Settings.tsx index adab9e6..6176cf0 100644 --- a/src/pages/Settings.tsx +++ b/src/pages/Settings.tsx @@ -8,6 +8,9 @@ import { cancelActiveDnsToggle, dnsErrorAtom, quickApplyOnToggleAtom, + // issue #153 + systemDnsAtom, + dnsDiscrepancyAtom, } from "../stores/profiles"; import { useWebKitPointerDown } from "../hooks/useWebKitPointerDown"; import { checkUpdate } from "../lib/tauri"; @@ -19,6 +22,9 @@ function Settings() { const dnsStatus = useAtomValue(dnsStatusAtom); const isDnsLoading = useAtomValue(isDnsLoadingAtom); const dnsError = useAtomValue(dnsErrorAtom); + // issue #153: 系统 DNS 实际状态 vs mHost 内存态的分歧 + const systemDns = useAtomValue(systemDnsAtom); + const dnsDiscrepancy = useAtomValue(dnsDiscrepancyAtom); const toggleDnsMode = useSetAtom(toggleDnsModeAtom); // issue #149 / #123 follow-up: DNS toggle must dedupe pointerdown + click. // The `useWebKitPointerDown.onPointerDown` wrapper is NOT used here — @@ -203,6 +209,81 @@ function Settings() { {/* DNS Mode Card */}

DNS Mode

+ {/* + Issue #153: `dnsEnabledAtom`(Rust 内存态)与系统 DNS 实际状态 + 分歧时的横幅。 + + 两种方向的用户含义完全不同,所以处理方式也不同: + + - `stuck_at_loopback`(显示 Stopped,但系统仍指向 127.0.0.1) + —— **危险**:用户的 DNS 已经指向一个没人监听的地址,解析会 + 直接失败。给一键恢复:调 `set_dns_mode(false)`。该后端路径 + **没有**「已禁用就短路」的分支(`set_dns_mode_disable` 直接 + 走 `disable_dns_mode()`),所以即使内存态已经是 false,它仍 + 会真的执行系统 DNS 还原 —— 这正是这里需要的能力。 + + - `not_pointing`(显示 Running,但系统没指向 mHost) + —— **不危险**:DNS 本身还能用,只是 mHost 的规则没生效。 + 只提示,不给一键修复 —— 要修就得重启 enable 流程 + (disable → enable),那会弹两次 sudo 并重建 DNS server, + 为「省一次手动开关」在特权路径上新增代码不值得 + (见 issue #153 的 Assumptions)。 + */} + {dnsDiscrepancy && ( +
+
+
+ {dnsDiscrepancy === "stuck_at_loopback" + ? "System DNS still points at mHost" + : "System DNS does not point at mHost"} +
+
+ {dnsDiscrepancy === "stuck_at_loopback" ? ( + <> + mHost reports DNS mode as{" "} + Stopped, but your system DNS is{" "} + {systemDns?.servers.join(", ") || "127.0.0.1"}{" "} + on {systemDns?.interface}. Domain resolution may be + broken right now. + + ) : ( + <> + mHost reports DNS mode as Running, but{" "} + {systemDns?.interface} is using{" "} + + {systemDns && systemDns.servers.length > 0 + ? systemDns.servers.join(", ") + : "the system default"} + + . mHost rules are not being applied. Toggle DNS mode + off and on to re-apply. + + )} +
+
+ {dnsDiscrepancy === "stuck_at_loopback" && ( + + )} +
+ )}
Status: diff --git a/src/pages/__tests__/Settings.test.tsx b/src/pages/__tests__/Settings.test.tsx index 5c0e0d6..383c2a8 100644 --- a/src/pages/__tests__/Settings.test.tsx +++ b/src/pages/__tests__/Settings.test.tsx @@ -7,7 +7,10 @@ import { dnsStatusAtom, isDnsLoadingAtom, quickApplyOnToggleAtom, + systemDnsAtom, } from "../../stores/profiles"; +import type { DnsStatus } from "../../types"; +import { POINTER_DOWN_DEBOUNCE_MS } from "../../hooks/useWebKitPointerDown"; // Define global __APP_VERSION__ for tests (globalThis as unknown as Record).__APP_VERSION__ = "0.2.0"; @@ -22,6 +25,12 @@ const mockGetDnsStatus = vi.fn().mockResolvedValue({ cache_capacity: 100, }); +const mockProbeSystemDns = vi.fn().mockResolvedValue({ + interface: "Wi-Fi", + servers: ["192.168.31.1"], + points_at_loopback: false, +}); + vi.mock("../../lib/tauri", async (importOriginal) => { const actual = await importOriginal(); return { @@ -30,6 +39,7 @@ vi.mock("../../lib/tauri", async (importOriginal) => { getDnsStatus: (...args: unknown[]) => mockGetDnsStatus(...args), setDnsMode: (...args: unknown[]) => mockSetDnsMode(...args), reloadDnsRules: vi.fn().mockResolvedValue(undefined), + probeSystemDns: (...args: unknown[]) => mockProbeSystemDns(...args), }; }); @@ -50,6 +60,13 @@ describe("Settings", () => { store.set(dnsEnabledAtom, false); store.set(dnsStatusAtom, null); store.set(isDnsLoadingAtom, false); + // issue #153: 默认「探测不可用」→ 不显示不一致横幅。相关用例各自覆写。 + store.set(systemDnsAtom, null); + mockProbeSystemDns.mockResolvedValue({ + interface: "Wi-Fi", + servers: ["192.168.31.1"], + points_at_loopback: false, + }); }); it("renders Settings page title", () => { @@ -186,3 +203,223 @@ describe("Settings", () => { expect(store.get(quickApplyOnToggleAtom)).toBe(true); }); }); + +// --------------------------------------------------------------------------- +// Issue #153 — 系统 DNS 不一致横幅 +// --------------------------------------------------------------------------- + +/** + * 横幅只在「mHost 内存态」与「系统 DNS 实际状态」**分歧**时出现。 + * + * 两种方向的用户含义截然不同,因此断言也不同: + * - `stuck_at_loopback` = #152 那个 bug 的样子(DNS 已经坏了)→ 给一键恢复 + * - `not_pointing` = 只是 mHost 规则没生效(DNS 还能用)→ 只提示 + * + * 一致态和「探测不可用」都必须**不**显示横幅 —— 后者尤其重要: + * 非 macOS / 断网时探测会失败,用户不该看到一条自己无法处理的假警报。 + */ +describe("Settings — system DNS discrepancy banner (issue #153)", () => { + const banner = () => screen.queryByTestId("dns-discrepancy-banner"); + const restoreButton = () => screen.queryByTestId("dns-restore-button"); + + function setProbe( + pointsAtLoopback: boolean, + servers: string[] = pointsAtLoopback ? ["127.0.0.1"] : ["8.8.8.8", "1.1.1.1"], + ) { + getDefaultStore().set(systemDnsAtom, { + interface: "Wi-Fi", + servers, + points_at_loopback: pointsAtLoopback, + }); + } + + const status: DnsStatus = { + running: true, + port: 1053, + upstream: ["8.8.8.8"], + // `as const` on the discriminator — a widened `{ kind: string }` is not + // assignable to the OriginalDns union. + original_dns: { kind: "manual", servers: ["192.168.31.1"] }, + rule_count: 4, + cache_capacity: 100, + }; + + // ---- consistent: no banner ---- + + it("hides the banner when DNS mode is on and system DNS points at mHost", () => { + const store = getDefaultStore(); + store.set(dnsEnabledAtom, true); + store.set(dnsStatusAtom, status); + setProbe(true); + + renderWithProviders(); + expect(banner()).not.toBeInTheDocument(); + }); + + it("hides the banner when DNS mode is off and system DNS is elsewhere", () => { + const store = getDefaultStore(); + store.set(dnsEnabledAtom, false); + setProbe(false); + + renderWithProviders(); + expect(banner()).not.toBeInTheDocument(); + }); + + it("hides the banner when the probe is unavailable", () => { + // systemDnsAtom 保持 null(探测失败 / 非 macOS / 断网) + getDefaultStore().set(dnsEnabledAtom, false); + + renderWithProviders(); + expect(banner()).not.toBeInTheDocument(); + }); + + // ---- stuck_at_loopback: the dangerous direction ---- + + it("shows a recovery banner when DNS mode is off but system DNS is 127.0.0.1", () => { + const store = getDefaultStore(); + store.set(dnsEnabledAtom, false); + setProbe(true, ["127.0.0.1"]); + + renderWithProviders(); + + const el = banner(); + expect(el).toBeInTheDocument(); + expect(el).toHaveAttribute("data-discrepancy", "stuck_at_loopback"); + // 标题刻意不写死 "127.0.0.1":IPv6-only 环境下 body 会显示 "::1", + // 写死的标题会和正文自相矛盾。精确值由正文的 servers 承担。 + expect( + screen.getByText("System DNS still points at mHost"), + ).toBeInTheDocument(); + expect(screen.getByText("127.0.0.1")).toBeInTheDocument(); + expect(screen.getByText(/Domain resolution may be/)).toBeInTheDocument(); + // 危险方向必须有恢复按钮。 + expect(restoreButton()).toBeInTheDocument(); + }); + + it("Restore system DNS triggers toggleDnsMode(false)", async () => { + const store = getDefaultStore(); + store.set(dnsEnabledAtom, false); + setProbe(true, ["127.0.0.1"]); + + renderWithProviders(); + + await act(async () => { + fireEvent.click(restoreButton()!); + }); + + // 内存态已经是 false 也要真的调 set_dns_mode(false) —— 后端的 + // set_dns_mode_disable 没有「已禁用就短路」的分支,会执行真正的 + // 系统 DNS 还原。这正是 #153 最危险方向需要的能力。 + expect(mockSetDnsMode).toHaveBeenCalledWith( + false, + expect.objectContaining({ signal: expect.anything() }), + ); + }); + + /** + * 回归测试:Restore 按钮**不能是一次性的**。 + * + * `useWebKitPointerDown` 的 `firedRef` 只在 `releaseSoon()` 里复位。 + * 如果这个按钮自己调 `fire()` 却不 release,guard 会永久 latch —— + * 用户点第一次恢复了系统 DNS(横幅消失);等他下次遇到同样的问题、 + * 横幅在**同一个 Settings 挂载内**重新出现时,按钮已经死了。 + * 症状极具迷惑性:「按钮坏了」,而真正原因在另一个组件的 hook 里。 + * + * 关键:两次点击必须发生在**同一个挂载**里。每次重新 render 都会拿到 + * 一个全新的 `useWebKitPointerDown`(`firedRef` 是 useRef),latch 就 + * 被掩盖了 —— 那样这个测试测不到任何东西。 + * + * 正确写法是复用主开关的 `handleToggleDns`(fire + releaseSoon 齐全)。 + * + * 用**增量**断言:全文件共用 `getDefaultStore()`,上一个测试的 + * `toggleDnsModeAtom` 续链可能跨过 `vi.clearAllMocks()` 才落地, + * 绝对计数会假失败。 + */ + it("Restore stays usable after a previous use (fire/release latch)", async () => { + const store = getDefaultStore(); + store.set(dnsEnabledAtom, false); + setProbe(true, ["127.0.0.1"]); + + // 一个挂载,两次故障 —— 真实用户路径。 + renderWithProviders(); + + await act(async () => {}); + const baseline = mockSetDnsMode.mock.calls.length; + + await act(async () => { + fireEvent.click(screen.getByTestId("dns-restore-button")); + }); + // 点击后 toggle 会用新探测覆盖快照 → 横幅消失。 + expect(screen.queryByTestId("dns-discrepancy-banner")).not.toBeInTheDocument(); + + // 第二次故障:横幅在同一个挂载内重新出现。 + // store.set 必须包在 act 里,否则 React 不会刷这条订阅更新。 + await act(async () => { + store.set(systemDnsAtom, { + interface: "Wi-Fi", + servers: ["127.0.0.1"], + points_at_loopback: true, + }); + // 让 `releaseSoon()` 的复位定时器走完 —— 真实用户两次点击间隔以秒计, + // 而 `fire()` 的 guard 在这段时间内是故意 latch 的。不等就会把 + // 「正常的防抖」误判成「latch bug」。 + await new Promise((r) => setTimeout(r, POINTER_DOWN_DEBOUNCE_MS + 20)); + }); + expect(screen.getByTestId("dns-restore-button")).toBeInTheDocument(); + + await act(async () => { + fireEvent.click(screen.getByTestId("dns-restore-button")); + }); + + // latch 存在时这里会是 1(只有第一次点了)。 + expect(mockSetDnsMode.mock.calls.length - baseline).toBe(2); + }); + + it("disables the Restore button while a DNS operation is in flight", () => { + const store = getDefaultStore(); + store.set(dnsEnabledAtom, false); + store.set(isDnsLoadingAtom, true); + setProbe(true, ["127.0.0.1"]); + + renderWithProviders(); + + expect(restoreButton()).toBeDisabled(); + expect(restoreButton()).toHaveTextContent("Restoring…"); + }); + + // ---- not_pointing: informational only ---- + + it("shows an informational banner (no action button) when DNS mode is on but system DNS isn't mHost", () => { + const store = getDefaultStore(); + store.set(dnsEnabledAtom, true); + store.set(dnsStatusAtom, status); + setProbe(false, ["8.8.8.8", "1.1.1.1"]); + + renderWithProviders(); + + const el = banner(); + expect(el).toBeInTheDocument(); + expect(el).toHaveAttribute("data-discrepancy", "not_pointing"); + expect( + screen.getByText("System DNS does not point at mHost"), + ).toBeInTheDocument(); + // 展示实际读到的 servers,让用户能自己判断是不是他改的。 + expect(screen.getByText(/8\.8\.8\.8, 1\.1\.1\.1/)).toBeInTheDocument(); + // 非危险方向**不**提供一键修复:修它要重启 enable 流程(两次 sudo), + // 不值得为省一次手动开关在特权路径上加代码。 + expect(restoreButton()).not.toBeInTheDocument(); + }); + + it("falls back to 'system default' wording when the probe returned no servers", () => { + const store = getDefaultStore(); + store.set(dnsEnabledAtom, true); + store.set(dnsStatusAtom, status); + // DHCP 默认:networksetup 返回 "There aren't any DNS Servers set" + setProbe(false, []); + + renderWithProviders(); + + expect(banner()).toBeInTheDocument(); + expect(screen.getByText(/the system default/)).toBeInTheDocument(); + }); +}); diff --git a/src/stores/__tests__/dns.test.ts b/src/stores/__tests__/dns.test.ts index 857878b..734dbdd 100644 --- a/src/stores/__tests__/dns.test.ts +++ b/src/stores/__tests__/dns.test.ts @@ -10,21 +10,31 @@ vi.mock("../../lib/tauri", () => ({ cancelDnsMode: vi.fn(), getDnsMode: vi.fn().mockResolvedValue(false), getDnsStatus: vi.fn().mockResolvedValue(null), + // issue #153: toggleDnsModeAtom 的成功分支会 fire-and-forget 探测。 + // 这个 mock 是全量替换(无 importOriginal),漏掉它会让 + // `probeSystemDns` 变成 undefined 并抛 TypeError。 + probeSystemDns: vi.fn().mockResolvedValue(null), })); +import type { SystemDnsSnapshot } from "../../types"; import { toggleDnsModeAtom, cancelActiveDnsToggle, + fetchDnsModeAtom, + probeSystemDnsAtom, dnsEnabledAtom, isDnsLoadingAtom, dnsErrorAtom, dnsStatusAtom, + systemDnsAtom, + dnsDiscrepancyAtom, } from "../profiles"; import { setDnsMode, cancelDnsMode, getDnsMode, getDnsStatus, + probeSystemDns, } from "../../lib/tauri"; /** @@ -156,4 +166,368 @@ describe("toggleDnsModeAtom cancel path (issue #149)", () => { // cancelDnsMode was NOT called because we didn't abort. expect(cancelDnsMode).not.toHaveBeenCalled(); }); -}); \ No newline at end of file +}); + +// --------------------------------------------------------------------------- +// Issue #153 — 系统 DNS 独立探测 +// --------------------------------------------------------------------------- + +/** + * `dnsDiscrepancyAtom` 是 issue #153 的判定核心:它把「mHost 内存态」 + * 和「系统实际状态」两份数据源合成一个三态结果。 + * + * 表格驱动覆盖 issue 原文那张真值表的全部四行,外加「探测不可用」 + * 这一行 —— 最后这行同样重要:`systemDnsAtom === null` 必须退回 null + * (不报警),否则非 macOS / 断网的用户会看到一条无法处理的假警报。 + */ +describe("dnsDiscrepancyAtom (issue #153)", () => { + const store = getDefaultStore(); + + beforeEach(() => { + store.set(dnsEnabledAtom, false); + store.set(systemDnsAtom, null); + }); + + interface Case { + name: string; + enabled: boolean; + pointsAtLoopback: boolean; + expected: string | null; + } + const cases: Case[] = [ + { + name: "enabled + loopback = consistent", + enabled: true, + pointsAtLoopback: true, + expected: null, + }, + { + name: "disabled + non-loopback = consistent", + enabled: false, + pointsAtLoopback: false, + expected: null, + }, + { + name: "enabled + non-loopback = not_pointing", + enabled: true, + pointsAtLoopback: false, + expected: "not_pointing", + }, + { + name: "disabled + loopback = stuck_at_loopback (the #152 bug)", + enabled: false, + pointsAtLoopback: true, + expected: "stuck_at_loopback", + }, + ]; + + for (const c of cases) { + it(`maps ${c.name} -> ${c.expected}`, () => { + store.set(dnsEnabledAtom, c.enabled); + store.set(systemDnsAtom, { + interface: "Wi-Fi", + servers: c.pointsAtLoopback ? ["127.0.0.1"] : ["8.8.8.8"], + points_at_loopback: c.pointsAtLoopback, + }); + expect(store.get(dnsDiscrepancyAtom)).toBe(c.expected); + }); + } + + it("returns null when the probe is unavailable (no data source = no alarm)", () => { + store.set(dnsEnabledAtom, false); + store.set(systemDnsAtom, null); + expect(store.get(dnsDiscrepancyAtom)).toBeNull(); + }); +}); + +/** + * `probeSystemDnsAtom` 的契约:**永不 reject,失败静默**。 + * + * 静默的具体含义(三条都要守住): + * 1. reject 被吞掉 —— 调用方是 fire-and-forget 或并行 await + * 2. `dnsErrorAtom` 不被写 —— 探测失败不是用户该看到的错误 + * 3. `isDnsLoadingAtom` 不被写 —— 探测不是用户发起的加载态 + * + * 写 `dnsErrorAtom` 特别危险:Settings 页顶部有 `alert alert-error` + * 区域,一个「route failed」会盖在页面上,而用户对此完全无能为力 + * (`route` 失败几乎总是因为没联网,用户自己知道)。 + */ +describe("probeSystemDnsAtom (issue #153)", () => { + const store = getDefaultStore(); + + beforeEach(() => { + vi.clearAllMocks(); + store.set(dnsEnabledAtom, false); + store.set(systemDnsAtom, null); + store.set(dnsErrorAtom, null); + store.set(isDnsLoadingAtom, false); + (probeSystemDns as unknown as { mockResolvedValue: (v: unknown) => void }) + .mockResolvedValue(null); + }); + + it("stores the snapshot on success", async () => { + (probeSystemDns as unknown as { mockResolvedValueOnce: (v: unknown) => void }) + .mockResolvedValueOnce({ + interface: "Wi-Fi", + servers: ["127.0.0.1"], + points_at_loopback: true, + }); + + await store.set(probeSystemDnsAtom); + + expect(store.get(systemDnsAtom)).toEqual({ + interface: "Wi-Fi", + servers: ["127.0.0.1"], + points_at_loopback: true, + }); + }); + + it("swallows rejection: clears snapshot, no error atom, no loading state", async () => { + (probeSystemDns as unknown as { mockRejectedValueOnce: (v: unknown) => void }) + .mockRejectedValueOnce("unsupported platform: system DNS probe is only supported on macOS"); + + // 关键:不抛。 + await expect(store.set(probeSystemDnsAtom)).resolves.toBeUndefined(); + + expect(store.get(systemDnsAtom)).toBeNull(); + expect(store.get(dnsErrorAtom)).toBeNull(); + expect(store.get(isDnsLoadingAtom)).toBe(false); + // 无从判断 → 不报警。 + expect(store.get(dnsDiscrepancyAtom)).toBeNull(); + }); + + it("clears a stale snapshot when a later probe fails", async () => { + // 先成功一次,让 UI 上已经挂着横幅。 + store.set(systemDnsAtom, { + interface: "Wi-Fi", + servers: ["127.0.0.1"], + points_at_loopback: true, + }); + expect(store.get(dnsDiscrepancyAtom)).toBe("stuck_at_loopback"); + + // 再失败 —— 必须清掉,否则横幅会永久停在一个已经无法验证的结论上。 + (probeSystemDns as unknown as { mockRejectedValueOnce: (v: unknown) => void }) + .mockRejectedValueOnce(new Error("network gone")); + + await store.set(probeSystemDnsAtom); + + expect(store.get(systemDnsAtom)).toBeNull(); + expect(store.get(dnsDiscrepancyAtom)).toBeNull(); + }); +}); + +/** + * 回归测试:toggle 期间不得闪现假警报。 + * + * 缺陷形态:disable 成功时 `dnsEnabledAtom` 先翻 false,而 `systemDnsAtom` + * 还留着「DNS 开着时 points_at_loopback=true」的旧快照 —— 组合起来立刻 + * 推导出 `stuck_at_loopback`,横幅凭空闪现一个「系统 DNS 卡在 127.0.0.1 / + * 点我恢复」的按钮。用户看到的是一次惊吓,而且他点的那个 Restore 按钮 + * 语义上完全错误(DNS 模式刚才是开着的,不存在卡住)。 + * + * 契约:toggle 一开始就作废旧探测(置 null = 不知道 = 不报警), + * 等新探测回来再决定横幅。 + */ +describe("toggleDnsModeAtom clears stale probe (issue #153)", () => { + const store = getDefaultStore(); + + beforeEach(() => { + vi.clearAllMocks(); + store.set(dnsEnabledAtom, true); + store.set(dnsStatusAtom, null); + store.set(dnsErrorAtom, null); + store.set(isDnsLoadingAtom, false); + (setDnsMode as unknown as { mockResolvedValue: (v: unknown) => void }) + .mockResolvedValue(undefined); + (getDnsStatus as unknown as { mockResolvedValue: (v: unknown) => void }) + .mockResolvedValue(null); + // 探测永远 pending:这样只断言「旧值被作废」,不受新值干扰。 + (probeSystemDns as unknown as { mockReturnValue: (v: unknown) => void }) + .mockReturnValue(new Promise(() => {})); + }); + + it("drops the stale snapshot at toggle start so no false banner flashes", async () => { + // toggle 前:DNS 开着 + 系统指向 127.0.0.1 —— 完全一致,无横幅。 + store.set(systemDnsAtom, { + interface: "Wi-Fi", + servers: ["127.0.0.1"], + points_at_loopback: true, + }); + expect(store.get(dnsDiscrepancyAtom)).toBeNull(); + + // toggle 之后、探测回来之前:内存态 false + 旧快照仍在 + // → 会推导出 stuck_at_loopback。契约要求此时是 null。 + await store.set(toggleDnsModeAtom, false); + + expect(store.get(systemDnsAtom)).toBeNull(); + expect(store.get(dnsDiscrepancyAtom)).toBeNull(); + }); +}); + +/** + * `fetchDnsModeAtom` 现在并行跑三件事:内存态 truth-fetch + status + + * 系统 DNS 探测。探测失败**不能**污染主路径。 + */ +describe("fetchDnsModeAtom with probe (issue #153)", () => { + const store = getDefaultStore(); + + beforeEach(() => { + vi.clearAllMocks(); + store.set(dnsEnabledAtom, false); + store.set(systemDnsAtom, null); + store.set(dnsErrorAtom, null); + store.set(dnsStatusAtom, null); + (getDnsMode as unknown as { mockResolvedValue: (v: unknown) => void }) + .mockResolvedValue(true); + (getDnsStatus as unknown as { mockResolvedValue: (v: unknown) => void }) + .mockResolvedValue({ + running: true, + port: 1053, + upstream: ["8.8.8.8"], + original_dns: { kind: "dhcp_empty" }, + rule_count: 3, + cache_capacity: 100, + }); + (probeSystemDns as unknown as { mockResolvedValue: (v: unknown) => void }) + .mockResolvedValue({ + interface: "Wi-Fi", + servers: ["127.0.0.1"], + points_at_loopback: true, + }); + }); + + it("populates all three atoms on the happy path", async () => { + await store.set(fetchDnsModeAtom); + + expect(store.get(dnsEnabledAtom)).toBe(true); + expect(store.get(systemDnsAtom)).not.toBeNull(); + expect(store.get(dnsDiscrepancyAtom)).toBeNull(); // 一致 + expect(store.get(dnsErrorAtom)).toBeNull(); + }); + + it("a failing probe does not fail the truth-fetch", async () => { + (probeSystemDns as unknown as { mockRejectedValueOnce: (v: unknown) => void }) + .mockRejectedValueOnce(new Error("networksetup exploded")); + + await store.set(fetchDnsModeAtom); + + // 主路径完整成功。 + expect(store.get(dnsEnabledAtom)).toBe(true); + expect(store.get(dnsStatusAtom)).not.toBeNull(); + expect(store.get(dnsErrorAtom)).toBeNull(); + // 只有探测那部分降级。 + expect(store.get(systemDnsAtom)).toBeNull(); + }); +}); + +/** + * 回归测试(issue #153,review #231 跟进):晚到的旧代探测必须被丢弃。 + * + * 竞态形态:启动时的探测 P1 因 wedged configd 卡住;期间用户完成一次 + * toggle,P2 已经拿到**新**快照并写入 atom;P1 随后返回,若无守卫就会用 + * **toggle 之前**的旧快照覆盖 P2,横幅短暂指向错误方向。 + * + * 用可控 deferred promise 精确复现这个时序:让 P1 一直 pending,先跑 + * P2 落地,再让 P1 返回。 + */ +describe("late-arriving probe is discarded (issue #153)", () => { + const store = getDefaultStore(); + + /** 一个由测试手动 resolve 的探测 promise。 */ + function deferred() { + let resolve!: (v: SystemDnsSnapshot) => void; + let reject!: (e: unknown) => void; + const promise = new Promise((res, rej) => { + resolve = res; + reject = rej; + }); + return { promise, resolve, reject }; + } + + beforeEach(() => { + vi.clearAllMocks(); + store.set(dnsEnabledAtom, false); + store.set(systemDnsAtom, null); + store.set(dnsErrorAtom, null); + store.set(isDnsLoadingAtom, false); + (getDnsMode as unknown as { mockResolvedValue: (v: unknown) => void }) + .mockResolvedValue(false); + (getDnsStatus as unknown as { mockResolvedValue: (v: unknown) => void }) + .mockResolvedValue(null); + }); + + it("a slow older probe must not overwrite a newer probe's snapshot", async () => { + const P1 = deferred(); // 启动探测:卡住(wedged configd) + const P2 = deferred(); // toggle 后的 re-probe + + // 返回类型也要是 mock 本身,否则链式第二次调用过不了 tsc。 + const asProbeMock = probeSystemDns as unknown as { + mockReturnValueOnce: (v: unknown) => { mockReturnValueOnce: (v: unknown) => void }; + }; + asProbeMock.mockReturnValueOnce(P1.promise).mockReturnValueOnce(P2.promise); + + // P1 起飞(不 await —— 它还没返回)。 + const p1Task = store.set(probeSystemDnsAtom); + // P2 起飞并先落地:系统 DNS 现在指向 mHost。 + const p2Task = store.set(probeSystemDnsAtom); + P2.resolve({ + interface: "Wi-Fi", + servers: ["127.0.0.1"], + points_at_loopback: true, + }); + await p2Task; + expect(store.get(systemDnsAtom)).toEqual({ + interface: "Wi-Fi", + servers: ["127.0.0.1"], + points_at_loopback: true, + }); + + // P1 现在才返回,带的是 toggle **之前**的状态:用户把 DNS 关了, + // 系统回到公网 DNS。 + P1.resolve({ + interface: "Wi-Fi", + servers: ["8.8.8.8"], + points_at_loopback: false, + }); + await p1Task; + + // 代数守卫必须让 P1 的结果作废 —— 否则这里会变成 8.8.8.8, + // 横幅也会短暂指向 not_pointing 这个错误方向。 + expect(store.get(systemDnsAtom)).toEqual({ + interface: "Wi-Fi", + servers: ["127.0.0.1"], + points_at_loopback: true, + }); + }); + + it("a late *failure* must not wipe a newer probe's snapshot either", async () => { + const P1 = deferred(); + const P2 = deferred(); + // 返回类型也要是 mock 本身,否则链式第二次调用过不了 tsc。 + const asProbeMock = probeSystemDns as unknown as { + mockReturnValueOnce: (v: unknown) => { mockReturnValueOnce: (v: unknown) => void }; + }; + asProbeMock.mockReturnValueOnce(P1.promise).mockReturnValueOnce(P2.promise); + + const p1Task = store.set(probeSystemDnsAtom); + const p2Task = store.set(probeSystemDnsAtom); + P2.resolve({ + interface: "Wi-Fi", + servers: ["127.0.0.1"], + points_at_loopback: true, + }); + await p2Task; + + // P1 失败(比如它启动时用户还没联网,probe 耗了几秒才返回错误)。 + // 无守卫的话这里走 catch 分支把 P2 的快照擦成 null,横幅无缘无故消失。 + P1.reject(new Error("route: no default route")); + await p1Task; + + expect(store.get(systemDnsAtom)).toEqual({ + interface: "Wi-Fi", + servers: ["127.0.0.1"], + points_at_loopback: true, + }); + expect(store.get(dnsErrorAtom)).toBeNull(); + }); +}); diff --git a/src/stores/profiles/actions.ts b/src/stores/profiles/actions.ts index 70e3084..d508ebd 100644 --- a/src/stores/profiles/actions.ts +++ b/src/stores/profiles/actions.ts @@ -1,5 +1,10 @@ import { atom } from "jotai"; -import type { Profile, AdBlockResponse, BlocklistFormat } from "../../types"; +import type { + Profile, + AdBlockResponse, + BlocklistFormat, + SystemDnsSnapshot, +} from "../../types"; import { listProfiles, getProfile, @@ -16,6 +21,7 @@ import { deleteSnapshot, getDnsMode, getDnsStatus, + probeSystemDns, setDnsMode, cancelDnsMode, reloadDnsRules, @@ -62,6 +68,7 @@ import { dnsStatusAtom, isDnsLoadingAtom, dnsErrorAtom, + systemDnsAtom, adBlockStateAtom, isAdBlockLoadingAtom, adBlockErrorAtom, @@ -360,14 +367,92 @@ export const deleteSnapshotAtom = atom(null, async (get, set, id: string) => { // ---- DNS action atoms ---- +/** + * Issue #153(review #231 跟进):探测代数守卫。 + * + * 竞态形态:启动时 `fetchDnsModeAtom` 发出的探测 P1 若因 wedged configd + * 卡住几秒,期间用户完成了一次 toggle(`toggleDnsModeAtom` 的 re-probe + * P2 已经拿到新快照并写入 atom),P1 随后才返回 —— 它会把这份**toggle + * 之前**的旧快照覆盖回去,横幅可能短暂指向错误方向。 + * + * 修法是给每次探测发一个递增代数,结果回来时若已经不是最新一代就 + * 丢弃。所有写入 `systemDnsAtom` 的路径(focus / toggle 成功 / toggle + * 取消 / 启动)都必须走 {@link applyProbe},否则守卫就漏了。 + * + * 放在 Jotai 之外(模块级)的原因同 `activeDnsToggleController`:这是 + * 跨 atom 的可变命令式状态,不该让每个订阅 `systemDnsAtom` 的组件在 + * 每次发探测时都重渲染。 + */ +let probeGeneration = 0; + +/** + * 发一次探测并标记代数。**永不 reject** —— `value` 为 null 表示「探测 + * 不可用」(非 macOS / route 失败 / 没联网),和「还没探测过」在 + * `dnsDiscrepancyAtom` 里是同一种语义:不报警。 + * + * 不直接 set atom:调用方可能想先 await 完再决定(`fetchDnsModeAtom` + * 要把探测并进 `Promise.all`),所以把代数一起交回去。 + */ +async function runProbe(): Promise { + const generation = ++probeGeneration; + try { + return { generation, value: await probeSystemDns() }; + } catch (e) { + // 静默:探测是建议性功能,`route` 失败几乎总是因为用户没联网, + // 弹 toast 只会制造噪音。写 dnsErrorAtom 会更糟 —— Settings 页顶部 + // 有 alert 区域,一条「route failed」会盖在页面上而用户无能为力。 + console.warn("[mHost] probeSystemDns failed (treating as unavailable):", e); + return { generation, value: null }; + } +} + +/** + * 写入探测结果,但**丢弃晚到的旧代**。 + * + * `null` 结果同样受代数保护:探测失败的 P1 也不能把更新的 P2 快照擦掉。 + */ +function applyProbe(set: ProbeSetter, r: ProbeResult): void { + if (r.generation !== probeGeneration) { + // 已有更新的探测在飞(或已落地)—— 这份结果是过时的,丢掉。 + return; + } + set(systemDnsAtom, r.value); +} + +interface ProbeResult { + generation: number; + value: SystemDnsSnapshot | null; +} + +/** Jotai write-atom 的 `set`,这里只用到写 `systemDnsAtom` 这一种。 */ +type ProbeSetter = (atom: typeof systemDnsAtom, value: SystemDnsSnapshot | null) => void; + +/** + * Issue #153: 探测系统 DNS 的实际状态,写入 `systemDnsAtom`。 + * + * **失败静默**:见 {@link runProbe}。绝不 reject —— 调用方都是 + * fire-and-forget 或并行 await,一个 reject 会连带污染主路径。 + * + * **代数守卫**:见 {@link applyProbe}。 + */ +export const probeSystemDnsAtom = atom(null, async (_get, set) => { + applyProbe(set, await runProbe()); +}); + export const fetchDnsModeAtom = atom(null, async (_get, set) => { set(isDnsLoadingAtom, true); set(dnsErrorAtom, null); try { - const enabled = await getDnsMode(); + // Issue #153: 探测与内存态 truth-fetch **并行**启动。`runProbe()` + // 自带 catch(永不 reject),所以不会拖垮也不会被主路径的 catch 吞掉。 + const [enabled, status, probed] = await Promise.all([ + getDnsMode(), + getDnsStatus(), + runProbe(), + ]); set(dnsEnabledAtom, enabled); - const status = await getDnsStatus(); set(dnsStatusAtom, status); + applyProbe(set, probed); } catch (err) { set(dnsErrorAtom, extractErrorMessage(err)); set(dnsStatusAtom, null); @@ -419,6 +504,23 @@ export function cancelActiveDnsToggle(): void { export const toggleDnsModeAtom = atom(null, async (_get, set, enabled: boolean) => { set(isDnsLoadingAtom, true); set(dnsErrorAtom, null); + // Issue #153: 同步作废旧探测结果。 + // + // 不这么做会闪一条假警报:disable 成功时 `dnsEnabledAtom` 先翻成 false, + // 而 `systemDnsAtom` 还停留在「DNS 开着时探测到的 points_at_loopback=true」 + // —— 两者组合立刻推导出 `stuck_at_loopback`,横幅闪现一个 + // 「系统 DNS 卡在 127.0.0.1 / 点我恢复」的按钮,几百毫秒后新探测回来 + // 才消失。用户看到的就是一次凭空的惊吓。 + // + // 置 null 而不是改判定逻辑:整个 toggle 窗口内真相是「不知道」, + // 而 `dnsDiscrepancyAtom` 对 null 返回 null(无从判断就不报警)—— + // 和探测失败的处理是同一条原则。 + // + // **刻意不经过 `applyProbe` 的代数守卫**:这不是一次探测的结果, + // 而是「主动宣布现有结论失效」。代数守卫的语义是「新探测比旧探测新, + // 别被旧结果覆盖」;这里恰恰相反 —— 我们要在新探测回来之前先把结论 + // 清空。若改成走守卫,一次更早的探测结果就会把它挡回来,假警报重现。 + set(systemDnsAtom, null); const ctrl = new AbortController(); activeDnsToggleController = ctrl; @@ -445,6 +547,10 @@ export const toggleDnsModeAtom = atom(null, async (_get, set, enabled: boolean) set(dnsEnabledAtom, enabled); const status = await getDnsStatus(); set(dnsStatusAtom, status); + // Issue #153: toggle 之后重新探测 —— enable/disable 的部分失败正是 + // #152 那类分歧的高发时刻。不 await:探测是建议性的,不该让 toggle + // 的 UI 卡在 loading 上;横幅晚几十毫秒出现无妨。 + void runProbe().then((probed) => applyProbe(set, probed)); } catch (err) { if (cancelled) { // 用户主动 cancel —— issue #149: @@ -460,6 +566,9 @@ export const toggleDnsModeAtom = atom(null, async (_get, set, enabled: boolean) set(dnsEnabledAtom, truth); const status = await getDnsStatus(); set(dnsStatusAtom, status); + // Issue #153: cancel 触发后端 rollback,rollback 期间正是系统 DNS + // 可能停在 127.0.0.1 的时刻(#152 同款)。跟着拨正一次真值。 + void runProbe().then((probed) => applyProbe(set, probed)); } catch { // 后端 truth fetch 失败,保留旧 UI 状态,等下次 fetch。 } diff --git a/src/stores/profiles/index.ts b/src/stores/profiles/index.ts index 489ca07..d71cf90 100644 --- a/src/stores/profiles/index.ts +++ b/src/stores/profiles/index.ts @@ -20,6 +20,9 @@ export { dnsStatusAtom, isDnsLoadingAtom, dnsErrorAtom, + // issue #153: 系统 DNS 实际状态 + 与内存态的分歧派生 + systemDnsAtom, + dnsDiscrepancyAtom, enabledDnsProfilesAtom, dnsRuleCountAtom, adBlockStateAtom, @@ -51,6 +54,7 @@ export { loadSnapshotAtom, deleteSnapshotAtom, fetchDnsModeAtom, + probeSystemDnsAtom, toggleDnsModeAtom, cancelActiveDnsToggle, fetchDnsProfilesAtom, diff --git a/src/stores/profiles/state.ts b/src/stores/profiles/state.ts index 0dfe3f0..d4a9a14 100644 --- a/src/stores/profiles/state.ts +++ b/src/stores/profiles/state.ts @@ -2,6 +2,8 @@ import { atom } from "jotai"; import type { Profile, DnsStatus, + DnsDiscrepancy, + SystemDnsSnapshot, AdBlockState, ApplyOutcome, } from "../../types"; @@ -53,6 +55,38 @@ export const dnsRuleCountAtom = atom((get) => ), ); +/** + * Issue #153: 系统 DNS 的**实际**配置(`probe_system_dns` IPC 的返回值)。 + * + * `null` 表示「探测不可用」—— 后端拒绝(非 macOS)、`route` 失败 + * (用户根本没联网)、或 IPC 报错。**探测失败不产生错误提示**,只让 + * `dnsDiscrepancyAtom` 回到 null(不显示横幅):这是建议性功能,为一个 + * 明显无网络的场景弹 toast 只会制造噪音。 + */ +export const systemDnsAtom = atom(null); + +/** + * Issue #153: `dnsEnabledAtom`(mHost 内存态)与系统实际状态的分歧。 + * + * | dnsEnabled | points_at_loopback | 分歧 | + * |------------|---------------------|-------------------| + * | true | true | null(一致) | + * | true | false | `not_pointing` | + * | false | true | `stuck_at_loopback` | + * | false | false | null(一致) | + * + * 纯派生,不发 IPC。`systemDnsAtom` 为 null 时返回 null(探测不可用 + * 不等于一致,但也不该报警 —— 无从判断就不报警)。 + */ +export const dnsDiscrepancyAtom = atom((get) => { + const probe = get(systemDnsAtom); + if (!probe) return null; + const enabled = get(dnsEnabledAtom); + if (enabled && !probe.points_at_loopback) return "not_pointing"; + if (!enabled && probe.points_at_loopback) return "stuck_at_loopback"; + return null; +}); + // ---- Apply confirm dialog atoms ---- export const applyConfirmOpenAtom = atom(false); diff --git a/src/types/index.ts b/src/types/index.ts index 6422789..2940901 100644 --- a/src/types/index.ts +++ b/src/types/index.ts @@ -31,6 +31,39 @@ export interface DnsStatus { cache_capacity: number; } +/** + * Issue #153: OS 侧系统 DNS 的实际配置(`probe_system_dns` IPC)。 + * + * 这是**独立于 Rust 内存态**的第二个数据源 —— `dnsEnabledAtom` 只反映 + * 「mHost 认为 DNS 模式是开是关」,本快照反映「系统实际上指向哪里」。 + * 两者分歧时(#152: disable 报告成功但系统仍卡在 127.0.0.1)Settings 页 + * 会显示不一致横幅。 + * + * 镜像 Rust `mhost_core::SystemDnsSnapshot`。 + */ +export interface SystemDnsSnapshot { + /** 默认路由对应的 hardware port,如 `Wi-Fi`。 */ + interface: string; + /** + * `networksetup -getdnsservers` 的原始条目,**未过滤**(含 `127.0.0.1`)。 + * 仅用于展示。空数组 = 用户没手动配(DHCP 默认)。 + */ + servers: string[]; + /** 任一条目指向 loopback / unspecified。 */ + points_at_loopback: boolean; +} + +/** + * Issue #153: `dnsEnabledAtom` 与 `SystemDnsSnapshot.points_at_loopback` + * 的分歧类型。`null` = 一致,或探测不可用(此时不显示横幅)。 + * + * - `stuck_at_loopback` — 显示已关闭,但系统 DNS 仍指向 127.0.0.1。 + * **危险方向**:用户的网络解析可能已完全中断。可一键恢复。 + * - `not_pointing` — 显示已开启,但系统 DNS 没指向 mHost。mHost 规则 + * 未生效,但 DNS 本身还能用。仅提示,不提供一键修复。 + */ +export type DnsDiscrepancy = "stuck_at_loopback" | "not_pointing"; + export interface HostRule { id: string; ip: string | null;