From d9da5231f1f79e8655093d37e4149bef6aee325e Mon Sep 17 00:00:00 2001 From: Mingyang Wu Date: Thu, 22 Oct 2026 23:49:54 +0800 Subject: [PATCH 1/3] Pass clippy -D warnings and keep tests off the live core and proxy tools - Fix the clippy lints Rust 1.99.1 reports (struct update syntax, repeat_n, as_chunks, iterator loops, a needless to_string, is_empty). - RELOAD_UNCERTAIN is now keyed by config.yaml path, so a test that marks its own file uncertain cannot leak the flag into another test running in parallel. - offline_app points the controller at 127.0.0.1:1, so a worker a test leaves running cannot reach a real core on 127.0.0.1:9090. - networksetup is looked up on PATH, so CI can shadow it with a stub that records and fails any call. Co-Authored-By: Claude Opus 5.5 --- src/control.rs | 17 ++++++++--------- src/main.rs | 7 +++++++ src/mihomo.rs | 45 ++++++++++++++++++++++++++++++--------------- src/ops.rs | 2 +- src/panels.rs | 4 +--- src/proxy_form.rs | 18 +++++++++++------- src/settings.rs | 10 ++++++---- src/ui.rs | 14 +++++++++----- src/ws.rs | 10 +++++----- 9 files changed, 78 insertions(+), 49 deletions(-) diff --git a/src/control.rs b/src/control.rs index 0be8936..9484ace 100644 --- a/src/control.rs +++ b/src/control.rs @@ -134,8 +134,7 @@ impl App { // The file now differs from the core, so the next // regeneration reloads even when its output is unchanged. if let Err(err) = live { - crate::mihomo::RELOAD_UNCERTAIN - .store(true, std::sync::atomic::Ordering::SeqCst); + crate::mihomo::set_reload_uncertain(&cfg, true); return Ok(format!( "已保存,重载配置或重启内核后生效 / Saved; the core did not apply it live ({err}), so it takes effect after the next reload or core restart" )); @@ -352,8 +351,7 @@ impl App { let running = self.snapshot.running; // After an unanswered reload the core may not run the file, so only a // reload makes them match. - let uncertain = - running && crate::mihomo::RELOAD_UNCERTAIN.load(std::sync::atomic::Ordering::SeqCst); + let uncertain = running && crate::mihomo::reload_uncertain(&self.config.controller); let change = match module_change(&raw, &next) { ModuleChange::Live(_) if !running => ModuleChange::Reload, _ if uncertain => ModuleChange::Reload, @@ -402,8 +400,7 @@ impl App { // the prompt a reload toggle shows. Keys may be // half-applied, so the next toggle reloads, and // asks first. - crate::mihomo::RELOAD_UNCERTAIN - .store(true, std::sync::atomic::Ordering::SeqCst); + crate::mihomo::set_reload_uncertain(&cfg, true); return Err(format!("{failed} ({err})")); } } @@ -745,9 +742,11 @@ impl App { pub fn import_profile(&mut self, text: &str) { let is_url = text.starts_with("https://") || text.starts_with("http://"); - let mut record = crate::compose::ProfileSource::default(); - record.routing = "subscription".into(); - record.kind = if is_url { "subscription" } else { "proxy" }.into(); + let mut record = crate::compose::ProfileSource { + routing: "subscription".into(), + kind: if is_url { "subscription" } else { "proxy" }.into(), + ..Default::default() + }; if is_url { record.subscription_url = text.trim().into(); record.name = crate::storage::label("", text, "new"); diff --git a/src/main.rs b/src/main.rs index 51989e1..a4485d7 100644 --- a/src/main.rs +++ b/src/main.rs @@ -778,6 +778,10 @@ impl LineEditor { self.text.chars().count() } + pub fn is_empty(&self) -> bool { + self.text.is_empty() + } + pub fn insert(&mut self, input: &str) -> bool { let clean: String = input .chars() @@ -4092,6 +4096,9 @@ mod tests { fn offline_app() -> App { let mut app = App::from_snapshot(Config::default(), CoreSnapshot::default()); app.screen = Screen::Dashboard; + // Nothing listens here, so a worker a test leaves running (a snapshot + // poll, say) cannot reach a real core on the default 127.0.0.1:9090. + app.config.controller.addr = "127.0.0.1:1".into(); app } diff --git a/src/mihomo.rs b/src/mihomo.rs index 161efba..689c9d9 100644 --- a/src/mihomo.rs +++ b/src/mihomo.rs @@ -1,5 +1,5 @@ use std::{ - collections::HashMap, + collections::{BTreeSet, HashMap}, fs, io::{self, BufRead, BufReader, Read, Write}, net::{SocketAddr, TcpStream, ToSocketAddrs}, @@ -4159,12 +4159,7 @@ fn regenerate_outcome( } else { generated.clone() }; - let reload = reload_needed( - old.as_deref(), - &candidate, - running, - RELOAD_UNCERTAIN.load(Ordering::SeqCst), - ); + let reload = reload_needed(old.as_deref(), &candidate, running, reload_uncertain(cfg)); // A file kept after an unanswered reload may already be live, so record // the matching cache and routing before reporting the unknown outcome. let unknown = if !reload { @@ -4338,7 +4333,7 @@ fn install_config_with( // The core runs the new config; until the file matches, the next // regeneration must reload even when its output is unchanged. let saved = save(); - RELOAD_UNCERTAIN.store(saved.is_err(), Ordering::SeqCst); + set_reload_uncertain(cfg, saved.is_err()); saved.map_err(|err| { format!("新配置已生效,但保存失败 / Applied, but could not save config.yaml: {err}") .into() @@ -4357,7 +4352,7 @@ fn install_config_with( // looks the same, so the core's own parse never vouched for this // text: check it before it can outlive a core that then restarts. Err(ReloadError::Unknown(err)) => { - RELOAD_UNCERTAIN.store(true, Ordering::SeqCst); + set_reload_uncertain(cfg, true); if !checked { check(false).map_err(|e| { format!( @@ -5021,7 +5016,7 @@ fn active_macos_network_service() -> Option { .or_else(|| scutil_value("State:/Network/Global/IPv6", "PrimaryInterface")); // A packet tunnel can be PrimaryService without a Setup/UserDefinedName. // Map the active underlying hardware interface to its actual service name. - let output = Command::new("/usr/sbin/networksetup") + let output = Command::new("networksetup") .arg("-listnetworkserviceorder") .output() .ok()?; @@ -5252,8 +5247,28 @@ fn put_config(cfg: &ControllerConfig, body: &str) -> Result<(), ReloadError> { } } -/// Set when a reload got no clear answer, so the core may not run the file. -pub(crate) static RELOAD_UNCERTAIN: AtomicBool = AtomicBool::new(false); +/// Config files whose last reload got no clear answer, so the core may not +/// run them. Keyed by path, so each controller's file (and each test's) has +/// its own. +static RELOAD_UNCERTAIN: Mutex> = Mutex::new(BTreeSet::new()); + +/// Whether a reload of `cfg`'s config.yaml got no clear answer since the +/// last one that did, so the core may not run the file. +pub(crate) fn reload_uncertain(cfg: &ControllerConfig) -> bool { + RELOAD_UNCERTAIN + .lock() + .unwrap_or_else(|e| e.into_inner()) + .contains(&config_yaml_path(cfg)) +} + +pub(crate) fn set_reload_uncertain(cfg: &ControllerConfig, uncertain: bool) { + let mut files = RELOAD_UNCERTAIN.lock().unwrap_or_else(|e| e.into_inner()); + if uncertain { + files.insert(config_yaml_path(cfg)); + } else { + files.remove(&config_yaml_path(cfg)); + } +} thread_local! { /// The UI channel of a `run_action` worker. Each worker runs one action, @@ -5319,7 +5334,7 @@ pub fn save_config_unchecked( // The core now runs a value the file lacks, so the next regeneration // must reload even when its output is unchanged. if saved.is_err() && running { - RELOAD_UNCERTAIN.store(true, Ordering::SeqCst); + set_reload_uncertain(cfg, true); } saved } @@ -8547,7 +8562,7 @@ proxy-providers: assert!(generated.is_file()); // An earlier reload with no answer forces the next one, which clears it. - RELOAD_UNCERTAIN.store(true, Ordering::SeqCst); + set_reload_uncertain(&cfg, true); let server = serve( listener.try_clone().unwrap(), vec![mode(), mode(), http("204 No Content", ""), proxies()], @@ -8555,7 +8570,7 @@ proxy-providers: let third = regenerate_config(&cfg, &[], true, false, true).unwrap(); assert!(server.join().unwrap()[2].starts_with("PUT /configs?force=true ")); assert!(third.contains("已校验并热重载"), "{third}"); - assert!(!RELOAD_UNCERTAIN.load(Ordering::SeqCst)); + assert!(!reload_uncertain(&cfg)); assert_eq!(checks_run(), 0); fs::remove_dir_all(home).unwrap(); } diff --git a/src/ops.rs b/src/ops.rs index a6233b2..8662fcf 100644 --- a/src/ops.rs +++ b/src/ops.rs @@ -1298,7 +1298,7 @@ fn parse_ipv6(value: &str) -> Option { } let zeros = 8 - left.len() - right.len(); let mut groups = left; - groups.extend(std::iter::repeat(0u16).take(zeros)); + groups.extend(std::iter::repeat_n(0u16, zeros)); groups.extend(right); groups } else if left.len() == 8 { diff --git a/src/panels.rs b/src/panels.rs index f2da82d..24d9043 100644 --- a/src/panels.rs +++ b/src/panels.rs @@ -1051,9 +1051,7 @@ fn draw_connection_detail(frame: &mut Frame, app: &mut App, area: Rect) { lines.push(kv( theme, "dst", - &format!("{} {}", conn.dest_geo, conn.dest_asn) - .trim() - .to_string(), + format!("{} {}", conn.dest_geo, conn.dest_asn).trim(), )); } if !conn.dns_mode.is_empty() { diff --git a/src/proxy_form.rs b/src/proxy_form.rs index 04d546e..9ef606d 100644 --- a/src/proxy_form.rs +++ b/src/proxy_form.rs @@ -104,8 +104,10 @@ impl Default for Form { } impl Form { pub fn from_record(id: String, source: ProfileSource) -> Self { - let mut f = Self::default(); - f.edit_id = Some(id); + let mut f = Self { + edit_id: Some(id), + ..Self::default() + }; f.name.load(&source.name); f.url.load(&source.subscription_url); if source.subscription_url.is_empty() { @@ -787,9 +789,11 @@ pub fn draw(frame: &mut Frame, app: &App, area: Rect) { mod tests { use super::*; fn filled(protocol: usize) -> Form { - let mut f = Form::default(); - f.kind = 1; - f.protocol = protocol; + let mut f = Form { + kind: 1, + protocol, + ..Form::default() + }; f.name.load("我的代理"); f.server.load("192.0.2.1"); f.secret @@ -803,10 +807,10 @@ mod tests { } #[test] fn form_records_validate_and_edit_without_losing_protocol_options() { - for i in 0..TYPES.len() { + for (i, kind) in TYPES.iter().enumerate() { let f = filled(i); let mut record = f.record().unwrap(); - assert_eq!(record.nodes[0]["type"], TYPES[i]); + assert_eq!(record.nodes[0]["type"], *kind); record.nodes[0]["udp"] = json!(true); let edited = Form::from_record("proxy-001".into(), record.clone()) .record() diff --git a/src/settings.rs b/src/settings.rs index 264fbc1..194e3ed 100644 --- a/src/settings.rs +++ b/src/settings.rs @@ -497,7 +497,10 @@ impl App { .map(|(_, v)| crate::ops::clean_scalar(v)) }) .unwrap_or_else(|| "0".into()); - if old == value.to_string() { + // Compare as text, as the file holds it: a String never + // equals a JSON number such as a port. + let new = value.to_string(); + if old == new { return Ok("端口未改变 / Port unchanged".into()); } if let Some(port) = value.as_u64().filter(|p| *p > 0) { @@ -522,7 +525,7 @@ impl App { )?; if !running || !live_patchable(&raw, &generated, &key, &value) - || crate::mihomo::RELOAD_UNCERTAIN.load(std::sync::atomic::Ordering::SeqCst) + || crate::mihomo::reload_uncertain(&cfg) { crate::mihomo::install_config(&cfg, &generated, running)?; return Ok("设置已保存 / Settings saved".into()); @@ -548,8 +551,7 @@ impl App { // The PATCH may have half-applied; the next // regeneration must reload even when its output is // unchanged. - crate::mihomo::RELOAD_UNCERTAIN - .store(true, std::sync::atomic::Ordering::SeqCst); + crate::mihomo::set_reload_uncertain(&cfg, true); Err(format!( "实时修改失败,重载也失败 / Live change failed ({err}); reload failed: {e}" )) diff --git a/src/ui.rs b/src/ui.rs index 1046673..14ad254 100644 --- a/src/ui.rs +++ b/src/ui.rs @@ -635,19 +635,23 @@ fn draw_rate_braille( let row_from_bottom = area.height as usize - 1 - row; let mut down_bits = 0u8; let mut up_bits = 0u8; - for sub in 0..4 { + for (sub, (&left, &right)) in BRAILLE_DOT_BITS[0] + .iter() + .zip(&BRAILLE_DOT_BITS[1]) + .enumerate() + { let dot_idx = row_from_bottom * 4 + (3 - sub); if dot_idx < d_l { - down_bits |= BRAILLE_DOT_BITS[0][sub]; + down_bits |= left; } if dot_idx < d_r { - down_bits |= BRAILLE_DOT_BITS[1][sub]; + down_bits |= right; } if dot_idx < u_l { - up_bits |= BRAILLE_DOT_BITS[0][sub]; + up_bits |= left; } if dot_idx < u_r { - up_bits |= BRAILLE_DOT_BITS[1][sub]; + up_bits |= right; } } let bits = down_bits | up_bits; diff --git a/src/ws.rs b/src/ws.rs index 75fd4a1..68a0b4e 100644 --- a/src/ws.rs +++ b/src/ws.rs @@ -96,10 +96,10 @@ fn sha1(data: &[u8]) -> [u8; 20] { msg.push(0); } msg.extend_from_slice(&((data.len() as u64).wrapping_mul(8)).to_be_bytes()); - for block in msg.chunks_exact(64) { + for block in msg.as_chunks::<64>().0 { let mut w = [0u32; 80]; - for (slot, word) in w.iter_mut().zip(block.chunks_exact(4)) { - *slot = u32::from_be_bytes([word[0], word[1], word[2], word[3]]); + for (slot, word) in w.iter_mut().zip(block.as_chunks::<4>().0) { + *slot = u32::from_be_bytes(*word); } for i in 16..80 { w[i] = (w[i - 3] ^ w[i - 8] ^ w[i - 14] ^ w[i - 16]).rotate_left(1); @@ -129,8 +129,8 @@ fn sha1(data: &[u8]) -> [u8; 20] { } } let mut out = [0u8; 20]; - for (chunk, v) in out.chunks_exact_mut(4).zip(h) { - chunk.copy_from_slice(&v.to_be_bytes()); + for (chunk, v) in out.as_chunks_mut::<4>().0.iter_mut().zip(h) { + *chunk = v.to_be_bytes(); } out } From 4f0bcd09ff0a09719bdc15101c7e583f6dd2cc6e Mon Sep 17 00:00:00 2001 From: Mingyang Wu Date: Thu, 22 Oct 2026 23:49:55 +0800 Subject: [PATCH 2/3] Add a CI workflow for rustfmt, clippy and tests Pull requests and pushes to main that change code run rustfmt, clippy for Linux, clippy and check for both macOS targets from Linux, tests on Linux and a release build. macOS tests run only on manual runs and on pull requests labelled "macos". The toolchain is pinned to 1.99.1, and the test job fails if anything ran networksetup or gsettings. Co-Authored-By: Claude Opus 5.5 --- .github/workflows/ci.yml | 114 +++++++++++++++++++++++++++++++++++++++ AGENTS.md | 2 + 2 files changed, 116 insertions(+) create mode 100644 .github/workflows/ci.yml diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..6cfb577 --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,114 @@ +name: CI + +on: + push: + branches: [main] + paths-ignore: + - "**.md" + - "docs/**" + - "assets/**" + - "LICENSE" + pull_request: + # labeled: adding the "macos" label starts a run with the macOS tests. + types: [opened, synchronize, reopened, labeled] + # Do not mark these jobs as required status checks while this filter + # exists: a skipped workflow leaves required checks Pending and blocks + # the pull request. + paths-ignore: + - "**.md" + - "docs/**" + - "assets/**" + - "LICENSE" + workflow_dispatch: + +permissions: + contents: read + +concurrency: + group: ci-${{ github.ref }} + cancel-in-progress: ${{ github.event_name == 'pull_request' }} + +env: + CARGO_TERM_COLOR: always + # Bumped by hand: clippy runs with -D warnings, and a new release can add + # lints that would fail unrelated pull requests. + RUST_TOOLCHAIN: "1.99.1" + +jobs: + lint: + name: Lint (rustfmt, clippy for Linux and macOS) + runs-on: ubuntu-latest + timeout-minutes: 15 + steps: + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + persist-credentials: false + - name: Install Rust + run: | + rustup toolchain install "$RUST_TOOLCHAIN" --profile minimal \ + --component clippy,rustfmt \ + --target aarch64-apple-darwin,x86_64-apple-darwin + rustup default "$RUST_TOOLCHAIN" + - uses: Swatinem/rust-cache@d1527c8f67ef9af357290475d4af4b0fab8f12e0 # v2.9.3 + - name: rustfmt + run: cargo fmt --check + - name: clippy (Linux) + run: cargo clippy --all-targets --locked -- -D warnings + # No C dependencies, so the macOS code paths check from Linux without + # spending macOS runner minutes. + - name: clippy (macOS, aarch64) + run: cargo clippy --all-targets --locked --target aarch64-apple-darwin -- -D warnings + - name: check (macOS, x86_64) + run: cargo check --all-targets --locked --target x86_64-apple-darwin + + test: + name: Test (${{ matrix.os }}) + strategy: + fail-fast: false + matrix: + # The repository is private, where macOS minutes count about 10x Linux + # against the included minutes, and the lint job already cross-checks + # the macOS targets. So macOS tests run only on manual runs and on + # pull requests labelled "macos". + os: ${{ (github.event_name == 'workflow_dispatch' || contains(github.event.pull_request.labels.*.name, 'macos')) && fromJSON('["ubuntu-latest","macos-latest"]') || fromJSON('["ubuntu-latest"]') }} + runs-on: ${{ matrix.os }} + timeout-minutes: 20 + steps: + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + persist-credentials: false + - name: Install Rust + run: | + rustup toolchain install "$RUST_TOOLCHAIN" --profile minimal + rustup default "$RUST_TOOLCHAIN" + - uses: Swatinem/rust-cache@d1527c8f67ef9af357290475d4af4b0fab8f12e0 # v2.9.3 + # Tests must never change the system proxy (AGENTS.md). These stand-ins + # come first on PATH, record every call that looks the tool up on PATH + # and fail; the step after the tests fails the job if any ran. + - name: Shadow networksetup and gsettings + run: | + shims="$RUNNER_TEMP/proxy-tool-shims" + mkdir -p "$shims" + for tool in networksetup gsettings; do + cat > "$shims/$tool" <> "$RUNNER_TEMP/proxy-tool-calls.log" + exit 1 + EOF + chmod +x "$shims/$tool" + done + echo "$shims" >> "$GITHUB_PATH" + - name: Test + run: cargo test --all-targets --locked + - name: Tests ran no system proxy tool + if: ${{ !cancelled() }} + run: | + log="$RUNNER_TEMP/proxy-tool-calls.log" + if [ -s "$log" ]; then + echo "::error::Tests ran a system proxy tool:" + cat "$log" + exit 1 + fi + - name: Release build + if: runner.os == 'Linux' + run: cargo build --release --locked diff --git a/AGENTS.md b/AGENTS.md index 5e92843..15791d7 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -5,7 +5,9 @@ ## Build & Verification Commands - Run unit tests: `cargo test --all-targets --locked` +- Lint: `cargo fmt --check` and `cargo clippy --all-targets --locked -- -D warnings` - Build release binary: `cargo build --release --locked` +- CI (`.github/workflows/ci.yml`) runs these on every pull request and push to main that changes code (changes only to `*.md`, `docs/`, `assets/` or `LICENSE` are skipped), with tests on Linux and clippy cross-checks for the macOS targets; macOS tests run only on manual runs (workflow_dispatch) and on pull requests labelled `macos`. The Rust version it uses is pinned there. - macOS global binary symlink: `/opt/homebrew/bin/terc` -> `/Users/grada/Documents/GitHub/terc/target/release/terc` ## Architectural & Design Rules From a18e2dda04df415b5d66666d106a0ace6c5c3b21 Mon Sep 17 00:00:00 2001 From: Mingyang Wu Date: Thu, 22 Oct 2026 23:49:55 +0800 Subject: [PATCH 3/3] Ignore macOS AppleDouble files Co-Authored-By: Claude Opus 5.5 --- .gitignore | 1 + 1 file changed, 1 insertion(+) diff --git a/.gitignore b/.gitignore index 0592392..735b356 100644 --- a/.gitignore +++ b/.gitignore @@ -1,2 +1,3 @@ /target .DS_Store +._*