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/.gitignore b/.gitignore index 0592392..735b356 100644 --- a/.gitignore +++ b/.gitignore @@ -1,2 +1,3 @@ /target .DS_Store +._* 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 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 }