diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index b1116a5..dfc56cf 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -50,7 +50,7 @@ jobs: strategy: fail-fast: false matrix: - os: [ubuntu-22.04, ubuntu-24.04, windows-2025] + os: [ubuntu-22.04, ubuntu-24.04, windows-2025, macos-15] runs-on: ${{ matrix.os }} timeout-minutes: 60 steps: diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 139a465..612a8d3 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -3,6 +3,7 @@ name: Release on: push: tags: ["v*"] + branches: [main] permissions: contents: read @@ -10,9 +11,11 @@ permissions: env: CARGO_TERM_COLOR: always -# Tag v* publishes Linux .deb + AppImage and Windows portable .exe + NSIS. -# Third-party VPN apps stay in-app (ADR 0004); this workflow does not vendor them. -# Build jobs only upload workflow artifacts. The final job publishes all four together. +# Tag v* publishes Linux .deb + AppImage, Windows portable .exe + NSIS, and the +# macOS disk image. A push to main publishes the same release when that version +# does not already have a finished GitHub release (ADR 0123). Third-party VPN +# apps stay in-app (ADR 0004); this workflow does not vendor them. +# Build jobs only upload workflow artifacts. The final job publishes them together. # Native runners follow the official Tauri GitHub pipeline: Linux WebKitGTK 4.1 on # Ubuntu 22.04 and 24.04, Windows MSVC + WebView2 on windows-2025. The 22.04 # packages are extra release assets (`*_ubuntu2204_*`) because that glibc is @@ -25,7 +28,48 @@ concurrency: cancel-in-progress: false jobs: + gate: + runs-on: ubuntu-24.04 + timeout-minutes: 10 + outputs: + publish: ${{ steps.meta.outputs.publish }} + release_tag: ${{ steps.meta.outputs.release_tag }} + steps: + - uses: actions/checkout@v4 + - name: Decide whether this version still needs a release + id: meta + shell: bash + env: + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + GH_REPO: ${{ github.repository }} + run: | + set -euo pipefail + version="$(tr -d '[:space:]' < version)" + tag="v${version}" + echo "release_tag=${tag}" >> "${GITHUB_OUTPUT}" + if [ "${GITHUB_REF_TYPE}" = "tag" ] && [ "${GITHUB_REF_NAME}" != "${tag}" ]; then + echo "Tag ${GITHUB_REF_NAME} does not match version file ${tag}" + exit 1 + fi + if [ "${GITHUB_REF_TYPE}" = "tag" ]; then + echo "publish=true" >> "${GITHUB_OUTPUT}" + exit 0 + fi + if view="$(gh release view "${tag}" --json isDraft 2>/dev/null)"; then + draft="$(printf '%s' "${view}" | jq -r '.isDraft')" + if [ "${draft}" = "true" ]; then + echo "publish=true" >> "${GITHUB_OUTPUT}" + else + echo "Release ${tag} is already published" + echo "publish=false" >> "${GITHUB_OUTPUT}" + fi + else + echo "publish=true" >> "${GITHUB_OUTPUT}" + fi + verify: + needs: gate + if: needs.gate.result == 'success' && needs.gate.outputs.publish == 'true' runs-on: ubuntu-24.04 timeout-minutes: 60 steps: @@ -44,14 +88,6 @@ jobs: with: workspaces: ". -> target" prefix-key: ubuntu-24.04 - - name: Verify tag matches the version file - shell: bash - run: | - expected="v$(tr -d '[:space:]' < version)" - if [ "${GITHUB_REF_NAME}" != "${expected}" ]; then - echo "Tag ${GITHUB_REF_NAME} does not match version file ${expected}" - exit 1 - fi - name: Install Linux desktop dependencies run: sudo apt-get update && sudo apt-get install -y libwebkit2gtk-4.1-dev libappindicator3-dev librsvg2-dev patchelf xdg-utils && pkg-config --exists webkit2gtk-4.1 - run: pnpm install --frozen-lockfile @@ -81,6 +117,8 @@ jobs: args: --bundles deb,appimage - platform: windows-2025 args: --bundles nsis + - platform: macos-15 + args: --bundles dmg runs-on: ${{ matrix.platform }} timeout-minutes: 90 steps: @@ -138,6 +176,7 @@ jobs: TAURI_SIGNING_PRIVATE_KEY_PASSWORD: ${{ secrets.TAURI_SIGNING_PRIVATE_KEY_PASSWORD }} run: node scripts/prepare-tauri-signing.mjs --require --github-env --verify-sign - name: Stage privileged helper + if: runner.os != 'macOS' shell: bash run: | if [ "${{ runner.os }}" = "Windows" ]; then @@ -146,8 +185,13 @@ jobs: ./scripts/stage-helper.sh fi - uses: tauri-apps/tauri-action@v1 + if: runner.os != 'macOS' with: args: ${{ matrix.args }} + - name: Build the macOS disk image + if: runner.os == 'macOS' + shell: bash + run: ./build.sh ci-macos - name: Mark Ubuntu 22.04 packages if: runner.os == 'Linux' shell: bash @@ -212,9 +256,17 @@ jobs: target/release/bundle/nsis/*-setup.exe.sig if-no-files-found: error retention-days: 7 + - name: Upload macOS release artifact + if: runner.os == 'macOS' + uses: actions/upload-artifact@v4 + with: + name: release-macos + path: artifacts/macos/*.dmg + if-no-files-found: error + retention-days: 7 publish: - needs: build + needs: [gate, build] runs-on: ubuntu-24.04 permissions: contents: write @@ -230,19 +282,22 @@ jobs: merge-multiple: true - name: Generate and validate latest.json shell: bash + env: + RELEASE_TAG: ${{ needs.gate.outputs.release_tag }} run: | set -euo pipefail node scripts/generate-latest-json.mjs \ --assets release-assets \ - --tag "${GITHUB_REF_NAME}" \ + --tag "${RELEASE_TAG}" \ --repo "${{ github.repository }}" \ --output release-assets/latest.json \ - --notes "BiFlow ${GITHUB_REF_NAME#v}" + --notes "BiFlow ${RELEASE_TAG#v}" - name: Validate and publish the complete release shell: bash env: GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} GH_REPO: ${{ github.repository }} + RELEASE_TAG: ${{ needs.gate.outputs.release_tag }} run: | set -euo pipefail mapfile -t debs < <(find release-assets -type f -name '*.deb' ! -name '*ubuntu2204*' -print | sort) @@ -257,23 +312,32 @@ jobs: mapfile -t installers < <(find release-assets -type f -name '*-setup.exe' -print | sort) mapfile -t installer_sigs < <(find release-assets -type f -name '*-setup.exe.sig' -print | sort) mapfile -t manifests < <(find release-assets -type f -name 'latest.json' -print | sort) + mapfile -t dmgs < <(find release-assets -type f -name '*.dmg' -print | sort) - if (( ${#debs[@]} != 1 || ${#deb_sigs[@]} != 1 || ${#appimages[@]} != 1 || ${#appimage_sigs[@]} != 1 || ${#ubuntu_debs[@]} != 1 || ${#ubuntu_deb_sigs[@]} != 1 || ${#ubuntu_appimages[@]} != 1 || ${#ubuntu_appimage_sigs[@]} != 1 || ${#portable[@]} != 1 || ${#installers[@]} != 1 || ${#installer_sigs[@]} != 1 || ${#manifests[@]} != 1 )); then - echo "Expected Ubuntu 24.04 and Ubuntu 22.04 deb/AppImage pairs with signatures, one portable exe, one NSIS installer and signature, and latest.json" + if (( ${#debs[@]} != 1 || ${#deb_sigs[@]} != 1 || ${#appimages[@]} != 1 || ${#appimage_sigs[@]} != 1 || ${#ubuntu_debs[@]} != 1 || ${#ubuntu_deb_sigs[@]} != 1 || ${#ubuntu_appimages[@]} != 1 || ${#ubuntu_appimage_sigs[@]} != 1 || ${#portable[@]} != 1 || ${#installers[@]} != 1 || ${#installer_sigs[@]} != 1 || ${#manifests[@]} != 1 || ${#dmgs[@]} != 1 )); then + echo "Expected Ubuntu 24.04 and Ubuntu 22.04 deb/AppImage pairs with signatures, one portable exe, one NSIS installer and signature, one macOS disk image, and latest.json" find release-assets -type f -print exit 1 fi - assets=("${debs[@]}" "${deb_sigs[@]}" "${appimages[@]}" "${appimage_sigs[@]}" "${ubuntu_debs[@]}" "${ubuntu_deb_sigs[@]}" "${ubuntu_appimages[@]}" "${ubuntu_appimage_sigs[@]}" "${portable[@]}" "${installers[@]}" "${installer_sigs[@]}" "${manifests[@]}") - if ! gh release view "${GITHUB_REF_NAME}" >/dev/null 2>&1; then - gh release create "${GITHUB_REF_NAME}" \ + tag="${RELEASE_TAG}" + if ! git ls-remote --exit-code --tags origin "refs/tags/${tag}" >/dev/null 2>&1; then + git config user.name "github-actions[bot]" + git config user.email "41898282+github-actions[bot]@users.noreply.github.com" + git tag "${tag}" + git push origin "refs/tags/${tag}" + fi + + assets=("${debs[@]}" "${deb_sigs[@]}" "${appimages[@]}" "${appimage_sigs[@]}" "${ubuntu_debs[@]}" "${ubuntu_deb_sigs[@]}" "${ubuntu_appimages[@]}" "${ubuntu_appimage_sigs[@]}" "${portable[@]}" "${installers[@]}" "${installer_sigs[@]}" "${dmgs[@]}" "${manifests[@]}") + if ! gh release view "${tag}" >/dev/null 2>&1; then + gh release create "${tag}" \ --draft \ --generate-notes \ - --title "BiFlow ${GITHUB_REF_NAME}" \ + --title "BiFlow ${tag}" \ --verify-tag fi - gh release upload "${GITHUB_REF_NAME}" "${assets[@]}" --clobber - gh release edit "${GITHUB_REF_NAME}" \ + gh release upload "${tag}" "${assets[@]}" --clobber + gh release edit "${tag}" \ --draft=false \ --prerelease=false \ - --title "BiFlow ${GITHUB_REF_NAME}" + --title "BiFlow ${tag}" diff --git a/AGENTS.md b/AGENTS.md index 6e17e05..ca05a03 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -71,6 +71,13 @@ If a required command fails or emits a warning from project code, fix it in the ## Lessons +- macOS route parsers used only by native cleanup still compile in Linux and + Windows library builds unless gated with `cfg(any(target_os = "macos", test))`. + Keep their pure route-deletion tests running on every host. Update vulnerable + lockfile dependencies instead of ignoring RustSec advisories. The updater is + used only for signed downloads; keep its unused archive installer disabled so + it cannot reintroduce a second, vulnerable ZIP dependency (ADR 0124). + - Copy native display preferences out of their mutex before `TrayIcon::set_title`. That operation can synchronously wait for the main thread; keeping the settings lock in an argument temporary can block a concurrent settings/shutdown action @@ -555,7 +562,7 @@ If a required command fails or emits a warning from project code, fix it in the - React Fast Refresh warns when a component module also exports a plain helper. Put shared helpers such as country-flag conversion in a separate module so `eslint --max-warnings 0` stays clean. - `cargo deny check` uses an explicit license allow list. Add `MPL-2.0` (cssparser via Tauri/wry), `Apache-2.0 WITH LLVM-exception`, `CDLA-Permissive-2.0`, and `BSL-1.0` (`clipboard-win` via `tauri-plugin-clipboard-manager`) when the lockfile needs them; do not blanket-allow. Workspace `path` crates look like `*` wildcards unless `allow-wildcard-paths = true` and those crates are unpublished (`publish = false`); cargo-deny 0.20 does not apply the path exception to public crates. Transitive unmaintained crates (GTK3 via Tauri, unic via urlpattern, proc-macro-error via glib) fail unless `unmaintained = "workspace"`. - Windows Clippy `unnecessary_wraps` fires on `#[cfg(not(unix))]` permission stubs that always `Ok(())`. Keep `Result` on the Unix chmod path and a `()` no-op off Unix, with `cfg` at the call site. Helper `main` must `cfg` Unix-only imports such as `tracing_subscriber::EnvFilter`; Windows Clippy treats them as unused. -- `github.ref_protected` is false for typical `v*` tags, so a release job with that `if` never runs. Trigger `.github/workflows/release.yml` on `push: tags: ["v*"]` only, verify before building, upload workflow artifacts, and publish only after every platform succeeds. +- `github.ref_protected` is false for typical `v*` tags, so a release job with that `if` never runs. Trigger `.github/workflows/release.yml` on `push: tags: ["v*"]` and on `main` when that version has no finished release. Verify before building, upload workflow artifacts, and publish only after every platform succeeds. A tag created with the workflow token does not start a second workflow, so the publish job must create the missing `v` tag itself and upload the macOS disk image in that same job (ADR 0123). - Ubuntu 24.04 renamed AppImage's FUSE 2 runtime package to `libfuse2t64`. Use that exact package in the Ubuntu 24.04 workflow and let `build.sh` select `libfuse2t64` or `libfuse2` from the local apt catalog. - A palette-only UI request changes the existing RGB tokens in `apps/desktop/src/index.css`. Do not add a second theme runtime, shared component CSS system, or TSX behavior/layout changes unless the request explicitly includes them. - Vitest's jsdom transform can expose a non-`file:` `import.meta.url`. Tests that inspect frontend source files should resolve them from the package test working directory (for example, `path.join(process.cwd(), "src/index.css")`). @@ -719,3 +726,31 @@ SHA-256")`); launchd `KeepAlive` then restarts it in a crash loop and the traffic. An opaque layered fill also hides Explorer's taskbar tint: retain layered composition but color-key only the background, and test composed background continuity and rightmost placement (ADR 0117). + +- Quit used to leave the helper-owned Mihomo TUN up while the desktop-owned + local proxy died with the process, and macOS DNS stayed on `127.0.0.1`. + Treat `localhost` / `127.0.0.1` / `::1` as one loopback proxy, clear every + configured local-proxy port, and still delete the TUN when that clear + fails. Stop Mihomo with `SIGTERM` before `SIGKILL` so it can remove its + own routes, flush the OS DNS cache, and release the capture if the desktop + has been gone for 90 seconds (ADR 0120). An update exit stays on Pause. +- A successful macOS DNS restore can still leave every service on + `127.0.0.1` when `system-dns.toml` captured the tunnel resolver, or when + `networksetup` returns success while `scutil --dns` does not change. Names + then fail and a raw IP can still connect. Never store or write loopback as + the original DNS. If the live resolver is still loopback, set the private + default gateway, and delete `netstat` routes that still name the TUN. Kill + a scoped Mihomo orphan on stop when the child handle is already empty + (ADR 0121). +- macOS `NET_RT_IFLIST2` `if_data64.ifi_ibytes` does not match `netstat -ib` + on current Darwin. Menu bar speeds must read `IFMIB_IFDATA` / + `IFDATA_GENERAL`. Wi-Fi reports `IFT_ETHER`, so classify with + `SIOCGIFMEDIA` and require `IFM_ACTIVE`. Exclude `utun`, `awdl`, `bridge`, + `anpi`, and the other virtual prefixes even when their flags look up. + `TrayIcon::set_title` is one proportional line and `None` does not clear + it, so the menu bar item changes width as the digits change. Draw upload + above download with the arrow on the right, in an 11-column 9-point + monospaced attributed title on the `TaoTrayTarget` status button. Hide the + status icon while that title is shown and restore the retained image when + it is cleared. Widen both lines together when a fixed unit is longer + (ADR 0122). diff --git a/Cargo.lock b/Cargo.lock index 7c85cda..aac6f5a 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -97,15 +97,6 @@ version = "1.0.104" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "330a5ed07fa54e4702c9d6c4174f74427fc0ef6e214bbd677ae50a5099946470" -[[package]] -name = "arbitrary" -version = "1.4.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c3d036a3c4ab069c7b410a2ce876bd74808d2d0888a82667669f8e783a898bf1" -dependencies = [ - "derive_arbitrary", -] - [[package]] name = "arboard" version = "3.6.1" @@ -893,17 +884,6 @@ dependencies = [ "serde_core", ] -[[package]] -name = "derive_arbitrary" -version = "1.4.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1e567bd82dcff979e4b03460c307b3cdc9e96fde3d73bed1496d2bc75d9dd62a" -dependencies = [ - "proc-macro2", - "quote", - "syn 2.0.119", -] - [[package]] name = "derive_more" version = "2.1.1" @@ -2066,7 +2046,7 @@ dependencies = [ [[package]] name = "iran-split-cli" -version = "6.2.66" +version = "6.2.73" dependencies = [ "async-trait", "chrono", @@ -2084,7 +2064,7 @@ dependencies = [ [[package]] name = "iran-split-clients" -version = "6.2.66" +version = "6.2.73" dependencies = [ "async-trait", "ipnet", @@ -2100,7 +2080,7 @@ dependencies = [ [[package]] name = "iran-split-config" -version = "6.2.66" +version = "6.2.73" dependencies = [ "hex", "rand 0.9.5", @@ -2113,7 +2093,7 @@ dependencies = [ [[package]] name = "iran-split-core" -version = "6.2.66" +version = "6.2.73" dependencies = [ "async-trait", "chrono", @@ -2130,7 +2110,7 @@ dependencies = [ [[package]] name = "iran-split-desktop" -version = "6.2.66" +version = "6.2.73" dependencies = [ "chrono", "dirs 6.0.0", @@ -2172,7 +2152,7 @@ dependencies = [ [[package]] name = "iran-split-helper" -version = "6.2.66" +version = "6.2.73" dependencies = [ "clap", "hex", @@ -2182,7 +2162,7 @@ dependencies = [ "iran-split-helper-winacl", "iran-split-ipc", "libc", - "nix", + "nix 0.30.1", "regex", "serde", "sha2", @@ -2197,7 +2177,7 @@ dependencies = [ [[package]] name = "iran-split-helper-winacl" -version = "6.2.66" +version = "6.2.73" dependencies = [ "tokio", "windows", @@ -2205,7 +2185,7 @@ dependencies = [ [[package]] name = "iran-split-ipc" -version = "6.2.66" +version = "6.2.73" dependencies = [ "bytes", "chrono", @@ -2218,7 +2198,7 @@ dependencies = [ [[package]] name = "iran-split-mihomo" -version = "6.2.66" +version = "6.2.73" dependencies = [ "chrono", "futures-util", @@ -2241,15 +2221,16 @@ dependencies = [ [[package]] name = "iran-split-netstats" -version = "6.2.66" +version = "6.2.73" dependencies = [ + "libc", "tempfile", "windows", ] [[package]] name = "iran-split-platform-linux" -version = "6.2.66" +version = "6.2.73" dependencies = [ "async-trait", "chrono", @@ -2273,7 +2254,7 @@ dependencies = [ [[package]] name = "iran-split-platform-macos" -version = "6.2.66" +version = "6.2.73" dependencies = [ "async-trait", "chrono", @@ -2297,7 +2278,7 @@ dependencies = [ [[package]] name = "iran-split-platform-win" -version = "6.2.66" +version = "6.2.73" dependencies = [ "async-trait", "chrono", @@ -2320,7 +2301,7 @@ dependencies = [ [[package]] name = "iran-split-rules" -version = "6.2.66" +version = "6.2.73" dependencies = [ "async-trait", "chrono", @@ -2342,9 +2323,12 @@ dependencies = [ [[package]] name = "iran-split-taskbar" -version = "6.2.66" +version = "6.2.73" dependencies = [ "iran-split-config", + "objc2", + "objc2-app-kit", + "objc2-foundation", "tracing", "tracing-subscriber", "windows", @@ -2795,6 +2779,18 @@ dependencies = [ "memoffset", ] +[[package]] +name = "nix" +version = "0.31.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cf20d2fde8ff38632c426f1165ed7436270b44f199fc55284c38276f9db47c3d" +dependencies = [ + "bitflags 2.13.1", + "cfg-if", + "cfg_aliases", + "libc", +] + [[package]] name = "nom" version = "8.0.0" @@ -3822,9 +3818,9 @@ dependencies = [ [[package]] name = "rustls" -version = "0.23.45" +version = "0.23.46" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0d41d731c7d2f962d1ccc364cec258de3c0e93b38c2fb3ba97ac74513048d634" +checksum = "48e13bd8c0e9365c43cfa5c9e8f9ad49d3c8444926c9aac819e0e4dc503c8fdf" dependencies = [ "once_cell", "ring", @@ -4804,7 +4800,6 @@ dependencies = [ "tokio", "url", "windows-sys 0.60.2", - "zip", ] [[package]] @@ -5429,6 +5424,12 @@ dependencies = [ "utf-8", ] +[[package]] +name = "typed-path" +version = "0.12.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8e28f89b80c87b8fb0cf04ab448d5dd0dd0ade2f8891bae878de66a75a28600e" + [[package]] name = "typeid" version = "1.0.3" @@ -6704,15 +6705,16 @@ dependencies = [ [[package]] name = "zip" -version = "4.6.1" +version = "9.0.5" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "caa8cd6af31c3b31c6631b8f483848b91589021b28fffe50adada48d4f4d2ed1" +checksum = "6e04976da629d3b04204ba8105b396a193af8ad2583b2c79bfb3ab8af580a11e" dependencies = [ - "arbitrary", "crc32fast", "flate2", "indexmap 2.14.0", "memchr", + "nix 0.31.3", + "typed-path", "zopfli", ] diff --git a/Cargo.toml b/Cargo.toml index ff8bbd6..fcc8316 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -19,7 +19,7 @@ members = [ ] [workspace.package] -version = "6.2.66" +version = "6.2.73" edition = "2021" license = "MIT OR Apache-2.0" rust-version = "1.88" @@ -57,7 +57,7 @@ toml = "0.8.22" tracing = "0.1.41" tracing-subscriber = { version = "0.3.19", features = ["env-filter", "json"] } uuid = { version = "1.16.0", features = ["serde", "v4"] } -zip = { version = "4.6.1", default-features = false, features = ["deflate"] } +zip = { version = "9.0.0", default-features = false, features = ["deflate"] } [workspace.lints.rust] unsafe_code = "forbid" diff --git a/README.md b/README.md index 2e15c7a..98a95ad 100644 --- a/README.md +++ b/README.md @@ -55,7 +55,7 @@ - **Status bar** — Internet reachability, public IP, approximate country, and lifetime sent/received totals. - **In-app install** — Connect can install the privileged helper, the bundled Mihomo build, and Hiddify when they are missing. Hiddify is only fetched while an enabled Hiddify client exists. - **English and Persian** — Built-in UI languages, with a tray menu for Connect/Disconnect, Pause/Resume, and Quit. -- **Taskbar and panel speed** — Settings → Speed display enables the Windows 11 taskbar widget or Ubuntu panel label and chooses bits/bytes per second, including kilo/mega/giga units. Rates measure the active physical Wi-Fi/Ethernet adapter's complete traffic across all applications, even while BiFlow is disconnected. Off by default. Windows places upload above download in a verified free gap beside the tray and hides safely when Explorer's layout is unsupported or crowded. Ubuntu uses the existing tray icon's label. See [display settings](docs/adr/0119-speed-display-settings.md), [adapter semantics](docs/adr/0117-physical-interface-taskbar.md) and [native compatibility](docs/adr/0116-windows-taskbar-speed.md). +- **Taskbar and panel speed** — Settings → Speed display enables the Windows 11 taskbar widget, the Ubuntu panel label, or the macOS menu bar title, and chooses bits/bytes per second, including kilo/mega/giga units. Rates measure the active physical Wi-Fi/Ethernet adapter's complete traffic across all applications, even while BiFlow is disconnected. Off by default. Windows places upload above download in a verified free gap beside the tray and hides safely when Explorer's layout is unsupported or crowded. Ubuntu uses the existing tray icon's label. macOS stacks each rate with its arrow on the right, hides the menu bar icon while speeds are shown, and keeps a fixed width. See [display settings](docs/adr/0119-speed-display-settings.md), [adapter semantics](docs/adr/0117-physical-interface-taskbar.md), [native compatibility](docs/adr/0116-windows-taskbar-speed.md), and [menu bar](docs/adr/0122-macos-menu-bar-speed.md). - **Linux and Windows** — Debian package, AppImage, portable `.exe`, and NSIS setup. Signed AppImage and NSIS builds can update in-app. - **Fits the window** — Desktop sidebar, phone-sized bottom navigation, and a resizable window down to 390×640. diff --git a/apps/desktop/package.json b/apps/desktop/package.json index 80ea5af..cbf7a47 100644 --- a/apps/desktop/package.json +++ b/apps/desktop/package.json @@ -1,6 +1,6 @@ { "name": "@iran-split/desktop", - "version": "6.2.66", + "version": "6.2.73", "private": true, "type": "module", "scripts": { diff --git a/apps/desktop/src/components/Settings.test.tsx b/apps/desktop/src/components/Settings.test.tsx index 5686395..569436a 100644 --- a/apps/desktop/src/components/Settings.test.tsx +++ b/apps/desktop/src/components/Settings.test.tsx @@ -43,6 +43,9 @@ describe("Settings", () => { expect( screen.queryByLabelText("Show network speed in panel"), ).not.toBeInTheDocument(); + expect( + screen.queryByLabelText("Show network speed in menu bar"), + ).not.toBeInTheDocument(); expect( screen.queryByRole("tab", { name: "Speed display" }), ).not.toBeInTheDocument(); @@ -61,6 +64,7 @@ describe("Settings", () => { it.each([ ["linux", "Show network speed in panel"], ["windows", "Show network speed on taskbar"], + ["macos", "Show network speed in menu bar"], ])( "saves %s speed units from a separate tab without changing network settings", async (platform, label) => { diff --git a/apps/desktop/src/components/Settings.tsx b/apps/desktop/src/components/Settings.tsx index 296381e..a96bc8e 100644 --- a/apps/desktop/src/components/Settings.tsx +++ b/apps/desktop/src/components/Settings.tsx @@ -37,6 +37,14 @@ const SPEED_UNITS = [ "gigabytes", ] as const; +function speedToggleKey( + platform: string | undefined, +): "panelSpeed" | "menuBarSpeed" | "taskbarSpeed" { + if (platform === "linux") return "panelSpeed"; + if (platform === "macos") return "menuBarSpeed"; + return "taskbarSpeed"; +} + const formSchema = z .object({ controllerPort: z.coerce.number().int().min(1).max(65535), @@ -131,7 +139,9 @@ export function Settings({ settings }: { settings: AppConfig }) { const { t } = useTranslation(); const { saveSettings, actionPending, boot } = useAppStore(); const speedSupported = - boot?.platform === "windows" || boot?.platform === "linux"; + boot?.platform === "windows" || + boot?.platform === "linux" || + boot?.platform === "macos"; const [tab, setTab] = usePageTab( "settings", SETTINGS_TABS.filter((id) => id !== "speed" || speedSupported), @@ -326,11 +336,7 @@ export function Settings({ settings }: { settings: AppConfig }) { >
diff --git a/apps/desktop/src/i18n/config.ts b/apps/desktop/src/i18n/config.ts index 7d3228f..6e89fe5 100644 --- a/apps/desktop/src/i18n/config.ts +++ b/apps/desktop/src/i18n/config.ts @@ -11,6 +11,7 @@ const resources = { diagnostics: "Diagnostics", taskbarSpeed: "Show network speed on taskbar", panelSpeed: "Show network speed in panel", + menuBarSpeed: "Show network speed in menu bar", speedUnits: { label: "Speed unit", auto_bits: "Automatic (bits/s)", @@ -434,6 +435,7 @@ const resources = { diagnostics: "عیب‌یابی", taskbarSpeed: "نمایش سرعت شبکه در نوار وظیفه", panelSpeed: "نمایش سرعت شبکه در پنل", + menuBarSpeed: "نمایش سرعت شبکه در نوار منو", speedUnits: { label: "واحد سرعت", auto_bits: "خودکار (بیت بر ثانیه)", diff --git a/crates/iran-split-config/src/lib.rs b/crates/iran-split-config/src/lib.rs index 29ada39..1d2e364 100644 --- a/crates/iran-split-config/src/lib.rs +++ b/crates/iran-split-config/src/lib.rs @@ -89,6 +89,27 @@ impl AppConfig { self.clients.iter().find(|client| client.id == id) } + /// Every local-proxy address, including disabled clients. + /// + /// A client that is no longer running can still own the OS proxy. Disconnect + /// and Quit clear each of these endpoints, not only the Hiddify default. + #[must_use] + pub fn local_proxy_endpoints(&self) -> Vec<(String, u16)> { + let mut endpoints = Vec::new(); + for client in &self.clients { + if let ClientConfig::LocalProxy { host, port, .. } = &client.config { + let endpoint = (host.clone(), *port); + if !endpoints.contains(&endpoint) { + endpoints.push(endpoint); + } + } + } + if endpoints.is_empty() { + endpoints.push(self.hiddify_endpoint()); + } + endpoints + } + /// Loopback SOCKS endpoint of the Hiddify instance, or the catalog default. #[must_use] pub fn hiddify_endpoint(&self) -> (String, u16) { @@ -390,7 +411,7 @@ impl Default for RulesConfig { reason = "each field is an independent user-facing behavior toggle" )] pub struct BehaviorConfig { - /// Windows taskbar or Linux `AppIndicator` panel display; ignored on macOS. + /// Windows taskbar, Linux `AppIndicator` panel, or macOS menu bar display. pub show_taskbar_speed: bool, pub taskbar_speed_unit: SpeedUnit, pub launch_at_login: bool, @@ -1006,7 +1027,15 @@ mod tests { let reloaded = store.load().expect("reload as development"); assert_eq!(reloaded.mihomo.controller_port, DEV_PROFILE_CONTROLLER_PORT); assert_eq!(reloaded.mihomo.mixed_port, DEV_PROFILE_MIXED_PORT); - assert_eq!(reloaded.mihomo.tun_name, DEV_PROFILE_TUN_NAME); + // macOS rewrites any non-`utunN` name on load. The dev name must not + // fall back to the installed app's TUN. + let expected_tun = if cfg!(target_os = "macos") { + MACOS_TUN_NAME + } else { + DEV_PROFILE_TUN_NAME + }; + assert_eq!(reloaded.mihomo.tun_name, expected_tun); + assert_ne!(reloaded.mihomo.tun_name, PRODUCTION_TUN_NAME); let persisted = toml::from_str::(&fs::read_to_string(&path).expect("read")) .expect("parse") .get("mihomo") @@ -1112,6 +1141,10 @@ mod tests { assert!(config.hiddify_client().is_none()); assert_eq!(config.enabled_clients().len(), 1); assert_eq!(config.hiddify_endpoint(), ("127.0.0.1".into(), 12_334)); + let ClientConfig::LocalProxy { host, port, .. } = &config.clients[0].config else { + panic!("Happ is a local proxy"); + }; + assert_eq!(config.local_proxy_endpoints(), vec![(host.clone(), *port)]); } #[test] diff --git a/crates/iran-split-core/src/lib.rs b/crates/iran-split-core/src/lib.rs index 4ec1df1..46181e1 100644 --- a/crates/iran-split-core/src/lib.rs +++ b/crates/iran-split-core/src/lib.rs @@ -14,6 +14,9 @@ use tokio_util::sync::CancellationToken; use tracing::{error, info, warn}; use uuid::Uuid; +mod proxy_match; +pub use proxy_match::{proxy_endpoint_matches, proxy_server_matches}; + #[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default)] #[serde(rename_all = "snake_case")] pub enum StackPhase { @@ -1945,8 +1948,31 @@ impl Engine { } } self.announce_stage(OperationStage::StoppingProxy).await; - self.backend.stop_user_proxy().await?; - self.backend.clear_hiddify_system_proxy().await?; + // A proxy-tool failure must not skip TUN and DNS cleanup. Leaving the + // capture in place is what makes the network unusable until the next + // Connect (ADR 0120). + if let Err(cause) = self.backend.stop_user_proxy().await { + warn!( + event = "operation.stop_proxy_failed", + section = "engine", + initiator = "run_stop", + cause = %cause, + trace_id = %operation_id, + trace_route = "engine_operation->platform_backend->stop_user_proxy", + "stop continued after the local proxy client could not be stopped" + ); + } + if let Err(cause) = self.backend.clear_hiddify_system_proxy().await { + warn!( + event = "operation.stop_clear_proxy_failed", + section = "engine", + initiator = "run_stop", + cause = %cause, + trace_id = %operation_id, + trace_route = "engine_operation->platform_backend->clear_hiddify_system_proxy", + "stop continued after the system proxy could not be cleared" + ); + } self.announce_stage(OperationStage::CleaningUp).await; let report = self.backend.cleanup_owned_state().await?; let tun = self.backend.tun_status().await?; @@ -2351,6 +2377,7 @@ mod tests { proxy_stops: AtomicUsize, proxy_clears: AtomicUsize, proxy_restores: AtomicUsize, + fail_proxy_clear: AtomicBool, } #[async_trait] @@ -2511,6 +2538,9 @@ mod tests { async fn clear_hiddify_system_proxy(&self) -> Result { self.proxy_clears.fetch_add(1, Ordering::SeqCst); + if self.fail_proxy_clear.load(Ordering::SeqCst) { + return Err(CoreError::Platform("system proxy clear failed".into())); + } Ok(false) } @@ -2674,6 +2704,26 @@ mod tests { ); } + #[tokio::test] + async fn stop_removes_the_tunnel_when_system_proxy_clear_fails() { + let backend = Arc::new(FakeBackend::default()); + backend.fail_proxy_clear.store(true, Ordering::SeqCst); + let engine = Engine::new(Arc::clone(&backend), &tokio::runtime::Handle::current()); + engine.start_stack(None).await.expect("start accepted"); + engine + .wait_for_phase(StackPhase::Running, Duration::from_secs(2)) + .await + .expect("running"); + + engine.stop_stack().await.expect("stop accepted"); + engine + .wait_for_phase(StackPhase::Stopped, Duration::from_secs(2)) + .await + .expect("stopped"); + assert!(!backend.tun.load(Ordering::SeqCst)); + assert!(backend.cleanups.load(Ordering::SeqCst) >= 1); + } + #[tokio::test] async fn resume_from_paused_restores_running() { let backend = Arc::new(FakeBackend::default()); diff --git a/crates/iran-split-core/src/proxy_match.rs b/crates/iran-split-core/src/proxy_match.rs new file mode 100644 index 0000000..2e8c2ca --- /dev/null +++ b/crates/iran-split-core/src/proxy_match.rs @@ -0,0 +1,126 @@ +//! Decide whether an OS proxy entry belongs to a `BiFlow` local client. +//! +//! Corporate proxies stay untouched. `localhost`, `127.0.0.1`, and `::1` are +//! the same loopback host, because clients do not all write the same spelling. + +/// `true` when `actual_host`:`actual_port` is the expected client endpoint. +#[must_use] +pub fn proxy_endpoint_matches( + actual_host: &str, + actual_port: &str, + expected_host: &str, + expected_port: u16, +) -> bool { + actual_port.trim() == expected_port.to_string() && hosts_match(actual_host, expected_host) +} + +/// `true` when a Windows `ProxyServer` string points at the expected endpoint. +/// +/// Accepts `host:port`, `http=host:port;https=host:port`, and a scheme prefix. +#[must_use] +pub fn proxy_server_matches(server: &str, expected_host: &str, expected_port: u16) -> bool { + server.split([';', ' ', ',']).any(|part| { + let value = part + .rsplit_once('=') + .map_or(part, |(_, value)| value) + .trim(); + let value = value + .trim_start_matches("https://") + .trim_start_matches("http://") + .trim_start_matches("socks5://") + .trim_start_matches("socks://"); + split_host_port(value).is_some_and(|(host, port)| { + proxy_endpoint_matches(&host, &port, expected_host, expected_port) + }) + }) +} + +fn hosts_match(actual: &str, expected: &str) -> bool { + let actual = normalize_host(actual); + let expected = normalize_host(expected); + actual.eq_ignore_ascii_case(&expected) || (is_loopback(&actual) && is_loopback(&expected)) +} + +fn normalize_host(host: &str) -> String { + host.trim() + .trim_matches(|character| character == '[' || character == ']') + .to_owned() +} + +fn is_loopback(host: &str) -> bool { + ["localhost", "127.0.0.1", "::1", "0:0:0:0:0:0:0:1"] + .iter() + .any(|candidate| host.eq_ignore_ascii_case(candidate)) +} + +fn split_host_port(value: &str) -> Option<(String, String)> { + if let Some(rest) = value.strip_prefix('[') { + let (host, port) = rest.split_once("]:")?; + return Some((host.to_owned(), port.to_owned())); + } + let (host, port) = value.rsplit_once(':')?; + Some((host.to_owned(), port.to_owned())) +} + +#[cfg(test)] +mod tests { + use super::{proxy_endpoint_matches, proxy_server_matches}; + + #[test] + fn loopback_spellings_share_one_endpoint() { + assert!(proxy_endpoint_matches( + "localhost", + "12334", + "127.0.0.1", + 12_334 + )); + assert!(proxy_endpoint_matches("::1", "10808", "127.0.0.1", 10_808)); + assert!(proxy_endpoint_matches( + "[::1]", + "10808", + "localhost", + 10_808 + )); + } + + #[test] + fn corporate_and_other_ports_stay_unmatched() { + assert!(!proxy_endpoint_matches( + "proxy.corp.example", + "8080", + "127.0.0.1", + 8080 + )); + assert!(!proxy_endpoint_matches( + "127.0.0.1", + "8080", + "127.0.0.1", + 12_334 + )); + } + + #[test] + fn windows_proxy_server_matches_each_loopback_spelling() { + assert!(proxy_server_matches( + "http=127.0.0.1:12334;https=127.0.0.1:12334;socks=127.0.0.1:12334", + "127.0.0.1", + 12_334 + )); + assert!(proxy_server_matches("localhost:10808", "127.0.0.1", 10_808)); + assert!(proxy_server_matches( + "http://[::1]:12334", + "127.0.0.1", + 12_334 + )); + assert!(!proxy_server_matches( + "proxy.corp.example:8080", + "127.0.0.1", + 8080 + )); + assert!(!proxy_server_matches( + "http=127.0.0.1:12334;https=10.1.1.1:8080", + "10.1.1.1", + 12_334 + )); + } +} diff --git a/crates/iran-split-helper/src/commands.rs b/crates/iran-split-helper/src/commands.rs index 5765223..7966346 100644 --- a/crates/iran-split-helper/src/commands.rs +++ b/crates/iran-split-helper/src/commands.rs @@ -29,6 +29,8 @@ pub(crate) async fn execute_audited( command = request.payload.audit_name(), "authorized helper command" ); + let _in_flight = supervisor.begin_command(); + supervisor.note_desktop_contact().await; let request_id = request.request_id; let protocol_version = request.protocol_version; let reply = execute(supervisor, request.payload).await; diff --git a/crates/iran-split-helper/src/lib.rs b/crates/iran-split-helper/src/lib.rs index 6e79a3a..ad1ecdf 100644 --- a/crates/iran-split-helper/src/lib.rs +++ b/crates/iran-split-helper/src/lib.rs @@ -9,8 +9,11 @@ use std::{ fs, io, path::{Path, PathBuf}, process::Stdio, - sync::{Arc, LazyLock}, - time::Duration, + sync::{ + atomic::{AtomicUsize, Ordering}, + Arc, LazyLock, + }, + time::{Duration, Instant}, }; use thiserror::Error; use tokio::{ @@ -21,6 +24,12 @@ use tokio::{ use uuid::Uuid; const PROCESS_STOP_TIMEOUT: Duration = Duration::from_secs(10); +/// Mihomo removes its own routes, nftables, and TUN on `SIGTERM`. Killing it +/// immediately leaves those rules in place and the network stays black-holed. +const GRACEFUL_STOP_TIMEOUT: Duration = Duration::from_secs(2); +/// The desktop health loop talks to the helper every 10 seconds. A longer +/// silence means the app crashed or was killed, so the capture must come down. +const DESKTOP_ABSENCE_LIMIT: Duration = Duration::from_secs(90); const MIHOMO_STAY_ALIVE: Duration = Duration::from_millis(400); const MAX_LOG_ENTRIES: usize = 2_000; const FIXED_GENERATION_FILES: [&str; 8] = [ @@ -172,6 +181,9 @@ pub struct Supervisor { registered: Mutex>, logs: Arc>>, pub(crate) side_tunnels: Mutex>, + desktop_contact: Mutex>, + commands_in_flight: AtomicUsize, + started_at: Instant, } impl Supervisor { @@ -183,9 +195,21 @@ impl Supervisor { registered: Mutex::new(HashMap::new()), logs: Arc::new(Mutex::new(VecDeque::with_capacity(MAX_LOG_ENTRIES))), side_tunnels: Mutex::new(HashMap::new()), + desktop_contact: Mutex::new(None), + commands_in_flight: AtomicUsize::new(0), + started_at: Instant::now(), } } + pub(crate) async fn note_desktop_contact(&self) { + *self.desktop_contact.lock().await = Some(Instant::now()); + } + + pub(crate) fn begin_command(&self) -> CommandInFlight<'_> { + self.commands_in_flight.fetch_add(1, Ordering::SeqCst); + CommandInFlight { supervisor: self } + } + #[must_use] pub const fn settings(&self) -> &HelperSettings { &self.settings @@ -418,9 +442,28 @@ impl Supervisor { stop_managed_child(managed).await?; } *current = None; + // Hold the child lock across the orphan kill so a concurrent start + // cannot spawn a new Mihomo while this stop is still tearing the + // previous one down. + if let Err(cause) = orphan::cleanup(&self.settings, Uuid::new_v4()).await { + tracing::warn!( + event = "helper.orphan_after_stop_failed", + section = "helper_process", + initiator = "helper_process", + cause = %cause, + trace_route = "helper_process->stop->owned_orphan_cleanup", + "scoped Mihomo orphan remained after stop" + ); + } drop(current); self.push_log("info", "mihomo_stopped", BTreeMap::new()) .await; + // DNS restore talks to networksetup for every service. Keep it off + // the child lock so a status read is not stuck behind that wait. + // Start takes the same lock before it spawns, and the desktop does + // not overlap stop with start. + #[cfg(target_os = "macos")] + self.release_macos_network().await; Ok(ProcessStatus { running: false, pid: None, @@ -429,6 +472,24 @@ impl Supervisor { }) } + /// Drops macOS tunnel routes and points system DNS back at the network + /// the machine had before Connect. + #[cfg(target_os = "macos")] + async fn release_macos_network(&self) { + clear_owned_routes(&self.settings.tun_name).await; + if let Err(cause) = macos_dns::restore(&self.settings.runtime_dir) { + tracing::warn!( + event = "helper.dns_restore_after_stop_failed", + section = "helper_dns", + initiator = "helper_process", + cause = %cause, + trace_route = "helper_process->stop->macos_dns", + "macOS DNS was not restored when Mihomo stopped" + ); + } + flush_dns_cache(); + } + /// Returns the current helper-owned process status. /// /// # Errors @@ -459,6 +520,8 @@ impl Supervisor { #[cfg(not(unix))] delete_owned_interface(&self.settings.tun_name); } + #[cfg(target_os = "macos")] + clear_owned_routes(&self.settings.tun_name).await; let tun_removed = !interface_exists(&self.settings.tun_name); let warnings = if tun_removed { Vec::new() @@ -471,7 +534,10 @@ impl Supervisor { // Restore the macOS system DNS (router resolver) now that the TUN is // down; Mihomo's `127.0.0.1:53` listener is gone with the process. #[cfg(target_os = "macos")] - macos_dns::restore(&self.settings.runtime_dir)?; + let dns_result = macos_dns::restore(&self.settings.runtime_dir); + flush_dns_cache(); + #[cfg(target_os = "macos")] + dns_result?; let report = CleanupReport { process_stopped, tun_removed, @@ -613,6 +679,15 @@ fn process_status(managed: Option<&ManagedChild>) -> ProcessStatus { } async fn stop_managed_child(managed: &mut ManagedChild) -> Result<(), HelperServiceError> { + if managed.child.try_wait()?.is_some() { + return Ok(()); + } + request_graceful_stop(&managed.child); + match tokio::time::timeout(GRACEFUL_STOP_TIMEOUT, managed.child.wait()).await { + Ok(Ok(_)) => return Ok(()), + Ok(Err(error)) => return Err(HelperServiceError::Process(error.to_string())), + Err(_) => {} + } if managed.child.try_wait()?.is_some() { return Ok(()); } @@ -627,6 +702,232 @@ async fn stop_managed_child(managed: &mut ManagedChild) -> Result<(), HelperServ Ok(()) } +#[cfg(unix)] +fn request_graceful_stop(child: &Child) { + let Some(pid) = child.id() else { + return; + }; + let Ok(pid) = i32::try_from(pid) else { + return; + }; + if let Err(cause) = nix::sys::signal::kill( + nix::unistd::Pid::from_raw(pid), + nix::sys::signal::Signal::SIGTERM, + ) { + // The child can exit between try_wait and kill; no signal is then needed. + if cause != nix::errno::Errno::ESRCH { + tracing::warn!( + event = "helper.graceful_stop_failed", + section = "helper_process", + initiator = "helper_process", + cause = %cause, + trace_route = "helper_process->stop->sigterm", + "graceful stop failed; the bounded force-stop fallback remains active" + ); + } + } +} + +#[cfg(windows)] +fn request_graceful_stop(child: &Child) { + use std::os::windows::process::CommandExt; + const CREATE_NO_WINDOW: u32 = 0x0800_0000; + let Some(pid) = child.id() else { + return; + }; + let result = std::process::Command::new("taskkill") + .args(["/PID", &pid.to_string()]) + .creation_flags(CREATE_NO_WINDOW) + .stdin(Stdio::null()) + .stdout(Stdio::null()) + .stderr(Stdio::null()) + .status(); + let cause = match result { + Ok(status) if status.success() => return, + Ok(_) => "taskkill_failed", + Err(_) => "taskkill_spawn_failed", + }; + tracing::warn!( + event = "helper.graceful_stop_failed", + section = "helper_process", + initiator = "helper_process", + cause, + trace_route = "helper_process->stop->taskkill", + "graceful stop failed; the bounded force-stop fallback remains active" + ); +} + +/// Drops cached fake-ip answers (`198.18.0.0/16`) so lookups work again after +/// the TUN is gone. A missing tool is ignored; a failed flush is not fatal. +fn flush_dns_cache() { + for (program, args) in dns_flush_commands() { + if !Path::new(program).is_file() { + continue; + } + match dns_flush_status(program, args) { + Ok(status) if status.success() => { + tracing::info!( + event = "helper.dns_cache_flushed", + section = "helper_dns", + initiator = "helper_process", + cause = "network_cleanup", + trace_route = "helper_process->cleanup->dns_cache", + "flushed the system DNS cache after releasing the tunnel" + ); + } + Ok(status) => tracing::warn!( + event = "helper.dns_cache_flush_failed", + section = "helper_dns", + initiator = "helper_process", + cause = "flush_command_failed", + trace_route = "helper_process->cleanup->dns_cache", + status = %status, + "system DNS cache was not flushed" + ), + Err(cause) => tracing::warn!( + event = "helper.dns_cache_flush_failed", + section = "helper_dns", + initiator = "helper_process", + cause = %cause, + trace_route = "helper_process->cleanup->dns_cache", + "system DNS cache flush could not start" + ), + } + } +} + +#[cfg(target_os = "macos")] +fn dns_flush_commands() -> Vec<(&'static str, Vec<&'static str>)> { + vec![ + ("/usr/bin/dscacheutil", vec!["-flushcache"]), + ("/usr/bin/killall", vec!["-HUP", "mDNSResponder"]), + ] +} + +#[cfg(target_os = "linux")] +fn dns_flush_commands() -> Vec<(&'static str, Vec<&'static str>)> { + vec![ + ("/usr/bin/resolvectl", vec!["flush-caches"]), + ("/usr/bin/systemd-resolve", vec!["--flush-caches"]), + ] +} + +#[cfg(windows)] +fn dns_flush_commands() -> Vec<(&'static str, Vec<&'static str>)> { + vec![(r"C:\Windows\System32\ipconfig.exe", vec!["/flushdns"])] +} + +#[cfg(not(any(target_os = "macos", target_os = "linux", windows)))] +fn dns_flush_commands() -> Vec<(&'static str, Vec<&'static str>)> { + Vec::new() +} + +fn dns_flush_status(program: &str, args: Vec<&str>) -> io::Result { + let mut command = std::process::Command::new(program); + command + .args(args) + .stdin(Stdio::null()) + .stdout(Stdio::null()) + .stderr(Stdio::null()); + #[cfg(windows)] + { + use std::os::windows::process::CommandExt; + const CREATE_NO_WINDOW: u32 = 0x0800_0000; + command.creation_flags(CREATE_NO_WINDOW); + } + command.status() +} + +struct CommandInFlight<'a> { + supervisor: &'a Supervisor, +} + +impl Drop for CommandInFlight<'_> { + fn drop(&mut self) { + self.supervisor + .commands_in_flight + .fetch_sub(1, Ordering::SeqCst); + } +} + +/// `true` when the desktop has not been seen for `limit` and a capture remains. +#[must_use] +fn abandoned_capture( + contact: Option, + started_at: Instant, + now: Instant, + in_flight: bool, + captured: bool, + limit: Duration, +) -> bool { + if in_flight || !captured { + return false; + } + let age = match contact { + Some(seen) => now.saturating_duration_since(seen), + None => now.saturating_duration_since(started_at), + }; + age >= limit +} + +impl Supervisor { + /// Stops Mihomo and restores DNS when the desktop process has disappeared. + pub(crate) async fn release_if_desktop_absent(&self) { + let in_flight = self.commands_in_flight.load(Ordering::SeqCst) > 0; + let contact = *self.desktop_contact.lock().await; + let captured = self.network_captured().await; + if !abandoned_capture( + contact, + self.started_at, + Instant::now(), + in_flight, + captured, + DESKTOP_ABSENCE_LIMIT, + ) { + return; + } + tracing::warn!( + event = "helper.desktop_absent", + section = "helper_lifecycle", + initiator = "helper_process", + cause = "desktop_contact_expired", + trace_route = "helper_process->absence_watch->cleanup", + "desktop contact expired while the tunnel was still up; releasing the network" + ); + if let Err(cause) = self.cleanup().await { + tracing::warn!( + event = "helper.desktop_absent_cleanup_failed", + section = "helper_lifecycle", + initiator = "helper_process", + cause = %cause, + trace_route = "helper_process->absence_watch->cleanup", + "network release after a missing desktop did not finish" + ); + } + if !self.network_captured().await { + *self.desktop_contact.lock().await = None; + } + } + + async fn network_captured(&self) -> bool { + let running = self + .status() + .await + .map(|status| status.running) + .unwrap_or(false); + running || interface_exists(&self.settings.tun_name) + } +} + +pub(crate) fn spawn_absence_watch(supervisor: Arc) { + tokio::spawn(async move { + loop { + tokio::time::sleep(Duration::from_secs(15)).await; + supervisor.release_if_desktop_absent().await; + } + }); +} + fn collect_generation_files(root: &Path) -> Result, HelperServiceError> { let mut names: Vec = FIXED_GENERATION_FILES .iter() @@ -966,6 +1267,112 @@ async fn delete_owned_interface(name: &str) -> Result<(), HelperServiceError> { Ok(()) } +/// A route row from `netstat -rn` whose interface column is the helper TUN. +#[cfg(any(target_os = "macos", test))] +#[derive(Debug, PartialEq, Eq)] +struct OwnedRoute { + destination: String, + gateway: String, + host_route: bool, +} + +/// `netstat` puts the interface in the fourth column. An optional expire +/// value comes after it, so the interface is not always the last field. +#[cfg(any(target_os = "macos", test))] +fn owned_routes(table: &str, interface: &str) -> Vec { + table + .lines() + .filter_map(|line| { + let columns: Vec<&str> = line.split_whitespace().collect(); + if columns.len() < 4 || columns[3] != interface { + return None; + } + let destination = columns[0]; + if destination.eq_ignore_ascii_case("destination") { + return None; + } + Some(OwnedRoute { + destination: destination.to_owned(), + gateway: columns[1].to_owned(), + host_route: columns[2].contains('H'), + }) + }) + .collect() +} + +#[cfg(any(target_os = "macos", test))] +fn route_delete_args(route: &OwnedRoute) -> Vec { + if route.destination == "default" { + return vec![ + "-n".into(), + "delete".into(), + "default".into(), + route.gateway.clone(), + ]; + } + let kind = if route.host_route { "-host" } else { "-net" }; + vec![ + "-n".into(), + "delete".into(), + kind.into(), + route.destination.clone(), + route.gateway.clone(), + ] +} + +#[cfg(target_os = "macos")] +async fn clear_owned_routes(interface: &str) { + let mut removed = 0usize; + let mut failed = 0usize; + for family in ["inet", "inet6"] { + let output = Command::new("/usr/sbin/netstat") + .args(["-rn", "-f", family]) + .env_clear() + .env("LC_ALL", "C") + .stdin(Stdio::null()) + .stdout(Stdio::piped()) + .stderr(Stdio::null()) + .output() + .await; + let Ok(output) = output else { + failed += 1; + continue; + }; + if !output.status.success() { + failed += 1; + continue; + } + let table = String::from_utf8_lossy(&output.stdout); + for route in owned_routes(&table, interface) { + let deleted = Command::new("/sbin/route") + .args(route_delete_args(&route)) + .env_clear() + .env("LC_ALL", "C") + .stdin(Stdio::null()) + .stdout(Stdio::null()) + .stderr(Stdio::null()) + .output() + .await; + match deleted { + Ok(result) if result.status.success() => removed += 1, + _ => failed += 1, + } + } + } + if removed > 0 || failed > 0 { + tracing::info!( + event = "helper.tun_routes_cleared", + section = "helper_network", + initiator = "helper_process", + cause = "network_cleanup", + removed, + failed, + trace_route = "helper_process->stop->route", + "removed routes that still pointed at the tunnel" + ); + } +} + #[cfg(target_os = "macos")] async fn delete_owned_interface(name: &str) -> Result<(), HelperServiceError> { // Mihomo creates a `utun` device and destroys it when the process exits, so @@ -1057,6 +1464,141 @@ pub fn clap_install_error_message(error: &clap::Error) -> Option { mod tests { use super::*; + #[test] + fn absence_releases_only_an_idle_capture() { + let now = Instant::now(); + let ago = |age: Duration| now.checked_sub(age).expect("instant stays in range"); + let started = ago(Duration::from_secs(120)); + assert!(abandoned_capture( + Some(ago(Duration::from_secs(90))), + started, + now, + false, + true, + DESKTOP_ABSENCE_LIMIT + )); + assert!(!abandoned_capture( + Some(ago(Duration::from_secs(10))), + started, + now, + false, + true, + DESKTOP_ABSENCE_LIMIT + )); + assert!(!abandoned_capture( + Some(ago(Duration::from_secs(120))), + started, + now, + true, + true, + DESKTOP_ABSENCE_LIMIT + )); + assert!(!abandoned_capture( + None, + started, + now, + false, + false, + DESKTOP_ABSENCE_LIMIT + )); + assert!(abandoned_capture( + None, + ago(DESKTOP_ABSENCE_LIMIT), + now, + false, + true, + DESKTOP_ABSENCE_LIMIT + )); + } + + #[cfg(unix)] + #[tokio::test] + async fn stop_waits_for_a_graceful_exit_before_killing() { + // Perl installs the ignore handler before it sleeps. A shell `trap` + // loses the race: stop's SIGTERM arrives before the trap is parsed. + let child = Command::new("perl") + .args(["-e", "$SIG{TERM} = 'IGNORE'; sleep 30;"]) + .kill_on_drop(true) + .spawn() + .expect("process that ignores SIGTERM"); + tokio::time::sleep(Duration::from_millis(200)).await; + let mut managed = ManagedChild { + child, + generation_id: Uuid::new_v4(), + started_at: String::new(), + }; + let started = Instant::now(); + stop_managed_child(&mut managed) + .await + .expect("process stopped"); + let elapsed = started.elapsed(); + assert!( + elapsed >= GRACEFUL_STOP_TIMEOUT, + "stop killed the process before the graceful wait: {elapsed:?}" + ); + assert!( + elapsed < PROCESS_STOP_TIMEOUT, + "stop waited for the force-kill timeout: {elapsed:?}" + ); + } + + #[test] + fn tun_routes_are_removed_without_touching_the_physical_default() { + let table = "\ +Routing tables + +Internet: +Destination Gateway Flags Netif Expire +default 10.0.0.1 UGScg en0 +1 198.18.0.1 UGSc utun9 +128.0/1 198.18.0.1 UGSc utun9 +198.18.0.1 198.18.0.1 UH utun9 +default fe80::1%utun9 UGcIg utun9 1 +"; + let routes = owned_routes(table, "utun9"); + assert_eq!( + routes, + vec![ + OwnedRoute { + destination: "1".into(), + gateway: "198.18.0.1".into(), + host_route: false, + }, + OwnedRoute { + destination: "128.0/1".into(), + gateway: "198.18.0.1".into(), + host_route: false, + }, + OwnedRoute { + destination: "198.18.0.1".into(), + gateway: "198.18.0.1".into(), + host_route: true, + }, + OwnedRoute { + destination: "default".into(), + gateway: "fe80::1%utun9".into(), + host_route: false, + }, + ] + ); + assert_eq!( + route_delete_args(&routes[0]), + vec!["-n", "delete", "-net", "1", "198.18.0.1"] + ); + assert_eq!( + route_delete_args(&routes[2]), + vec!["-n", "delete", "-host", "198.18.0.1", "198.18.0.1"] + ); + assert_eq!( + route_delete_args(&routes[3]), + vec!["-n", "delete", "default", "fe80::1%utun9"] + ); + let physical = owned_routes(table, "en0"); + assert_eq!(physical.len(), 1); + assert_eq!(physical[0].destination, "default"); + assert_eq!(physical[0].gateway, "10.0.0.1"); + } + #[test] fn redaction_removes_common_credentials_and_bounds_output() { let input = format!( diff --git a/crates/iran-split-helper/src/linux.rs b/crates/iran-split-helper/src/linux.rs index 392bdec..9e80149 100644 --- a/crates/iran-split-helper/src/linux.rs +++ b/crates/iran-split-helper/src/linux.rs @@ -36,6 +36,7 @@ pub async fn run_linux(config_path: &Path) -> Result<(), HelperServiceError> { let listener = UnixListener::bind(&socket_path)?; apply_socket_permissions(&socket_path, settings.authorized_gid)?; let supervisor = Arc::new(Supervisor::new(settings)); + super::spawn_absence_watch(Arc::clone(&supervisor)); info!( event = "helper.listening", section = "helper_ipc", diff --git a/crates/iran-split-helper/src/macos.rs b/crates/iran-split-helper/src/macos.rs index c759f94..e76e7ea 100644 --- a/crates/iran-split-helper/src/macos.rs +++ b/crates/iran-split-helper/src/macos.rs @@ -46,6 +46,7 @@ pub async fn run_macos(config_path: &Path) -> Result<(), HelperServiceError> { let listener = UnixListener::bind(&socket_path)?; apply_socket_permissions(&socket_path, settings.authorized_gid)?; let supervisor = Arc::new(Supervisor::new(settings)); + super::spawn_absence_watch(Arc::clone(&supervisor)); info!( event = "helper.listening", section = "helper_ipc", diff --git a/crates/iran-split-helper/src/macos_dns.rs b/crates/iran-split-helper/src/macos_dns.rs index 0dfcd9e..2a875da 100644 --- a/crates/iran-split-helper/src/macos_dns.rs +++ b/crates/iran-split-helper/src/macos_dns.rs @@ -2,6 +2,7 @@ //! recovery file is private, root-owned, and never copied into diagnostics. use super::HelperServiceError; use std::{collections::BTreeMap, fs, io::Write, net::IpAddr, path::Path}; +use tracing::warn; type Snapshot = BTreeMap>; const SNAPSHOT_FILE: &str = "system-dns.toml"; @@ -24,17 +25,28 @@ fn run_action(runtime: &Path, apply: bool) -> Result<(), HelperServiceError> { initiator = "helper_process", cause = "dns_lifecycle", %trace_id, operation, trace_route = "helper_process->macos_dns->networksetup", "updating macOS system DNS"); let result = if apply { - apply_with(runtime, &mut networksetup) + apply_with( + runtime, + &mut networksetup, + &mut scutil_dns, + &mut default_gateway_text, + ) } else { - restore_with(runtime, &mut networksetup) + restore_with( + runtime, + &mut networksetup, + &mut scutil_dns, + &mut default_gateway_text, + ) }; - match &result { - Ok(services) => tracing::info!(event = "helper.dns_completed", section = "helper_dns", + if let Ok(services) = &result { + tracing::info!(event = "helper.dns_completed", section = "helper_dns", initiator = "helper_process", cause = "dns_update_succeeded", %trace_id, operation, services, - trace_route = "helper_process->macos_dns->networksetup", "macOS system DNS updated"), - Err(_) => tracing::warn!(event = "helper.dns_failed", section = "helper_dns", + trace_route = "helper_process->macos_dns->networksetup", "macOS system DNS updated"); + } else { + tracing::warn!(event = "helper.dns_failed", section = "helper_dns", initiator = "helper_process", cause = "networksetup_or_recovery_file_failed", %trace_id, operation, - trace_route = "helper_process->macos_dns->networksetup", "macOS DNS recovery remains pending"), + trace_route = "helper_process->macos_dns->networksetup", "macOS DNS recovery remains pending"); } result.map(|_| ()) } @@ -56,6 +68,8 @@ fn networksetup(args: &[String]) -> Result { fn apply_with( runtime: &Path, command: &mut impl FnMut(&[String]) -> Result, + live_dns: &mut impl FnMut() -> Result, + gateway: &mut impl FnMut() -> Result, ) -> Result { let output = command(&["-listallnetworkservices".into()])?; let services = parse_services(&output); @@ -65,17 +79,30 @@ fn apply_with( )); } // Reuse the pre-takeover values after a repeated start/helper restart. + // A previous session may have stored 127.0.0.1; that is our listener, + // not the machine's resolver, so it must not be written back later. let mut snapshot = read_snapshot(runtime)?.unwrap_or_default(); + let (sanitized, discarded) = without_loopback_snapshot(&snapshot); + if discarded > 0 { + note_loopback_discarded(discarded); + snapshot = sanitized; + } for service in &services { - if !snapshot.contains_key(service) { - let output = command(&["-getdnsservers".into(), service.clone()])?; - snapshot.insert(service.clone(), parse_dns(&output)?); + if snapshot.contains_key(service) { + continue; } + let output = command(&["-getdnsservers".into(), service.clone()])?; + let servers = parse_dns(&output)?; + let recoverable = without_loopback(&servers); + if recoverable.len() != servers.len() { + note_loopback_discarded(1); + } + snapshot.insert(service.clone(), recoverable); } write_snapshot(runtime, &snapshot)?; for service in &services { if let Err(error) = command(&dns_arguments(service, &["127.0.0.1".into()])) { - if restore_with(runtime, command).is_err() { + if restore_with(runtime, command, live_dns, gateway).is_err() { return Err(HelperServiceError::Process( "macOS DNS apply failed; recovery remains pending".into(), )); @@ -89,10 +116,17 @@ fn apply_with( fn restore_with( runtime: &Path, command: &mut impl FnMut(&[String]) -> Result, + live_dns: &mut impl FnMut() -> Result, + gateway: &mut impl FnMut() -> Result, ) -> Result { let Some(snapshot) = read_snapshot(runtime)? else { return Ok(0); }; + let (snapshot, discarded) = without_loopback_snapshot(&snapshot); + if discarded > 0 { + note_loopback_discarded(discarded); + write_snapshot(runtime, &snapshot)?; + } let mut first_error = None; for (service, servers) in &snapshot { if let Err(error) = command(&dns_arguments(service, servers)) { @@ -102,12 +136,187 @@ fn restore_with( if let Some(error) = first_error { return Err(error); } + if still_loopback(command, live_dns, snapshot.keys())? { + let gateway = parse_default_gateway(&gateway()?); + let Some(gateway) = gateway else { + warn!( + event = "helper.dns_still_loopback", + section = "helper_dns", + initiator = "helper_process", + cause = "no_private_gateway", + trace_route = "helper_process->macos_dns->restore", + "macOS DNS still uses the tunnel resolver" + ); + return Err(HelperServiceError::Process( + "macOS DNS still uses the tunnel resolver".into(), + )); + }; + warn!( + event = "helper.dns_router_fallback", + section = "helper_dns", + initiator = "helper_process", + cause = "live_resolver_still_loopback", + services = snapshot.len(), + trace_route = "helper_process->macos_dns->restore", + "live macOS DNS stayed on the tunnel resolver; using the LAN gateway" + ); + for service in snapshot.keys() { + command(&dns_arguments(service, &[gateway.clone()]))?; + } + if still_loopback(command, live_dns, snapshot.keys())? { + return Err(HelperServiceError::Process( + "macOS DNS still uses the tunnel resolver".into(), + )); + } + } fs::remove_file(runtime.join(SNAPSHOT_FILE))?; #[cfg(unix)] fs::File::open(runtime)?.sync_all()?; Ok(snapshot.len()) } +fn still_loopback<'a>( + command: &mut impl FnMut(&[String]) -> Result, + live_dns: &mut impl FnMut() -> Result, + services: impl Iterator, +) -> Result { + if primary_is_loopback(&live_dns()?) { + return Ok(true); + } + for service in services { + let output = command(&["-getdnsservers".into(), service.clone()])?; + if parse_dns(&output)? + .iter() + .any(|server| is_loopback_resolver(server)) + { + return Ok(true); + } + } + Ok(false) +} + +fn note_loopback_discarded(services: usize) { + warn!( + event = "helper.dns_loopback_discarded", + section = "helper_dns", + initiator = "helper_process", + cause = "tunnel_resolver_in_snapshot", + services, + trace_route = "helper_process->macos_dns->snapshot", + "saved macOS DNS pointed at the tunnel resolver; DHCP will be restored" + ); +} + +fn without_loopback_snapshot(snapshot: &Snapshot) -> (Snapshot, usize) { + let mut discarded = 0usize; + let mut clean = Snapshot::new(); + for (service, servers) in snapshot { + let recoverable = without_loopback(servers); + if recoverable.len() != servers.len() { + discarded += 1; + } + clean.insert(service.clone(), recoverable); + } + (clean, discarded) +} + +fn without_loopback(servers: &[String]) -> Vec { + servers + .iter() + .filter(|server| !is_loopback_resolver(server)) + .cloned() + .collect() +} + +fn is_loopback_resolver(server: &str) -> bool { + server + .parse::() + .is_ok_and(|address| address.is_loopback()) +} + +/// The first `scutil --dns` resolver is the one unqualified names use. +fn primary_nameservers(scutil_dns: &str) -> Vec { + let mut servers = Vec::new(); + let mut in_first = false; + for line in scutil_dns.lines() { + let line = line.trim(); + if line.starts_with("resolver #") { + if in_first { + break; + } + in_first = true; + continue; + } + if !in_first { + continue; + } + let Some(rest) = line.strip_prefix("nameserver[") else { + continue; + }; + let Some((_, address)) = rest.split_once(':') else { + continue; + }; + let address = address.trim(); + if !address.is_empty() { + servers.push(address.to_owned()); + } + } + servers +} + +fn primary_is_loopback(scutil_dns: &str) -> bool { + primary_nameservers(scutil_dns) + .iter() + .any(|server| is_loopback_resolver(server)) +} + +/// A private IPv4 default gateway can answer DNS when DHCP restore does not +/// clear a stuck `127.0.0.1`. The tunnel gateway `198.18.0.1` is not private. +fn parse_default_gateway(output: &str) -> Option { + for line in output.lines() { + let Some(rest) = line.trim().strip_prefix("gateway:") else { + continue; + }; + let candidate = rest.trim(); + let Ok(addr) = candidate.parse::() else { + return None; + }; + return match addr { + IpAddr::V4(ip) if ip.is_private() => Some(candidate.to_owned()), + _ => None, + }; + } + None +} + +#[cfg(target_os = "macos")] +fn scutil_dns() -> Result { + let output = std::process::Command::new("/usr/sbin/scutil") + .arg("--dns") + .env("LC_ALL", "C") + .output()?; + if !output.status.success() { + return Err(HelperServiceError::Process( + "macOS DNS status could not be read".into(), + )); + } + Ok(String::from_utf8_lossy(&output.stdout).into_owned()) +} + +#[cfg(target_os = "macos")] +fn default_gateway_text() -> Result { + let output = std::process::Command::new("/sbin/route") + .args(["-n", "get", "default"]) + .env("LC_ALL", "C") + .output()?; + if !output.status.success() { + return Err(HelperServiceError::Process( + "macOS default route could not be read".into(), + )); + } + Ok(String::from_utf8_lossy(&output.stdout).into_owned()) +} + fn parse_services(output: &str) -> Vec { output .lines() @@ -193,8 +402,8 @@ fn write_snapshot(runtime: &Path, snapshot: &Snapshot) -> Result<(), HelperServi #[cfg(test)] mod tests { use super::{ - apply_with, dns_arguments, parse_dns, parse_services, read_snapshot, restore_with, - SNAPSHOT_FILE, + apply_with, dns_arguments, parse_default_gateway, parse_dns, parse_services, + primary_is_loopback, read_snapshot, restore_with, SNAPSHOT_FILE, }; use crate::HelperServiceError; #[cfg(unix)] @@ -216,7 +425,13 @@ mod tests { #[test] fn dhcp_dns_is_restored_with_empty_and_the_snapshot_is_private() { let root = tempfile::tempdir().expect("tempdir"); - apply_with(root.path(), &mut network_response).expect("apply"); + apply_with( + root.path(), + &mut network_response, + &mut healthy_resolver, + &mut no_gateway, + ) + .expect("apply"); #[cfg(unix)] { use std::os::unix::fs::PermissionsExt; @@ -230,69 +445,113 @@ mod tests { ); } let mut calls = Vec::new(); - restore_with(root.path(), &mut |args| { - calls.push(args.to_vec()); - Ok(String::new()) - }) + restore_with( + root.path(), + &mut |args| { + calls.push(args.to_vec()); + network_response(args) + }, + &mut healthy_resolver, + &mut no_gateway, + ) .expect("restore"); - assert_eq!(calls, vec![vec!["-setdnsservers", "Wi-Fi", "Empty"]]); + assert!(calls + .iter() + .any(|call| { call.as_slice() == ["-setdnsservers", "Wi-Fi", "Empty"] })); + assert!(!calls + .iter() + .any(|call| call.iter().any(|part| part == "127.0.0.1"))); assert!(!root.path().join(SNAPSHOT_FILE).exists()); } #[test] fn failed_restore_keeps_recovery_for_a_new_helper() { let root = tempfile::tempdir().expect("tempdir"); - apply_with(root.path(), &mut network_response).expect("apply"); - assert!( - restore_with(root.path(), &mut |_| Err(HelperServiceError::Process( - "fixture".into() - ))) - .is_err() - ); + apply_with( + root.path(), + &mut network_response, + &mut healthy_resolver, + &mut no_gateway, + ) + .expect("apply"); + assert!(restore_with( + root.path(), + &mut |_| Err(HelperServiceError::Process("fixture".into())), + &mut healthy_resolver, + &mut no_gateway, + ) + .is_err()); assert!(root.path().join(SNAPSHOT_FILE).is_file()); - restore_with(root.path(), &mut network_response).expect("retry"); + restore_with( + root.path(), + &mut network_response, + &mut healthy_resolver, + &mut no_gateway, + ) + .expect("retry"); assert!(!root.path().join(SNAPSHOT_FILE).exists()); } #[test] fn repeated_start_preserves_the_original_resolvers() { let root = tempfile::tempdir().expect("tempdir"); - apply_with(root.path(), &mut |args| { - if args[0] == "-getdnsservers" { - Ok("8.8.8.8\n2001:4860:4860::8888\n".into()) - } else { - network_response(args) - } - }) + apply_with( + root.path(), + &mut |args| { + if args[0] == "-getdnsservers" { + Ok("8.8.8.8\n2001:4860:4860::8888\n".into()) + } else { + network_response(args) + } + }, + &mut healthy_resolver, + &mut no_gateway, + ) .expect("first start"); - apply_with(root.path(), &mut |args| { - assert_ne!(args[0], "-getdnsservers", "must not recapture loopback DNS"); - network_response(args) - }) + apply_with( + root.path(), + &mut |args| { + assert_ne!(args[0], "-getdnsservers", "must not recapture loopback DNS"); + network_response(args) + }, + &mut healthy_resolver, + &mut no_gateway, + ) .expect("restarted helper"); let snapshot = read_snapshot(root.path()) .expect("snapshot") .expect("present"); assert_eq!(snapshot["Wi-Fi"], vec!["8.8.8.8", "2001:4860:4860::8888"]); - restore_with(root.path(), &mut network_response).expect("restore"); + restore_with( + root.path(), + &mut network_response, + &mut healthy_resolver, + &mut no_gateway, + ) + .expect("restore"); } #[test] fn partial_apply_rolls_back_every_service() { let root = tempfile::tempdir().expect("tempdir"); let mut restores = Vec::new(); - let result = apply_with(root.path(), &mut |args| { - if args[0] == "-listallnetworkservices" { - return Ok("header\nWi-Fi\nEthernet\n".into()); - } - if args[0] == "-setdnsservers" && args[2] == "127.0.0.1" && args[1] == "Ethernet" { - return Err(HelperServiceError::Process("fixture".into())); - } - if args[0] == "-setdnsservers" && args[2] == "Empty" { - restores.push(args[1].clone()); - } - network_response(args) - }); + let result = apply_with( + root.path(), + &mut |args| { + if args[0] == "-listallnetworkservices" { + return Ok("header\nWi-Fi\nEthernet\n".into()); + } + if args[0] == "-setdnsservers" && args[2] == "127.0.0.1" && args[1] == "Ethernet" { + return Err(HelperServiceError::Process("fixture".into())); + } + if args[0] == "-setdnsservers" && args[2] == "Empty" { + restores.push(args[1].clone()); + } + network_response(args) + }, + &mut healthy_resolver, + &mut no_gateway, + ); assert!(result.is_err()); assert_eq!(restores, vec!["Ethernet", "Wi-Fi"]); assert!(!root.path().join(SNAPSHOT_FILE).exists()); @@ -301,14 +560,19 @@ mod tests { #[test] fn invalid_resolver_output_stops_before_any_mutation() { let root = tempfile::tempdir().expect("tempdir"); - assert!(apply_with(root.path(), &mut |args| { - assert_ne!(args[0], "-setdnsservers"); - if args[0] == "-getdnsservers" { - Ok("unexpected error text".into()) - } else { - network_response(args) - } - }) + assert!(apply_with( + root.path(), + &mut |args| { + assert_ne!(args[0], "-setdnsservers"); + if args[0] == "-getdnsservers" { + Ok("unexpected error text".into()) + } else { + network_response(args) + } + }, + &mut healthy_resolver, + &mut no_gateway, + ) .is_err()); assert!(!root.path().join(SNAPSHOT_FILE).exists()); assert_eq!( @@ -321,4 +585,167 @@ mod tests { vec!["-setdnsservers", "Wi-Fi", "1.1.1.1"] ); } + + #[test] + fn loopback_dns_is_stored_as_dhcp_and_not_written_back() { + let root = tempfile::tempdir().expect("tempdir"); + apply_with( + root.path(), + &mut |args| { + if args[0] == "-getdnsservers" { + Ok("127.0.0.1\n::1\n".into()) + } else { + network_response(args) + } + }, + &mut healthy_resolver, + &mut no_gateway, + ) + .expect("apply"); + let snapshot = read_snapshot(root.path()) + .expect("snapshot") + .expect("present"); + assert!(snapshot["Wi-Fi"].is_empty()); + let mut sets = Vec::new(); + restore_with( + root.path(), + &mut |args| { + if args[0] == "-setdnsservers" { + sets.push(args.to_vec()); + } + network_response(args) + }, + &mut healthy_resolver, + &mut no_gateway, + ) + .expect("restore"); + assert_eq!(sets, vec![vec!["-setdnsservers", "Wi-Fi", "Empty"]]); + } + + #[test] + fn stuck_loopback_resolver_uses_the_lan_gateway() { + use std::cell::{Cell, RefCell}; + use std::rc::Rc; + + let root = tempfile::tempdir().expect("tempdir"); + apply_with( + root.path(), + &mut |args| { + if args[0] == "-getdnsservers" { + Ok("127.0.0.1\n".into()) + } else { + network_response(args) + } + }, + &mut healthy_resolver, + &mut no_gateway, + ) + .expect("apply"); + let using_gateway = Rc::new(Cell::new(false)); + let sets = Rc::new(RefCell::new(Vec::new())); + restore_with( + root.path(), + &mut { + let using_gateway = Rc::clone(&using_gateway); + let sets = Rc::clone(&sets); + move |args| { + if args[0] == "-setdnsservers" { + sets.borrow_mut().push(args[2..].to_vec()); + if args[2] == "10.0.0.1" { + using_gateway.set(true); + } + } + if args[0] == "-getdnsservers" { + return if using_gateway.get() { + Ok("10.0.0.1\n".into()) + } else { + Ok("127.0.0.1\n".into()) + }; + } + Ok(String::new()) + } + }, + &mut { + let using_gateway = Rc::clone(&using_gateway); + move || { + if using_gateway.get() { + Ok("resolver #1\n nameserver[0] : 10.0.0.1\n".into()) + } else { + Ok("resolver #1\n nameserver[0] : 127.0.0.1\n".into()) + } + } + }, + &mut || Ok(" gateway: 10.0.0.1\n".into()), + ) + .expect("restore"); + let sets = sets.borrow(); + assert!(sets.iter().any(|servers| servers == &["Empty".to_owned()])); + assert!(sets + .iter() + .any(|servers| servers == &["10.0.0.1".to_owned()])); + assert!(!root.path().join(SNAPSHOT_FILE).exists()); + } + + #[test] + fn stuck_loopback_without_a_lan_gateway_keeps_recovery() { + let root = tempfile::tempdir().expect("tempdir"); + apply_with( + root.path(), + &mut |args| { + if args[0] == "-getdnsservers" { + Ok("127.0.0.1\n".into()) + } else { + network_response(args) + } + }, + &mut healthy_resolver, + &mut no_gateway, + ) + .expect("apply"); + let error = restore_with( + root.path(), + &mut |args| { + if args[0] == "-getdnsservers" { + Ok("127.0.0.1\n".into()) + } else { + Ok(String::new()) + } + }, + &mut || Ok("resolver #1\n nameserver[0] : 127.0.0.1\n".into()), + &mut || Ok(" gateway: 198.18.0.1\n".into()), + ) + .expect_err("tunnel resolver remains"); + assert!(error.to_string().contains("tunnel resolver")); + let snapshot = read_snapshot(root.path()).expect("snapshot").expect("kept"); + assert!(snapshot["Wi-Fi"].is_empty()); + assert!(!snapshot["Wi-Fi"].iter().any(|server| server == "127.0.0.1")); + } + + #[test] + fn live_dns_and_gateway_parsers_ignore_the_tunnel() { + assert!(primary_is_loopback( + "resolver #1\n nameserver[0] : 127.0.0.1\n\nresolver #2\n domain : local\n nameserver[0] : 8.8.8.8\n" + )); + assert!(!primary_is_loopback( + "resolver #1\n nameserver[0] : 9.9.9.9\n" + )); + assert_eq!( + parse_default_gateway(" gateway: 10.0.0.1\n interface: en0\n").as_deref(), + Some("10.0.0.1") + ); + assert_eq!(parse_default_gateway(" gateway: 198.18.0.1\n"), None); + assert_eq!(parse_default_gateway(" gateway: 8.8.8.8\n"), None); + } + + // Production DNS callbacks return `Result`. These fixtures match that + // signature even though the fixture itself cannot fail. + #[allow(clippy::unnecessary_wraps)] + fn healthy_resolver() -> Result { + Ok("resolver #1\n nameserver[0] : 9.9.9.9\n".into()) + } + + #[allow(clippy::unnecessary_wraps)] + fn no_gateway() -> Result { + Ok(String::new()) + } } diff --git a/crates/iran-split-helper/src/windows.rs b/crates/iran-split-helper/src/windows.rs index 4ed92f2..f80e26f 100644 --- a/crates/iran-split-helper/src/windows.rs +++ b/crates/iran-split-helper/src/windows.rs @@ -65,6 +65,7 @@ pub fn persist_install_error(message: &str) { pub async fn run_named_pipe(config_path: &Path) -> Result<(), HelperServiceError> { let settings = HelperSettings::load(config_path)?; let supervisor = Arc::new(Supervisor::new(settings)); + super::spawn_absence_watch(Arc::clone(&supervisor)); let mut server = create_helper_server(PIPE_NAME, true).map_err(HelperServiceError::Io)?; info!( event = "helper.listening", diff --git a/crates/iran-split-netstats/Cargo.toml b/crates/iran-split-netstats/Cargo.toml index 67ef8f0..36fbf7b 100644 --- a/crates/iran-split-netstats/Cargo.toml +++ b/crates/iran-split-netstats/Cargo.toml @@ -9,6 +9,9 @@ publish.workspace = true [dev-dependencies] tempfile.workspace = true +[target.'cfg(target_os = "macos")'.dependencies] +libc.workspace = true + [target.'cfg(windows)'.dependencies] windows = { version = "0.61.1", features = [ "Win32_Foundation", diff --git a/crates/iran-split-netstats/src/lib.rs b/crates/iran-split-netstats/src/lib.rs index 8098cc9..b9abee3 100644 --- a/crates/iran-split-netstats/src/lib.rs +++ b/crates/iran-split-netstats/src/lib.rs @@ -9,6 +9,10 @@ pub use windows::sample; mod linux; #[cfg(target_os = "linux")] pub use linux::sample; +#[cfg(target_os = "macos")] +mod macos; +#[cfg(target_os = "macos")] +pub use macos::sample; /// One adapter's complete hardware-interface counters. Never sum TUN + NIC. #[derive(Clone, Copy, Debug, PartialEq, Eq)] @@ -59,7 +63,7 @@ impl RateTracker { } } -#[cfg(any(windows, target_os = "linux", test))] +#[cfg(any(windows, target_os = "linux", target_os = "macos", test))] #[derive(Clone, Copy)] struct Candidate { sample: Sample, @@ -68,7 +72,7 @@ struct Candidate { default_metric: Option, } -#[cfg(any(windows, target_os = "linux", test))] +#[cfg(any(windows, target_os = "linux", target_os = "macos", test))] fn select(candidates: &[Candidate]) -> Option { let eligible: Vec<_> = candidates .iter() diff --git a/crates/iran-split-netstats/src/macos.rs b/crates/iran-split-netstats/src/macos.rs new file mode 100644 index 0000000..6c25040 --- /dev/null +++ b/crates/iran-split-netstats/src/macos.rs @@ -0,0 +1,545 @@ +//! Physical adapter counters for the menu bar. +//! +//! `NET_RT_IFLIST2`'s `ifi_ibytes` does not match the 64-bit link total on +//! current Darwin. `IFMIB_IFDATA` / `IFDATA_GENERAL` does, and it is what +//! `netstat -ib` reports. Names, addresses, and routes stay inside this module. +use crate::{select, Candidate, Sample}; +use std::mem::size_of; + +const CTL_NET: i32 = 4; +const PF_LINK: i32 = 18; +const NETLINK_GENERIC: i32 = 0; +const IFMIB_SYSTEM: i32 = 1; +const IFMIB_IFDATA: i32 = 2; +const IFMIB_IFCOUNT: i32 = 1; +const IFDATA_GENERAL: i32 = 1; +const NET_RT_DUMP2: i32 = 7; +const ROUTE_HEADER: usize = 92; +const RTF_UP: u32 = 0x1; +const RTF_HOST: u32 = 0x4; +const RTF_REJECT: u32 = 0x8; +const RTF_BLACKHOLE: u32 = 0x1000; +const RTF_GLOBAL: u32 = 0x4000_0000; +const IFF_UP: u32 = 0x1; +const IFF_LOOPBACK: u32 = 0x8; +const IFF_POINTOPOINT: u32 = 0x10; +const IFF_RUNNING: u32 = 0x40; +const IFM_NMASK: i32 = 0x0000_00e0; +const IFM_ETHER: i32 = 0x0000_0020; +const IFM_IEEE80211: i32 = 0x0000_0080; +const IFM_AVALID: i32 = 0x1; +const IFM_ACTIVE: i32 = 0x2; +/// `_IOWR('i', 56, struct ifmediareq)` for the 44-byte media request. +const SIOCGIFMEDIA: libc::c_ulong = 0xc02c_6938; + +#[repr(C, packed(4))] +struct IfData64 { + kind: u8, + _unused: [u8; 7], + _mtu_metric: [u32; 2], + _before_bytes: [u64; 6], + received: u64, + sent: u64, + _tail: [u8; 48], +} + +#[repr(C)] +struct IfMibData { + name: [u8; 16], + _pcount: u32, + flags: u32, + _queue: [u32; 3], + _filler: [u32; 4], + data: IfData64, +} + +#[repr(C, packed(4))] +struct IfMediaReq { + name: [u8; 16], + current: i32, + _mask: i32, + status: i32, + _active: i32, + _count: i32, + _list: u64, +} + +struct Adapter { + index: u16, + class: &'static str, + up: bool, + sent: u64, + received: u64, +} + +const _: () = assert!(size_of::() == 128); +const _: () = assert!(size_of::() == 180); +const _: () = assert!(size_of::() == 44); + +/// Reads one active hardware adapter without privileges or network changes. +/// +/// # Errors +/// Returns only safe classifications. Interface names and addresses stay private. +pub fn sample() -> Result, &'static str> { + let probe = MediaProbe::open(); + let adapters = read_adapters(&probe)?; + let defaults = read_default_metrics()?; + Ok(choose(&adapters, &defaults)) +} + +fn read_adapters(probe: &MediaProbe) -> Result, &'static str> { + let count = interface_count()?; + let mut adapters = Vec::new(); + for index in 1..=count { + if let Some(adapter) = read_adapter(index, probe)? { + adapters.push(adapter); + } + } + Ok(adapters) +} + +fn interface_count() -> Result { + let mut mib = [ + CTL_NET, + PF_LINK, + NETLINK_GENERIC, + IFMIB_SYSTEM, + IFMIB_IFCOUNT, + ]; + let mut count = 0i32; + let mut len = size_of::(); + sysctl(&mut mib, (&raw mut count).cast(), &raw mut len)?; + if count < 0 { + return Err("interface_table_failed"); + } + Ok(count) +} + +fn read_adapter(index: i32, probe: &MediaProbe) -> Result, &'static str> { + let mut mib = [ + CTL_NET, + PF_LINK, + NETLINK_GENERIC, + IFMIB_IFDATA, + index, + IFDATA_GENERAL, + ]; + let mut row = zero_mib(); + let mut len = size_of::(); + if let Err(error) = sysctl(&mut mib, (&raw mut row).cast(), &raw mut len) { + if error == "interface_missing" { + return Ok(None); + } + return Err(error); + } + if len < size_of::() { + return Err("interface_table_failed"); + } + let Some(name) = c_name(&row.name) else { + return Ok(None); + }; + if is_virtual(name) || row.flags & (IFF_LOOPBACK | IFF_POINTOPOINT) != 0 { + return Ok(None); + } + let media = probe.media(name); + let Some(class) = media + .as_ref() + .map(|media| media.current) + .and_then(media_class) + .or_else(|| type_class(row.data.kind)) + else { + return Ok(None); + }; + let up = link_up(row.flags, media.map(|media| media.status)); + let index = u16::try_from(index).map_err(|_| "interface_table_failed")?; + Ok(Some(Adapter { + index, + class, + up, + sent: row.data.sent, + received: row.data.received, + })) +} + +fn read_default_metrics() -> Result, &'static str> { + let mut metrics = Vec::new(); + for family in [libc::AF_INET, libc::AF_INET6] { + let mut mib = [CTL_NET, libc::PF_ROUTE, 0, family, NET_RT_DUMP2, 0]; + let table = sysctl_vec(&mut mib)?; + for (index, metric) in parse_default_metrics(&table)? { + remember(&mut metrics, index, metric); + } + } + Ok(metrics) +} + +fn choose(adapters: &[Adapter], defaults: &[(u16, u64)]) -> Option { + let candidates: Vec = adapters + .iter() + .filter(|adapter| adapter.up) + .map(|adapter| Candidate { + sample: Sample { + identity: u64::from(adapter.index), + class: adapter.class, + sent: adapter.sent, + received: adapter.received, + }, + hardware: true, + up: true, + default_metric: defaults + .iter() + .find(|(index, _)| *index == adapter.index) + .map(|(_, metric)| *metric), + }) + .collect(); + select(&candidates) +} + +fn remember(metrics: &mut Vec<(u16, u64)>, index: u16, metric: u64) { + if let Some(existing) = metrics.iter_mut().find(|(id, _)| *id == index) { + existing.1 = existing.1.min(metric); + } else { + metrics.push((index, metric)); + } +} + +fn parse_default_metrics(table: &[u8]) -> Result, &'static str> { + let mut metrics = Vec::new(); + let mut offset = 0; + while offset + 4 <= table.len() { + let msglen = usize::from(u16::from_ne_bytes([table[offset], table[offset + 1]])); + if msglen < 4 || offset.saturating_add(msglen) > table.len() { + return Err("route_table_invalid"); + } + if msglen >= ROUTE_HEADER { + let index = u16::from_ne_bytes([table[offset + 4], table[offset + 5]]); + let mut flag_bytes = [0u8; 4]; + flag_bytes.copy_from_slice(&table[offset + 8..offset + 12]); + let flags = u32::from_ne_bytes(flag_bytes); + let destination = &table[offset + ROUTE_HEADER..offset + msglen]; + if usable_default(flags) && destination_is_default(destination) { + let metric = if flags & RTF_GLOBAL == 0 { + 10_000 + u64::from(index) + } else { + 0 + }; + remember(&mut metrics, index, metric); + } + } + offset += msglen; + } + Ok(metrics) +} + +fn usable_default(flags: u32) -> bool { + flags & RTF_UP != 0 && flags & (RTF_HOST | RTF_REJECT | RTF_BLACKHOLE) == 0 +} + +fn destination_is_default(bytes: &[u8]) -> bool { + if bytes.len() < 2 { + return false; + } + let len = usize::from(bytes[0]); + if len < 2 || len > bytes.len() { + return false; + } + match bytes[1] { + 2 if len >= 8 => bytes[4..8].iter().all(|byte| *byte == 0), + 30 if len >= 24 => bytes[8..24].iter().all(|byte| *byte == 0), + _ => false, + } +} + +fn is_virtual(name: &str) -> bool { + const PREFIXES: &[&str] = &[ + "lo", "gif", "stf", "anpi", "ap", "awdl", "llw", "bridge", "utun", "ipsec", "ppp", "pktap", + "vmnet", "vnic", "feth", "tap", "tun", "xhc", + ]; + PREFIXES.iter().any(|prefix| { + name.strip_prefix(prefix) + .is_some_and(|rest| rest.is_empty() || rest.bytes().all(|byte| byte.is_ascii_digit())) + }) +} + +fn media_class(current: i32) -> Option<&'static str> { + match current & IFM_NMASK { + IFM_IEEE80211 => Some("wifi"), + IFM_ETHER => Some("ethernet"), + _ => None, + } +} + +fn type_class(kind: u8) -> Option<&'static str> { + match kind { + 71 => Some("wifi"), + 6 => Some("ethernet"), + _ => None, + } +} + +fn link_up(flags: u32, media_status: Option) -> bool { + if flags & IFF_UP == 0 { + return false; + } + match media_status { + Some(status) if status & IFM_AVALID != 0 => status & IFM_ACTIVE != 0, + _ => flags & IFF_RUNNING != 0, + } +} + +fn c_name(bytes: &[u8]) -> Option<&str> { + let end = bytes + .iter() + .position(|byte| *byte == 0) + .unwrap_or(bytes.len()); + let name = std::str::from_utf8(&bytes[..end]).ok()?; + (!name.is_empty()).then_some(name) +} + +struct Media { + current: i32, + status: i32, +} + +struct MediaProbe { + fd: i32, +} + +impl MediaProbe { + fn open() -> Self { + let fd = unsafe { libc::socket(libc::AF_INET, libc::SOCK_DGRAM, 0) }; + Self { fd } + } + + fn media(&self, name: &str) -> Option { + if self.fd < 0 { + return None; + } + let mut request = IfMediaReq { + name: [0; 16], + current: 0, + _mask: 0, + status: 0, + _active: 0, + _count: 0, + _list: 0, + }; + let bytes = name.as_bytes(); + if bytes.len() >= request.name.len() { + return None; + } + request.name[..bytes.len()].copy_from_slice(bytes); + let rc = unsafe { libc::ioctl(self.fd, SIOCGIFMEDIA, &raw mut request) }; + if rc < 0 { + return None; + } + Some(Media { + current: request.current, + status: request.status, + }) + } +} + +impl Drop for MediaProbe { + fn drop(&mut self) { + if self.fd < 0 { + return; + } + // `Drop` cannot report the error. Retry an interrupted close, then + // abandon the descriptor; the next sample opens a new socket. + while unsafe { libc::close(self.fd) } < 0 { + if std::io::Error::last_os_error().kind() != std::io::ErrorKind::Interrupted { + break; + } + } + } +} + +fn zero_mib() -> IfMibData { + IfMibData { + name: [0; 16], + _pcount: 0, + flags: 0, + _queue: [0; 3], + _filler: [0; 4], + data: IfData64 { + kind: 0, + _unused: [0; 7], + _mtu_metric: [0; 2], + _before_bytes: [0; 6], + received: 0, + sent: 0, + _tail: [0; 48], + }, + } +} + +fn sysctl(mib: &mut [i32], old: *mut libc::c_void, oldlen: *mut usize) -> Result<(), &'static str> { + let rc = unsafe { + libc::sysctl( + mib.as_mut_ptr(), + u32::try_from(mib.len()).unwrap_or(u32::MAX), + old, + oldlen, + std::ptr::null_mut(), + 0, + ) + }; + if rc == 0 { + Ok(()) + } else if std::io::Error::last_os_error().raw_os_error() == Some(libc::ENOENT) { + Err("interface_missing") + } else { + Err("interface_table_failed") + } +} + +fn sysctl_vec(mib: &mut [i32]) -> Result, &'static str> { + for _ in 0..2 { + let mut len = 0usize; + let rc = unsafe { + libc::sysctl( + mib.as_mut_ptr(), + u32::try_from(mib.len()).unwrap_or(u32::MAX), + std::ptr::null_mut(), + &raw mut len, + std::ptr::null_mut(), + 0, + ) + }; + if rc != 0 { + return Err("route_table_failed"); + } + let mut buffer = vec![0u8; len]; + let rc = unsafe { + libc::sysctl( + mib.as_mut_ptr(), + u32::try_from(mib.len()).unwrap_or(u32::MAX), + buffer.as_mut_ptr().cast(), + &raw mut len, + std::ptr::null_mut(), + 0, + ) + }; + if rc == 0 { + buffer.truncate(len); + return Ok(buffer); + } + if std::io::Error::last_os_error().raw_os_error() != Some(libc::ENOMEM) { + return Err("route_table_failed"); + } + } + Err("route_table_failed") +} + +#[cfg(test)] +mod tests { + use super::*; + + fn sockaddr_v4(addr: [u8; 4]) -> [u8; 8] { + let mut bytes = [0u8; 8]; + bytes[0] = 8; + bytes[1] = 2; + bytes[4..].copy_from_slice(&addr); + bytes + } + + fn sockaddr_v6(addr: [u8; 16]) -> [u8; 28] { + let mut bytes = [0u8; 28]; + bytes[0] = 28; + bytes[1] = 30; + bytes[8..24].copy_from_slice(&addr); + bytes + } + + fn route(index: u16, flags: u32, destination: &[u8]) -> Vec { + let msglen = ROUTE_HEADER + destination.len(); + let mut bytes = vec![0u8; msglen]; + bytes[..2].copy_from_slice(&u16::try_from(msglen).unwrap().to_ne_bytes()); + bytes[4..6].copy_from_slice(&index.to_ne_bytes()); + bytes[8..12].copy_from_slice(&flags.to_ne_bytes()); + bytes[ROUTE_HEADER..].copy_from_slice(destination); + bytes + } + + fn adapter(index: u16, class: &'static str, up: bool) -> Adapter { + Adapter { + index, + class, + up, + sent: 14_000, + received: 13_000, + } + } + + #[test] + fn virtual_names_and_media_separate_wifi_from_tunnels() { + assert!(is_virtual("utun9")); + assert!(is_virtual("awdl0")); + assert!(is_virtual("bridge0")); + assert!(is_virtual("anpi1")); + assert!(is_virtual("ap1")); + assert!(!is_virtual("en0")); + assert!(!is_virtual("en10")); + assert_eq!(media_class(0x80), Some("wifi")); + assert_eq!(media_class(0x0010_0020), Some("ethernet")); + assert_eq!(media_class(0x2), None); + assert!(link_up(0x8863, Some(0x3))); + assert!(!link_up(0x8863, Some(0x1))); + assert!(!link_up(0x8862, None)); + assert!(link_up(0x8863, None)); + } + + #[test] + fn global_default_beats_a_tunnel_and_a_host_route() { + let mut table = route(2, RTF_UP | RTF_GLOBAL, &sockaddr_v4([0, 0, 0, 0])); + table.extend(route(14, RTF_UP, &sockaddr_v4([0, 0, 0, 0]))); + table.extend(route( + 9, + RTF_UP | RTF_HOST | RTF_GLOBAL, + &sockaddr_v4([0, 0, 0, 0]), + )); + table.extend(route(3, RTF_UP | RTF_REJECT, &sockaddr_v4([0, 0, 0, 0]))); + table.extend(route( + 4, + RTF_UP | RTF_GLOBAL, + &sockaddr_v4([192, 168, 1, 1]), + )); + let mut v6 = [0u8; 16]; + v6[15] = 1; + table.extend(route(5, RTF_UP | RTF_GLOBAL, &sockaddr_v6(v6))); + table.extend(route(7, RTF_UP | RTF_GLOBAL, &sockaddr_v6([0; 16]))); + let metrics = parse_default_metrics(&table).unwrap(); + assert_eq!(metrics, [(2, 0), (14, 10_014), (7, 0)]); + assert_eq!( + parse_default_metrics(&[1, 0, 0, 0]), + Err("route_table_invalid") + ); + } + + #[test] + fn active_link_with_the_default_route_is_the_only_sample() { + let adapters = [ + adapter(4, "ethernet", false), + adapter(14, "wifi", true), + adapter(8, "ethernet", true), + ]; + let chosen = choose(&adapters, &[(14, 0), (8, 10_008)]).unwrap(); + assert_eq!(chosen.identity, 14); + assert_eq!(chosen.class, "wifi"); + assert_eq!(chosen.sent, 14_000); + assert_eq!(choose(&adapters, &[]), None); + assert_eq!( + choose(&[adapter(14, "wifi", true)], &[]).unwrap().identity, + 14 + ); + } + + #[test] + fn host_snapshot_is_readable_without_elevation() { + let result = sample().expect("macOS physical interface snapshot"); + if let Some(sample) = result { + assert!(matches!(sample.class, "wifi" | "ethernet")); + } + } +} diff --git a/crates/iran-split-platform-linux/src/lib.rs b/crates/iran-split-platform-linux/src/lib.rs index c568f2f..979354b 100644 --- a/crates/iran-split-platform-linux/src/lib.rs +++ b/crates/iran-split-platform-linux/src/lib.rs @@ -1734,10 +1734,16 @@ impl PlatformBackend for LinuxBackend { async fn clear_hiddify_system_proxy(&self) -> Result { let config = self.config.read().await.clone(); let persist = system_proxy::snapshot_path(&self.paths.user_data_dir); - let (host, port) = config.hiddify_endpoint(); - system_proxy::clear_if_hiddify(&host, port, &persist) - .await - .map(|cleared| cleared.is_some()) + let mut cleared = false; + for (host, port) in config.local_proxy_endpoints() { + if system_proxy::clear_if_hiddify(&host, port, &persist) + .await? + .is_some() + { + cleared = true; + } + } + Ok(cleared) } async fn restore_hiddify_system_proxy(&self) -> Result<(), CoreError> { diff --git a/crates/iran-split-platform-linux/src/system_proxy.rs b/crates/iran-split-platform-linux/src/system_proxy.rs index 6c3d514..25336e4 100644 --- a/crates/iran-split-platform-linux/src/system_proxy.rs +++ b/crates/iran-split-platform-linux/src/system_proxy.rs @@ -20,17 +20,19 @@ pub fn points_at_hiddify(snapshot: &Snapshot, host: &str, port: u16) -> bool { if matches!(snapshot.mode.as_str(), "none" | "0" | "") { return false; } - let needle = format!("{host}:{port}"); - for candidate in [ - format!("{}:{}", snapshot.http_host, snapshot.http_port), - format!("{}:{}", snapshot.https_host, snapshot.https_port), - format!("{}:{}", snapshot.socks_host, snapshot.socks_port), - ] { - if candidate == needle { - return true; - } - } - false + iran_split_core::proxy_endpoint_matches(&snapshot.http_host, &snapshot.http_port, host, port) + || iran_split_core::proxy_endpoint_matches( + &snapshot.https_host, + &snapshot.https_port, + host, + port, + ) + || iran_split_core::proxy_endpoint_matches( + &snapshot.socks_host, + &snapshot.socks_port, + host, + port, + ) } pub fn snapshot_path(user_data_dir: &Path) -> PathBuf { @@ -371,6 +373,16 @@ mod tests { }; assert!(points_at_hiddify(&snapshot, "127.0.0.1", 12334)); assert!(!points_at_hiddify(&snapshot, "10.0.0.1", 8080)); + let localhost = Snapshot { + mode: "manual".into(), + http_host: "localhost".into(), + http_port: "10808".into(), + https_host: String::new(), + https_port: "0".into(), + socks_host: String::new(), + socks_port: "0".into(), + }; + assert!(points_at_hiddify(&localhost, "127.0.0.1", 10808)); } #[test] diff --git a/crates/iran-split-platform-macos/src/lib.rs b/crates/iran-split-platform-macos/src/lib.rs index eba41f8..77582e0 100644 --- a/crates/iran-split-platform-macos/src/lib.rs +++ b/crates/iran-split-platform-macos/src/lib.rs @@ -1835,10 +1835,16 @@ impl PlatformBackend for MacosBackend { async fn clear_hiddify_system_proxy(&self) -> Result { let config = self.config.read().await.clone(); let persist = system_proxy::snapshot_path(&self.paths.user_data_dir); - let (host, port) = config.hiddify_endpoint(); - system_proxy::clear_if_hiddify(&host, port, &persist) - .await - .map(|cleared| cleared.is_some()) + let mut cleared = false; + for (host, port) in config.local_proxy_endpoints() { + if system_proxy::clear_if_hiddify(&host, port, &persist) + .await? + .is_some() + { + cleared = true; + } + } + Ok(cleared) } async fn restore_hiddify_system_proxy(&self) -> Result<(), CoreError> { diff --git a/crates/iran-split-platform-macos/src/system_proxy.rs b/crates/iran-split-platform-macos/src/system_proxy.rs index 3db6803..3c2a1e6 100644 --- a/crates/iran-split-platform-macos/src/system_proxy.rs +++ b/crates/iran-split-platform-macos/src/system_proxy.rs @@ -46,13 +46,28 @@ pub fn points_at_hiddify(snapshot: &Snapshot, host: &str, port: u16) -> bool { if matches!(snapshot.mode.as_str(), "none" | "") { return false; } - let needle = format!("{host}:{port}"); snapshot.services.iter().any(|service| { - service.web_enabled && format!("{}:{}", service.http_host, service.http_port) == needle - || service.secure_web_enabled - && format!("{}:{}", service.https_host, service.https_port) == needle - || service.socks_enabled - && format!("{}:{}", service.socks_host, service.socks_port) == needle + (service.web_enabled + && iran_split_core::proxy_endpoint_matches( + &service.http_host, + &service.http_port, + host, + port, + )) + || (service.secure_web_enabled + && iran_split_core::proxy_endpoint_matches( + &service.https_host, + &service.https_port, + host, + port, + )) + || (service.socks_enabled + && iran_split_core::proxy_endpoint_matches( + &service.socks_host, + &service.socks_port, + host, + port, + )) }) } @@ -364,6 +379,11 @@ mod tests { }; assert!(points_at_hiddify(&snapshot, "127.0.0.1", 12334)); assert!(!points_at_hiddify(&snapshot, "10.0.0.1", 8080)); + let localhost = Snapshot { + mode: "manual".into(), + services: vec![service("Wi-Fi", "localhost", "10808")], + }; + assert!(points_at_hiddify(&localhost, "127.0.0.1", 10808)); } #[test] diff --git a/crates/iran-split-platform-win/src/lib.rs b/crates/iran-split-platform-win/src/lib.rs index e1f4925..7f57356 100644 --- a/crates/iran-split-platform-win/src/lib.rs +++ b/crates/iran-split-platform-win/src/lib.rs @@ -1882,10 +1882,16 @@ impl PlatformBackend for WindowsBackend { async fn clear_hiddify_system_proxy(&self) -> Result { let config = self.config.read().await.clone(); let persist = system_proxy::snapshot_path(&self.paths.user_data_dir); - let (host, port) = config.hiddify_endpoint(); - system_proxy::clear_if_hiddify(&host, port, &persist) - .await - .map(|cleared| cleared.is_some()) + let mut cleared = false; + for (host, port) in config.local_proxy_endpoints() { + if system_proxy::clear_if_hiddify(&host, port, &persist) + .await? + .is_some() + { + cleared = true; + } + } + Ok(cleared) } async fn restore_hiddify_system_proxy(&self) -> Result<(), CoreError> { diff --git a/crates/iran-split-platform-win/src/system_proxy.rs b/crates/iran-split-platform-win/src/system_proxy.rs index 5c5f9a6..7cd5577 100644 --- a/crates/iran-split-platform-win/src/system_proxy.rs +++ b/crates/iran-split-platform-win/src/system_proxy.rs @@ -18,7 +18,7 @@ pub fn points_at_hiddify(snapshot: &Snapshot, host: &str, port: u16) -> bool { if snapshot.proxy_enable == 0 { return false; } - snapshot.proxy_server.contains(&format!("{host}:{port}")) + iran_split_core::proxy_server_matches(&snapshot.proxy_server, host, port) } pub fn snapshot_path(user_data_dir: &Path) -> PathBuf { @@ -160,6 +160,14 @@ mod tests { }; assert!(points_at_hiddify(&snapshot, "127.0.0.1", 12334)); assert!(!points_at_hiddify(&snapshot, "10.0.0.1", 8080)); + assert!(points_at_hiddify( + &Snapshot { + proxy_enable: 1, + proxy_server: "localhost:10808".into(), + }, + "127.0.0.1", + 10808 + )); } #[test] diff --git a/crates/iran-split-taskbar/Cargo.toml b/crates/iran-split-taskbar/Cargo.toml index 75cfcbb..9f118b9 100644 --- a/crates/iran-split-taskbar/Cargo.toml +++ b/crates/iran-split-taskbar/Cargo.toml @@ -13,6 +13,32 @@ tracing.workspace = true [dev-dependencies] tracing-subscriber.workspace = true +[target.'cfg(target_os = "macos")'.dependencies] +objc2 = "=0.6.4" +objc2-foundation = { version = "=0.3.2", features = [ + "NSArray", + "NSAttributedString", + "NSDictionary", + "NSObject", + "NSString", +] } +objc2-app-kit = { version = "=0.3.2", default-features = false, features = [ + "NSApplication", + "NSAttributedString", + "NSButton", + "NSColor", + "NSControl", + "NSFont", + "NSImage", + "NSFontDescriptor", + "NSParagraphStyle", + "NSResponder", + "NSView", + "NSWindow", + "objc2-core-foundation", + "std", +] } + [target.'cfg(windows)'.dependencies] windows = { version = "0.61.1", features = [ "Win32_Foundation", diff --git a/crates/iran-split-taskbar/src/lib.rs b/crates/iran-split-taskbar/src/lib.rs index c777937..2189cee 100644 --- a/crates/iran-split-taskbar/src/lib.rs +++ b/crates/iran-split-taskbar/src/lib.rs @@ -1,8 +1,12 @@ //! Native Windows taskbar boundary. No statistics collection or `WebView`. use iran_split_config::SpeedUnit; +#[cfg(target_os = "macos")] +mod macos; #[cfg(windows)] mod native; +#[cfg(target_os = "macos")] +pub use macos::{apply_menu_bar_label, menu_bar_is_main_thread}; #[cfg(windows)] pub use native::{diagnostic_renderer, diagnostic_status, Display, Status}; @@ -88,6 +92,35 @@ pub fn format_rate_in(rate: Option, unit: SpeedUnit) -> String { } } +/// Columns for one auto-scaled rate, a space, and the arrow. `99.9 Kbps` +/// is the longest automatic rate, so normal updates do not resize the item. +const MENU_BAR_COLUMNS: usize = 11; + +/// Upload above download, each line the same width, arrows aligned. +#[must_use] +pub fn menu_bar_label(upload: Option, download: Option, unit: SpeedUnit) -> String { + let upload = format_rate_in(upload, unit); + let download = format_rate_in(download, unit); + let width = MENU_BAR_COLUMNS + .max(aligned_width(&upload)) + .max(aligned_width(&download)); + format!( + "{}\n{}", + align_line('↑', &upload, width), + align_line('↓', &download, width) + ) +} + +fn aligned_width(rate: &str) -> usize { + 2 + rate.chars().count() +} + +fn align_line(arrow: char, rate: &str, width: usize) -> String { + let body = aligned_width(rate); + let pad = width.saturating_sub(body); + format!("{}{rate} {arrow}", " ".repeat(pad)) +} + #[cfg(test)] mod tests { use super::*; @@ -140,6 +173,27 @@ mod tests { } } + #[test] + fn menu_bar_lines_share_one_width_and_keep_the_arrows_aligned() { + let label = menu_bar_label(Some(14_000.0), Some(450_000.0), SpeedUnit::AutoBits); + let lines: Vec<_> = label.lines().collect(); + assert_eq!(lines.len(), 2); + assert_eq!(lines[0].chars().count(), lines[1].chars().count()); + assert_eq!(lines[0].chars().count(), MENU_BAR_COLUMNS); + assert!(lines[0].ends_with("112 Kbps ↑")); + assert!(lines[1].ends_with("3.6 Mbps ↓")); + let quiet = menu_bar_label(None, Some(0.0), SpeedUnit::AutoBits); + let quiet: Vec<_> = quiet.lines().collect(); + assert_eq!(quiet[0].chars().count(), quiet[1].chars().count()); + assert_eq!(quiet[0].chars().count(), lines[0].chars().count()); + assert!(quiet[0].ends_with("— bps ↑")); + assert!(quiet[1].ends_with("0 bps ↓")); + let wide = menu_bar_label(Some(2_000_000_000.0), Some(1.0), SpeedUnit::Bits); + let wide: Vec<_> = wide.lines().collect(); + assert_eq!(wide[0].chars().count(), wide[1].chars().count()); + assert!(wide[0].chars().count() > MENU_BAR_COLUMNS); + } + #[test] fn upload_is_above_download_with_distinct_values() { assert_eq!( diff --git a/crates/iran-split-taskbar/src/macos.rs b/crates/iran-split-taskbar/src/macos.rs new file mode 100644 index 0000000..a6fbb7b --- /dev/null +++ b/crates/iran-split-taskbar/src/macos.rs @@ -0,0 +1,157 @@ +//! Two-line menu bar title. A plain status-item title stays one proportional +//! line, so the item changes width every time the rate changes. +use objc2::runtime::AnyObject; +use objc2::{AnyThread, MainThreadMarker, Message}; +use objc2_app_kit::{ + NSApplication, NSButton, NSColor, NSFont, NSFontAttributeName, NSForegroundColorAttributeName, + NSImage, NSMutableParagraphStyle, NSParagraphStyleAttributeName, NSView, NSWindow, +}; +use objc2_foundation::{NSAttributedString, NSDictionary, NSRect, NSString}; + +/// True when `AppKit` calls are legal on this thread. +#[must_use] +pub fn menu_bar_is_main_thread() -> bool { + MainThreadMarker::new().is_some() +} + +/// Draws `label` on `BiFlow`'s status item. `None` clears the title. +/// +/// Returns whether that status item was found. +#[must_use] +pub fn apply_menu_bar_label(label: Option<&str>) -> bool { + let Some(marker) = MainThreadMarker::new() else { + return false; + }; + let Some(button) = status_button(marker) else { + return false; + }; + let tip = NSString::from_str("BiFlow"); + button.setToolTip(Some(&tip)); + if let Some(label) = label { + let font = NSFont::monospacedSystemFontOfSize_weight(9.0, 0.0); + let style = NSMutableParagraphStyle::new(); + style.setMaximumLineHeight(9.0); + style.setLineSpacing(-2.0); + let text = NSString::from_str(label); + let attrs = attributes(&font, &style, &NSColor::labelColor()); + // SAFETY: the dictionary uses the font, paragraph, and foreground + // attribute names, and each value is the matching AppKit object. + let title = unsafe { + NSAttributedString::initWithString_attributes( + NSAttributedString::alloc(), + &text, + Some(&attrs), + ) + }; + button.setFont(Some(&font)); + button.setAttributedTitle(&title); + show_logo(&button, false); + } else { + button.setTitle(&NSString::from_str("")); + show_logo(&button, true); + } + fit_click_target(&button); + true +} + +fn attributes( + font: &NSFont, + style: &NSMutableParagraphStyle, + color: &NSColor, +) -> objc2::rc::Retained> { + // SAFETY: Apple's attribute-name constants are non-null for the process lifetime. + let keys: [&NSString; 3] = unsafe { + [ + NSFontAttributeName, + NSParagraphStyleAttributeName, + NSForegroundColorAttributeName, + ] + }; + let objects = [object_ref(font), object_ref(style), object_ref(color)]; + NSDictionary::from_slices(&keys, &objects) +} + +fn object_ref(object: &impl Message) -> &AnyObject { + // SAFETY: every `Message` is an Objective-C object, which is the layout + // `AnyObject` describes. The reference stays borrowed from `object`. + unsafe { &*std::ptr::from_ref(object).cast::() } +} + +fn status_button(marker: MainThreadMarker) -> Option> { + let mut buttons = Vec::new(); + for window in windows(marker) { + if let Some(root) = window.contentView() { + collect_buttons(&root, &mut buttons); + } + } + buttons + .iter() + .find(|button| tooltip(button).as_deref() == Some("BiFlow")) + .cloned() + .or_else(|| (buttons.len() == 1).then(|| buttons.remove(0))) +} + +fn windows(marker: MainThreadMarker) -> Vec> { + let list = NSApplication::sharedApplication(marker).windows(); + (0..list.count()) + .map(|index| list.objectAtIndex(index)) + .collect() +} + +fn collect_buttons(view: &NSView, buttons: &mut Vec>) { + let children = view.subviews(); + for index in 0..children.count() { + let child = children.objectAtIndex(index); + if child.class().name() == c"TaoTrayTarget" { + // SAFETY: the overlay is retained for this call and is only read + // on the main thread. Its superview is the status button. + let Some(parent) = (unsafe { child.superview() }) else { + continue; + }; + if let Ok(button) = parent.downcast::() { + buttons.push(button); + } + } else { + collect_buttons(&child, buttons); + } + } +} + +fn tooltip(button: &NSButton) -> Option { + button.toolTip().map(|tip| tip.to_string()) +} + +fn show_logo(button: &NSButton, visible: bool) { + std::thread_local! { + static MENU_BAR_LOGO: std::cell::RefCell>> = + const { std::cell::RefCell::new(None) }; + } + if visible { + let restored = MENU_BAR_LOGO.with(|logo| logo.borrow().clone()); + if let Some(image) = restored { + button.setImage(Some(&image)); + } + return; + } + if let Some(image) = button.image() { + MENU_BAR_LOGO.with(|logo| *logo.borrow_mut() = Some(image)); + } + button.setImage(None); +} + +fn fit_click_target(button: &NSButton) { + // A leftover overlay frame can keep `sizeToFit` from shrinking the item. + place_overlay(button, NSRect::ZERO); + button.sizeToFit(); + place_overlay(button, button.bounds()); +} + +fn place_overlay(button: &NSButton, frame: NSRect) { + let children = button.subviews(); + for index in 0..children.count() { + let child = children.objectAtIndex(index); + if child.class().name() == c"TaoTrayTarget" { + child.setFrame(frame); + } + } +} diff --git a/docs/DESIGN.md b/docs/DESIGN.md index 0771482..230fad8 100644 --- a/docs/DESIGN.md +++ b/docs/DESIGN.md @@ -15,7 +15,7 @@ rule has to change, update this file in the same commit. (`pageTabs`, `setPage(page, tab)`): - Routing: Sites / Lists / Apps / Iran rules - Troubleshoot: Live / Test / Tools / Logs - - Settings: Network / Behavior / Speed display (Windows/Linux) / About + - Settings: Network / Behavior / Speed display (Windows/Linux/macOS) / About - Every page starts with `PageHeader`: one-line title, optional info tip, and primary action on the same row. @@ -115,8 +115,13 @@ border-ink/10 pt-3`, with small buttons download beside the existing BiFlow tray icon in the top panel. Use the native AppIndicator label, inherit the panel theme, and clear the label when disabled (ADR 0118). Linux settings call it the panel rather than the Windows taskbar. +- On macOS, the same preference and sampler show upload above download, with + the arrow on the right of each rate, in a fixed 11-column monospaced title. + Hide the menu bar icon while speeds are shown and restore it when disabled. + Inherit the menu bar theme, and clear the title when disabled (ADR 0122). + macOS settings call it the menu bar. - Settings → Speed display contains only the enable toggle and unit selector. - The saved unit applies immediately to both native renderers. Bits/bytes and + The saved unit applies immediately to all native renderers. Bits/bytes and their kilo/mega/giga scales are decimal (1000); legacy settings default to automatic bits per second (ADR 0119). diff --git a/docs/adr/0049-state-aware-tray-menu.md b/docs/adr/0049-state-aware-tray-menu.md index 51d52aa..e96b542 100644 --- a/docs/adr/0049-state-aware-tray-menu.md +++ b/docs/adr/0049-state-aware-tray-menu.md @@ -27,4 +27,5 @@ the labels did not follow the live stack phase. - Open and About remain available from the window; left-click still shows the main window. -- Quit exits the UI without disconnecting, matching the previous Quit UI item. +- Quit releases the owned tunnel, system DNS, and loopback system proxy before + the process exits, so the machine keeps a working network (ADR 0120). diff --git a/docs/adr/0119-speed-display-settings.md b/docs/adr/0119-speed-display-settings.md index 0a7ef61..9407dd1 100644 --- a/docs/adr/0119-speed-display-settings.md +++ b/docs/adr/0119-speed-display-settings.md @@ -4,7 +4,8 @@ Status: Accepted (6.2.66) Move the existing taskbar/panel toggle from Behavior into Settings → Speed display on Windows and Linux. This tab contains only the enable toggle and unit selector. -Other platforms preserve both preferences without exposing an unsupported tab. +macOS uses the same tab (ADR 0122). Platforms without a native speed surface +preserve both preferences without exposing the tab. Reuse the shared tab/form/save flow and English/Persian localization. Persist `behavior.taskbar_speed_unit` as a typed `SpeedUnit` with serde defaults. diff --git a/docs/adr/0120-release-network-on-quit.md b/docs/adr/0120-release-network-on-quit.md new file mode 100644 index 0000000..6cd2a4f --- /dev/null +++ b/docs/adr/0120-release-network-on-quit.md @@ -0,0 +1,46 @@ +# 0120: Release the network when BiFlow is not in use + +## Status + +Accepted + +## Context + +Quit left Mihomo running. The privileged helper owns that process with +`kill_on_drop(false)`, and the desktop owns the local proxy client. Closing +the app therefore dropped the proxy while the TUN kept capturing every +packet. macOS system DNS stayed on `127.0.0.1`, and a system proxy left on +`localhost` or another client port kept sending browsers at a closed socket. +The same machine worked again only after BiFlow was opened and connected. + +Disconnect could also skip TUN cleanup when clearing the system proxy +failed, and `SIGKILL` did not let Mihomo remove its own routes. Fake-ip +answers in `198.18.0.0/16` stayed cached after the tunnel was gone. + +## Decision + +- Tray Quit and every other non-update exit run Disconnect to completion + before the process leaves: stop Mihomo, stop an owned local proxy, clear + every managed loopback system proxy, restore DNS, and delete the TUN. +- An in-app update still only pauses, so Hiddify can keep running while the + package is replaced. +- `localhost`, `127.0.0.1`, and `::1` are one loopback host. Clear a system + proxy when it points at any configured local-proxy client, including a + disabled one. Leave a non-loopback corporate proxy alone. +- A system-proxy clear failure is logged and does not skip TUN cleanup. +- Stop sends `SIGTERM` (and `taskkill` without `/F` on Windows), waits two + seconds, then force-kills. Cleanup then flushes the OS DNS cache. +- If the desktop has not contacted the helper for 90 seconds and a capture + is still up, the helper releases the network itself. Health refresh is + every 10 seconds, so a running app is not treated as gone. A command in + flight is never interrupted. + +## Consequences + +- Internet works again after Quit, Disconnect, or a crash, without opening + BiFlow. +- The absence watch needs the updated helper. Quit already uses the existing + stop and cleanup commands, so the first exit after an upgrade restores the + network even before Connect installs that helper. +- Native DNS-flush tools still have to be proven on each OS; a missing tool + does not fail cleanup. diff --git a/docs/adr/0121-restore-dns-without-loopback.md b/docs/adr/0121-restore-dns-without-loopback.md new file mode 100644 index 0000000..894a5ee --- /dev/null +++ b/docs/adr/0121-restore-dns-without-loopback.md @@ -0,0 +1,44 @@ +# 0121: Restore macOS DNS without writing the tunnel resolver back + +## Status + +Accepted + +## Context + +Disconnect on macOS reported a successful DNS restore and then every site +failed. Mihomo had exited, so nothing answered `127.0.0.1:53`, but the +system resolver was still that address. A direct TCP connection to an IP +could succeed while `google.com` could not. + +The helper stores each network service's DNS in `system-dns.toml` before +pointing those services at `127.0.0.1`. Restore writes that file back and +deletes it. If a previous session captured `127.0.0.1` (the resolver was +already ours, or `networksetup` reported success while `scutil --dns` stayed +on the tunnel), the next disconnect installs `127.0.0.1` again and the +following connect saves it once more. Split routes via `198.18.0.1` on the +TUN can also outlive the process when the child handle was lost or Mihomo +did not remove them. + +## Decision + +- A loopback address is never a saved resolver. Capture and restore drop + `127.0.0.0/8` and `::1`. A service left with no servers is restored with + `networksetup` `Empty` (DHCP). +- After that write, read `scutil --dns` and each service. If the live + resolver is still loopback, set every restored service to the private IPv4 + default gateway. Do not use `198.18.0.1` or a public gateway. If no private + gateway exists, keep the recovery file and report the failure. +- Delete the recovery file only after the live resolver is not loopback. +- On stop, kill a scoped Mihomo orphan (the same command-line match as + start) and delete `netstat` routes whose interface is this helper's TUN, + including a default route that still points at that interface. Leave the + physical interface's default route in place. + +## Consequences + +- Disconnect returns name lookup to DHCP, or to the LAN gateway when DHCP + does not clear a stuck `127.0.0.1`. +- A custom resolver that was only `127.0.0.1` before Connect is restored as + DHCP. A saved non-loopback resolver is kept. +- The gateway address is not written to `helper.log`. diff --git a/docs/adr/0122-macos-menu-bar-speed.md b/docs/adr/0122-macos-menu-bar-speed.md new file mode 100644 index 0000000..7043c56 --- /dev/null +++ b/docs/adr/0122-macos-menu-bar-speed.md @@ -0,0 +1,44 @@ +# ADR 0122: Physical adapter speeds in the macOS menu bar + +Status: Accepted (6.2.72) + +macOS already has a status item for BiFlow. Reuse that tray, menu, and +lifecycle rather than adding a second status item. Draw upload above download +with the arrow on the right of each rate, in an 11-column monospaced title, so +the item keeps one width while the digits change. Eleven columns fit the +longest automatic rate (`99.9 Kbps`). A longer fixed unit widens both lines +together. Hide the status icon while the speeds are shown, and put it back +when the title is cleared. The shared formatter and the saved +`show_taskbar_speed` / `taskbar_speed_unit` preferences supply the rates. +Settings → Speed display is available on macOS and labels the toggle “Show +network speed in menu bar”. It is off by default. Disabling clears only the +title and keeps the menu. + +`TrayIcon::set_title` draws one proportional line and does not clear a title +when passed `None`. The menu bar path therefore sets an attributed title on +the existing status button: 9-point monospaced type, `labelColor`, and a +9-point line height so both lines fit the menu bar. The button is the +`TaoTrayTarget` superview. After each change the click overlay is resized to +the button. If that button cannot be found, the same text is submitted with +`set_title` and the snapshot records `plain_title`. Ubuntu keeps the +single-line `AppIndicator` label. + +Read counters from `IFMIB_IFDATA` / `IFDATA_GENERAL`. `NET_RT_IFLIST2`'s +`if_data64.ifi_ibytes` does not match the 64-bit link total `netstat -ib` +reports on current Darwin, so that source must not drive the label. Wi-Fi +interfaces report `IFT_ETHER`; classify them with `SIOCGIFMEDIA` +(`IFM_IEEE80211` versus `IFM_ETHER`) and treat a link as up only when +`IFM_ACTIVE` is set. Exclude virtual prefixes (`utun`, `awdl`, `bridge`, +`anpi`, and the other tunnel and private interfaces) even when their flags +look up. Choose the active hardware adapter that owns the global default +route. No names, addresses, or routes leave the provider. + +The one-second sampler, unavailable dashes, and preference application match +Ubuntu. Sampling continues while disconnected and while the main window is +hidden, and it stops on exit. Environment snapshots record `menu_bar_label`, +the adapter class, and whether the title was submitted. `label_set` proves +BiFlow's status button was found and the stacked title was applied. + +Coverage includes virtual-name and media classification, default-route +selection, a host read without elevation, equal-width menu bar lines, macOS +settings unit tests, and the English/Persian speed-save flow. diff --git a/docs/adr/0123-publish-macos-release.md b/docs/adr/0123-publish-macos-release.md new file mode 100644 index 0000000..4efd73d --- /dev/null +++ b/docs/adr/0123-publish-macos-release.md @@ -0,0 +1,22 @@ +# ADR 0123: Publish the macOS disk image with the version release + +Status: Accepted (6.2.72) + +The release workflow built Linux and Windows packages only after a `v*` tag. +Merging to `main` therefore left the macOS disk image with no download, and a +follow-up tag push was easy to forget. + +A push to `main` publishes `v` when that GitHub release is missing or +still a draft. An existing finished release is left alone, so a later commit +that does not change `version` does not rebuild. A `v*` tag that matches the +version file still publishes, including a re-run. The macOS job runs +`./build.sh ci-macos` on `macos-15`, which keeps the headless disk-image +fallback, and the publish job uploads `BiFlow__aarch64.dmg` next to +the signed Linux and Windows packages. + +The disk image is a manual download. It is not an updater target: `latest.json` +still lists only the signed Debian package, AppImage, and NSIS installer. + +The publish job creates the missing tag itself. A tag pushed with the +workflow token does not start another workflow, so the tag cannot be relied on +to launch this release. diff --git a/docs/adr/0124-pr-validation-and-safe-archives.md b/docs/adr/0124-pr-validation-and-safe-archives.md new file mode 100644 index 0000000..29f0478 --- /dev/null +++ b/docs/adr/0124-pr-validation-and-safe-archives.md @@ -0,0 +1,26 @@ +# ADR 0124: Cross-platform PR validation and safe archive dependencies + +Status: Accepted (6.2.73) + +PR 11 failed Linux and Windows Clippy because macOS route parsers were compiled +outside their native caller. Compile those helpers on macOS and in tests only; +the existing route ownership and deletion tests still run on every host. +Run the Rust CI job on macos-15 as well as Linux and Windows so the native menu +bar boundary is checked before merge, rather than first compiling during release. + +RustSec also rejected rustls 0.23.45 and zip 4.6.1. Pin the resolved rustls to +0.23.46 and move the shared ZIP reader to version 9. Keep Rust 1.88 support. + +BiFlow uses tauri-plugin-updater to authenticate metadata and download signed +bytes. Its own github_update module stages and installs the exact native package. +Disable the plugin's unused archive installation feature so its transitive ZIP 4 +reader is absent, while retaining rustls TLS and signature verification. Native +package installation and the signed target names stay unchanged. + +Graceful-stop failures emit structured warnings before the existing bounded +force-stop fallback. Unix ESRCH is an intentional no-op when the child has +already exited between the status read and signal delivery. + +Validate ordinary ZIP extraction and parent traversal rejection alongside the +existing signed-update and cross-platform route tests, then run the full local +CI gate and native CI jobs before merging. diff --git a/docs/adr/README.md b/docs/adr/README.md index 93bdf8f..2378b30 100644 --- a/docs/adr/README.md +++ b/docs/adr/README.md @@ -123,3 +123,8 @@ Keep this index current. Add a new ADR for each non-obvious change, or update th | [0117](./0117-physical-interface-taskbar.md) | Complete physical adapter throughput in a compact tray-side widget | Accepted | | [0118](./0118-ubuntu-panel-speed.md) | Physical adapter speeds beside the Ubuntu tray icon | Accepted | | [0119](./0119-speed-display-settings.md) | Dedicated speed display settings and units | Accepted | +| [0120](./0120-release-network-on-quit.md) | Release the tunnel, DNS, and system proxy when BiFlow is not in use | Accepted | +| [0121](./0121-restore-dns-without-loopback.md) | Restore macOS DNS without writing the tunnel resolver back | Accepted | +| [0122](./0122-macos-menu-bar-speed.md) | Stacked fixed-width menu bar speeds with the icon hidden | Accepted | +| [0123](./0123-publish-macos-release.md) | Publish the macOS disk image when the version reaches main | Accepted | +| [0124](./0124-pr-validation-and-safe-archives.md) | Cross-platform PR validation and safe archive dependencies | Accepted | diff --git a/docs/screenshots/settings-speed.png b/docs/screenshots/settings-speed.png index 1a9e3b5..9d28f28 100644 Binary files a/docs/screenshots/settings-speed.png and b/docs/screenshots/settings-speed.png differ diff --git a/e2e/taskbar-speed.spec.ts b/e2e/taskbar-speed.spec.ts index fec84c1..7e6af5e 100644 --- a/e2e/taskbar-speed.spec.ts +++ b/e2e/taskbar-speed.spec.ts @@ -11,6 +11,11 @@ for (const { platform, label, persian } of [ label: "Show network speed in panel", persian: "نمایش سرعت شبکه در پنل", }, + { + platform: "MacIntel", + label: "Show network speed in menu bar", + persian: "نمایش سرعت شبکه در نوار منو", + }, ]) { test(`${platform} speed preference has a Persian label`, async ({ page }) => { await page.addInitScript((platform) => { diff --git a/package.json b/package.json index 555813e..89c4e46 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "iran-split-desktop-workspace", - "version": "6.2.66", + "version": "6.2.73", "private": true, "packageManager": "pnpm@9.0.1", "engines": { diff --git a/scripts/build-plan.test.mjs b/scripts/build-plan.test.mjs index 99f6e34..533217a 100644 --- a/scripts/build-plan.test.mjs +++ b/scripts/build-plan.test.mjs @@ -481,7 +481,7 @@ describe("release artifact names", () => { assert.match(hook, /run pnpm\.cmd check/); }); - it("publishes deb, appimage, portable exe, and nsis from v* tags only", () => { + it("publishes deb, appimage, portable exe, nsis, and the macOS disk image", () => { const workflow = readFileSync( join(root, ".github/workflows/release.yml"), "utf8", @@ -489,7 +489,12 @@ describe("release artifact names", () => { const ci = readFileSync(join(root, ".github/workflows/ci.yml"), "utf8"); assert.match(workflow, /tags:\s*\[["']v\*["']\]/); - assert.doesNotMatch(workflow, /branches:\s*\[main\]/); + assert.match(workflow, /branches:\s*\[main\]/); + assert.match(workflow, /platform: macos-15/); + assert.match(workflow, /\.\/build\.sh ci-macos/); + assert.match(workflow, /artifacts\/macos\/\*\.dmg/); + assert.match(workflow, /-name '\*\.dmg'/); + assert.match(workflow, /git push origin "refs\/tags\/\$\{tag\}"/); assert.doesNotMatch(workflow, /ref_protected/); assert.match(workflow, /--bundles deb,appimage/); assert.match(workflow, /--bundles nsis/); diff --git a/src-tauri/Cargo.toml b/src-tauri/Cargo.toml index 79f7a9c..0c4c496 100644 --- a/src-tauri/Cargo.toml +++ b/src-tauri/Cargo.toml @@ -37,7 +37,8 @@ tauri-plugin-clipboard-manager = "2.3.2" tauri-plugin-dialog = "2.3.2" tauri-plugin-process = "2.3.0" tauri-plugin-single-instance = "2.2.0" -tauri-plugin-updater = "2.7.1" +# Only verify and download signed bytes; github_update owns package installation. +tauri-plugin-updater = { version = "2.7.1", default-features = false, features = ["rustls-tls"] } tempfile.workspace = true thiserror.workspace = true tokio.workspace = true @@ -58,6 +59,8 @@ iran-split-netstats = { path = "../crates/iran-split-netstats" } [target.'cfg(target_os = "macos")'.dependencies] iran-split-platform-macos = { path = "../crates/iran-split-platform-macos" } +iran-split-taskbar = { path = "../crates/iran-split-taskbar" } +iran-split-netstats = { path = "../crates/iran-split-netstats" } [lints] workspace = true diff --git a/src-tauri/src/deps.rs b/src-tauri/src/deps.rs index 4756d16..7a5d561 100644 --- a/src-tauri/src/deps.rs +++ b/src-tauri/src/deps.rs @@ -838,6 +838,28 @@ fn open_url_allowed(url: &str) -> bool { mod tests { use super::*; + #[test] + fn zip_extraction_keeps_nested_files_inside_destination() { + let mut archive = zip::ZipWriter::new(Cursor::new(Vec::new())); + for name in ["nested/mihomo.exe", "../outside.exe"] { + archive + .start_file(name, zip::write::SimpleFileOptions::default()) + .expect("start ZIP entry"); + archive.write_all(b"payload").expect("write ZIP entry"); + } + let bytes = archive.finish().expect("finish ZIP").into_inner(); + let root = tempfile::tempdir().expect("temporary root"); + let destination = root.path().join("extracted"); + + extract_zip(&bytes, &destination).expect("extract ZIP"); + + assert_eq!( + fs::read(destination.join("nested/mihomo.exe")).expect("nested executable"), + b"payload" + ); + assert!(!root.path().join("outside.exe").exists()); + } + #[test] fn catalogs_only_allow_official_release_hosts() { assert!(url_allowed(&hiddify_linux_appimage_url())); diff --git a/src-tauri/src/environment/mod.rs b/src-tauri/src/environment/mod.rs index 41cb27f..8d4727d 100644 --- a/src-tauri/src/environment/mod.rs +++ b/src-tauri/src/environment/mod.rs @@ -555,6 +555,12 @@ async fn collect(context: CollectContext) -> EnvironmentReport { report.taskbar_adapter = Some(crate::taskbar::adapter_state()); report.taskbar_speed = Some(crate::taskbar::panel_state()); } + #[cfg(target_os = "macos")] + { + report.taskbar_renderer = Some("menu_bar_label"); + report.taskbar_adapter = Some(crate::taskbar::adapter_state()); + report.taskbar_speed = Some(crate::taskbar::panel_state()); + } report.findings = findings(&report, context.config.as_ref()); report.collection_ms = u64::try_from(started.elapsed().as_millis()).unwrap_or(u64::MAX); report diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index bf21da4..fbd9e1d 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -11,7 +11,7 @@ mod platform_paths; mod profile; mod profile_picker; mod reachability; -#[cfg(any(windows, target_os = "linux"))] +#[cfg(any(windows, target_os = "linux", target_os = "macos"))] mod taskbar; mod traffic; mod tray; @@ -26,8 +26,8 @@ use iran_split_config::{ ValidationIssue, }; use iran_split_core::{ - ComponentPhase, Engine, LifecycleBusy, OperationAccepted, PlatformBackend, StackPhase, - StackSnapshot, + ComponentPhase, CoreError, Engine, LifecycleBusy, OperationAccepted, PlatformBackend, + StackPhase, StackSnapshot, }; use iran_split_mihomo::{ActiveConnection, ControllerClient}; use iran_split_rules::{ @@ -1208,7 +1208,7 @@ async fn save_settings( "saved settings" ); services.backend.update_config(saved.clone()).await; - #[cfg(any(windows, target_os = "linux"))] + #[cfg(any(windows, target_os = "linux", target_os = "macos"))] taskbar::apply_preferences( &app, saved.behavior.show_taskbar_speed, @@ -2823,6 +2823,10 @@ async fn restore_stack_after_failed_install(services: &AppServices) { } fn schedule_update_exit(app: &AppHandle, operation_id: Uuid) -> OperationAccepted { + // The installer already paused the tunnel. Quitting for an update must not + // run the full disconnect, which would stop Hiddify before the package + // replaces the app (ADR 0025, ADR 0120). + UPDATE_EXIT_KEEPS_NETWORK.store(true, Ordering::SeqCst); diagnostics::flush(); let exit_app = app.clone(); tauri::async_runtime::spawn(async move { @@ -3600,8 +3604,8 @@ fn handle_tray_menu(app: &AppHandle, event: &MenuEvent) { section = "lifecycle", initiator = "tray_menu", cause = "quit_selected", - trace_route = "tray_menu->application_exit", - "quit requested without disconnect" + trace_route = "tray_menu->application_exit->stop_stack", + "quit requested; the owned tunnel, DNS, and system proxy will be released" ); app.exit(0); } @@ -3893,7 +3897,7 @@ fn setup_application(app: &mut tauri::App) -> Result<(), Box bool { + !update_exit +} + +fn handle_run_event(handle: &AppHandle, event: &tauri::RunEvent) { match event { - tauri::RunEvent::ExitRequested { code, .. } => diagnostics::exit_requested(*code), + tauri::RunEvent::ExitRequested { api, code, .. } => { + diagnostics::exit_requested(*code); + if !exit_releases_owned_network(UPDATE_EXIT_KEEPS_NETWORK.load(Ordering::SeqCst)) { + return; + } + if NETWORK_RELEASE_STARTED.swap(true, Ordering::SeqCst) { + return; + } + api.prevent_exit(); + let app = handle.clone(); + tauri::async_runtime::spawn(async move { + release_owned_network(&app).await; + app.exit(0); + }); + } tauri::RunEvent::Exit => { - #[cfg(any(windows, target_os = "linux"))] + #[cfg(any(windows, target_os = "linux", target_os = "macos"))] taskbar::shutdown(handle); diagnostics::close_session(); } @@ -4149,6 +4173,63 @@ fn handle_run_event( } } +async fn release_owned_network(app: &AppHandle) { + let Ok(services) = services(app) else { + return; + }; + info!( + event = "session.network_release_started", + section = "lifecycle", + initiator = "application_exit", + cause = "quit", + trace_route = "application_exit->stop_stack", + "releasing the tunnel, system DNS, and system proxy before exit" + ); + let engine = &services.engine; + if let Some(operation_id) = engine.snapshot().operation_id { + engine.cancel_operation(operation_id).await; + } + let deadline = std::time::Instant::now() + Duration::from_secs(50); + loop { + match engine.stop_stack().await { + Ok(accepted) => { + if !accepted.already_complete { + let remaining = deadline.saturating_duration_since(std::time::Instant::now()); + if let Err(cause) = engine + .wait_for_phase(StackPhase::Stopped, remaining.max(Duration::from_secs(1))) + .await + { + warn!( + event = "session.network_release_failed", + section = "lifecycle", + initiator = "application_exit", + cause = %cause, + trace_route = "application_exit->stop_stack", + "quit continued after the network release did not finish" + ); + } + } + break; + } + Err(CoreError::OperationInProgress) if std::time::Instant::now() < deadline => { + tokio::time::sleep(Duration::from_millis(200)).await; + } + Err(cause) => { + warn!( + event = "session.network_release_failed", + section = "lifecycle", + initiator = "application_exit", + cause = %cause, + trace_route = "application_exit->stop_stack", + "quit continued without a completed disconnect" + ); + break; + } + } + } + diagnostics::flush(); +} + #[cfg(test)] mod tests { use super::*; @@ -4157,6 +4238,12 @@ mod tests { DEV_PROFILE_VAR, DEV_SYSTEM_RUNTIME_VAR, }; + #[test] + fn quit_releases_the_network_and_an_update_exit_does_not() { + assert!(exit_releases_owned_network(false)); + assert!(!exit_releases_owned_network(true)); + } + /// Regression (ADR 0115): the application's data roots and the /// diagnostics log came from two independent reconstructions of /// `BIFLOW_DEV_PROFILE`. Assert they are now one policy, and that a diff --git a/src-tauri/src/taskbar.rs b/src-tauri/src/taskbar.rs index 6d20f31..5bb0792 100644 --- a/src-tauri/src/taskbar.rs +++ b/src-tauri/src/taskbar.rs @@ -19,7 +19,7 @@ pub struct Taskbar { unit: Mutex, } static ADAPTER_STATE: Mutex<&'static str> = Mutex::new("disabled"); -#[cfg(target_os = "linux")] +#[cfg(any(target_os = "linux", target_os = "macos"))] static PANEL_STATE: Mutex<&'static str> = Mutex::new("disabled"); pub fn adapter_state() -> &'static str { @@ -105,7 +105,7 @@ pub fn apply_preferences(app: &AppHandle, enabled: bool, unit: Sp display.set_unit(unit); display.set_enabled(enabled); } - #[cfg(target_os = "linux")] + #[cfg(any(target_os = "linux", target_os = "macos"))] update_panel(app, enabled, None, unit); tracing::info!( event = "taskbar.preference_applied", @@ -171,7 +171,7 @@ fn spawn_interface_sampler(app: AppHandle) { rates.map(|rates| rates.download), ); }; - #[cfg(target_os = "linux")] + #[cfg(any(target_os = "linux", target_os = "macos"))] { let unit = *state.unit.lock().expect("speed unit"); update_panel(&app, state.enabled.load(Ordering::Acquire), rates, unit); @@ -217,7 +217,7 @@ pub fn shutdown(app: &AppHandle) { state.enabled.store(false, Ordering::Release); #[cfg(windows)] drop(state.display.lock().expect("taskbar state").take()); - #[cfg(target_os = "linux")] + #[cfg(any(target_os = "linux", target_os = "macos"))] { let unit = *state.unit.lock().expect("speed unit"); update_panel(app, false, None, unit); @@ -225,11 +225,16 @@ pub fn shutdown(app: &AppHandle) { } } -#[cfg(target_os = "linux")] +#[cfg(any(target_os = "linux", target_os = "macos"))] pub fn panel_state() -> &'static str { *PANEL_STATE.lock().expect("panel state") } +#[cfg(target_os = "linux")] +const TRAY_LABEL_ROUTE: &str = "shared_stats->tray_label->appindicator"; +#[cfg(target_os = "macos")] +const TRAY_LABEL_ROUTE: &str = "shared_stats->tray_label->menu_bar"; + #[cfg(target_os = "linux")] fn panel_label( enabled: bool, @@ -245,6 +250,21 @@ fn panel_label( }) } +#[cfg(target_os = "macos")] +fn panel_label( + enabled: bool, + rates: Option, + unit: SpeedUnit, +) -> Option { + enabled.then(|| { + iran_split_taskbar::menu_bar_label( + rates.map(|rates| rates.upload), + rates.map(|rates| rates.download), + unit, + ) + }) +} + #[cfg(target_os = "linux")] fn update_panel( app: &AppHandle, @@ -265,15 +285,105 @@ fn update_panel( }, None => "unavailable", }; + record_panel_state(status); +} + +#[cfg(target_os = "macos")] +fn update_panel( + app: &AppHandle, + enabled: bool, + rates: Option, + unit: SpeedUnit, +) { + let label = panel_label(enabled, rates, unit); + let status = if iran_split_taskbar::menu_bar_is_main_thread() { + menu_bar_submission(label.as_deref()) + } else { + submit_menu_bar(app, label.clone()) + }; + let status = if status == "unavailable" { + plain_menu_bar_title(app, label) + } else { + status + }; + record_panel_state(status); +} + +#[cfg(target_os = "macos")] +fn plain_menu_bar_title(app: &AppHandle, label: Option) -> &'static str { + let enabled = label.is_some(); + match app.tray_by_id("main") { + Some(tray) => match tray.set_title(label) { + Ok(()) => { + if enabled { + "plain_title" + } else { + "disabled" + } + } + Err(_) => "failed", + }, + None => "unavailable", + } +} + +#[cfg(target_os = "macos")] +fn menu_bar_submission(label: Option<&str>) -> &'static str { + if iran_split_taskbar::apply_menu_bar_label(label) { + if label.is_some() { + "label_set" + } else { + "disabled" + } + } else { + "unavailable" + } +} + +#[cfg(target_os = "macos")] +fn submit_menu_bar(app: &AppHandle, label: Option) -> &'static str { + let (sender, receiver) = std::sync::mpsc::sync_channel(1); + let queued = app.run_on_main_thread(move || { + let status = menu_bar_submission(label.as_deref()); + if sender.send(status).is_err() { + tracing::warn!( + event = "taskbar.panel_unavailable", + section = "taskbar", + initiator = "interface_sampler", + cause = "waiter_dropped", + trace_route = TRAY_LABEL_ROUTE, + "menu bar label result had no waiter" + ); + } + }); + if queued.is_err() { + return "failed"; + } + receiver + .recv_timeout(std::time::Duration::from_secs(2)) + .unwrap_or("failed") +} + +#[cfg(any(target_os = "linux", target_os = "macos"))] +fn record_panel_state(status: &'static str) { let mut previous = PANEL_STATE.lock().expect("panel state"); if status != *previous { - if matches!(status, "failed" | "unavailable") { + if status == "plain_title" { tracing::warn!( event = "taskbar.panel_unavailable", section = "taskbar", initiator = "interface_sampler", cause = status, - trace_route = "shared_stats->tray_label->appindicator", + trace_route = TRAY_LABEL_ROUTE, + "menu bar title fell back to a plain label" + ); + } else if matches!(status, "failed" | "unavailable") { + tracing::warn!( + event = "taskbar.panel_unavailable", + section = "taskbar", + initiator = "interface_sampler", + cause = status, + trace_route = TRAY_LABEL_ROUTE, "panel speed label could not be updated" ); } else { @@ -282,7 +392,7 @@ fn update_panel( section = "taskbar", initiator = "interface_sampler", cause = status, - trace_route = "shared_stats->tray_label->appindicator", + trace_route = TRAY_LABEL_ROUTE, "panel speed label state changed" ); } @@ -319,3 +429,29 @@ mod tests { assert_eq!(panel_label(false, rates, SpeedUnit::Megabytes), None); } } + +#[cfg(all(test, target_os = "macos"))] +mod tests { + use super::*; + + #[test] + fn panel_label_stacks_equal_width_lines_and_clears_when_disabled() { + let idle = panel_label(true, None, SpeedUnit::AutoBits).expect("idle label"); + let idle_lines: Vec<_> = idle.lines().collect(); + assert_eq!(idle_lines.len(), 2); + assert_eq!(idle_lines[0].chars().count(), idle_lines[1].chars().count()); + assert!(idle_lines[0].ends_with("— bps ↑")); + assert!(idle_lines[1].ends_with("— bps ↓")); + let rates = Some(iran_split_netstats::Rates { + upload: 14_000.0, + download: 450_000.0, + }); + let live = panel_label(true, rates, SpeedUnit::AutoBits).expect("live label"); + let live_lines: Vec<_> = live.lines().collect(); + assert_eq!(live_lines[0].chars().count(), idle_lines[0].chars().count()); + assert_eq!(live_lines[1].chars().count(), idle_lines[0].chars().count()); + assert!(live_lines[0].ends_with("112 Kbps ↑")); + assert!(live_lines[1].ends_with("3.6 Mbps ↓")); + assert_eq!(panel_label(false, rates, SpeedUnit::Megabytes), None); + } +} diff --git a/src-tauri/tauri.conf.json b/src-tauri/tauri.conf.json index bd1589d..d530899 100644 --- a/src-tauri/tauri.conf.json +++ b/src-tauri/tauri.conf.json @@ -1,7 +1,7 @@ { "$schema": "https://schema.tauri.app/config/2", "productName": "BiFlow", - "version": "6.2.66", + "version": "6.2.73", "identifier": "app.biflow.desktop", "build": { "beforeDevCommand": "pnpm bundle:check && pnpm --dir apps/desktop dev", diff --git a/version b/version index fda905c..19fa144 100644 --- a/version +++ b/version @@ -1 +1 @@ -6.2.66 +6.2.73