Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
114 changes: 114 additions & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
@@ -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" <<EOF
#!/bin/sh
echo "$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
1 change: 1 addition & 0 deletions .gitignore
Original file line number Diff line number Diff line change
@@ -1,2 +1,3 @@
/target
.DS_Store
._*
2 changes: 2 additions & 0 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
17 changes: 8 additions & 9 deletions src/control.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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"
));
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -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})"));
}
}
Expand Down Expand Up @@ -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");
Expand Down
7 changes: 7 additions & 0 deletions src/main.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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()
Expand Down Expand Up @@ -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
}

Expand Down
45 changes: 30 additions & 15 deletions src/mihomo.rs
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
use std::{
collections::HashMap,
collections::{BTreeSet, HashMap},
fs,
io::{self, BufRead, BufReader, Read, Write},
net::{SocketAddr, TcpStream, ToSocketAddrs},
Expand Down Expand Up @@ -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 {
Expand Down Expand Up @@ -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()
Expand All @@ -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!(
Expand Down Expand Up @@ -5021,7 +5016,7 @@ fn active_macos_network_service() -> Option<String> {
.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()?;
Expand Down Expand Up @@ -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<BTreeSet<PathBuf>> = 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,
Expand Down Expand Up @@ -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
}
Expand Down Expand Up @@ -8547,15 +8562,15 @@ 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()],
);
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();
}
Expand Down
2 changes: 1 addition & 1 deletion src/ops.rs
Original file line number Diff line number Diff line change
Expand Up @@ -1298,7 +1298,7 @@ fn parse_ipv6(value: &str) -> Option<u128> {
}
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 {
Expand Down
4 changes: 1 addition & 3 deletions src/panels.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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() {
Expand Down
18 changes: 11 additions & 7 deletions src/proxy_form.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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() {
Expand Down Expand Up @@ -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
Expand All @@ -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()
Expand Down
10 changes: 6 additions & 4 deletions src/settings.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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) {
Expand All @@ -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());
Expand All @@ -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}"
))
Expand Down
Loading
Loading