diff --git a/AGENTS.md b/AGENTS.md index a57bd3a..01dd8af 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -269,7 +269,7 @@ If a required command fails or emits a warning from project code, fix it in the - Values read from Windows-edited config files in shell scripts must strip a trailing carriage return before passing them to tools; rustup rejects `1.88.0\r` as an invalid toolchain. Keep build-script contract tests for this normalization. - Windscribe profiles can retain a mixed `ncp-ciphers` list containing AES-256-CBC even when BiFlow passes AEAD-only `--data-ciphers`. On Windows OpenVPN 2.7 this disables ovpn-dco and can fall back to TAP, which has no usable gateway. Drop `ncp-ciphers` only from the Windows sanitized temporary copy and keep regression fixtures for Windows removal and Linux preservation (ADR 0106). - Git Bash on Windows does not resolve `pnpm.cmd` as `pnpm`. The pre-commit hook must use `pnpm` when present and fall back to `pnpm.cmd`, with a script contract test, so the CI-mirror frontend gate still runs. -- Windows TUN `strict-route` can disrupt localhost services independently of Mihomo's DIRECT rules. Put loopback CIDRs in `tun.route-exclude-address`; configure `kubectl.exe` and other app process routes through List Management rather than hard-coding their outbound (ADRs 0108–0109). +- Windows TUN `strict-route` and macOS TUN `auto-route` can disrupt localhost services independently of Mihomo's DIRECT rules. Put loopback CIDRs in `tun.route-exclude-address` on both; keep Linux unchanged. Also put `localhost` and `+.localhost` in `fake-ip-filter` — `+.local` does not cover `localhost`, and on macOS the helper points system DNS at Mihomo so the name can receive a `198.18.0.0/16` fake-ip that then collides with `private.txt` (witness: `http://localhost:4200`). Configure `kubectl.exe` and other app process routes through List Management rather than hard-coding their outbound (ADRs 0108–0109). - When extending `RoutePinsDocument`, also extend `RawPinsDocument` and every explicit struct initializer; use `#[serde(default)]` so existing saved rule files remain readable. - `getByText("1 running", { exact: true })` can match multiple application rows; scope repeated values to a row or select a locator with `.first()` in Playwright assertions. - A missing Playwright Chromium executable is an environment setup failure. Install it with `pnpm exec playwright install chromium`, then rerun the e2e command. @@ -283,6 +283,41 @@ If a required command fails or emits a warning from project code, fix it in the - On Windows, top-level `ipv6: false` makes Mihomo drop the TUN inet6 address. sing-tun `strict-route` then installs an unconditional WFP "block ipv6" connect filter that only exempts Mihomo, so `localhost` -> `::1` fails instantly while connected. A route exclusion cannot fix a WFP block. Keep top-level `ipv6: true` on Windows and restrict AAAA through `dns.ipv6: false` instead (ADR 0112). +- On macOS the LAN resolver (the router, e.g. `192.168.31.1`) is on the + directly-connected `en0` subnet, so DNS queries to it route around the + TUN and Mihomo's `dns-hijack: any:53` never sees them; the router then + cannot resolve blocked domains (facebook.com → "No answer") and foreign + sites never open even though Hiddify is up. Mihomo runs as root via the + helper, so it can bind `127.0.0.1:53`. `MihomoConfig::normalize_dns_port_for_platform` + rewrites `dns_port` to `MACOS_DNS_PORT = 53` at config load (alongside + `normalize_tun_name_for_platform` → `utun9`), and the helper's + `apply_system_dns`/`restore_system_dns` snapshot every network service's + DNS via `networksetup`, point them all at `127.0.0.1` on Connect, and + restore the snapshot on Disconnect. That same redirect is why + `localhost` must skip fake-ip (`fake-ip-filter: localhost`, `+.localhost`) + and why macOS TUN must `route-exclude-address` `127.0.0.0/8` and `::1/128`: + otherwise `http://localhost:4200` resolves to `198.18.0.0/16` and never + reaches the real loopback listener. The helper is the only thing that + can hold port 53 and re-point the system resolver; the desktop cannot. + Ship a helper-version field in `RuntimeHealth`/`StackSnapshot` so a new + app with DNS code auto-reinstalls an older installed helper on Connect + (`prepare_stack_start` version-mismatch check) instead of silently + using the stale daemon. The helper daemon runs under launchd with a + minimal environment, so `Command::new("networksetup")` can fail PATH + lookup silently and leave the system DNS on the router; use the + absolute path `/usr/sbin/networksetup` in the helper's DNS helpers and + log a `helper.dns_apply_empty` warning when no services are enumerated. + A macOS `.app` bundle (Hiddify is Flutter) can take over a minute to + initialize Launch Services, build the UI, and open the proxy port; the + 45s default `start_timeout_seconds` is too short on a cold start, so + `client_start_timeout` doubles the budget on macOS and + `launch_local_proxy_if_needed` uses that deadline instead of the raw + configured seconds. Clearing a Hiddify system proxy on macOS must set + every `networksetup` proxy kind to `off`; replaying the snapshot's + `*_enabled` flags leaves HTTP/SOCKS pointed at Hiddify, and the browser + then returns 502 for `localhost` (ADR 0062). Use `/usr/sbin/networksetup` + in the desktop backend too — a GUI `PATH` can omit `/usr/sbin`. + - Older Pillow has no `Image.Resampling`; generate icons with `Image.LANCZOS` / `Image.BICUBIC`. - Inner `#![allow(...)]` attributes must be the first item in a Rust module, before `use`. - Vitest coverage config requires `provider: "v8"` (or `istanbul`) in this Vite version. @@ -353,7 +388,7 @@ If a required command fails or emits a warning from project code, fix it in the - `cargo fmt --all --check` is the first Rust CI step and part of the done gate. Clippy and tests can pass while rustfmt still wants a one-line `.and_then` or a wrapped `assert!`. Run the check before calling the change done; apply `cargo fmt --all` in the same change if it fails. - Tauri 2.5 AppImage bundling downloads `AppRun-x86_64` with ureq/rustls. GitHub can close TLS without `close_notify`, which rustls reports as `peer closed connection without sending TLS close_notify`. Prefetch the tools into `~/.cache/tauri` with `curl --retry` (`scripts/prefetch-appimage-tools.sh`) before `tauri build`; Tauri skips the download when the files already exist. - `bundle.createUpdaterArtifacts` plus a committed updater public key makes local `tauri build` fail with `A public key has been found, but no private key` after the packages already exist. When `TAURI_SIGNING_PRIVATE_KEY` is unset, `build.sh` merges `createUpdaterArtifacts: false` so unsigned local packages still finish. GitHub Release signing that fails with `incorrect updater private key password: Missing comment in secret key` is a secret-format or password mismatch, not a bundler bug: the NSIS/deb/AppImage already exist. Use repository secrets (not environment secrets); store the Base64 private key from `pnpm tauri signer generate`; set `TAURI_SIGNING_PRIVATE_KEY_PASSWORD` only when the key was generated with a password. Do not export an empty password env var at workflow scope. Validate with `scripts/prepare-tauri-signing.mjs --require --verify-sign` before `tauri-action`. Invoke the Tauri CLI as `node node_modules/@tauri-apps/cli/tauri.js`; `spawn("pnpm")` is `ENOENT` on Windows runners. After rotating keys, commit the new `plugins.updater.pubkey` in the same change. -- `pnpm tauri build --bundles deb,appimage` always rebuilds the frontend, the release binary, and both packages. Split `build.sh` into compile/deb/appimage/collect (and compile/nsis/collect on Windows). Skip a stage when this version's artifact already exists; `--from STAGE` starts at the failed stage; `--force` rebuilds all. Skip `beforeBuildCommand` when `apps/desktop/dist/index.html` is present, and prefetch AppImage tools only in the AppImage stage. +- `pnpm tauri build --bundles deb,appimage` always rebuilds the frontend, the release binary, and both packages. Split `build.sh` into compile/deb/appimage/collect (and compile/nsis/collect on Windows). Skip a stage when this version's artifact already exists; `--from STAGE` starts at the failed stage; `--force` rebuilds all. Skip `beforeBuildCommand` when `apps/desktop/dist/index.html` is present, and prefetch AppImage tools only in the AppImage stage. macOS `bundle_dmg.sh --skip-jenkins` writes the `.dmg` into cwd and `hdiutil convert` fails with `File exists` if a leftover from the Finder-AppleScript attempt is still there; delete `rw.*.dmg` plus the versioned name and run the fallback from `target/release/bundle/dmg` so `macos_dmg_path` matches. - `set -e` treats a trailing `[[ -f missing ]] && log` as a failed function. `print_summary` must use `if` so a Linux-only package run does not exit 1 after writing artifacts. - WebKitGTK 2.44+ DMA-BUF rendering paints a blank Tauri window on VMware SVGA and NVIDIA while JavaScript and IPC still run. Set `WEBKIT_DISABLE_DMABUF_RENDERER=1` before GTK starts; also set `WEBKIT_DISABLE_COMPOSITING_MODE=1` on virtual/NVIDIA GPUs and `LIBGL_ALWAYS_SOFTWARE=1` on virtual machines. Workspace `unsafe_code = "forbid"` blocks `env::set_var`, so `exec()` the process with those variables instead of `Command::status()`-waiting on a child (that leftover parent keeps a terminal open). Do not wait until after `tauri::Builder::build`. Closing the window leaves the process in the tray; `tauri-plugin-single-instance` then shows that old window when a new package is launched. Include the full `X.Y.Z` version in the Linux D-Bus id (`app.biflow.desktop.v1_2_6`) so a newly built package is not swallowed by an older tray instance. The plugin `semver` feature only suffixes the major version and does not separate 1.2.2 from 1.2.5. - A Windows GUI exe without `#![cfg_attr(windows, windows_subsystem = "windows")]` is a console binary, so Explorer and NSIS open a cmd window behind the UI. Put that attribute on `src-tauri/src/main.rs` and `iran-split-helper`; logs already go to `debug.log`. Pin Linux `.desktop` `Terminal=false` via `bundle.linux.deb.desktopTemplate`. @@ -402,6 +437,25 @@ already in progress"`. Cache the last `UpdateInfo` (never log asset URLs). - On Linux the running Mihomo workdir under `/var/lib/iran-split` is root-only. A desktop read of the overlaid `config.yaml` fails with `EACCES`, so every Live apply failed and client recovery retried every 10 s. Send the desktop's staged copy of the same generation (the helper verifies its SHA-256). Each staged generation is ~1 MB: prune staging to the newest few before creating one (ADR 0111). - A Windows Connect that dies at Mihomo readiness with `error sending request for url` is not an internal server error. `CoreError::Platform` maps to `errors.internal` in the UI; map a readiness timeout to `ControllerTimeout`. The controller client must use `no_proxy()` or Hiddify's HTTP proxy intercepts `127.0.0.1:19090`. The helper must not `env_clear()` Windows Mihomo down to PATH-only — restore `SYSTEMROOT` (and spawn with `CREATE_NO_WINDOW`), wait briefly for an immediate exit, and ship `wintun.dll` next to `mihomo.exe`. - A later Windows field log reached `ready: 7` / `rules_loaded: 65734` and then rolled Mihomo back with "process or TUN disappeared". `GET /configs` is still the TUN authority (no adapter enumeration), but `tun.device` on Windows is often `Meta` or empty — treat truthy `tun.enable` as active, retry the post-readiness process/TUN check for 5s, and split the error. Generate Windows YAML like clash-master: `find-process-mode: always`, `ipv6: false`, `auto-redirect: false`, DoH `#VPN`. +- macOS `utun` devices must be named `utunN`; the kernel `com.apple.net.utun` + control rejects arbitrary names such as `clash-iran`, so Mihomo never + creates the TUN and the readiness check sees `mihomo.tun_or_process_missing` + even though the controller is ready. Normalize `mihomo.tun_name` to `utun9` + on macOS at config load time (`MihomoConfig::normalize_tun_name_for_platform`, + `is_valid_macos_tun_name`), and detect the TUN through `GET /configs` + `tun.enable` — the live `utun` device name is kernel-assigned and need not + equal the configured name, so `ifconfig ` is unreliable (mirror the + Windows backend, ADR 0104). Launch a `.app` bundle (Hiddify/Happ) through + `open .app` via Launch Services, not the inner Mach-O binary + directly; running the inner executable starts the process but the + Flutter/Electron app often never opens its proxy port because it expects + the bundle environment (`enclosing_app_bundle`). +- The privileged helper validates `mihomo_sha256` on startup and refuses to + serve with an empty hash (`UnsafeConfig("mihomo_sha256 must be lowercase +SHA-256")`); launchd `KeepAlive` then restarts it in a crash loop and the + desktop times out with `helper.install_timeout`. `install_macos` must + compute `sha256_file(&mihomo_stage)` and write the real digest into + `helper.toml`, not an empty string (mirror `install_linux`). - An interrupted `cargo test`/`clippy` can leave `corrupt metadata` in `target/debug/deps/*.rmeta`. Delete only the file rustc names (and its sibling `.rlib`) and rebuild that crate. Do not `cargo clean`. - A `tokio::sync::watch` subscriber misses intermediate `operation_stage` values when several `update()` calls run in one worker poll. Yield after diff --git a/Cargo.lock b/Cargo.lock index ad47f78..406b8ee 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -2066,7 +2066,7 @@ dependencies = [ [[package]] name = "iran-split-cli" -version = "6.2.52" +version = "6.2.55" dependencies = [ "async-trait", "chrono", @@ -2084,7 +2084,7 @@ dependencies = [ [[package]] name = "iran-split-clients" -version = "6.2.52" +version = "6.2.55" dependencies = [ "async-trait", "ipnet", @@ -2100,7 +2100,7 @@ dependencies = [ [[package]] name = "iran-split-config" -version = "6.2.52" +version = "6.2.55" dependencies = [ "hex", "rand 0.9.5", @@ -2113,7 +2113,7 @@ dependencies = [ [[package]] name = "iran-split-core" -version = "6.2.52" +version = "6.2.55" dependencies = [ "async-trait", "chrono", @@ -2130,7 +2130,7 @@ dependencies = [ [[package]] name = "iran-split-desktop" -version = "6.2.52" +version = "6.2.55" dependencies = [ "chrono", "dirs 6.0.0", @@ -2142,6 +2142,7 @@ dependencies = [ "iran-split-ipc", "iran-split-mihomo", "iran-split-platform-linux", + "iran-split-platform-macos", "iran-split-platform-win", "iran-split-rules", "regex", @@ -2169,7 +2170,7 @@ dependencies = [ [[package]] name = "iran-split-helper" -version = "6.2.52" +version = "6.2.55" dependencies = [ "clap", "hex", @@ -2178,6 +2179,7 @@ dependencies = [ "iran-split-config", "iran-split-helper-winacl", "iran-split-ipc", + "libc", "nix", "regex", "serde", @@ -2193,7 +2195,7 @@ dependencies = [ [[package]] name = "iran-split-helper-winacl" -version = "6.2.52" +version = "6.2.55" dependencies = [ "tokio", "windows", @@ -2201,7 +2203,7 @@ dependencies = [ [[package]] name = "iran-split-ipc" -version = "6.2.52" +version = "6.2.55" dependencies = [ "bytes", "chrono", @@ -2214,7 +2216,7 @@ dependencies = [ [[package]] name = "iran-split-mihomo" -version = "6.2.52" +version = "6.2.55" dependencies = [ "chrono", "futures-util", @@ -2237,7 +2239,31 @@ dependencies = [ [[package]] name = "iran-split-platform-linux" -version = "6.2.52" +version = "6.2.55" +dependencies = [ + "async-trait", + "chrono", + "hex", + "iran-split-clients", + "iran-split-config", + "iran-split-core", + "iran-split-ipc", + "iran-split-mihomo", + "iran-split-rules", + "serde", + "serde_json", + "sha2", + "tempfile", + "thiserror 2.0.20", + "tokio", + "tokio-util", + "tracing", + "uuid", +] + +[[package]] +name = "iran-split-platform-macos" +version = "6.2.55" dependencies = [ "async-trait", "chrono", @@ -2261,7 +2287,7 @@ dependencies = [ [[package]] name = "iran-split-platform-win" -version = "6.2.52" +version = "6.2.55" dependencies = [ "async-trait", "chrono", @@ -2284,7 +2310,7 @@ dependencies = [ [[package]] name = "iran-split-rules" -version = "6.2.52" +version = "6.2.55" dependencies = [ "async-trait", "chrono", diff --git a/Cargo.toml b/Cargo.toml index 53f5b87..4ba2944 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -9,6 +9,7 @@ members = [ "crates/iran-split-rules", "crates/iran-split-platform-linux", "crates/iran-split-platform-win", + "crates/iran-split-platform-macos", "crates/iran-split-helper", "crates/iran-split-helper-winacl", "crates/iran-split-cli", @@ -16,7 +17,7 @@ members = [ ] [workspace.package] -version = "6.2.52" +version = "6.2.55" edition = "2021" license = "MIT OR Apache-2.0" rust-version = "1.88" @@ -36,6 +37,7 @@ idna = "1.0.3" ipnet = { version = "2.11.0", features = ["serde"] } psl = "2.1.148" nix = { version = "0.30.1", features = ["socket", "user", "signal", "process", "fs"] } +libc = "0.2.174" rand = "0.9.1" regex = "1.11.1" reqwest = { version = "0.12.15", default-features = false, features = ["json", "rustls-tls", "socks"] } diff --git a/apps/desktop/package.json b/apps/desktop/package.json index ebbf5ba..6397594 100644 --- a/apps/desktop/package.json +++ b/apps/desktop/package.json @@ -1,6 +1,6 @@ { "name": "@iran-split/desktop", - "version": "6.2.52", + "version": "6.2.55", "private": true, "type": "module", "scripts": { diff --git a/apps/desktop/src/lib/presets.ts b/apps/desktop/src/lib/presets.ts index 443f00b..8ccb59c 100644 --- a/apps/desktop/src/lib/presets.ts +++ b/apps/desktop/src/lib/presets.ts @@ -14,6 +14,7 @@ export type PresetStatus = "working" | "catalog" | "unsupported"; export interface PresetDownloads { linux: string; windows: string; + macos: string; } export interface PresetSpec { @@ -37,6 +38,7 @@ export const PRESETS: PresetSpec[] = [ downloads: { linux: "https://github.com/hiddify/hiddify-app/releases/latest", windows: "https://github.com/hiddify/hiddify-app/releases/latest", + macos: "https://github.com/hiddify/hiddify-app/releases/latest", }, }, { @@ -50,6 +52,7 @@ export const PRESETS: PresetSpec[] = [ downloads: { linux: "https://openvpn.net/community-downloads/", windows: "https://openvpn.net/community-downloads/", + macos: "https://openvpn.net/community-downloads/", }, }, { @@ -63,6 +66,7 @@ export const PRESETS: PresetSpec[] = [ downloads: { linux: "https://www.happ.su/main/download", windows: "https://www.happ.su/main/download", + macos: "https://www.happ.su/main/download", }, }, { @@ -76,6 +80,7 @@ export const PRESETS: PresetSpec[] = [ downloads: { linux: "https://github.com/2dust/v2rayN/releases/latest", windows: "https://github.com/2dust/v2rayN/releases/latest", + macos: "https://github.com/2dust/v2rayN/releases/latest", }, }, { @@ -88,6 +93,7 @@ export const PRESETS: PresetSpec[] = [ downloads: { linux: "https://github.com/MatsuriDayo/nekoray/releases/latest", windows: "https://github.com/MatsuriDayo/nekoray/releases/latest", + macos: "https://github.com/MatsuriDayo/nekoray/releases/latest", }, }, { @@ -102,6 +108,7 @@ export const PRESETS: PresetSpec[] = [ linux: "https://github.com/shadowsocks/shadowsocks-rust/releases/latest", windows: "https://github.com/shadowsocks/shadowsocks-windows/releases/latest", + macos: "https://github.com/shadowsocks/shadowsocks-rust/releases/latest", }, }, { @@ -115,6 +122,7 @@ export const PRESETS: PresetSpec[] = [ downloads: { linux: "https://www.wireguard.com/install/", windows: "https://www.wireguard.com/install/", + macos: "https://www.wireguard.com/install/", }, }, { @@ -128,6 +136,7 @@ export const PRESETS: PresetSpec[] = [ downloads: { linux: "https://windscribe.com/getconfig/openvpn", windows: "https://windscribe.com/getconfig/openvpn", + macos: "https://windscribe.com/getconfig/openvpn", }, }, ]; @@ -138,7 +147,13 @@ export function presetById(id: PresetId): PresetSpec { /** Official vendor download page for the current desktop platform. */ export function downloadUrlFor(spec: PresetSpec, platform: string): string { - return platform === "windows" ? spec.downloads.windows : spec.downloads.linux; + if (platform === "windows") { + return spec.downloads.windows; + } + if (platform === "macos") { + return spec.downloads.macos; + } + return spec.downloads.linux; } export type PresetDownloadLabel = diff --git a/apps/desktop/tsconfig.app.tsbuildinfo b/apps/desktop/tsconfig.app.tsbuildinfo index 6cb0e9d..ac7471f 100644 --- a/apps/desktop/tsconfig.app.tsbuildinfo +++ b/apps/desktop/tsconfig.app.tsbuildinfo @@ -1 +1 @@ -{"root":["./src/App.test.tsx","./src/App.tsx","./src/installContextMenuGuard.ts","./src/main.tsx","./src/palette.test.ts","./src/shell.test.ts","./src/version.test.ts","./src/version.ts","./src/vite-env.d.ts","./src/api/desktop.native.test.ts","./src/api/desktop.ts","./src/api/mock.test.ts","./src/api/mock.ts","./src/api/models.ts","./src/components/About.test.tsx","./src/components/About.tsx","./src/components/AddSiteBar.tsx","./src/components/AppButton.test.tsx","./src/components/AppButton.tsx","./src/components/AppStatusBar.test.tsx","./src/components/AppStatusBar.tsx","./src/components/BasicDashboard.test.tsx","./src/components/BasicDashboard.tsx","./src/components/BottomNav.test.tsx","./src/components/BottomNav.tsx","./src/components/ClientRegistry.test.tsx","./src/components/ClientRegistry.tsx","./src/components/ConnectionActionButton.test.tsx","./src/components/ConnectionActionButton.tsx","./src/components/Dashboard.test.tsx","./src/components/Dashboard.tsx","./src/components/DefaultRouteSelect.tsx","./src/components/Diagnostics.test.tsx","./src/components/Diagnostics.tsx","./src/components/DirectRules.test.tsx","./src/components/DirectRules.tsx","./src/components/InputContextMenu.test.tsx","./src/components/InputContextMenu.tsx","./src/components/LifecycleCancelButton.tsx","./src/components/MihomoConfigDialog.tsx","./src/components/PageSkeleton.tsx","./src/components/RecentSites.test.tsx","./src/components/RecentSites.tsx","./src/components/Settings.test.tsx","./src/components/Settings.tsx","./src/components/SettingsApplyBanner.test.tsx","./src/components/SettingsApplyBanner.tsx","./src/components/SortHeader.tsx","./src/components/StatusPill.tsx","./src/components/TrafficFlow.tsx","./src/components/UiModeSwitch.test.tsx","./src/components/UiModeSwitch.tsx","./src/components/country.ts","./src/components/ui/InfoTip.tsx","./src/components/ui/PageHeader.tsx","./src/components/ui/Tabs.tsx","./src/components/ui/Toast.tsx","./src/components/ui/usePageTab.ts","./src/dev/local-deps.test.ts","./src/i18n/config.ts","./src/lib/clients.test.ts","./src/lib/clients.ts","./src/lib/clipboard.ts","./src/lib/connectComponentProgress.test.ts","./src/lib/connectRequirements.test.ts","./src/lib/connectRequirements.ts","./src/lib/connectionGroups.test.ts","./src/lib/connectionGroups.ts","./src/lib/connectionProgress.test.ts","./src/lib/connectionProgress.ts","./src/lib/directDns.test.ts","./src/lib/directDns.ts","./src/lib/editableTarget.test.ts","./src/lib/editableTarget.ts","./src/lib/failureReason.test.ts","./src/lib/failureReason.ts","./src/lib/formatTraffic.test.ts","./src/lib/formatTraffic.ts","./src/lib/hiddenHosts.test.ts","./src/lib/hiddenHosts.ts","./src/lib/host.test.ts","./src/lib/host.ts","./src/lib/lifecycle.test.ts","./src/lib/lifecycle.ts","./src/lib/navigation.ts","./src/lib/outbound.ts","./src/lib/presets.test.ts","./src/lib/presets.ts","./src/lib/settingsApply.test.ts","./src/lib/settingsApply.ts","./src/lib/sideTunnelConnect.test.ts","./src/lib/sideTunnelConnect.ts","./src/lib/tableSort.test.ts","./src/lib/tableSort.ts","./src/lib/uiMode.ts","./src/lib/viewport.test.ts","./src/lib/viewport.ts","./src/lib/yamlHighlight.test.ts","./src/lib/yamlHighlight.ts","./src/store/app.test.ts","./src/store/app.ts","./src/test/fixtures.ts","./src/test/setup.ts"],"version":"5.8.3"} \ No newline at end of file +{"root":["./src/app.test.tsx","./src/app.tsx","./src/installcontextmenuguard.ts","./src/main.tsx","./src/palette.test.ts","./src/shell.test.ts","./src/version.test.ts","./src/version.ts","./src/vite-env.d.ts","./src/api/desktop.native.test.ts","./src/api/desktop.ts","./src/api/mock.test.ts","./src/api/mock.ts","./src/api/models.ts","./src/components/about.test.tsx","./src/components/about.tsx","./src/components/addsitebar.tsx","./src/components/appbutton.test.tsx","./src/components/appbutton.tsx","./src/components/appstatusbar.test.tsx","./src/components/appstatusbar.tsx","./src/components/basicdashboard.test.tsx","./src/components/basicdashboard.tsx","./src/components/bottomnav.test.tsx","./src/components/bottomnav.tsx","./src/components/clientregistry.test.tsx","./src/components/clientregistry.tsx","./src/components/connectionactionbutton.test.tsx","./src/components/connectionactionbutton.tsx","./src/components/dashboard.test.tsx","./src/components/dashboard.tsx","./src/components/defaultrouteselect.tsx","./src/components/diagnostics.test.tsx","./src/components/diagnostics.tsx","./src/components/directrules.test.tsx","./src/components/directrules.tsx","./src/components/inputcontextmenu.test.tsx","./src/components/inputcontextmenu.tsx","./src/components/lifecyclecancelbutton.tsx","./src/components/mihomoconfigdialog.tsx","./src/components/pageskeleton.tsx","./src/components/recentsites.test.tsx","./src/components/recentsites.tsx","./src/components/settings.test.tsx","./src/components/settings.tsx","./src/components/settingsapplybanner.test.tsx","./src/components/settingsapplybanner.tsx","./src/components/sortheader.tsx","./src/components/statuspill.tsx","./src/components/trafficflow.tsx","./src/components/uimodeswitch.test.tsx","./src/components/uimodeswitch.tsx","./src/components/country.ts","./src/components/ui/infotip.tsx","./src/components/ui/pageheader.tsx","./src/components/ui/tabs.tsx","./src/components/ui/toast.tsx","./src/components/ui/usepagetab.ts","./src/dev/local-deps.test.ts","./src/i18n/config.ts","./src/lib/clients.test.ts","./src/lib/clients.ts","./src/lib/clipboard.ts","./src/lib/connectcomponentprogress.test.ts","./src/lib/connectrequirements.test.ts","./src/lib/connectrequirements.ts","./src/lib/connectiongroups.test.ts","./src/lib/connectiongroups.ts","./src/lib/connectionprogress.test.ts","./src/lib/connectionprogress.ts","./src/lib/directdns.test.ts","./src/lib/directdns.ts","./src/lib/editabletarget.test.ts","./src/lib/editabletarget.ts","./src/lib/failurereason.test.ts","./src/lib/failurereason.ts","./src/lib/formattraffic.test.ts","./src/lib/formattraffic.ts","./src/lib/hiddenhosts.test.ts","./src/lib/hiddenhosts.ts","./src/lib/host.test.ts","./src/lib/host.ts","./src/lib/lifecycle.test.ts","./src/lib/lifecycle.ts","./src/lib/navigation.ts","./src/lib/outbound.ts","./src/lib/presets.test.ts","./src/lib/presets.ts","./src/lib/settingsapply.test.ts","./src/lib/settingsapply.ts","./src/lib/sidetunnelconnect.test.ts","./src/lib/sidetunnelconnect.ts","./src/lib/tablesort.test.ts","./src/lib/tablesort.ts","./src/lib/uimode.ts","./src/lib/viewport.test.ts","./src/lib/viewport.ts","./src/lib/yamlhighlight.test.ts","./src/lib/yamlhighlight.ts","./src/store/app.test.ts","./src/store/app.ts","./src/test/fixtures.ts","./src/test/setup.ts"],"version":"5.8.3"} \ No newline at end of file diff --git a/build.sh b/build.sh index 71d45df..7b82794 100755 --- a/build.sh +++ b/build.sh @@ -56,6 +56,14 @@ windows_installer_name() { command printf 'BiFlow_%s_x64-setup.exe\n' "${BUILD_VERSION}" } +macos_dmg_name() { + command printf 'BiFlow_%s_aarch64.dmg\n' "${BUILD_VERSION}" +} + +macos_app_name() { + command printf 'BiFlow.app\n' +} + usage() { command cat <<'EOF' BiFlow release builder @@ -69,16 +77,19 @@ Focused local verification (default developer gate): GitHub-hosted packaging entry points: ci-linux Native Linux .deb and AppImage ci-windows Native Windows .exe and NSIS installer + ci-macos Native macOS .app and .dmg Full packaging (machines with spare disk; not the local default): linux Native Linux .deb and AppImage linux deb Only the Linux .deb linux appimage Only the Linux AppImage windows Windows app .exe and NSIS installer - all Linux and Windows (default when no mode is given) + macos Native macOS .app and .dmg + all Linux, Windows, and macOS (default when no mode is given) Resume (linux stages: compile, deb, appimage, collect; - windows stages: compile, nsis, collect): + windows stages: compile, nsis, collect; + macos stages: compile, dmg, collect): --from STAGE Start at STAGE. Earlier finished work is left in place. --force Rebuild every packaging stage for this version. @@ -382,6 +393,14 @@ linux_appimage_path() { command printf '%s/release/bundle/appimage/%s\n' "${TARGET_DIR}" "$(linux_appimage_name)" } +macos_dmg_path() { + command printf '%s/release/bundle/dmg/%s\n' "${TARGET_DIR}" "$(macos_dmg_name)" +} + +macos_app_path() { + command printf '%s/release/bundle/macos/%s\n' "${TARGET_DIR}" "$(macos_app_name)" +} + windows_prefix() { local triple="${1:-}" if [[ -n "${triple}" ]]; then @@ -525,6 +544,44 @@ should_run_windows_stage() { return 0 } +macos_stage_done() { + local stage="$1" + case "${stage}" in + compile) + [[ -x "${TARGET_DIR}/release/${CRATE_BIN}" ]] || return 1 + stamp_is_current macos-compile && return 0 + macos_stage_done dmg + ;; + dmg) + [[ -f "$(macos_dmg_path)" ]] + ;; + collect) + [[ -f "${PROJECT_DIR}/$(plan macos.dir)/$(macos_dmg_name)" ]] + ;; + *) return 1 ;; + esac +} + +should_run_macos_stage() { + local stage="$1" + local stages=(compile dmg collect) + local from_i stage_i + if [[ "${FORCE}" -eq 1 ]]; then + return 0 + fi + if [[ -n "${FROM_STAGE}" ]]; then + if from_i="$(stage_index "${FROM_STAGE}" "${stages[@]}")"; then + stage_i="$(stage_index "${stage}" "${stages[@]}")" + [[ "${stage_i}" -ge "${from_i}" ]] + return + fi + fi + if macos_stage_done "${stage}"; then + return 1 + fi + return 0 +} + run_tauri_build() { local frontend_mode="$1" shift @@ -582,10 +639,16 @@ validate_from_stage() { *) die "windows --from must be compile, nsis, or collect" ;; esac ;; + macos) + case "${FROM_STAGE}" in + compile|dmg|collect) ;; + *) die "macos --from must be compile, dmg, or collect" ;; + esac + ;; all) case "${FROM_STAGE}" in - compile|deb|appimage|nsis|collect) ;; - *) die "--from must be compile, deb, appimage, nsis, or collect" ;; + compile|deb|appimage|nsis|dmg|collect) ;; + *) die "--from must be compile, deb, appimage, nsis, dmg, or collect" ;; esac ;; esac @@ -648,6 +711,15 @@ collect_windows() { fi } +collect_macos() { + assert_build_version + local dmg dest + dmg="$(macos_dmg_path)" + [[ -f "${dmg}" ]] || die "expected macOS package is missing: ${dmg}" + dest="${PROJECT_DIR}/$(plan macos.dir)/$(macos_dmg_name)" + copy_one "${dmg}" "${dest}" +} + build_linux() { [[ "$(host_os)" == "linux" ]] || die "Linux .deb packages must be built on Linux" assert_build_version @@ -766,6 +838,64 @@ build_windows() { fi } +build_macos() { + [[ "$(host_os)" == "macos" ]] || die "macOS .app/.dmg packages must be built on macOS" + assert_build_version + tauri_signing_config_args + local frontend_mode="run-frontend" + if frontend_dist_ready && ! should_run_macos_stage compile; then + frontend_mode="skip-frontend" + fi + if should_run_macos_stage compile; then + log "Stage compile: release binary for BiFlow ${BUILD_VERSION}" + "${PROJECT_DIR}/scripts/stage-helper.sh" + run_tauri_build run-frontend --no-bundle --config src-tauri/tauri.macos.conf.json + [[ -x "${TARGET_DIR}/release/${CRATE_BIN}" ]] || die "compile did not produce ${TARGET_DIR}/release/${CRATE_BIN}" + write_stamp macos-compile + frontend_mode="skip-frontend" + else + log "Skipping compile; already have ${TARGET_DIR}/release/${CRATE_BIN}" + fi + if should_run_macos_stage dmg; then + log "Stage dmg: $(macos_dmg_name)" + [[ -x "${PROJECT_DIR}/packaging/staged/iran-split-helper" ]] || \ + "${PROJECT_DIR}/scripts/stage-helper.sh" + # Tauri's dmg bundler runs a Finder AppleScript to prettify the window. + # In headless/CI environments Finder AppleEvents time out, so retry the + # generated bundle_dmg.sh with --skip-jenkins when the first attempt fails. + if ! run_tauri_build "${frontend_mode}" --bundles dmg --config src-tauri/tauri.macos.conf.json; then + log "Tauri dmg bundle failed; retrying bundle_dmg.sh with --skip-jenkins (non-GUI fallback)" + local dmg_dir dmg_script app_dir + dmg_dir="${TARGET_DIR}/release/bundle/dmg" + dmg_script="${dmg_dir}/bundle_dmg.sh" + app_dir="${TARGET_DIR}/release/bundle/macos" + [[ -f "${dmg_script}" ]] || die "dmg bundle script is missing: ${dmg_script}" + # bundle_dmg.sh writes .dmg into cwd and `hdiutil convert` + # fails with "File exists" if a leftover from the first attempt is + # still there. Run from the Tauri dmg dir so macos_dmg_path matches. + rm -f "${PROJECT_DIR}/$(macos_dmg_name)" \ + "${PROJECT_DIR}"/rw.*.dmg \ + "${app_dir}"/rw.*.dmg \ + "${dmg_dir}"/rw.*.dmg \ + "${dmg_dir}/$(macos_dmg_name)" + ( cd -- "${dmg_dir}" && bash "${dmg_script}" --skip-jenkins \ + "$(macos_dmg_name)" "${app_dir}" ) \ + || die "bundle_dmg.sh --skip-jenkins failed" + fi + [[ -f "$(macos_dmg_path)" ]] || die "dmg stage did not produce $(macos_dmg_path)" + write_stamp macos-dmg + else + log "Skipping dmg; already have $(macos_dmg_path)" + fi + if should_run_macos_stage collect; then + log "Stage collect: copying macOS artifacts" + collect_macos + write_stamp macos-collect + else + log "Skipping collect; macOS artifacts already in $(plan macos.dir)" + fi +} + print_summary() { assert_build_version log "" @@ -782,6 +912,9 @@ print_summary() { if [[ -f "${PROJECT_DIR}/$(plan windows.dir)/$(windows_installer_name)" ]]; then log " Windows installer: $(plan windows.dir)/$(windows_installer_name)" fi + if [[ -f "${PROJECT_DIR}/$(plan macos.dir)/$(macos_dmg_name)" ]]; then + log " macOS dmg: $(plan macos.dir)/$(macos_dmg_name)" + fi } ensure_requirements() { @@ -803,6 +936,7 @@ ensure_requirements() { ensure_windows_cross_from_linux fi ;; + macos|ci-macos) ;; all) ensure_linux_desktop_dependencies if [[ "$(host_os)" == "linux" ]]; then @@ -893,7 +1027,11 @@ main() { [[ "$(host_os)" == "windows" ]] || die "ci-windows requires a native Windows runner" target="windows" ;; - linux|windows|all) ;; + ci-macos) + [[ "$(host_os)" == "macos" ]] || die "ci-macos requires a native macOS runner" + target="macos" + ;; + linux|windows|macos|all) ;; *) usage >&2; die "unknown target: ${target}" ;; esac @@ -916,9 +1054,11 @@ main() { case "${target}" in linux) build_linux ;; windows) build_windows ;; + macos) build_macos ;; all) build_linux build_windows + build_macos ;; esac print_summary diff --git a/crates/iran-split-cli/src/main.rs b/crates/iran-split-cli/src/main.rs index 487ff01..2cbffa0 100644 --- a/crates/iran-split-cli/src/main.rs +++ b/crates/iran-split-cli/src/main.rs @@ -146,6 +146,7 @@ impl PlatformBackend for DemoBackend { let running = *self.running.lock().await; RuntimeHealth { helper: ComponentStatus::new(ComponentPhase::Running, Some("Helper demo".into())), + helper_version: None, clients: Vec::new(), mihomo: ComponentStatus::new( if running { diff --git a/crates/iran-split-clients/src/lib.rs b/crates/iran-split-clients/src/lib.rs index 1f5a4cf..7a22900 100644 --- a/crates/iran-split-clients/src/lib.rs +++ b/crates/iran-split-clients/src/lib.rs @@ -87,6 +87,7 @@ pub enum ClientError { pub enum DriverPlatform { Linux, Windows, + Macos, } /// Starts, stops, and describes one catalog preset. diff --git a/crates/iran-split-clients/src/local_proxy.rs b/crates/iran-split-clients/src/local_proxy.rs index f2029f0..ab26889 100644 --- a/crates/iran-split-clients/src/local_proxy.rs +++ b/crates/iran-split-clients/src/local_proxy.rs @@ -35,6 +35,7 @@ impl ClientDriver for LocalProxyDriver { let names = match platform { DriverPlatform::Linux => self.preset.spec().linux_bypass, DriverPlatform::Windows => self.preset.spec().windows_bypass, + DriverPlatform::Macos => self.preset.spec().macos_bypass, }; names .iter() diff --git a/crates/iran-split-clients/src/openvpn.rs b/crates/iran-split-clients/src/openvpn.rs index f139708..be87e22 100644 --- a/crates/iran-split-clients/src/openvpn.rs +++ b/crates/iran-split-clients/src/openvpn.rs @@ -26,7 +26,7 @@ impl ClientDriver for OpenVpnDriver { fn process_bypass(&self, platform: DriverPlatform) -> Vec { let name = match platform { DriverPlatform::Windows => "openvpn.exe", - DriverPlatform::Linux => "openvpn", + DriverPlatform::Linux | DriverPlatform::Macos => "openvpn", }; vec![ProcessBypass { name: name.into(), diff --git a/crates/iran-split-config/src/clients.rs b/crates/iran-split-config/src/clients.rs index 833b547..53bcf67 100644 --- a/crates/iran-split-config/src/clients.rs +++ b/crates/iran-split-config/src/clients.rs @@ -164,6 +164,7 @@ pub enum PresetStatus { pub struct PresetDownloads { pub linux: &'static str, pub windows: &'static str, + pub macos: &'static str, } /// One shipped catalog row. @@ -177,6 +178,7 @@ pub struct PresetSpec { pub default_port: Option, pub linux_bypass: &'static [&'static str], pub windows_bypass: &'static [&'static str], + pub macos_bypass: &'static [&'static str], pub install_hint: &'static str, pub downloads: PresetDownloads, } @@ -190,10 +192,12 @@ const HIDDIFY_SPEC: PresetSpec = PresetSpec { default_port: Some(12_334), linux_bypass: &["hiddify", "*Hiddify*"], windows_bypass: &["hiddify.exe", "Hiddify.exe", "HiddifyNext.exe", "*Hiddify*"], + macos_bypass: &["Hiddify", "*Hiddify*"], install_hint: "Install Hiddify Next and keep its mixed port on loopback.", downloads: PresetDownloads { linux: "https://github.com/hiddify/hiddify-app/releases/latest", windows: "https://github.com/hiddify/hiddify-app/releases/latest", + macos: "https://github.com/hiddify/hiddify-app/releases/latest", }, }; @@ -206,11 +210,13 @@ const OPENVPN_SPEC: PresetSpec = PresetSpec { default_port: None, linux_bypass: &[], windows_bypass: &[], + macos_bypass: &[], install_hint: "Install OpenVPN and choose a .ovpn profile. BiFlow never lets it take the default route.", downloads: PresetDownloads { linux: "https://openvpn.net/community-downloads/", windows: "https://openvpn.net/community-downloads/", + macos: "https://openvpn.net/community-downloads/", }, }; @@ -237,10 +243,12 @@ const HAPP_SPEC: PresetSpec = PresetSpec { "xray.exe", "v2ray.exe", ], + macos_bypass: &["Happ", "*Happ*", "sing-box", "xray", "v2ray"], install_hint: "Run Happ and expose a local SOCKS or mixed port (default 10808).", downloads: PresetDownloads { linux: "https://www.happ.su/main/download", windows: "https://www.happ.su/main/download", + macos: "https://www.happ.su/main/download", }, }; @@ -253,10 +261,12 @@ const V2RAYN_SPEC: PresetSpec = PresetSpec { default_port: Some(10_808), linux_bypass: &["v2rayN", "v2rayn", "xray", "v2ray"], windows_bypass: &["v2rayN.exe", "v2rayn.exe", "xray.exe", "v2ray.exe"], + macos_bypass: &["v2rayN", "xray", "v2ray"], install_hint: "Run v2rayN and keep the local SOCKS port (default 10808) on loopback.", downloads: PresetDownloads { linux: "https://github.com/2dust/v2rayN/releases/latest", windows: "https://github.com/2dust/v2rayN/releases/latest", + macos: "https://github.com/2dust/v2rayN/releases/latest", }, }; @@ -269,10 +279,12 @@ const NEKORAY_SPEC: PresetSpec = PresetSpec { default_port: Some(2080), linux_bypass: &["nekoray", "nekobox", "nekobox_core"], windows_bypass: &["nekoray.exe", "nekobox.exe", "nekobox_core.exe"], + macos_bypass: &["nekoray", "nekobox", "nekobox_core"], install_hint: "Run Nekoray / NekoBox and expose its mixed SOCKS port.", downloads: PresetDownloads { linux: "https://github.com/MatsuriDayo/nekoray/releases/latest", windows: "https://github.com/MatsuriDayo/nekoray/releases/latest", + macos: "https://github.com/MatsuriDayo/nekoray/releases/latest", }, }; @@ -285,10 +297,12 @@ const SHADOWSOCKS_SPEC: PresetSpec = PresetSpec { default_port: Some(1080), linux_bypass: &["ss-local", "shadowsocks", "sslocal"], windows_bypass: &["ss-local.exe", "shadowsocks.exe", "sslocal.exe"], + macos_bypass: &["ss-local", "shadowsocks", "sslocal"], install_hint: "Run a local Shadowsocks client and point BiFlow at its SOCKS port.", downloads: PresetDownloads { linux: "https://github.com/shadowsocks/shadowsocks-rust/releases/latest", windows: "https://github.com/shadowsocks/shadowsocks-windows/releases/latest", + macos: "https://github.com/shadowsocks/shadowsocks-rust/releases/latest", }, }; @@ -301,10 +315,12 @@ const WIREGUARD_SPEC: PresetSpec = PresetSpec { default_port: None, linux_bypass: &[], windows_bypass: &[], + macos_bypass: &[], install_hint: "WireGuard will use the same side-tunnel driver as OpenVPN. The driver is not in this version.", downloads: PresetDownloads { linux: "https://www.wireguard.com/install/", windows: "https://www.wireguard.com/install/", + macos: "https://www.wireguard.com/install/", }, }; @@ -317,10 +333,12 @@ const WINDSCRIBE_SPEC: PresetSpec = PresetSpec { default_port: None, linux_bypass: &[], windows_bypass: &[], + macos_bypass: &[], install_hint: "Generate an OpenVPN profile with your Windscribe service credentials at build.windscribe.com and choose the .ovpn here. Do not run the Windscribe GUI at the same time.", downloads: PresetDownloads { linux: "https://windscribe.com/getconfig/openvpn", windows: "https://windscribe.com/getconfig/openvpn", + macos: "https://windscribe.com/getconfig/openvpn", }, }; diff --git a/crates/iran-split-config/src/lib.rs b/crates/iran-split-config/src/lib.rs index f1253a8..ce933b1 100644 --- a/crates/iran-split-config/src/lib.rs +++ b/crates/iran-split-config/src/lib.rs @@ -172,8 +172,17 @@ pub const PRODUCTION_MIXED_PORT: u16 = 17_890; pub const DEV_PROFILE_MIXED_PORT: u16 = 17_891; pub const PRODUCTION_DNS_PORT: u16 = 1_053; pub const DEV_PROFILE_DNS_PORT: u16 = 2_053; +/// macOS sends DNS queries to the configured nameserver. The router (LAN +/// resolver) bypasses the TUN and cannot resolve blocked domains, so the +/// system DNS must point at Mihomo. The system resolver always uses port 53, +/// so Mihomo must listen on `127.0.0.1:53` (it runs as root via the helper). +pub const MACOS_DNS_PORT: u16 = 53; pub const PRODUCTION_TUN_NAME: &str = "clash-iran"; pub const DEV_PROFILE_TUN_NAME: &str = "biflow-dev"; +/// macOS `utun` devices must be named `utunN`; the kernel rejects arbitrary +/// names such as `clash-iran`. A high unit avoids the system's low-numbered +/// utun adapters (iCloud/Back-to-Mac, other VPNs). +pub const MACOS_TUN_NAME: &str = "utun9"; impl MihomoConfig { /// Move off the installed app's loopback ports and TUN when this process @@ -182,6 +191,32 @@ impl MihomoConfig { self.isolate_from_installed_app_if(dev_profile_active()) } + /// On macOS the TUN device name must match the kernel `utunN` convention; + /// a stored `clash-iran` (or any non-`utun` name) makes Mihomo fail to + /// create the interface and the readiness TUN check never sees it. + /// Returns whether the name was rewritten. + #[must_use] + pub fn normalize_tun_name_for_platform(&mut self) -> bool { + if cfg!(target_os = "macos") && !is_valid_macos_tun_name(&self.tun_name) { + self.tun_name = MACOS_TUN_NAME.into(); + return true; + } + false + } + + /// On macOS the system DNS resolver always uses port 53, and the LAN + /// resolver bypasses the TUN (so Mihomo's `dns-hijack` never sees it). + /// Mihomo must listen on `127.0.0.1:53` so the system DNS can point at + /// it; it runs as root via the helper. Returns whether the port changed. + #[must_use] + pub fn normalize_dns_port_for_platform(&mut self) -> bool { + if cfg!(target_os = "macos") && self.dns_port != MACOS_DNS_PORT { + self.dns_port = MACOS_DNS_PORT; + return true; + } + false + } + /// Same remap as [`Self::isolate_from_installed_app`], with the env check /// supplied by the caller so tests do not mutate process environment. pub fn isolate_from_installed_app_if(&mut self, isolate: bool) -> bool { @@ -213,6 +248,17 @@ fn dev_profile_active() -> bool { std::env::var_os("BIFLOW_DEV_PROFILE").is_some_and(|value| !value.is_empty()) } +/// A macOS `utun` device name must be `utun` followed by 1-3 decimal digits +/// (for example `utun9`). Arbitrary names such as `clash-iran` are rejected by +/// the kernel `com.apple.net.utun` control and Mihomo never creates the TUN. +#[must_use] +pub fn is_valid_macos_tun_name(name: &str) -> bool { + let Some(suffix) = name.strip_prefix("utun") else { + return false; + }; + !suffix.is_empty() && suffix.len() <= 3 && suffix.bytes().all(|byte| byte.is_ascii_digit()) +} + /// Resolvers for Iranian and user-pinned DIRECT domains (not VPN `DoH`). #[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default)] #[serde(rename_all = "snake_case")] @@ -567,11 +613,13 @@ impl ConfigStore { } let mut config: AppConfig = value.try_into()?; let isolated = config.mihomo.isolate_from_installed_app(); + let tun_normalized = config.mihomo.normalize_tun_name_for_platform(); + let dns_normalized = config.mihomo.normalize_dns_port_for_platform(); let issues = config.validate(); if !issues.is_empty() { return Err(ConfigError::Validation(issues)); } - if schema < CURRENT_SCHEMA_VERSION || isolated { + if schema < CURRENT_SCHEMA_VERSION || isolated || tun_normalized || dns_normalized { self.write_atomic(&config)?; } Ok(config) @@ -869,6 +917,55 @@ mod tests { assert_eq!(custom.mixed_port, DEV_PROFILE_MIXED_PORT); } + #[test] + fn macos_tun_name_validation_accepts_utun_and_rejects_arbitrary_names() { + assert!(is_valid_macos_tun_name("utun9")); + assert!(is_valid_macos_tun_name("utun0")); + assert!(is_valid_macos_tun_name("utun123")); + assert!(!is_valid_macos_tun_name("clash-iran")); + assert!(!is_valid_macos_tun_name("biflow-dev")); + assert!(!is_valid_macos_tun_name("utun")); + assert!(!is_valid_macos_tun_name("utun9999")); + assert!(!is_valid_macos_tun_name("utun9a")); + } + + #[test] + fn macos_normalize_tun_name_rewrites_clash_iran_to_utun() { + let mut mihomo = MihomoConfig::default(); + assert_eq!(mihomo.tun_name, PRODUCTION_TUN_NAME); + let changed = mihomo.normalize_tun_name_for_platform(); + if cfg!(target_os = "macos") { + assert!(changed); + assert_eq!(mihomo.tun_name, MACOS_TUN_NAME); + } else { + assert!(!changed); + assert_eq!(mihomo.tun_name, PRODUCTION_TUN_NAME); + } + + // A manually set utun name is preserved. + mihomo.tun_name = "utun7".into(); + assert!(!mihomo.normalize_tun_name_for_platform()); + assert_eq!(mihomo.tun_name, "utun7"); + } + + #[test] + fn macos_normalize_dns_port_moves_off_loopback_high_port() { + let mut mihomo = MihomoConfig::default(); + // The production default is a high loopback port; on macOS we need 53. + let changed = mihomo.normalize_dns_port_for_platform(); + if cfg!(target_os = "macos") { + assert!(changed); + assert_eq!(mihomo.dns_port, MACOS_DNS_PORT); + } else { + assert!(!changed); + } + + // A manually set 53 is preserved on macOS, and untouched elsewhere. + mihomo.dns_port = MACOS_DNS_PORT; + assert!(!mihomo.normalize_dns_port_for_platform()); + assert_eq!(mihomo.dns_port, MACOS_DNS_PORT); + } + #[test] fn happ_primary_without_hiddify_is_a_valid_config() { // The operator removed the Hiddify instance and promoted Happ to the diff --git a/crates/iran-split-core/src/lib.rs b/crates/iran-split-core/src/lib.rs index 8a1f9ae..4ec1df1 100644 --- a/crates/iran-split-core/src/lib.rs +++ b/crates/iran-split-core/src/lib.rs @@ -266,6 +266,8 @@ pub struct StackSnapshot { pub operation_id: Option, pub helper: ComponentStatus, #[serde(default)] + pub helper_version: Option, + #[serde(default)] pub clients: Vec, pub mihomo: ComponentStatus, pub tun: ComponentStatus, @@ -289,6 +291,7 @@ impl Default for StackSnapshot { operation_client: None, operation_id: None, helper: ComponentStatus::default(), + helper_version: None, clients: Vec::new(), mihomo: ComponentStatus::default(), tun: ComponentStatus::default(), @@ -361,6 +364,7 @@ pub struct ReadinessReport { #[derive(Debug, Clone, PartialEq, Eq, Default)] pub struct RuntimeHealth { pub helper: ComponentStatus, + pub helper_version: Option, pub clients: Vec, pub mihomo: ComponentStatus, pub tun: ComponentStatus, @@ -1845,6 +1849,7 @@ impl Engine { let health = self.backend.connect_progress_health().await; self.update(|snapshot| { snapshot.helper = health.helper; + snapshot.helper_version = health.helper_version; snapshot.clients = merge_clients_during_connect(&snapshot.clients, &health.clients); }); tokio::task::yield_now().await; @@ -2162,6 +2167,7 @@ fn explain_failed_component(status: &mut ComponentStatus, detail: &str) { fn apply_health(snapshot: &mut StackSnapshot, health: RuntimeHealth) { snapshot.helper = health.helper; + snapshot.helper_version = health.helper_version; snapshot.clients = health.clients; snapshot.mihomo = health.mihomo; snapshot.tun = health.tun; @@ -2368,6 +2374,7 @@ mod tests { let tun = self.tun.load(Ordering::SeqCst); RuntimeHealth { helper, + helper_version: None, clients: vec![ClientComponentStatus { exit_ip: None, id: ClientId::parse("11111111-1111-1111-1111-111111111111").expect("uuid"), diff --git a/crates/iran-split-helper/Cargo.toml b/crates/iran-split-helper/Cargo.toml index c811320..70e8aa5 100644 --- a/crates/iran-split-helper/Cargo.toml +++ b/crates/iran-split-helper/Cargo.toml @@ -25,6 +25,7 @@ uuid.workspace = true [target.'cfg(unix)'.dependencies] nix.workspace = true +libc.workspace = true [target.'cfg(windows)'.dependencies] iran-split-helper-winacl = { path = "../iran-split-helper-winacl" } diff --git a/crates/iran-split-helper/src/lib.rs b/crates/iran-split-helper/src/lib.rs index def87fe..3a4bf85 100644 --- a/crates/iran-split-helper/src/lib.rs +++ b/crates/iran-split-helper/src/lib.rs @@ -334,6 +334,12 @@ impl Supervisor { drop(current); self.push_log("info", "mihomo_started", BTreeMap::new()) .await; + // On macOS the system resolver queries the configured nameserver, and + // the LAN/router DNS bypasses the TUN so Mihomo's `dns-hijack` never + // sees it. Point every network service at `127.0.0.1` (Mihomo listens + // on port 53 as root) so DNS flows through the TUN. + #[cfg(target_os = "macos")] + crate::macos::apply_system_dns(); Ok(status) } @@ -445,14 +451,14 @@ impl Supervisor { pub async fn cleanup(&self) -> Result { self.stop_all_side_tunnels().await; let process_stopped = !self.stop().await?.running; - let interface_path = Path::new("/sys/class/net").join(&self.settings.tun_name); - if interface_path.exists() { + let was_active = interface_exists(&self.settings.tun_name); + if was_active { #[cfg(unix)] delete_owned_interface(&self.settings.tun_name).await?; #[cfg(not(unix))] delete_owned_interface(&self.settings.tun_name); } - let tun_removed = !interface_path.exists(); + let tun_removed = !interface_exists(&self.settings.tun_name); let warnings = if tun_removed { Vec::new() } else { @@ -461,6 +467,10 @@ impl Supervisor { self.settings.tun_name )] }; + // 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")] + crate::macos::restore_system_dns(); let report = CleanupReport { process_stopped, tun_removed, @@ -930,7 +940,7 @@ pub fn redact(input: &str) -> String { .into_owned() } -#[cfg(unix)] +#[cfg(target_os = "linux")] async fn delete_owned_interface(name: &str) -> Result<(), HelperServiceError> { let binary = [Path::new("/usr/sbin/ip"), Path::new("/usr/bin/ip")] .into_iter() @@ -955,17 +965,65 @@ async fn delete_owned_interface(name: &str) -> Result<(), HelperServiceError> { Ok(()) } +#[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 + // a leftover interface is rare. `ifconfig delete` removes the + // address; the kernel reaps the interface once it has no addresses and no + // controlling process. + let output = Command::new("/sbin/ifconfig") + .args([name, "delete"]) + .env_clear() + .stdin(Stdio::null()) + .stdout(Stdio::null()) + .stderr(Stdio::piped()) + .output() + .await?; + if !output.status.success() { + return Err(HelperServiceError::Process( + String::from_utf8_lossy(&output.stderr) + .chars() + .take(1_024) + .collect(), + )); + } + Ok(()) +} + #[cfg(not(unix))] fn delete_owned_interface(_name: &str) {} +/// Returns `true` when a network interface with `name` is present. +#[cfg(target_os = "linux")] +fn interface_exists(name: &str) -> bool { + Path::new("/sys/class/net").join(name).exists() +} + +/// Returns `true` when a network interface with `name` is present. macOS has no +/// `/sys/class/net`; `ifconfig ` exiting successfully is the portable +/// equivalent. +#[cfg(not(target_os = "linux"))] +fn interface_exists(name: &str) -> bool { + std::process::Command::new("ifconfig") + .arg(name) + .stdout(Stdio::null()) + .stderr(Stdio::null()) + .status() + .is_ok_and(|status| status.success()) +} + mod commands; -#[cfg(unix)] +#[cfg(target_os = "linux")] mod linux; - -#[cfg(unix)] +#[cfg(target_os = "linux")] pub use linux::run_linux; +#[cfg(target_os = "macos")] +mod macos; +#[cfg(target_os = "macos")] +pub use macos::run_macos; + #[cfg(windows)] mod windows; diff --git a/crates/iran-split-helper/src/macos.rs b/crates/iran-split-helper/src/macos.rs new file mode 100644 index 0000000..c9b0ee9 --- /dev/null +++ b/crates/iran-split-helper/src/macos.rs @@ -0,0 +1,316 @@ +use super::{commands, HelperServiceError, HelperSettings, Supervisor}; +use iran_split_ipc::{HelloReply, HelperCommand, HelperError, HelperReply, PROTOCOL_VERSION}; +use nix::unistd::{chown, Gid, Uid}; +use std::{ + collections::HashMap, + fs, + os::unix::fs::{FileTypeExt, PermissionsExt}, + path::Path, + process::Command, + sync::{Arc, Mutex}, +}; +use tokio::net::{UnixListener, UnixStream}; +use tracing::{info, warn}; + +/// Snapshot of per-service DNS servers captured before `BiFlow` took over the +/// system resolver, restored on disconnect so the user gets the router DNS +/// back when the TUN is down. +static DNS_SNAPSHOT: Mutex>>> = Mutex::new(None); + +/// The absolute path to `networksetup`. The helper runs as a launchd daemon +/// with a minimal environment; relying on `PATH` lookup can fail silently and +/// leave the system DNS on the LAN router (which bypasses the TUN). +const NETWORKSETUP: &str = "/usr/sbin/networksetup"; + +/// Lists every network service `networksetup` reports (skipping the header +/// line and the asterisk-prefixed disabled-service annotation). +fn list_network_services() -> Vec { + let output = Command::new(NETWORKSETUP) + .arg("-listallnetworkservices") + .output(); + let Ok(output) = output else { + return Vec::new(); + }; + String::from_utf8_lossy(&output.stdout) + .lines() + .skip(1) + .filter_map(|line| { + let name = line.trim_start_matches('*').trim(); + if name.is_empty() { + None + } else { + Some(name.to_owned()) + } + }) + .collect() +} + +fn get_dnsservers(service: &str) -> Vec { + let output = Command::new(NETWORKSETUP) + .args(["-getdnsservers", service]) + .output(); + let Ok(output) = output else { + return Vec::new(); + }; + String::from_utf8_lossy(&output.stdout) + .lines() + .map(str::trim) + .filter(|line| !line.is_empty() && !line.starts_with("There aren't")) + .map(str::to_owned) + .collect() +} + +fn set_dnsservers(service: &str, servers: &[String]) { + let mut args: Vec = vec!["-setdnsservers".into(), service.into()]; + args.extend(servers.iter().cloned()); + let _ = Command::new(NETWORKSETUP).args(args).status(); +} + +/// Snapshots the current per-service DNS configuration and points every +/// network service at `127.0.0.1` so the system resolver queries Mihomo +/// (which listens on `127.0.0.1:53`). The LAN/router resolver bypasses the +/// TUN and cannot resolve blocked domains. +pub fn apply_system_dns() { + let mut snapshot = HashMap::new(); + for service in list_network_services() { + snapshot.insert(service.clone(), get_dnsservers(&service)); + set_dnsservers(&service, &["127.0.0.1".to_owned()]); + } + if snapshot.is_empty() { + warn!( + event = "helper.dns_apply_empty", + section = "helper_dns", + initiator = "helper_process", + cause = "no_network_services", + trace_route = "helper_process->networksetup->system_dns", + "could not enumerate network services; system DNS left unchanged" + ); + } else { + info!( + event = "helper.dns_applied", + section = "helper_dns", + initiator = "helper_process", + cause = "stack_start", + trace_route = "helper_process->networksetup->system_dns", + services = snapshot.len(), + "redirected the macOS system DNS to Mihomo" + ); + } + if let Ok(mut guard) = DNS_SNAPSHOT.lock() { + *guard = Some(snapshot); + } +} + +/// Restores the DNS servers captured by [`apply_system_dns`] for every +/// service, clearing the override when the original had none. +pub fn restore_system_dns() { + let snapshot = { + let Ok(mut guard) = DNS_SNAPSHOT.lock() else { + return; + }; + guard.take() + }; + let Some(snapshot) = snapshot else { + return; + }; + for (service, servers) in &snapshot { + set_dnsservers(service, servers); + } + info!( + event = "helper.dns_restored", + section = "helper_dns", + initiator = "helper_process", + cause = "stack_cleanup", + trace_route = "helper_process->networksetup->system_dns", + services = snapshot.len(), + "restored the macOS system DNS" + ); +} + +/// Runs the macOS helper service and accepts authenticated local IPC clients. +/// +/// # Errors +/// +/// Returns an error when configuration, socket setup, or client acceptance +/// fails. +pub async fn run_macos(config_path: &Path) -> Result<(), HelperServiceError> { + let settings = HelperSettings::load(config_path)?; + let socket_path = settings.socket_path.clone(); + let socket_parent = socket_path + .parent() + .ok_or_else(|| HelperServiceError::UnsafeConfig("socket has no parent".into()))?; + fs::create_dir_all(socket_parent)?; + apply_socket_dir_permissions(socket_parent, settings.authorized_gid)?; + if socket_path.exists() { + let metadata = fs::symlink_metadata(&socket_path)?; + if !metadata.file_type().is_socket() { + return Err(HelperServiceError::UnsafeConfig( + "refusing to replace a non-socket at socket_path".into(), + )); + } + fs::remove_file(&socket_path)?; + } + let listener = UnixListener::bind(&socket_path)?; + apply_socket_permissions(&socket_path, settings.authorized_gid)?; + let supervisor = Arc::new(Supervisor::new(settings)); + info!( + event = "helper.listening", + section = "helper_ipc", + initiator = "helper_process", + cause = "startup_complete", + trace_route = "helper_process->unix_socket->ipc_listener", + path = %socket_path.display(), + "helper listening" + ); + + loop { + let (stream, _) = listener.accept().await?; + let supervisor = Arc::clone(&supervisor); + tokio::spawn(async move { + if let Err(error) = handle_connection(stream, supervisor).await { + warn!( + event = "helper.connection_failed", + section = "helper_ipc", + initiator = "ipc_client", + cause = %error, + trace_route = "ipc_client->helper_connection->command_handler", + "helper connection closed with an error" + ); + } + }); + } +} + +fn apply_socket_dir_permissions(path: &Path, gid: u32) -> Result<(), HelperServiceError> { + fs::set_permissions(path, fs::Permissions::from_mode(0o750))?; + chown_root_group(path, gid) +} + +fn apply_socket_permissions(path: &Path, gid: u32) -> Result<(), HelperServiceError> { + fs::set_permissions(path, fs::Permissions::from_mode(0o660))?; + chown_root_group(path, gid) +} + +fn chown_root_group(path: &Path, gid: u32) -> Result<(), HelperServiceError> { + if gid == 0 || !nix::unistd::geteuid().is_root() { + return Ok(()); + } + chown(path, Some(Uid::from_raw(0)), Some(Gid::from_raw(gid))) + .map_err(|error| HelperServiceError::Io(std::io::Error::other(error.to_string()))) +} + +/// Returns the peer's effective UID for audit logging. +/// +/// The workspace forbids `unsafe`, so the helper cannot call `getpeereid(2)` +/// directly. Access control is enforced by the socket itself — it is owned by +/// `root:authorized_gid` with mode `0o660`, so only members of the authorized +/// group can connect. The audit identity is therefore the configured +/// authorized UID, which on a single-user macOS install is the desktop user +/// that owns the `BiFlow` profile. +fn peer_uid(supervisor: &Supervisor) -> u32 { + supervisor.settings().authorized_uid +} + +async fn handle_connection( + mut stream: UnixStream, + supervisor: Arc, +) -> Result<(), HelperServiceError> { + let peer_uid = peer_uid(&supervisor); + + let hello = commands::read_request(&mut stream).await?; + let HelperCommand::Hello { + supported_protocols, + .. + } = &hello.payload + else { + commands::send_reply( + &mut stream, + hello.reply(HelperReply::Error(HelperError { + code: "HELLO_REQUIRED".into(), + message: "the first command must negotiate the protocol".into(), + retryable: false, + })), + ) + .await?; + return Ok(()); + }; + if !supported_protocols.contains(&PROTOCOL_VERSION) { + commands::send_reply( + &mut stream, + hello.reply(HelperReply::Error(HelperError { + code: "PROTOCOL_MISMATCH".into(), + message: "no supported protocol version overlaps".into(), + retryable: false, + })), + ) + .await?; + return Ok(()); + } + commands::send_reply( + &mut stream, + hello.reply(HelperReply::Hello(HelloReply { + helper_version: env!("CARGO_PKG_VERSION").into(), + selected_protocol: PROTOCOL_VERSION, + capabilities: vec![ + "runtime_generation_v1".into(), + "owned_network_cleanup_v1".into(), + "bounded_logs_v1".into(), + ], + })), + ) + .await?; + + loop { + let request = match commands::read_request(&mut stream).await { + Ok(request) => request, + Err(HelperServiceError::Protocol(iran_split_ipc::ProtocolError::Io(error))) + if matches!( + error.kind(), + std::io::ErrorKind::UnexpectedEof | std::io::ErrorKind::ConnectionReset + ) => + { + return Ok(()); + } + Err(error) => return Err(error), + }; + request.payload.validate()?; + commands::send_reply( + &mut stream, + commands::execute_audited(&supervisor, request, &peer_uid.to_string()).await, + ) + .await?; + } +} + +#[cfg(test)] +mod tests { + use super::{apply_socket_dir_permissions, apply_socket_permissions}; + use std::fs; + use std::os::unix::fs::PermissionsExt; + + #[test] + fn socket_permission_helpers_set_group_readable_modes() { + let directory = tempfile::tempdir().expect("tempdir"); + let parent = directory.path(); + let socket = parent.join("helper.sock"); + fs::write(&socket, []).expect("socket fixture"); + apply_socket_dir_permissions(parent, 0).expect("dir mode"); + apply_socket_permissions(&socket, 0).expect("socket mode"); + assert_eq!( + fs::metadata(parent) + .expect("parent meta") + .permissions() + .mode() + & 0o777, + 0o750 + ); + assert_eq!( + fs::metadata(&socket) + .expect("socket meta") + .permissions() + .mode() + & 0o777, + 0o660 + ); + } +} diff --git a/crates/iran-split-helper/src/main.rs b/crates/iran-split-helper/src/main.rs index 80aa812..779dfe8 100644 --- a/crates/iran-split-helper/src/main.rs +++ b/crates/iran-split-helper/src/main.rs @@ -48,7 +48,7 @@ fn config_path(arguments: &Arguments) -> PathBuf { .unwrap_or_else(|| PathBuf::from(DEFAULT_CONFIG)) } -#[cfg(unix)] +#[cfg(target_os = "linux")] #[tokio::main] async fn main() -> Result<(), Box> { init_tracing(); @@ -60,6 +60,18 @@ async fn main() -> Result<(), Box> { Ok(()) } +#[cfg(target_os = "macos")] +#[tokio::main] +async fn main() -> Result<(), Box> { + init_tracing(); + let arguments = Arguments::parse(); + if arguments.install || arguments.uninstall { + return Err("macOS helper installation uses the launchd installer".into()); + } + iran_split_helper::run_macos(&config_path(&arguments)).await?; + Ok(()) +} + #[cfg(windows)] fn arguments_or_exit() -> Arguments { match Arguments::try_parse() { diff --git a/crates/iran-split-mihomo/src/lib.rs b/crates/iran-split-mihomo/src/lib.rs index 92f3728..d6948d4 100644 --- a/crates/iran-split-mihomo/src/lib.rs +++ b/crates/iran-split-mihomo/src/lib.rs @@ -51,6 +51,7 @@ pub enum MihomoError { pub enum Platform { Linux, Windows, + Macos, } #[derive(Debug, Clone, PartialEq, Eq)] @@ -293,6 +294,14 @@ pub fn generate_config_with_handles( let fake_ip_filter = vec![ "+.lan".into(), "+.local".into(), + // `localhost` is not covered by `+.local`. If it receives a fake-ip + // (198.18.0.0/16), `private.txt`'s 198.18.0.0/15 sends the connection + // DIRECT to a nonexistent address and `http://localhost:4200` never + // reaches the real loopback listener. macOS is the witness: the helper + // points system DNS at Mihomo, so the lookup no longer stops at + // /etc/hosts (ADR 0108 on Windows; same collision as ADR 0061). + "localhost".into(), + "+.localhost".into(), "localhost.ptlogin2.qq.com".into(), "rule-set:custom-direct-domains".into(), "rule-set:iran-domains".into(), @@ -324,7 +333,12 @@ pub fn generate_config_with_handles( auto_redirect: false, auto_detect_interface: true, strict_route: platform == Platform::Windows, - route_exclude_address: if platform == Platform::Windows { + // Windows `strict-route` and macOS `auto-route` can steal loopback + // destinations even when the rule list says DIRECT. Keep + // `localhost` / `127.0.0.1` / `::1` on the OS loopback interface + // so local dev servers (witness: `:4200`) stay reachable. Linux + // already keeps `lo` more specific than the TUN default. + route_exclude_address: if matches!(platform, Platform::Windows | Platform::Macos) { vec!["127.0.0.0/8".into(), "::1/128".into()] } else { Vec::new() @@ -388,9 +402,9 @@ pub fn generate_config_with_handles( port: outbound.port, udp: outbound.udp, interface_name: outbound.interface_name.clone(), - // `routing-mark` is a Linux fwmark. On Windows it does not - // steer the socket and can drop the dial. - routing_mark: (platform != Platform::Windows) + // `routing-mark` is a Linux fwmark. On Windows and macOS it + // does not steer the socket and can drop the dial. + routing_mark: (platform == Platform::Linux) .then_some(outbound.routing_mark) .flatten(), }) @@ -576,6 +590,7 @@ fn process_bypass_rules(app: &AppConfig, platform: Platform) -> Vec { let driver_platform = match platform { Platform::Linux => DriverPlatform::Linux, Platform::Windows => DriverPlatform::Windows, + Platform::Macos => DriverPlatform::Macos, }; let mut rules: Vec = process_bypass_union(&app.clients, driver_platform) .into_iter() @@ -603,6 +618,13 @@ fn process_bypass_rules(app: &AppConfig, platform: Platform) -> Vec { "PROCESS-NAME,BiFlow.exe,DIRECT".into(), ]); } + Platform::Macos => { + rules.extend([ + "PROCESS-NAME,tailscaled,DIRECT".into(), + "PROCESS-NAME,iran-split-desktop,DIRECT".into(), + "PROCESS-NAME,BiFlow,DIRECT".into(), + ]); + } } rules } @@ -1588,6 +1610,8 @@ mod tests { .and_then(serde_yaml::Value::as_sequence) .expect("fake-ip-filter"); for key in [ + "localhost", + "+.localhost", "rule-set:custom-direct-domains", "rule-set:iran-domains", "rule-set:iran-business-domains", @@ -1651,6 +1675,32 @@ mod tests { assert!(!generated.yaml.contains("kubectl.exe")); } + #[test] + fn macos_excludes_loopback_from_tun_routes() { + let generated = generate_config( + &AppConfig::default(), + Platform::Macos, + &paths(), + &RoutePinsDocument::default(), + ) + .expect("config"); + assert!(generated.yaml.contains("route-exclude-address:")); + assert!(generated.yaml.contains("- 127.0.0.0/8")); + assert!(generated.yaml.contains("- ::1/128")); + assert!(generated.yaml.contains("strict-route: false")); + let parsed: serde_yaml::Value = + serde_yaml::from_str(&generated.yaml).expect("generated yaml"); + let filter = parsed + .get("dns") + .and_then(|dns| dns.get("fake-ip-filter")) + .and_then(serde_yaml::Value::as_sequence) + .expect("fake-ip-filter"); + assert!(filter.iter().any(|item| item.as_str() == Some("localhost"))); + assert!(filter + .iter() + .any(|item| item.as_str() == Some("+.localhost"))); + } + #[test] fn application_routes_are_emitted_before_destination_rules() { let mut routes = RoutePinsDocument::default(); @@ -1748,6 +1798,8 @@ mod tests { .and_then(serde_yaml::Value::as_sequence) .expect("fake-ip-filter"); for key in [ + "localhost", + "+.localhost", "rule-set:custom-direct-domains", "rule-set:iran-domains", "rule-set:iran-business-domains", @@ -2586,6 +2638,11 @@ mod tests { workspace.join("vendor/mihomo/windows-x86_64/mihomo.exe"), Platform::Windows, ) + } else if cfg!(target_os = "macos") { + ( + workspace.join("vendor/mihomo/darwin/mihomo"), + Platform::Macos, + ) } else { ( workspace.join("vendor/mihomo/linux-x86_64/mihomo"), diff --git a/crates/iran-split-platform-linux/src/lib.rs b/crates/iran-split-platform-linux/src/lib.rs index c27c164..9d54ddd 100644 --- a/crates/iran-split-platform-linux/src/lib.rs +++ b/crates/iran-split-platform-linux/src/lib.rs @@ -1316,8 +1316,13 @@ impl PlatformBackend for LinuxBackend { } let live_route = self.observe_live_route(&config, &handles).await; + let helper_version = match &helper_result { + Ok(status) => status.version.clone(), + Err(_) => None, + }; RuntimeHealth { helper, + helper_version, clients, mihomo, tun, diff --git a/crates/iran-split-platform-macos/Cargo.toml b/crates/iran-split-platform-macos/Cargo.toml new file mode 100644 index 0000000..af4f06b --- /dev/null +++ b/crates/iran-split-platform-macos/Cargo.toml @@ -0,0 +1,33 @@ +[package] +name = "iran-split-platform-macos" +version.workspace = true +edition.workspace = true +license.workspace = true +rust-version.workspace = true +publish.workspace = true + +[dependencies] +async-trait.workspace = true +chrono.workspace = true +hex.workspace = true +iran-split-clients = { path = "../iran-split-clients" } +iran-split-config = { path = "../iran-split-config" } +iran-split-core = { path = "../iran-split-core" } +iran-split-ipc = { path = "../iran-split-ipc" } +iran-split-mihomo = { path = "../iran-split-mihomo" } +iran-split-rules = { path = "../iran-split-rules" } +serde.workspace = true +serde_json.workspace = true +sha2.workspace = true +tempfile.workspace = true +thiserror.workspace = true +tokio.workspace = true +tokio-util.workspace = true +tracing.workspace = true +uuid.workspace = true + +[dev-dependencies] +tempfile.workspace = true + +[lints] +workspace = true diff --git a/crates/iran-split-platform-macos/src/lib.rs b/crates/iran-split-platform-macos/src/lib.rs new file mode 100644 index 0000000..eba41f8 --- /dev/null +++ b/crates/iran-split-platform-macos/src/lib.rs @@ -0,0 +1,2746 @@ +#![cfg(target_os = "macos")] + +use async_trait::async_trait; +use iran_split_clients::{ + local_proxy_endpoint, synthesized_local_handle, ClientDriver, EgressHandle, OpenVpnDriver, +}; +use iran_split_config::{ + AppConfig, ClientConfig, ClientInstance, EgressKind, ExecutableSetting, PresetId, +}; +use iran_split_core::{ + CleanupReport, ClientComponentStatus, ComponentPhase, ComponentStatus, CoreError, HelperStatus, + LiveRoute, PlatformBackend, ProcessStatus, ProviderSummary, ReadinessReport, RuntimeGeneration, + RuntimeHealth, TunStatus, +}; +use iran_split_ipc::{ + helper_ipc_reply_timeout, read_frame, validate_envelope, write_frame, Envelope, HelperCommand, + HelperReply, HELPER_IPC_FRAME_TIMEOUT_SECS, PROTOCOL_VERSION, +}; +use iran_split_mihomo::{ + expected_match_proxy, generate_config_with_handles, match_reload_disagrees, + probe_hiddify_egress, redact_controller_secret, validate_with_binary, ControllerClient, + MihomoError, Platform, RuntimePaths, +}; +use iran_split_rules::{DirectTarget, Outbound, RoutePinsDocument}; +use sha2::{Digest, Sha256}; +use std::{ + fs, + io::Write, + net::IpAddr, + path::{Path, PathBuf}, + process::Stdio, + sync::Arc, + time::Duration, +}; +use tempfile::NamedTempFile; +use thiserror::Error; +use tokio::{ + net::{TcpStream, UnixStream}, + process::{Child, Command}, + sync::{Mutex, RwLock}, +}; +use tokio_util::sync::CancellationToken; +use tracing::{error, info, warn}; +use uuid::Uuid; + +mod system_proxy; + +const IPC_TIMEOUT: Duration = Duration::from_secs(HELPER_IPC_FRAME_TIMEOUT_SECS); + +#[derive(Debug, Error)] +pub enum MacosBackendError { + #[error("helper IPC failed: {0}")] + Protocol(#[from] iran_split_ipc::ProtocolError), + #[error("helper connection failed: {0}")] + Io(#[from] std::io::Error), + #[error("helper request timed out")] + Timeout, + #[error("helper response did not match the request")] + ResponseMismatch, + #[error("helper returned {code}: {message}")] + Helper { code: String, message: String }, +} + +#[derive(Debug, Clone)] +pub struct HelperClient { + socket_path: PathBuf, +} + +impl HelperClient { + #[must_use] + pub fn new(socket_path: impl Into) -> Self { + Self { + socket_path: socket_path.into(), + } + } + + /// Sends one validated command to the privileged helper. + /// + /// # Errors + /// + /// Returns an error when validation, connection, protocol negotiation, or + /// the helper operation fails. + pub async fn request(&self, command: HelperCommand) -> Result { + let command_name = command.audit_name(); + let request_id = Uuid::new_v4(); + info!( + event = "helper.request_started", + section = "helper_ipc", + initiator = "macos_platform_backend", + cause = "backend_operation", + trace_id = %request_id, + trace_route = "desktop_engine->macos_platform_backend->helper_ipc", + command = command_name, + "helper request started" + ); + if let Err(cause) = command.validate() { + error!( + event = "helper.request_failed", + section = "helper_ipc", + initiator = "macos_platform_backend", + cause = %cause, + trace_id = %request_id, + trace_route = "desktop_engine->macos_platform_backend->helper_ipc->validation", + command = command_name, + "helper request validation failed" + ); + return Err(cause.into()); + } + let result = self.request_validated(command).await; + match &result { + Ok(_) => info!( + event = "helper.request_completed", + section = "helper_ipc", + initiator = "macos_platform_backend", + cause = "none", + trace_id = %request_id, + trace_route = "desktop_engine->macos_platform_backend->helper_ipc->reply", + command = command_name, + "helper request completed" + ), + Err(cause) => error!( + event = "helper.request_failed", + section = "helper_ipc", + initiator = "macos_platform_backend", + cause = %cause, + trace_id = %request_id, + trace_route = "desktop_engine->macos_platform_backend->helper_ipc->error", + command = command_name, + "helper request failed" + ), + } + result + } + + async fn request_validated( + &self, + command: HelperCommand, + ) -> Result { + let mut stream = tokio::time::timeout(IPC_TIMEOUT, UnixStream::connect(&self.socket_path)) + .await + .map_err(|_| MacosBackendError::Timeout)??; + let hello = Envelope::new(HelperCommand::Hello { + client_version: env!("CARGO_PKG_VERSION").into(), + supported_protocols: vec![PROTOCOL_VERSION], + }); + let hello_reply = exchange(&mut stream, &hello, IPC_TIMEOUT).await?; + match hello_reply.payload { + HelperReply::Hello(reply) if reply.selected_protocol == PROTOCOL_VERSION => {} + HelperReply::Error(error) => { + return Err(MacosBackendError::Helper { + code: error.code, + message: error.message, + }); + } + _ => return Err(MacosBackendError::ResponseMismatch), + } + let request = Envelope::new(command); + let budget = helper_ipc_reply_timeout(&request.payload); + let response = exchange(&mut stream, &request, budget).await?; + match response.payload { + HelperReply::Error(error) => Err(MacosBackendError::Helper { + code: error.code, + message: error.message, + }), + reply => Ok(reply), + } + } +} + +async fn exchange( + stream: &mut UnixStream, + request: &Envelope, + budget: Duration, +) -> Result, MacosBackendError> { + tokio::time::timeout(IPC_TIMEOUT, write_frame(stream, request)) + .await + .map_err(|_| MacosBackendError::Timeout)??; + let reply: Envelope = tokio::time::timeout(budget, read_frame(stream)) + .await + .map_err(|_| MacosBackendError::Timeout)??; + validate_envelope(&reply)?; + if reply.request_id != request.request_id { + return Err(MacosBackendError::ResponseMismatch); + } + Ok(reply) +} + +#[derive(Debug, Clone)] +pub struct MacosPaths { + pub socket_path: PathBuf, + pub user_data_dir: PathBuf, + pub system_runtime_dir: PathBuf, + pub resources_dir: PathBuf, + pub rules_cache_dir: PathBuf, + pub mihomo_binary: PathBuf, +} + +#[derive(Debug, Clone)] +struct PreparedGeneration { + generation: RuntimeGeneration, + config_path: PathBuf, +} + +#[derive(Debug)] +pub struct MacosBackend { + config: Arc>, + helper: HelperClient, + paths: MacosPaths, + prepared: Mutex>, + launched_hiddify: Mutex>, + egress_exit_ip: Mutex>, + egress_handles: Mutex>, + side_tunnel_auth_files: Mutex>, + launched_clients: Mutex>, + client_exit_ips: Mutex>, + /// Why each client failed at its last start attempt. Connect only warns + /// for optional clients, so without this the card said "Starting" or + /// "Stopped" with no detail and the operator had to read debug.log. + client_failures: Mutex>, + /// Connect-time override for `StartSideTunnel` (progressive 15/30/60s UX). + side_tunnel_connect_timeout: Mutex>, + /// Config hash that already passed `mihomo -t` in this process. + validated_config_sha256: Mutex>, +} + +impl MacosBackend { + #[must_use] + pub fn new(config: AppConfig, paths: MacosPaths) -> Self { + Self { + config: Arc::new(RwLock::new(config)), + helper: HelperClient::new(&paths.socket_path), + paths, + prepared: Mutex::new(None), + launched_hiddify: Mutex::new(None), + egress_exit_ip: Mutex::new(None), + egress_handles: Mutex::new(Vec::new()), + side_tunnel_auth_files: Mutex::new(Vec::new()), + launched_clients: Mutex::new(Vec::new()), + client_exit_ips: Mutex::new(std::collections::HashMap::new()), + client_failures: Mutex::new(std::collections::HashMap::new()), + side_tunnel_connect_timeout: Mutex::new(None), + validated_config_sha256: Mutex::new(None), + } + } + + async fn effective_side_tunnel_timeout(&self, configured: u64) -> u64 { + self.side_tunnel_connect_timeout + .lock() + .await + .unwrap_or(configured) + } + + pub async fn update_config(&self, config: AppConfig) { + *self.config.write().await = config; + } + + /// Reads at most `maximum` recent helper service log entries. + /// + /// # Errors + /// + /// Returns an error when the helper exchange fails or returns an unexpected + /// reply variant. + pub async fn service_logs( + &self, + maximum: u16, + ) -> Result, MacosBackendError> { + match self + .helper + .request(HelperCommand::CollectServiceLogs { + max_entries: maximum, + }) + .await? + { + HelperReply::Logs(logs) => Ok(logs), + _ => Err(MacosBackendError::ResponseMismatch), + } + } + + async fn helper_request(&self, command: HelperCommand) -> Result { + self.helper + .request(command) + .await + .map_err(|error| CoreError::Platform(error.to_string())) + } + + async fn register_runtime(&self, generation: &RuntimeGeneration) -> Result<(), CoreError> { + match self + .helper_request(HelperCommand::RegisterRuntimeGeneration { + generation_id: generation.generation_id, + config_sha256: generation.config_sha256.clone(), + }) + .await? + { + HelperReply::GenerationRegistered { generation_id } + if generation_id == generation.generation_id => + { + Ok(()) + } + _ => Err(CoreError::Platform("generation registration failed".into())), + } + } + + async fn spawn_core(&self, generation: &RuntimeGeneration) -> Result<(), CoreError> { + match self + .helper_request(HelperCommand::StartMihomo { + generation_id: generation.generation_id, + config_sha256: generation.config_sha256.clone(), + }) + .await? + { + HelperReply::ProcessStatus(status) if status.running => Ok(()), + _ => Err(CoreError::MihomoStartFailed( + "helper did not report a running process".into(), + )), + } + } + + async fn hot_reload_running( + &self, + rebind_host: Option<&str>, + config_path: &Path, + ) -> Result<(), CoreError> { + let config = self.config.read().await.clone(); + let controller = ControllerClient::new( + &config.mihomo.controller_host, + config.mihomo.controller_port, + config.mihomo.controller_secret.clone(), + ) + .map_err(|error| CoreError::ConfigInvalid(error.to_string()))?; + info!( + event = "mihomo.hot_reload_started", + section = "rules", + initiator = "macos_platform_backend", + cause = "live_pin_apply", + trace_route = "engine->macos_platform_backend->mihomo_controller", + "reloading Mihomo config without restarting the process" + ); + controller + .hot_reload(config_path) + .await + .map_err(|error| CoreError::MihomoStartFailed(error.to_string()))?; + let generation_id = config_path + .parent() + .and_then(|dir| dir.file_name()) + .map_or_else( + || "unknown".to_owned(), + |name| name.to_string_lossy().into_owned(), + ); + let match_proxy = match controller.live_match_proxy().await { + Ok(proxy) => proxy.unwrap_or_else(|| "missing".into()), + Err(cause) => { + warn!( + event = "mihomo.live_match_failed", + section = "rules", + initiator = "macos_platform_backend", + cause = %cause, + generation_id = %generation_id, + trace_route = "engine->macos_platform_backend->mihomo_controller", + "could not read the live MATCH rule after reload" + ); + "unavailable".into() + } + }; + ensure_live_match( + &std::fs::read_to_string(config_path).unwrap_or_default(), + &match_proxy, + &generation_id, + "macos_platform_backend", + )?; + info!( + event = "mihomo.hot_reload_succeeded", + section = "rules", + initiator = "macos_platform_backend", + cause = "controller_204", + generation_id = %generation_id, + match_proxy = %match_proxy, + trace_route = "engine->macos_platform_backend->mihomo_controller", + "live Mihomo config reloaded" + ); + let Some(host) = rebind_host else { + match controller.close_all_connections().await { + Ok(()) => info!( + event = "mihomo.connections_reset", + section = "rules", + initiator = "macos_platform_backend", + cause = "settings_apply", + trace_route = "engine->macos_platform_backend->mihomo_controller", + "closed live connections so they reconnect on the reloaded default route" + ), + Err(cause) => warn!( + event = "mihomo.connections_reset_failed", + section = "rules", + initiator = "macos_platform_backend", + cause = %cause, + trace_route = "engine->macos_platform_backend->mihomo_controller", + "could not close live connections after the settings reload" + ), + } + return Ok(()); + }; + match controller.close_connections_for_pin_apply(host).await { + Ok(closed) => info!( + event = "mihomo.connections_rebound", + section = "rules", + initiator = "macos_platform_backend", + cause = "pin_apply", + trace_route = "engine->macos_platform_backend->mihomo_controller", + closed, + "closed live connections for the moved pin so they reconnect on the new outbound" + ), + Err(cause) => warn!( + event = "mihomo.connections_rebind_failed", + section = "rules", + initiator = "macos_platform_backend", + cause = %cause, + trace_route = "engine->macos_platform_backend->mihomo_controller", + "could not close matching connections after the live reload" + ), + } + Ok(()) + } + + /// Loopback SOCKS endpoint of a client that is already serving traffic. + /// + /// `ready` are the handles collected so far in this connect, because + /// `egress_handles` is only published after every client has started. + async fn ready_proxy_endpoint(&self, ready: &[EgressHandle]) -> Option<(String, u16)> { + let config = self.config.read().await.clone(); + let published = self.egress_handles.lock().await.clone(); + config + .enabled_clients() + .into_iter() + .filter(|candidate| { + ready + .iter() + .chain(published.iter()) + .any(|handle| handle.client_id == candidate.id && handle.ready) + }) + .find_map(local_proxy_endpoint) + } + + /// Resolves the profile hostname once through a ready client and reuses the + /// stored address later. The imported `.ovpn` is never rewritten. + async fn pin_side_tunnel_remote( + &self, + profile: &Path, + proxy: Option<&(String, u16)>, + ) -> Result, CoreError> { + let proxy = proxy.map(|(host, port)| (host.as_str(), *port)); + match iran_split_clients::pin_profile_remote(profile, proxy, &self.paths.user_data_dir) + .await + { + Ok(None) => { + info!( + event = "side_tunnel.remote_resolve_skipped", + section = "clients", + initiator = "start_openvpn_client", + cause = "no_stored_address", + trace_route = "engine->platform_backend->doh_resolver", + "no stored side-tunnel address; using the profile hostname" + ); + Ok(None) + } + Ok(Some(pin)) => { + if !pin.persisted { + warn!( + event = "side_tunnel.remote_pin_not_stored", + section = "clients", + initiator = "start_openvpn_client", + cause = "cache_write_failed", + trace_route = "engine->platform_backend->doh_resolver", + "side-tunnel address was resolved but not stored for the next start" + ); + } + info!( + event = "side_tunnel.remote_resolved", + section = "clients", + initiator = "start_openvpn_client", + cause = "stored_or_fresh_pin", + trace_route = "engine->platform_backend->doh_resolver", + "pinned the side-tunnel server without logging the address or editing the profile" + ); + Ok(Some((pin.address, pin.port))) + } + Err(error) if error.contains("openvpn profile") => Err(CoreError::ConfigInvalid(error)), + Err(error) => { + error!( + event = "side_tunnel.remote_resolve_failed", + section = "clients", + initiator = "start_openvpn_client", + cause = %error, + trace_route = "engine->platform_backend->doh_resolver", + "refusing to start OpenVPN on a filtered DNS name" + ); + Err(CoreError::Platform(error)) + } + } + } + + async fn start_openvpn_client( + &self, + client: &ClientInstance, + cancel: CancellationToken, + ready: &[EgressHandle], + ) -> Result { + let mut handle = OpenVpnDriver + .ensure(client, cancel) + .await + .map_err(|error| CoreError::ConfigInvalid(error.to_string()))?; + let ClientConfig::OwnedSideTunnel { + profile_path, + executable, + username, + password, + start_timeout_seconds, + } = &client.config + else { + return Err(CoreError::ConfigInvalid( + "openvpn instance is not a side tunnel".into(), + )); + }; + let profile = profile_path + .clone() + .ok_or_else(|| CoreError::ConfigInvalid("openvpn profile path is missing".into()))?; + let executable = match executable { + ExecutableSetting::Auto => None, + ExecutableSetting::Path(path) => Some(path.clone()), + }; + let auth_file = write_side_tunnel_auth( + &self.paths.user_data_dir, + username.as_deref(), + password.as_deref(), + )?; + let proxy = self.ready_proxy_endpoint(ready).await; + let pinned_remote = self + .pin_side_tunnel_remote(&profile, proxy.as_ref()) + .await?; + if let Some((address, _)) = pinned_remote { + handle.transport_excludes = vec![iran_split_clients::public_host_exclude(address) + .map_err(|error| { + CoreError::Platform(format!("side-tunnel bypass prefix is invalid: {error}")) + })?]; + } + let timeout_seconds = self + .effective_side_tunnel_timeout(*start_timeout_seconds) + .await; + let result = self + .helper_request(HelperCommand::StartSideTunnel { + driver: "openvpn".into(), + client_id: client.id.into(), + profile, + executable, + auth_file: auth_file.as_ref().map(|file| file.path().to_path_buf()), + timeout_seconds, + pinned_remote, + socks_proxy: proxy, + }) + .await?; + // OpenVPN re-reads the auth file on soft restarts, so it must outlive + // the start call; it is dropped (deleted) on cleanup. + if let Some(file) = auth_file { + self.side_tunnel_auth_files.lock().await.push(file); + } + match result { + HelperReply::SideTunnel(status) if status.running => { + if let Some(outbound) = handle.outbound.as_mut() { + outbound.interface_name = status.device; + outbound.routing_mark = status.routing_mark; + } + handle.ready = true; + Ok(handle) + } + HelperReply::SideTunnel(_) => { + Err(CoreError::Platform("side tunnel did not start".into())) + } + _ => Err(CoreError::Platform("unexpected side tunnel reply".into())), + } + } + + async fn ensure_enabled_clients(&self, cancel: CancellationToken) -> Result<(), CoreError> { + let config = self.config.read().await.clone(); + *self.egress_exit_ip.lock().await = None; + let mut handles = Vec::new(); + for client in config.enabled_clients() { + let required = config.default_route.client_id() == Some(client.id); + let started = match client.spec().kind { + EgressKind::LocalProxy => { + self.start_local_proxy_client(client, required, &cancel) + .await + } + EgressKind::OwnedSideTunnel => { + self.start_openvpn_client(client, cancel.clone(), &handles) + .await + } + EgressKind::Unsupported => Err(CoreError::ConfigInvalid( + "this catalog entry cannot be started".into(), + )), + }; + match started { + Ok(handle) => { + self.client_failures.lock().await.remove(&client.id); + handles.push(handle); + self.egress_handles.lock().await.clone_from(&handles); + } + Err(CoreError::Cancelled) => return Err(CoreError::Cancelled), + Err(error) if !required => { + self.client_failures + .lock() + .await + .insert(client.id, error.to_string()); + warn!( + event = "client.ensure_failed", + section = "clients", + initiator = "ensure_clients", + cause = %error, + trace_route = "engine_operation->macos_platform_backend->ensure_clients", + "optional client failed; connect continues" + ); + } + Err(error) => { + self.client_failures + .lock() + .await + .insert(client.id, error.to_string()); + return Err(error); + } + } + } + *self.egress_handles.lock().await = handles; + Ok(()) + } + + /// Live recovery for local proxies that were dead at connect time + /// (ADR 0076). Connect probes each optional client exactly once; when the + /// operator starts and connects Happ minutes later, its pinned domains + /// keep the client group (ADR 0082) but still need this re-check to attach + /// the live SOCKS bind. The engine then hot-applies the routing. + async fn recover_local_proxy_clients(&self) -> Result { + let config = self.config.read().await.clone(); + let handles = self.egress_handles.lock().await.clone(); + let mut recovered = false; + for client in clients_missing_egress(&config, &handles) { + let Some((host, port)) = local_proxy_endpoint(client) else { + continue; + }; + if !Self::tcp_listening(&host, port).await { + continue; + } + match probe_hiddify_egress(&host, port, Duration::from_secs(3)).await { + Ok(exit_ip) => { + self.client_failures.lock().await.remove(&client.id); + let Some(handle) = synthesized_local_handle(client) else { + continue; + }; + info!( + event = "client.recovered_egress", + section = "clients", + initiator = "recover_clients", + cause = "egress_probe_succeeded", + trace_route = "engine->macos_platform_backend->recover_clients", + client = client.spec().id, + "a local proxy egress became reachable after connect" + ); + let is_default = config.default_route.client_id() == Some(client.id); + self.client_exit_ips + .lock() + .await + .insert(client.id, exit_ip.clone()); + if is_default { + *self.egress_exit_ip.lock().await = Some(exit_ip); + } + self.client_failures.lock().await.remove(&client.id); + self.egress_handles.lock().await.push(handle); + recovered = true; + } + Err(error) => { + self.client_failures.lock().await.insert( + client.id, + format!( + "{host}:{port} accepts connections but no traffic flows; connect the client itself" + ), + ); + info!( + event = "client.recover_probe_failed", + section = "clients", + initiator = "recover_clients", + cause = %error, + trace_route = "engine->macos_platform_backend->recover_clients", + client = client.spec().id, + "local proxy port answers but its egress is not usable yet" + ); + } + } + } + Ok(recovered) + } + + async fn start_local_proxy_client( + &self, + client: &ClientInstance, + required: bool, + cancel: &CancellationToken, + ) -> Result { + let Some((host, port)) = local_proxy_endpoint(client) else { + return Err(CoreError::ConfigInvalid( + "local proxy handle is missing".into(), + )); + }; + if let Some(exit_ip) = self.reuse_cached_egress(client.id, &host, port).await { + if required { + *self.egress_exit_ip.lock().await = Some(exit_ip); + } + return synthesized_local_handle(client) + .ok_or_else(|| CoreError::ConfigInvalid("local proxy handle is missing".into())); + } + if !Self::tcp_listening(&host, port).await { + if required { + self.launch_local_proxy_if_needed(client, &host, port, cancel) + .await?; + } else { + // An optional client must not block Connect while its port + // opens (production debug.log shows ~18s stalls waiting for + // Happ). Launch it and let ADR 0076 recovery attach the + // egress once it actually serves. + self.spawn_local_proxy(client).await?; + return Err(CoreError::Platform(format!( + "{} was launched in the background; its egress joins routing once it serves", + client.spec().id + ))); + } + } + // ADR 0018: every local-proxy egress is verified before the TUN starts, + // so pinned or MATCH traffic cannot blackhole into a dead proxy. + let probe_for = if required { + client_start_timeout(client) + } else { + Duration::from_secs(3) + }; + let exit_ip = probe_hiddify_egress(&host, port, probe_for) + .await + .map_err(|error| { + CoreError::Platform(format!( + "{} egress probe failed on {host}:{port}: {error}", + client.spec().id + )) + })?; + self.client_exit_ips + .lock() + .await + .insert(client.id, exit_ip.clone()); + if required { + *self.egress_exit_ip.lock().await = Some(exit_ip); + } + synthesized_local_handle(client) + .ok_or_else(|| CoreError::ConfigInvalid("local proxy handle is missing".into())) + } + + /// Resolves and spawns a local-proxy binary without waiting for its port: + /// configured path first, then the preset's process names on PATH. + async fn spawn_local_proxy(&self, client: &ClientInstance) -> Result<(), CoreError> { + let ClientConfig::LocalProxy { executable, .. } = &client.config else { + return Err(CoreError::ConfigInvalid( + "client is not a local proxy".into(), + )); + }; + let resolved = match executable { + ExecutableSetting::Path(path) => path.is_file().then(|| path.clone()), + ExecutableSetting::Auto if client.preset == PresetId::Hiddify => { + Self::discover_hiddify(&self.config.read().await.clone(), &self.paths.user_data_dir) + } + ExecutableSetting::Auto => discover_local_proxy_binary(&client.spec()), + }; + let Some(binary) = resolved else { + warn!( + event = "local_proxy.binary_not_found", + section = "local_proxy", + initiator = "macos_platform_backend", + cause = "discovery_empty", + trace_route = "engine->macos_platform_backend->local_proxy_discovery", + preset = ?client.preset, + "local proxy executable was not found; the operator must start it manually" + ); + return Err(CoreError::Platform(format!( + "{} is not running and its executable was not found; start it once or set its path on the client card", + client.spec().id + ))); + }; + // A macOS `.app` bundle must be launched through Launch Services + // (`open .app`). Running the inner Mach-O binary directly + // starts the process but the Flutter/Electron app often never + // initializes its proxy port because it expects the bundle + // environment. Detect the enclosing bundle and use `open`. + let (child, via_open) = if let Some(bundle) = enclosing_app_bundle(&binary) { + info!( + event = "local_proxy.launching", + section = "local_proxy", + initiator = "macos_platform_backend", + cause = "port_not_listening", + trace_route = "engine->macos_platform_backend->launch_services", + preset = ?client.preset, + binary = %binary.display(), + bundle = %bundle.display(), + "launching the local proxy .app bundle via open" + ); + ( + Command::new("open") + .arg(&bundle) + .stdin(Stdio::null()) + .stdout(Stdio::null()) + .stderr(Stdio::null()) + .kill_on_drop(false) + .spawn() + .map_err(|error| CoreError::Platform(error.to_string()))?, + true, + ) + } else { + info!( + event = "local_proxy.launching", + section = "local_proxy", + initiator = "macos_platform_backend", + cause = "port_not_listening", + trace_route = "engine->macos_platform_backend->process_spawn", + preset = ?client.preset, + binary = %binary.display(), + "launching the local proxy binary directly" + ); + ( + Command::new(binary) + .stdin(Stdio::null()) + .stdout(Stdio::null()) + .stderr(Stdio::null()) + .kill_on_drop(false) + .spawn() + .map_err(|error| CoreError::Platform(error.to_string()))?, + false, + ) + }; + let _ = via_open; + if client.preset == PresetId::Hiddify { + *self.launched_hiddify.lock().await = Some(child); + } else { + self.launched_clients.lock().await.push(child); + } + Ok(()) + } + + /// Launches a required local-proxy client that is not listening yet and + /// waits until the port answers (the default-route egress must be + /// verified before the TUN starts). + async fn launch_local_proxy_if_needed( + &self, + client: &ClientInstance, + host: &str, + port: u16, + cancel: &CancellationToken, + ) -> Result<(), CoreError> { + let ClientConfig::LocalProxy { .. } = &client.config else { + return Err(CoreError::ConfigInvalid( + "client is not a local proxy".into(), + )); + }; + self.spawn_local_proxy(client).await?; + let deadline = tokio::time::Instant::now() + client_start_timeout(client); + loop { + if Self::tcp_listening(host, port).await { + return Ok(()); + } + if tokio::time::Instant::now() >= deadline { + return Err(CoreError::Platform(format!( + "{} was launched but its local port did not open in time", + client.spec().id + ))); + } + tokio::select! { + () = cancel.cancelled() => return Err(CoreError::Cancelled), + () = tokio::time::sleep(Duration::from_millis(250)) => {} + } + } + } + + async fn hiddify_listening(config: &AppConfig) -> bool { + let (host, port) = config.hiddify_endpoint(); + Self::tcp_listening(&host, port).await + } + + async fn tcp_listening(host: &str, port: u16) -> bool { + tokio::time::timeout(Duration::from_millis(750), TcpStream::connect((host, port))) + .await + .is_ok_and(|result| result.is_ok()) + } + + async fn client_component( + client: &ClientInstance, + handles: &[EgressHandle], + last_failure: Option<&String>, + ) -> ComponentStatus { + if !client.enabled { + return ComponentStatus::new(ComponentPhase::Unavailable, None); + } + match client.spec().kind { + EgressKind::LocalProxy => match local_proxy_endpoint(client) { + Some((host, port)) if Self::tcp_listening(&host, port).await => { + ComponentStatus::new( + ComponentPhase::Running, + Some(format!("Listening on {host}:{port}")), + ) + } + Some((host, port)) => ComponentStatus::new( + ComponentPhase::Stopped, + Some(last_failure.cloned().unwrap_or_else(|| { + format!( + "nothing is listening on {host}:{port}; check the port on the client card" + ) + })), + ), + None => ComponentStatus::new( + ComponentPhase::Stopped, + Some("local proxy is not listening".into()), + ), + }, + EgressKind::OwnedSideTunnel => { + if handles + .iter() + .any(|handle| handle.client_id == client.id && handle.ready) + { + ComponentStatus::new(ComponentPhase::Running, None) + } else { + ComponentStatus::new( + ComponentPhase::Stopped, + last_failure + .cloned() + .or_else(|| iran_split_clients::side_tunnel_stopped_reason(client)), + ) + } + } + EgressKind::Unsupported => ComponentStatus::new(ComponentPhase::Unavailable, None), + } + } + + fn discover_hiddify(config: &AppConfig, data: &Path) -> Option { + if let ExecutableSetting::Path(path) = &config.hiddify_executable() { + return resolve_macos_binary(path); + } + // On macOS, `.app` bundles must be launched through `open` (Launch + // Services). A plain `hiddify` binary in the data directory may be a + // leftover Linux AppImage symlink (ELF, not Mach-O) that cannot run on + // macOS, so prefer `.app` bundles first and verify any plain binary is + // a Mach-O executable before accepting it. + let mut candidates = vec![ + data.join("apps/Hiddify.app"), + PathBuf::from("/Applications/Hiddify.app"), + data.join("bin/hiddify"), + PathBuf::from("/usr/local/bin/hiddify"), + PathBuf::from("/opt/homebrew/bin/hiddify"), + ]; + if let Some(home) = std::env::var_os("HOME") { + let home = PathBuf::from(home); + // The installed app keeps its managed Hiddify here. A dev run + // uses an isolated profile, so without these entries it could + // not find the Hiddify the user already installed. + candidates.push(home.join("Applications/Hiddify.app")); + candidates.push(home.join(".local/share/biflow/apps/Hiddify.app")); + candidates.push(home.join(".local/share/biflow/bin/hiddify")); + } + if let Some(path) = std::env::var_os("PATH") { + for dir in std::env::split_paths(&path) { + candidates.push(dir.join("hiddify")); + } + } + candidates + .into_iter() + .find_map(|candidate| resolve_macos_binary(&candidate)) + } + + fn helper_component(result: Result) -> ComponentStatus { + match result { + Ok(status) if status.available && status.authorized => ComponentStatus::new( + ComponentPhase::Running, + status + .version + .map(|version| format!("Helper {version} is ready")), + ), + Ok(status) if status.available => ComponentStatus::new( + ComponentPhase::Degraded, + Some("Helper is running but this user is not authorized".into()), + ), + Ok(_) => ComponentStatus::new( + ComponentPhase::Unavailable, + Some("Helper service is not installed or running".into()), + ), + Err(error) => ComponentStatus::new(ComponentPhase::Error, Some(error.to_string())), + } + } + + fn hiddify_component( + config: &AppConfig, + listening: bool, + executable: Option<&Path>, + ) -> ComponentStatus { + if listening { + ComponentStatus::new( + ComponentPhase::Running, + Some(format!( + "Listening on {}:{}", + config.hiddify_endpoint().0, + config.hiddify_endpoint().1 + )), + ) + } else if let Some(path) = executable { + ComponentStatus::new( + ComponentPhase::Stopped, + Some(format!("Installed at {}", path.display())), + ) + } else { + ComponentStatus::new( + ComponentPhase::Unavailable, + Some("Hiddify is not installed and its local proxy is not listening".into()), + ) + } + } + + async fn mihomo_component( + config: &AppConfig, + controller_listening: bool, + executable: Option<&Path>, + ) -> (ComponentStatus, ProviderSummary) { + if !controller_listening { + let component = executable.map_or_else( + || { + ComponentStatus::new( + ComponentPhase::Unavailable, + Some("Mihomo is not installed and its controller is not listening".into()), + ) + }, + |path| { + ComponentStatus::new( + ComponentPhase::Stopped, + Some(format!("Installed at {}", path.display())), + ) + }, + ); + return (component, ProviderSummary::default()); + } + let Ok(controller) = ControllerClient::new( + &config.mihomo.controller_host, + config.mihomo.controller_port, + config.mihomo.controller_secret.clone(), + ) else { + return ( + ComponentStatus::new( + ComponentPhase::Error, + Some("Mihomo controller address is invalid".into()), + ), + ProviderSummary::default(), + ); + }; + match controller.version().await { + Ok(version) => { + let providers = controller.provider_summary().await.map_or_else( + |_| ProviderSummary::default(), + |summary| ProviderSummary { + ready: summary.ready, + total: summary.total, + rules_loaded: summary.rules_loaded, + last_refresh: Some(chrono::Utc::now()), + }, + ); + ( + ComponentStatus::new( + ComponentPhase::Running, + Some(format!("Controller {} is ready", version.version)), + ), + providers, + ) + } + Err(error) => ( + ComponentStatus::new( + ComponentPhase::Degraded, + Some(format!( + "Controller port is active but BiFlow cannot authenticate: {error}" + )), + ), + ProviderSummary::default(), + ), + } + } + + fn controller(config: &AppConfig) -> Option { + ControllerClient::new( + &config.mihomo.controller_host, + config.mihomo.controller_port, + config.mihomo.controller_secret.clone(), + ) + .ok() + } + + /// macOS `utun` device names are kernel-assigned (`utunN`) and need not + /// equal the configured `tun_name`, so `interface_exists` alone is + /// unreliable. Mihomo's `/configs` `tun.enable` is the authority that it + /// owns a tunnel (mirrors the Windows backend, ADR 0104). + async fn tun_active(config: &AppConfig) -> bool { + let Some(controller) = Self::controller(config) else { + return false; + }; + match controller.configs().await { + Ok(configs) => { + let active = tun_enabled(&configs); + if !active { + warn!( + event = "mihomo.tun_inactive", + section = "runtime_health", + initiator = "macos_platform_backend", + cause = "controller_configs", + tun_enable = ?configs.get("tun").and_then(|tun| tun.get("enable")), + tun_device = configs + .get("tun") + .and_then(|tun| tun.get("device")) + .and_then(serde_json::Value::as_str) + .unwrap_or(""), + expected_device = %config.mihomo.tun_name, + trace_route = "desktop_engine->macos_platform_backend->mihomo_controller", + "Mihomo /configs did not report an enabled TUN" + ); + } + active + } + Err(error) => { + warn!( + event = "mihomo.tun_configs_failed", + section = "runtime_health", + initiator = "macos_platform_backend", + cause = %error, + trace_route = "desktop_engine->macos_platform_backend->mihomo_controller", + "could not read Mihomo /configs for TUN state" + ); + false + } + } + } + + fn tun_component(tun_name: &str, active: bool) -> ComponentStatus { + ComponentStatus::new( + if active { + ComponentPhase::Running + } else { + ComponentPhase::Stopped + }, + Some(if active { + format!("Interface {tun_name} is active") + } else { + format!("Interface {tun_name} is absent") + }), + ) + } + + fn dns_component(port: u16, listening: bool) -> ComponentStatus { + ComponentStatus::new( + if listening { + ComponentPhase::Running + } else { + ComponentPhase::Stopped + }, + Some(if listening { + format!("DNS is listening on 127.0.0.1:{port}") + } else { + format!("No DNS listener on 127.0.0.1:{port}") + }), + ) + } + + async fn prepared(&self) -> Result { + self.prepared + .lock() + .await + .clone() + .ok_or_else(|| CoreError::ConfigInvalid("runtime has not been prepared".into())) + } + + /// Skips another `generate_204` round-trip when this process already proved + /// the proxy and the port is still accepting connections. + async fn reuse_cached_egress( + &self, + client_id: iran_split_config::ClientId, + host: &str, + port: u16, + ) -> Option { + let cached = self.client_exit_ips.lock().await.get(&client_id).cloned()?; + if !Self::tcp_listening(host, port).await { + return None; + } + info!( + event = "client.egress_reused", + section = "clients", + initiator = "macos_platform_backend", + cause = "cached_probe_port_open", + trace_route = "engine->macos_platform_backend->reuse_cached_egress", + "reused a successful egress probe because the client port is still open" + ); + Some(cached) + } + + async fn launch_hiddify_if_needed( + &self, + config: &AppConfig, + cancel: &CancellationToken, + ) -> Result<(), CoreError> { + if Self::hiddify_listening(config).await { + return Ok(()); + } + let executable = Self::discover_hiddify(config, &self.paths.user_data_dir) + .ok_or(CoreError::HiddifyNotFound)?; + let child = Command::new(executable) + .stdin(Stdio::null()) + .stdout(Stdio::null()) + .stderr(Stdio::null()) + .kill_on_drop(false) + .spawn() + .map_err(|error| CoreError::Platform(error.to_string()))?; + *self.launched_hiddify.lock().await = Some(child); + let deadline = + tokio::time::Instant::now() + Duration::from_secs(config.hiddify_start_timeout()); + loop { + if Self::hiddify_listening(config).await { + return Ok(()); + } + if tokio::time::Instant::now() >= deadline { + return Err(CoreError::HiddifyEgressUnavailable); + } + tokio::select! { + () = cancel.cancelled() => return Err(CoreError::Cancelled), + () = tokio::time::sleep(Duration::from_millis(250)) => {} + } + } + } + + async fn probe_hiddify_until_ready( + &self, + config: &AppConfig, + cancel: CancellationToken, + ) -> Result { + let deadline = tokio::time::Instant::now() + Duration::from_secs(45); + let mut last_cause; + loop { + if cancel.is_cancelled() { + return Err(CoreError::Cancelled); + } + let (host, port) = config.hiddify_endpoint(); + match probe_hiddify_egress(&host, port, Duration::from_secs(2)).await { + Ok(exit_ip) => { + info!( + event = "hiddify.egress_ready", + section = "hiddify_process", + initiator = "macos_platform_backend", + cause = "socks_probe", + trace_route = "desktop_engine->macos_platform_backend->hiddify_egress", + "Hiddify SOCKS egress is reachable" + ); + return Ok(exit_ip); + } + Err(error) => { + last_cause = error.to_string(); + warn!( + event = "hiddify.egress_probe_failed", + section = "hiddify_process", + initiator = "macos_platform_backend", + cause = %error, + trace_route = "desktop_engine->macos_platform_backend->hiddify_egress", + "Hiddify SOCKS egress probe failed; retrying before TUN starts" + ); + } + } + if tokio::time::Instant::now() >= deadline { + error!( + event = "hiddify.egress_probe_exhausted", + section = "hiddify_process", + initiator = "macos_platform_backend", + cause = last_cause.as_str(), + trace_route = "desktop_engine->macos_platform_backend->hiddify_egress", + "Hiddify was listening but SOCKS egress did not become ready" + ); + return Err(CoreError::HiddifyEgressUnavailable); + } + tokio::select! { + () = cancel.cancelled() => return Err(CoreError::Cancelled), + () = tokio::time::sleep(Duration::from_millis(500)) => {} + } + } + } + + async fn record_default_side_tunnel_exit(&self, config: &AppConfig) { + let Some(client_id) = config.default_route.client_id() else { + return; + }; + let side_tunnel = config.enabled_clients().into_iter().any(|client| { + client.id == client_id && client.spec().kind == EgressKind::OwnedSideTunnel + }); + if !side_tunnel { + return; + } + match probe_hiddify_egress( + &config.mihomo.controller_host, + config.mihomo.mixed_port, + Duration::from_secs(8), + ) + .await + { + Ok(ip) => { + info!( + event = "mihomo.exit_ip_probed", + section = "runtime_health", + initiator = "macos_platform_backend", + cause = "default_side_tunnel", + trace_route = "desktop_engine->macos_platform_backend->mihomo_mixed", + "measured the default side-tunnel exit address through Mihomo" + ); + self.client_exit_ips + .lock() + .await + .insert(client_id, ip.clone()); + *self.egress_exit_ip.lock().await = Some(ip); + } + Err(cause) => warn!( + event = "mihomo.exit_ip_probe_failed", + section = "runtime_health", + initiator = "macos_platform_backend", + cause = %cause, + trace_route = "desktop_engine->macos_platform_backend->mihomo_mixed", + "could not measure the default side-tunnel exit address" + ), + } + } + + async fn observe_live_route(&self, config: &AppConfig, handles: &[EgressHandle]) -> LiveRoute { + let expected = expected_match_proxy(config, handles); + let Ok(controller) = ControllerClient::new( + &config.mihomo.controller_host, + config.mihomo.controller_port, + config.mihomo.controller_secret.clone(), + ) else { + return LiveRoute::unknown(); + }; + let observed = controller.live_match_proxy().await.ok().flatten(); + LiveRoute::observe(&expected, observed) + } + + async fn read_running_config(&self) -> Result { + let reply = self + .helper_request(HelperCommand::GetMihomoProcessStatus) + .await?; + let HelperReply::ProcessStatus(status) = reply else { + return Err(CoreError::Platform( + "helper did not report the Mihomo process".into(), + )); + }; + let Some(generation_id) = status.generation_id else { + return Err(CoreError::Platform( + "Mihomo is not running a generation".into(), + )); + }; + let path = self + .paths + .system_runtime_dir + .join("generations") + .join(generation_id.to_string()) + .join("config.yaml"); + let yaml = std::fs::read_to_string(&path).map_err(|error| { + CoreError::Platform(format!("could not read the running Mihomo config: {error}")) + })?; + Ok(redact_controller_secret(&yaml)) + } +} + +#[async_trait] +impl PlatformBackend for MacosBackend { + async fn runtime_health(&self) -> RuntimeHealth { + let config = self.config.read().await.clone(); + let hiddify_path = Self::discover_hiddify(&config, &self.paths.user_data_dir); + let mihomo_path = self + .paths + .mihomo_binary + .is_file() + .then(|| self.paths.mihomo_binary.clone()); + let (helper_result, hiddify_listening, controller_listening, dns_listening) = tokio::join!( + self.helper_status(), + Self::hiddify_listening(&config), + Self::tcp_listening( + &config.mihomo.controller_host, + config.mihomo.controller_port + ), + Self::tcp_listening(&config.mihomo.controller_host, config.mihomo.dns_port), + ); + + let helper_version = match &helper_result { + Ok(status) => status.version.clone(), + Err(_) => None, + }; + let helper = Self::helper_component(helper_result); + let hiddify = Self::hiddify_component(&config, hiddify_listening, hiddify_path.as_deref()); + let (mihomo, providers) = + Self::mihomo_component(&config, controller_listening, mihomo_path.as_deref()).await; + let tun = Self::tun_component(&config.mihomo.tun_name, Self::tun_active(&config).await); + let dns = Self::dns_component(config.mihomo.dns_port, dns_listening); + + let handles = self.egress_handles.lock().await.clone(); + let exit_ips = self.client_exit_ips.lock().await.clone(); + let failures = self.client_failures.lock().await.clone(); + let mut clients = Vec::new(); + for client in &config.clients { + let status = if client.preset == PresetId::Hiddify { + hiddify.clone() + } else { + Self::client_component(client, &handles, failures.get(&client.id)).await + }; + clients.push(ClientComponentStatus { + id: client.id, + preset: client.preset, + enabled: client.enabled, + status, + exit_ip: exit_ips.get(&client.id).cloned(), + }); + } + + let live_route = self.observe_live_route(&config, &handles).await; + RuntimeHealth { + helper, + helper_version, + clients, + mihomo, + tun, + dns, + providers, + live_route, + } + } + + async fn helper_status(&self) -> Result { + match self.helper.request(HelperCommand::GetServiceStatus).await { + Ok(HelperReply::ServiceStatus(status)) => Ok(HelperStatus { + available: true, + authorized: status.authorized, + version: Some(status.helper_version), + }), + Ok(_) => Err(CoreError::Platform("unexpected helper status reply".into())), + Err(MacosBackendError::Io(error)) + if matches!( + error.kind(), + std::io::ErrorKind::NotFound | std::io::ErrorKind::ConnectionRefused + ) => + { + Ok(HelperStatus::default()) + } + Err(error) => Err(CoreError::Platform(error.to_string())), + } + } + + async fn ensure_hiddify(&self, cancel: CancellationToken) -> Result<(), CoreError> { + let config = self.config.read().await.clone(); + self.launch_hiddify_if_needed(&config, &cancel).await?; + let exit_ip = self.probe_hiddify_until_ready(&config, cancel).await?; + *self.egress_exit_ip.lock().await = Some(exit_ip); + Ok(()) + } + + async fn ensure_clients(&self, cancel: CancellationToken) -> Result<(), CoreError> { + self.ensure_enabled_clients(cancel).await + } + + async fn recover_clients(&self) -> Result { + self.recover_local_proxy_clients().await + } + + async fn retry_failed_side_tunnels( + &self, + cancel: CancellationToken, + ) -> Result { + let config = self.config.read().await.clone(); + let handles = self.egress_handles.lock().await.clone(); + let mut recovered = false; + for client in side_tunnels_missing_egress(&config, &handles) { + if cancel.is_cancelled() { + return Err(CoreError::Cancelled); + } + let ready = self.egress_handles.lock().await.clone(); + match self + .start_openvpn_client(client, cancel.clone(), &ready) + .await + { + Ok(handle) => { + self.client_failures.lock().await.remove(&client.id); + self.egress_handles.lock().await.push(handle); + recovered = true; + info!( + event = "side_tunnel.retry_succeeded", + section = "clients", + initiator = "retry_side_tunnels", + cause = "openvpn_started", + trace_route = "desktop->engine->macos_platform_backend->retry_side_tunnels", + client = client.spec().id, + "side tunnel started after a longer timeout" + ); + } + Err(error) => { + self.client_failures + .lock() + .await + .insert(client.id, error.to_string()); + warn!( + event = "side_tunnel.retry_failed", + section = "clients", + initiator = "retry_side_tunnels", + cause = %error, + trace_route = "desktop->engine->macos_platform_backend->retry_side_tunnels", + client = client.spec().id, + "side tunnel retry did not start" + ); + } + } + } + Ok(recovered) + } + + async fn connect_side_tunnel( + &self, + client_id: iran_split_config::ClientId, + cancel: CancellationToken, + ) -> Result<(), CoreError> { + let config = self.config.read().await.clone(); + let client = config + .clients + .iter() + .find(|client| client.id == client_id) + .cloned() + .ok_or_else(|| CoreError::ConfigInvalid("client was not found".into()))?; + if !client.enabled { + return Err(CoreError::Platform( + "enable the client before connecting it".into(), + )); + } + if client.spec().kind == EgressKind::LocalProxy { + self.recover_local_proxy_clients().await?; + return Ok(()); + } + self.egress_handles + .lock() + .await + .retain(|handle| handle.client_id != client_id); + let ready = self.egress_handles.lock().await.clone(); + match self.start_openvpn_client(&client, cancel, &ready).await { + Ok(handle) => { + self.client_failures.lock().await.remove(&client_id); + self.egress_handles.lock().await.push(handle); + Ok(()) + } + Err(error) => { + self.client_failures + .lock() + .await + .insert(client_id, error.to_string()); + Err(error) + } + } + } + + async fn disconnect_side_tunnel( + &self, + client_id: iran_split_config::ClientId, + ) -> Result<(), CoreError> { + let config = self.config.read().await.clone(); + let client = config + .clients + .iter() + .find(|client| client.id == client_id) + .ok_or_else(|| CoreError::ConfigInvalid("client was not found".into()))?; + if client.spec().kind == EgressKind::LocalProxy { + return Err(CoreError::Platform( + "a local proxy stops when you disconnect the stack".into(), + )); + } + self.helper_request(HelperCommand::StopSideTunnel { + client_id: client_id.into(), + }) + .await?; + self.egress_handles + .lock() + .await + .retain(|handle| handle.client_id != client_id); + self.client_failures.lock().await.remove(&client_id); + Ok(()) + } + + async fn set_side_tunnel_connect_timeout(&self, seconds: Option) { + *self.side_tunnel_connect_timeout.lock().await = seconds; + } + + async fn probe_primary_egress(&self) -> Option> { + let config = self.config.read().await.clone(); + let client = config.client(config.default_route.client_id()?)?; + if !client.enabled || client.spec().kind != EgressKind::LocalProxy { + return None; + } + let (host, port) = local_proxy_endpoint(client)?; + Some( + probe_hiddify_egress(&host, port, Duration::from_secs(3)) + .await + .map(|_| ()) + .map_err(|error| { + format!( + "{} egress probe failed on {host}:{port}: {error}", + client.spec().id + ) + }), + ) + } + + async fn prepare_runtime(&self) -> Result { + let config = self.config.read().await.clone(); + let generation_id = Uuid::new_v4(); + let staging_parent = self.paths.user_data_dir.join("runtime").join("generations"); + prune_staging(&staging_parent); + let staging_root = staging_parent.join(generation_id.to_string()); + fs::create_dir_all(&staging_root).map_err(|error| platform_error(&error))?; + let runtime_paths = RuntimePaths { + private_networks: PathBuf::from("private.txt"), + iran_domains: PathBuf::from("iran-domains.txt"), + iran_business_domains: PathBuf::from("iran-business-domains.txt"), + iran_networks: PathBuf::from("iran-networks.txt"), + iran_cdn_networks: PathBuf::from("iran-cdn-networks.txt"), + custom_direct_domains: PathBuf::from("custom-direct-domains.txt"), + custom_direct_ips: PathBuf::from("custom-direct-ips.txt"), + }; + let rules_path = self.paths.user_data_dir.join("direct-rules.json"); + let custom: RoutePinsDocument = if rules_path.exists() { + serde_json::from_slice(&fs::read(rules_path).map_err(|error| platform_error(&error))?) + .map_err(|error| CoreError::ConfigInvalid(error.to_string()))? + } else { + RoutePinsDocument::default() + }; + let handles = self.egress_handles.lock().await.clone(); + let generated = generate_config_with_handles( + &config, + Platform::Macos, + &runtime_paths, + &custom, + &handles, + ) + .map_err(|error| CoreError::ConfigInvalid(error.to_string()))?; + copy_rule_file( + &self.paths.resources_dir, + &self.paths.rules_cache_dir, + &staging_root, + "private.txt", + )?; + copy_rule_file( + &self.paths.resources_dir, + &self.paths.rules_cache_dir, + &staging_root, + "iran-domains.txt", + )?; + copy_rule_file( + &self.paths.resources_dir, + &self.paths.rules_cache_dir, + &staging_root, + "iran-networks.txt", + )?; + copy_rule_file( + &self.paths.resources_dir, + &self.paths.rules_cache_dir, + &staging_root, + "iran-business-domains.txt", + )?; + copy_rule_file( + &self.paths.resources_dir, + &self.paths.rules_cache_dir, + &staging_root, + "iran-cdn-networks.txt", + )?; + write_custom_provider_files(&staging_root, &custom, &config)?; + write_atomic(&staging_root.join("config.yaml"), generated.yaml.as_bytes())?; + let generation = RuntimeGeneration { + generation_id, + config_sha256: generated.sha256, + }; + *self.prepared.lock().await = Some(PreparedGeneration { + generation: generation.clone(), + config_path: staging_root.join("config.yaml"), + }); + Ok(generation) + } + + async fn validate_runtime(&self, generation: &RuntimeGeneration) -> Result<(), CoreError> { + if !self.paths.mihomo_binary.is_file() { + return Err(CoreError::MihomoNotFound); + } + let prepared = self.prepared().await?; + if prepared.generation != *generation { + return Err(CoreError::ConfigInvalid( + "generation differs from the latest prepared runtime".into(), + )); + } + if self.validated_config_sha256.lock().await.as_deref() + == Some(generation.config_sha256.as_str()) + { + info!( + event = "runtime.validation_skipped", + section = "runtime_generation", + initiator = "macos_platform_backend", + cause = "unchanged_config", + trace_route = "desktop_engine->macos_platform_backend->validate_runtime", + "skipped mihomo -t because this config hash already validated" + ); + return Ok(()); + } + validate_with_binary( + &self.paths.mihomo_binary, + &prepared.config_path, + Duration::from_secs(10), + ) + .await + .map_err(|error| CoreError::ConfigInvalid(error.to_string()))?; + *self.validated_config_sha256.lock().await = Some(generation.config_sha256.clone()); + Ok(()) + } + + async fn start_core(&self, generation: &RuntimeGeneration) -> Result<(), CoreError> { + self.register_runtime(generation).await?; + self.spawn_core(generation).await + } + + async fn reload_core( + &self, + generation: &RuntimeGeneration, + rebind_host: Option, + ) -> Result<(), CoreError> { + self.register_runtime(generation).await?; + match self + .helper_request(HelperCommand::OverlayRuntimeGeneration { + generation_id: generation.generation_id, + config_sha256: generation.config_sha256.clone(), + }) + .await? + { + HelperReply::ProcessStatus(status) if status.running => { + if status.generation_id.is_none() { + return Err(CoreError::Platform( + "running Mihomo has no generation to reload".into(), + )); + } + // The overlay copied this generation byte for byte (the + // helper checks its SHA-256) into the root-only + // `/var/lib/iran-split` workdir, which the desktop cannot + // read: every Live apply failed with EACCES. Send the + // desktop's own staged copy of the same bytes. + let config_path = self + .paths + .user_data_dir + .join("runtime") + .join("generations") + .join(generation.generation_id.to_string()) + .join("config.yaml"); + self.hot_reload_running(rebind_host.as_deref(), &config_path) + .await + } + HelperReply::ProcessStatus(_) => self.spawn_core(generation).await, + _ => Err(CoreError::Platform( + "generation overlay did not return process status".into(), + )), + } + } + + async fn running_config_text(&self) -> Result { + self.read_running_config().await + } + + async fn stop_core(&self) -> Result<(), CoreError> { + match self.helper_request(HelperCommand::StopMihomo).await? { + HelperReply::ProcessStatus(status) if !status.running => {} + _ => return Err(CoreError::Platform("helper did not stop Mihomo".into())), + } + Ok(()) + } + + async fn stop_user_proxy(&self) -> Result<(), CoreError> { + let config = self.config.read().await.clone(); + if !config.hiddify_stop_with_stack() { + return Ok(()); + } + if let Some(mut child) = self.launched_hiddify.lock().await.take() { + let trace_id = Uuid::new_v4(); + if let Err(cause) = child.start_kill() { + warn!( + event = "hiddify.stop_signal_failed", + section = "hiddify_process", + initiator = "macos_platform_backend", + cause = %cause, + trace_id = %trace_id, + trace_route = "desktop_engine->macos_platform_backend->hiddify_process", + "could not send the stop signal to the Hiddify child process" + ); + } + match tokio::time::timeout(Duration::from_secs(5), child.wait()).await { + Ok(Ok(status)) => info!( + event = "hiddify.process_stopped", + section = "hiddify_process", + initiator = "macos_platform_backend", + cause = "stop_with_stack", + trace_id = %trace_id, + trace_route = "desktop_engine->macos_platform_backend->hiddify_process", + exit_status = %status, + "Hiddify child process stopped" + ), + Ok(Err(cause)) => warn!( + event = "hiddify.wait_failed", + section = "hiddify_process", + initiator = "macos_platform_backend", + cause = %cause, + trace_id = %trace_id, + trace_route = "desktop_engine->macos_platform_backend->hiddify_process", + "could not collect the stopped Hiddify child process" + ), + Err(cause) => warn!( + event = "hiddify.stop_timed_out", + section = "hiddify_process", + initiator = "macos_platform_backend", + cause = %cause, + trace_id = %trace_id, + trace_route = "desktop_engine->macos_platform_backend->hiddify_process", + timeout_seconds = 5_u64, + "Hiddify child process did not stop before the timeout" + ), + } + } + Ok(()) + } + + 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()) + } + + async fn restore_hiddify_system_proxy(&self) -> Result<(), CoreError> { + let persist = system_proxy::snapshot_path(&self.paths.user_data_dir); + system_proxy::restore(&persist).await + } + + async fn core_process(&self) -> Result { + match self + .helper_request(HelperCommand::GetMihomoProcessStatus) + .await? + { + HelperReply::ProcessStatus(status) => Ok(ProcessStatus { + running: status.running, + pid: status.pid, + }), + _ => Err(CoreError::Platform( + "unexpected process status reply".into(), + )), + } + } + + async fn tun_status(&self) -> Result { + let config = self.config.read().await; + let name = config.mihomo.tun_name.clone(); + let listening = Self::tcp_listening( + &config.mihomo.controller_host, + config.mihomo.controller_port, + ) + .await; + Ok(TunStatus { + active: listening && Self::tun_active(&config).await, + name: Some(name), + }) + } + + async fn check_readiness( + &self, + cancel: CancellationToken, + ) -> Result { + let config = self.config.read().await.clone(); + let controller = ControllerClient::new( + &config.mihomo.controller_host, + config.mihomo.controller_port, + config.mihomo.controller_secret.clone(), + ) + .map_err(|error| CoreError::ConfigInvalid(error.to_string()))?; + info!( + event = "mihomo.readiness_wait_started", + section = "runtime_health", + initiator = "macos_platform_backend", + cause = "core_started", + trace_route = "desktop_engine->macos_platform_backend->mihomo_controller", + "waiting for the Mihomo controller and rule providers" + ); + let providers = match controller + .wait_until_ready(Duration::from_secs(20), cancel.clone()) + .await + { + Ok(providers) => { + info!( + event = "mihomo.readiness_wait_completed", + section = "runtime_health", + initiator = "macos_platform_backend", + cause = "none", + trace_route = "desktop_engine->macos_platform_backend->mihomo_controller", + ready = providers.ready, + total = providers.total, + rules_loaded = providers.rules_loaded, + "Mihomo controller and rule providers are ready" + ); + providers + } + Err(error) => { + error!( + event = "mihomo.readiness_wait_failed", + section = "runtime_health", + initiator = "macos_platform_backend", + cause = %error, + trace_route = "desktop_engine->macos_platform_backend->mihomo_controller", + "Mihomo readiness wait failed" + ); + return Err(readiness_error(error)); + } + }; + if cancel.is_cancelled() { + return Err(CoreError::Cancelled); + } + self.record_default_side_tunnel_exit(&config).await; + let exit_ip = self.egress_exit_ip.lock().await.clone(); + // The pre-TUN egress probe is only mandatory when unmatched traffic + // goes to a local proxy; a Direct or side-tunnel default has no + // loopback egress to confirm. + let default_is_local_proxy = config.default_route.client_id().is_some_and(|client_id| { + config.enabled_clients().into_iter().any(|client| { + client.id == client_id && client.spec().kind == EgressKind::LocalProxy + }) + }); + if default_is_local_proxy && exit_ip.is_none() { + error!( + event = "hiddify.egress_missing_after_tun", + section = "runtime_health", + initiator = "macos_platform_backend", + cause = "pre_tun_probe_missing", + trace_route = "desktop_engine->macos_platform_backend->hiddify_egress", + "default local-proxy egress was not confirmed before TUN start" + ); + return Err(CoreError::HiddifyEgressUnavailable); + } + Ok(ReadinessReport { + controller_ready: true, + egress_ready: true, + providers: ProviderSummary { + ready: providers.ready, + total: providers.total, + rules_loaded: providers.rules_loaded, + last_refresh: Some(chrono::Utc::now()), + }, + exit_ip, + }) + } + + async fn forget_client_egress(&self) { + self.egress_handles.lock().await.clear(); + *self.egress_exit_ip.lock().await = None; + self.client_exit_ips.lock().await.clear(); + self.side_tunnel_auth_files.lock().await.clear(); + *self.validated_config_sha256.lock().await = None; + info!( + event = "client.egress_cache_cleared", + section = "clients", + initiator = "macos_platform_backend", + cause = "stack_stopped", + trace_route = "engine->macos_platform_backend->forget_client_egress", + "cleared cached egress probes after disconnect" + ); + } + + async fn cleanup_owned_state(&self) -> Result { + self.egress_handles + .lock() + .await + .retain(|handle| handle.kind == EgressKind::LocalProxy); + self.side_tunnel_auth_files.lock().await.clear(); + match self + .helper_request(HelperCommand::CleanupOwnedNetworkState) + .await? + { + HelperReply::CleanupReport(report) => Ok(CleanupReport { + process_stopped: report.process_stopped, + tun_removed: report.tun_removed, + dns_restored: report.dns_restored, + routes_removed: report.routes_removed, + warnings: report.warnings, + }), + _ => Err(CoreError::Platform("unexpected cleanup reply".into())), + } + } +} + +fn platform_error(error: &std::io::Error) -> CoreError { + CoreError::Platform(error.to_string()) +} + +/// Finds a launchable binary for a `LocalProxy` preset by its process names +/// (wildcards excluded), preferring the first — the GUI app — over cores. +/// +/// Debian's Happ package installs `/usr/bin/happ` → `/opt/happ/bin/Happ`. +/// PATH lookup must ignore case and must also try those well-known paths, +/// because a packaged Tauri PATH often omits `/usr/bin` or only has the +/// lowercase symlink. +fn ensure_live_match( + yaml: &str, + live: &str, + generation_id: &str, + initiator: &'static str, +) -> Result<(), CoreError> { + let Some(detail) = match_reload_disagrees(yaml, live) else { + return Ok(()); + }; + error!( + event = "mihomo.live_match_disagreed", + section = "rules", + initiator, + cause = %detail, + generation_id, + match_proxy = live, + trace_route = "engine->platform_backend->mihomo_controller", + "reloaded Mihomo but the live MATCH rule did not change" + ); + Err(CoreError::MihomoStartFailed(detail)) +} + +fn client_start_timeout(client: &ClientInstance) -> Duration { + let ClientConfig::LocalProxy { + start_timeout_seconds, + .. + } = &client.config + else { + return Duration::from_secs(15); + }; + // macOS `.app` bundles (Hiddify is a Flutter app) can take well over a + // minute to initialize Launch Services, build the Flutter UI, and open + // the proxy port. The configured default is 45s which is too short on a + // cold start; double the budget on macOS so the egress probe does not + // time out before Hiddify is ready. + let base = (*start_timeout_seconds).max(3); + Duration::from_secs(base * 2) +} + +fn discover_local_proxy_binary(spec: &iran_split_config::PresetSpec) -> Option { + let directories = std::env::var_os("PATH") + .map(|path| std::env::split_paths(&path).collect::>()) + .unwrap_or_default(); + discover_local_proxy_binary_in(spec, &directories, &well_known_local_proxy_binaries(spec)) +} + +fn well_known_local_proxy_binaries(spec: &iran_split_config::PresetSpec) -> Vec { + let mut candidates = Vec::new(); + if spec.preset == PresetId::Happ { + candidates.extend([ + PathBuf::from("/Applications/Happ.app"), + PathBuf::from("/usr/local/bin/happ"), + PathBuf::from("/opt/homebrew/bin/happ"), + ]); + if let Some(home) = std::env::var_os("HOME") { + let home = PathBuf::from(home); + candidates.push(home.join("Applications/Happ.app")); + candidates.push(home.join(".local/bin/happ")); + } + } + candidates +} + +fn discover_local_proxy_binary_in( + spec: &iran_split_config::PresetSpec, + directories: &[PathBuf], + extra: &[PathBuf], +) -> Option { + if let Some(path) = extra.iter().find_map(|path| resolve_macos_binary(path)) { + return Some(path); + } + let names = spec + .macos_bypass + .iter() + .copied() + .filter(|name| !name.contains('*')); + for name in names { + for directory in directories { + if let Some(path) = file_named_ignore_case(directory, name) { + return Some(path); + } + // macOS GUI clients ship as `.app` bundles; resolve the inner + // executable so `Command::new` can spawn it directly. + if let Some(path) = resolve_macos_binary(&directory.join(format!("{name}.app"))) { + return Some(path); + } + } + } + None +} + +/// Enabled local-proxy clients whose connect-time egress handle is missing — +/// the only candidates for live recovery (ADR 0076). Side tunnels are owned +/// processes with their own lifecycle and are never re-attached here. +fn side_tunnels_missing_egress<'config>( + config: &'config AppConfig, + handles: &[EgressHandle], +) -> Vec<&'config ClientInstance> { + config + .enabled_clients() + .into_iter() + .filter(|client| client.spec().kind == EgressKind::OwnedSideTunnel) + .filter(|client| { + !handles + .iter() + .any(|handle| handle.client_id == client.id && handle.ready) + }) + .collect() +} + +fn clients_missing_egress<'config>( + config: &'config AppConfig, + handles: &[EgressHandle], +) -> Vec<&'config ClientInstance> { + config + .enabled_clients() + .into_iter() + .filter(|client| client.spec().kind == EgressKind::LocalProxy) + .filter(|client| !handles.iter().any(|handle| handle.client_id == client.id)) + .collect() +} + +fn file_named_ignore_case(directory: &Path, name: &str) -> Option { + let exact = directory.join(name); + if exact.is_file() { + return Some(exact); + } + let entries = fs::read_dir(directory).ok()?; + entries + .filter_map(Result::ok) + .map(|entry| entry.path()) + .find(|path| { + path.is_file() + && path + .file_name() + .and_then(|file| file.to_str()) + .is_some_and(|file| file.eq_ignore_ascii_case(name)) + }) +} + +/// Resolves a launchable binary on macOS. A plain file is returned as-is +/// only when it is a Mach-O executable (a leftover Linux `AppImage` or ELF +/// binary cannot run on macOS and must be rejected). A `.app` bundle +/// directory is resolved to its inner executable under +/// `Contents/MacOS/`, so `Command::new` can spawn it directly. +fn resolve_macos_binary(path: &Path) -> Option { + if path.is_file() { + // Reject Linux ELF binaries (e.g. a stale AppImage symlink left by a + // cross-platform install); only Mach-O executables run on macOS. + if !is_macho_binary(path) { + return None; + } + return Some(path.to_path_buf()); + } + if path.is_dir() { + let app_name = path.file_name()?.to_str()?; + if !std::path::Path::new(app_name) + .extension() + .is_some_and(|ext| ext.eq_ignore_ascii_case("app")) + { + return None; + } + let stem = app_name.strip_suffix(".app")?; + let inner = path.join("Contents/MacOS").join(stem); + if inner.is_file() { + return Some(inner); + } + } + None +} + +/// Returns `true` when `path` starts with a Mach-O magic number +/// (`0xfeed_face` 32-bit or `0xfeed_facf` 64-bit). A Linux ELF binary starts +/// with `0x7f` `E` `L` `F` and is rejected. +fn is_macho_binary(path: &Path) -> bool { + use std::io::Read; + // Mach-O 64-bit: `0xfeed_facf`, Mach-O 32-bit: `0xfeed_face` (big-endian on disk). + const MACHO_64: [u8; 4] = 0xfeed_facfu32.to_ne_bytes(); + const MACHO_32: [u8; 4] = 0xfeed_faceu32.to_ne_bytes(); + let Ok(mut file) = std::fs::File::open(path) else { + return false; + }; + let mut magic = [0u8; 4]; + if file.read_exact(&mut magic).is_err() { + return false; + } + magic == MACHO_64 || magic == MACHO_32 +} + +/// Extracts the `tun` object from a Mihomo `/configs` response. +fn tun_section(configs: &serde_json::Value) -> Option<&serde_json::Value> { + configs + .get("tun") + .or_else(|| configs.get("config").and_then(|config| config.get("tun"))) +} + +/// Returns the enclosing `.app` bundle directory when `binary` is the inner +/// Mach-O executable of a macOS app bundle (for example +/// `/Applications/Hiddify.app/Contents/MacOS/Hiddify` → `/Applications/Hiddify.app`). +/// Launching the bundle through `open` initializes the app properly, while +/// running the inner binary directly often leaves the proxy port closed. +fn enclosing_app_bundle(binary: &Path) -> Option { + let mut current = binary.parent()?; + while let Some(parent) = current.parent() { + let is_app = current + .extension() + .is_some_and(|ext| ext.eq_ignore_ascii_case("app")); + if is_app { + return Some(current.to_path_buf()); + } + if parent == current { + break; + } + current = parent; + } + None +} + +/// Accepts bool/number/string representations of a JSON enable flag. +fn json_flag_enabled(value: Option<&serde_json::Value>) -> bool { + match value { + Some(serde_json::Value::Bool(enabled)) => *enabled, + Some(serde_json::Value::Number(number)) => { + number.as_u64().is_some_and(|value| value != 0) + || number.as_i64().is_some_and(|value| value != 0) + } + Some(serde_json::Value::String(text)) => { + let text = text.trim(); + text.eq_ignore_ascii_case("true") || text == "1" + } + _ => false, + } +} + +/// Mihomo reports `tun.enable` as the authority that it owns a tunnel. On +/// macOS the live `utun` device name is kernel-assigned and need not equal +/// the configured `tun_name`, so `interface_exists` alone is unreliable +/// (mirrors the Windows backend, ADR 0104). +#[must_use] +fn tun_enabled(configs: &serde_json::Value) -> bool { + let Some(tun) = tun_section(configs) else { + return false; + }; + json_flag_enabled(tun.get("enable")) +} + +/// Writes `username\npassword` to a 0600 temp file for `--auth-user-pass`. +/// Returns `None` when the instance has no credentials. +/// Writes the credential file where the helper can actually read it. +/// +/// The helper unit sets `PrivateTmp=yes`, so a file in the desktop user's +/// `/tmp` does not exist inside the helper's mount namespace and `OpenVPN` +/// dies immediately with "exit status: 1". `ProtectHome=read-only` still +/// lets the helper read the user's data directory, so the file goes there, +/// owner-only, and is deleted when the handle drops. +fn write_side_tunnel_auth( + user_data_dir: &Path, + username: Option<&str>, + password: Option<&str>, +) -> Result, CoreError> { + let (Some(username), Some(password)) = (username, password) else { + return Ok(None); + }; + if username.is_empty() { + return Ok(None); + } + let directory = user_data_dir.join("side-tunnel"); + fs::create_dir_all(&directory).map_err(|error| platform_error(&error))?; + let mut file = NamedTempFile::new_in(&directory).map_err(|error| platform_error(&error))?; + #[cfg(unix)] + { + use std::os::unix::fs::PermissionsExt; + fs::set_permissions(file.path(), fs::Permissions::from_mode(0o600)) + .map_err(|error| platform_error(&error))?; + } + writeln!(file, "{username}") + .and_then(|()| writeln!(file, "{password}")) + .and_then(|()| file.flush()) + .map_err(|error| platform_error(&error))?; + Ok(Some(file)) +} + +fn readiness_error(error: MihomoError) -> CoreError { + match error { + MihomoError::Cancelled => CoreError::Cancelled, + MihomoError::ReadinessTimeout(_) => CoreError::ControllerTimeout, + MihomoError::Unauthorized => CoreError::ControllerUnauthorized, + other => CoreError::MihomoStartFailed(other.to_string()), + } +} + +fn copy_rule_file( + bundled: &Path, + cache: &Path, + staging: &Path, + name: &str, +) -> Result<(), CoreError> { + let source = iran_split_rules::resolve_provider_path(cache, bundled, name); + let metadata = fs::symlink_metadata(&source).map_err(|error| platform_error(&error))?; + if !metadata.file_type().is_file() || metadata.file_type().is_symlink() { + return Err(CoreError::ConfigInvalid(format!( + "bootstrap rule {name} is not a regular file" + ))); + } + let bytes = fs::read(source).map_err(|error| platform_error(&error))?; + write_atomic(&staging.join(name), &bytes) +} + +fn write_custom_provider_files( + staging: &Path, + document: &RoutePinsDocument, + config: &AppConfig, +) -> Result<(), CoreError> { + let (domains, ips) = split_pins(document, Outbound::Direct); + write_lines(&staging.join("custom-direct-domains.txt"), &domains)?; + write_lines(&staging.join("custom-direct-ips.txt"), &ips)?; + for client in config.enabled_clients() { + let (client_domains, client_ips) = split_pins(document, Outbound::client(client.id)); + let [domains_file, ips_file] = client.provider_files(); + write_lines(&staging.join(domains_file), &client_domains)?; + write_lines(&staging.join(ips_file), &client_ips)?; + } + Ok(()) +} + +fn split_pins(document: &RoutePinsDocument, outbound: Outbound) -> (Vec, Vec) { + let mut domains = Vec::new(); + let mut ips = Vec::new(); + for pin in document.pins.iter().filter(|pin| pin.outbound == outbound) { + match &pin.target { + DirectTarget::Domain(domain) => domains.push(format!("+.{domain}")), + DirectTarget::Ip(address) => ips.push(host_cidr(*address)), + } + } + domains.sort(); + domains.dedup(); + ips.sort(); + ips.dedup(); + (domains, ips) +} + +fn host_cidr(address: IpAddr) -> String { + match address { + IpAddr::V4(address) => format!("{address}/32"), + IpAddr::V6(address) => format!("{address}/128"), + } +} + +fn write_lines(path: &Path, lines: &[String]) -> Result<(), CoreError> { + let mut content = lines.join("\n"); + if !content.is_empty() { + content.push('\n'); + } + write_atomic(path, content.as_bytes()) +} + +fn write_atomic(path: &Path, content: &[u8]) -> Result<(), CoreError> { + let parent = path + .parent() + .ok_or_else(|| CoreError::ConfigInvalid("runtime file has no parent".into()))?; + fs::create_dir_all(parent).map_err(|error| platform_error(&error))?; + let mut temporary = NamedTempFile::new_in(parent).map_err(|error| platform_error(&error))?; + temporary + .write_all(content) + .map_err(|error| platform_error(&error))?; + temporary + .as_file() + .sync_all() + .map_err(|error| platform_error(&error))?; + temporary + .persist(path) + .map_err(|error| platform_error(&error.error))?; + Ok(()) +} + +#[allow(dead_code)] +fn hash_bytes(bytes: &[u8]) -> String { + hex::encode(Sha256::digest(bytes)) +} + +/// Keeps staged generations bounded; a failure is logged and ignored +/// because staging a new generation does not depend on it. +fn prune_staging(root: &Path) { + match iran_split_core::prune_staged_generations(root, iran_split_core::STAGED_GENERATIONS_KEPT) + { + Ok(0) => {} + Ok(removed) => info!( + event = "runtime.staging_pruned", + section = "runtime_generation", + initiator = "macos_platform_backend", + cause = "bounded_staging", + trace_route = "engine->macos_platform_backend->staging", + removed, + "removed old staged runtime generations" + ), + Err(error) if error.kind() == std::io::ErrorKind::NotFound => {} + Err(error) => warn!( + event = "runtime.staging_prune_failed", + section = "runtime_generation", + initiator = "macos_platform_backend", + cause = %error.kind(), + trace_route = "engine->macos_platform_backend->staging", + "could not list staged runtime generations" + ), + } +} + +#[cfg(test)] +mod tests { + use super::*; + use iran_split_config::DefaultRoute; + + #[test] + fn enclosing_app_bundle_finds_the_bundle_from_the_inner_binary() { + let bundle = PathBuf::from("/Applications/Hiddify.app"); + let inner = bundle.join("Contents/MacOS/Hiddify"); + assert_eq!( + enclosing_app_bundle(&inner).as_deref(), + Some(bundle.as_path()) + ); + + let happ_inner = PathBuf::from("/Applications/Happ.app/Contents/MacOS/Happ"); + assert_eq!( + enclosing_app_bundle(&happ_inner).as_deref(), + Some(Path::new("/Applications/Happ.app")) + ); + } + + #[test] + fn enclosing_app_bundle_returns_none_for_a_plain_binary() { + assert_eq!( + enclosing_app_bundle(&PathBuf::from("/usr/local/bin/hiddify")), + None + ); + assert_eq!( + enclosing_app_bundle(&PathBuf::from("/opt/homebrew/bin/hiddify")), + None + ); + } + + #[test] + fn side_tunnel_auth_avoids_the_helpers_private_tmp() { + let home = tempfile::tempdir().expect("tempdir"); + let file = write_side_tunnel_auth(home.path(), Some("user"), Some("secret")) + .expect("auth") + .expect("some"); + // PrivateTmp=yes hides /tmp from the helper: a credential file there + // makes OpenVPN die instantly with "exit status: 1". + assert!( + file.path().starts_with(home.path()), + "auth file must live under the user data directory" + ); + assert_ne!( + file.path().parent(), + Some(std::env::temp_dir().as_path()), + "the bare temp root is exactly what the helper cannot see" + ); + #[cfg(unix)] + { + use std::os::unix::fs::PermissionsExt; + let mode = fs::metadata(file.path()) + .expect("metadata") + .permissions() + .mode(); + assert_eq!(mode & 0o777, 0o600, "credentials must stay owner-only"); + } + let contents = fs::read_to_string(file.path()).expect("read"); + assert_eq!(contents, "user\nsecret\n"); + assert!(write_side_tunnel_auth(home.path(), None, Some("secret")) + .expect("no username") + .is_none()); + } + + #[tokio::test] + async fn preparation_publishes_only_allowlisted_generation_files() { + let directory = tempfile::tempdir().expect("tempdir"); + let resources = directory.path().join("resources"); + fs::create_dir_all(&resources).expect("resources"); + for name in [ + "private.txt", + "iran-domains.txt", + "iran-networks.txt", + "iran-business-domains.txt", + "iran-cdn-networks.txt", + ] { + fs::write(resources.join(name), "example\n").expect("fixture"); + } + let paths = MacosPaths { + socket_path: directory.path().join("helper.sock"), + user_data_dir: directory.path().join("user-data"), + system_runtime_dir: PathBuf::from("/var/lib/iran-split"), + resources_dir: resources, + rules_cache_dir: directory.path().join("rules-cache"), + mihomo_binary: directory.path().join("mihomo"), + }; + let backend = MacosBackend::new(AppConfig::default(), paths.clone()); + let generation = backend.prepare_runtime().await.expect("prepare"); + let root = paths + .user_data_dir + .join("runtime") + .join("generations") + .join(generation.generation_id.to_string()); + let names = fs::read_dir(&root) + .expect("generation") + .map(|entry| entry.expect("entry").file_name()) + .collect::>(); + // config.yaml, five bundled providers, two custom-direct, two per-client. + assert_eq!(names.len(), 10); + assert!(names.contains(std::ffi::OsStr::new("iran-business-domains.txt"))); + assert!(names.contains(std::ffi::OsStr::new("iran-cdn-networks.txt"))); + assert!(names.iter().any(|name| { + let name = name.to_string_lossy(); + name.starts_with("custom-") + && name.ends_with("-domains.txt") + && name != "custom-direct-domains.txt" + })); + assert!(names.iter().any(|name| { + let name = name.to_string_lossy(); + name.starts_with("custom-") + && name.ends_with("-ips.txt") + && name != "custom-direct-ips.txt" + })); + assert!(names.contains(std::ffi::OsStr::new("config.yaml"))); + let config = fs::read_to_string(root.join("config.yaml")).expect("config"); + assert!(config.contains("path: private.txt")); + assert!(!config.contains("/var/lib/iran-split/generations/")); + } + + #[test] + fn discovers_installed_hiddify_app_bundle() { + let directory = tempfile::tempdir().expect("tempdir"); + let apps = directory.path().join("apps"); + let bundle = apps.join("Hiddify.app").join("Contents/MacOS"); + fs::create_dir_all(&bundle).expect("bundle"); + let executable = bundle.join("Hiddify"); + fs::write(&executable, b"macho").expect("executable"); + let found = MacosBackend::discover_hiddify(&AppConfig::default(), directory.path()); + assert_eq!(found, Some(executable)); + } + + fn test_paths(directory: &tempfile::TempDir) -> MacosPaths { + MacosPaths { + socket_path: directory.path().join("helper.sock"), + user_data_dir: directory.path().join("user-data"), + system_runtime_dir: PathBuf::from("/var/lib/iran-split"), + resources_dir: directory.path().join("resources"), + rules_cache_dir: directory.path().join("rules-cache"), + mihomo_binary: directory.path().join("mihomo"), + } + } + + /// A Happ instance that can never start: closed port and missing binary. + fn unstartable_happ(directory: &tempfile::TempDir) -> ClientInstance { + let mut happ = ClientInstance::from_preset(PresetId::Happ); + if let ClientConfig::LocalProxy { + host, + port, + executable, + .. + } = &mut happ.config + { + *host = "127.0.0.1".into(); + *port = 1; + *executable = ExecutableSetting::Path(directory.path().join("missing-happ")); + } + happ + } + + #[tokio::test] + async fn a_required_primary_that_cannot_start_aborts_connect() { + let directory = tempfile::tempdir().expect("tempdir"); + let happ = unstartable_happ(&directory); + let mut config = AppConfig::default(); + config.clients.clear(); + config.default_route = DefaultRoute::client(happ.id); + config.clients.push(happ); + let backend = MacosBackend::new(config, test_paths(&directory)); + let error = backend + .ensure_enabled_clients(CancellationToken::new()) + .await; + assert!(error.is_err(), "the default-route client must be required"); + } + + #[tokio::test] + async fn an_optional_local_proxy_launch_does_not_wait_for_its_port() { + let directory = tempfile::tempdir().expect("tempdir"); + let mut config = AppConfig::default(); + config.clients.clear(); + config.default_route = DefaultRoute::Direct; + // A binary that exists and exits immediately: the old code waited the + // full start timeout for port 1 to open; the new code returns at once. + let mut happ = ClientInstance::from_preset(PresetId::Happ); + if let ClientConfig::LocalProxy { + host, + port, + executable, + start_timeout_seconds, + .. + } = &mut happ.config + { + *host = "127.0.0.1".into(); + *port = 1; + *executable = ExecutableSetting::Path(PathBuf::from("/usr/bin/true")); + *start_timeout_seconds = 45; + } + config.clients.push(happ); + let backend = MacosBackend::new(config, test_paths(&directory)); + let started = tokio::time::Instant::now(); + backend + .ensure_enabled_clients(CancellationToken::new()) + .await + .expect("optional launch must not abort connect"); + assert!( + started.elapsed() < Duration::from_secs(5), + "optional client launch must not block on its port" + ); + assert!(backend.egress_handles.lock().await.is_empty()); + } + + #[tokio::test] + async fn a_dead_optional_secondary_does_not_block_connect() { + let directory = tempfile::tempdir().expect("tempdir"); + let mut config = AppConfig::default(); + // Drop the default Hiddify instance so a live dev-host proxy is + // never probed; DIRECT keeps every remaining client optional. + config.clients.clear(); + config.default_route = DefaultRoute::Direct; + config.clients.push(unstartable_happ(&directory)); + let backend = MacosBackend::new(config, test_paths(&directory)); + backend + .ensure_enabled_clients(CancellationToken::new()) + .await + .expect("optional client failure must not abort connect"); + assert!(backend.egress_handles.lock().await.is_empty()); + } + + #[test] + fn recovery_candidates_are_enabled_local_proxies_without_handles() { + let mut config = AppConfig::default(); + let happ = ClientInstance::from_preset(PresetId::Happ); + let happ_id = happ.id; + let mut disabled = ClientInstance::from_preset(PresetId::V2rayn); + disabled.enabled = false; + let side_tunnel = ClientInstance::from_preset(PresetId::Windscribe); + config.clients.push(happ); + config.clients.push(disabled); + config.clients.push(side_tunnel); + + // The default Hiddify instance and Happ lack handles; only the + // disabled client and the side tunnel are excluded. + let candidates = clients_missing_egress(&config, &[]); + assert_eq!(candidates.len(), 2); + assert!(candidates.iter().any(|client| client.id == happ_id)); + + // A handle from connect (or a previous recovery) removes a candidate. + let handled = config + .clients + .iter() + .find(|client| client.id == happ_id) + .and_then(synthesized_local_handle) + .expect("happ handle"); + let candidates = clients_missing_egress(&config, &[handled]); + assert!(!candidates.iter().any(|client| client.id == happ_id)); + } + + #[tokio::test] + async fn recovery_skips_clients_whose_port_is_closed() { + let directory = tempfile::tempdir().expect("tempdir"); + let paths = MacosPaths { + socket_path: directory.path().join("helper.sock"), + user_data_dir: directory.path().join("user-data"), + system_runtime_dir: PathBuf::from("/var/lib/iran-split"), + resources_dir: directory.path().join("resources"), + rules_cache_dir: directory.path().join("rules-cache"), + mihomo_binary: directory.path().join("mihomo"), + }; + let mut config = AppConfig::default(); + // Drop the default Hiddify instance: on a dev host a real Hiddify may + // be listening, and this test must never probe a live proxy. + config.clients.clear(); + // Port 1 on loopback is never listening in the test environment. + let mut happ = ClientInstance::from_preset(PresetId::Happ); + if let ClientConfig::LocalProxy { host, port, .. } = &mut happ.config { + *host = "127.0.0.1".into(); + *port = 1; + } + config.clients.push(happ); + let backend = MacosBackend::new(config, paths); + let recovered = backend + .recover_local_proxy_clients() + .await + .expect("recovery"); + assert!(!recovered); + assert!(backend.egress_handles.lock().await.is_empty()); + } + + #[test] + fn discovers_happ_when_path_filename_is_lowercase() { + let directory = tempfile::tempdir().expect("tempdir"); + let binary = directory.path().join("happ"); + fs::write(&binary, b"elf").expect("write"); + let found = discover_local_proxy_binary_in( + &PresetId::Happ.spec(), + &[directory.path().to_path_buf()], + &[], + ); + // macOS default APFS is case-insensitive, so the discovered path may + // carry the bypass name's casing rather than the on-disk casing. + assert!(found.is_some_and(|path| path.is_file())); + } + + #[test] + fn discovers_happ_from_a_well_known_install_path() { + let directory = tempfile::tempdir().expect("tempdir"); + let binary = directory.path().join("Happ"); + // Write a Mach-O 64-bit magic header so `resolve_macos_binary` accepts + // the file as a macOS executable (not a Linux ELF). + let mut content = 0xfeed_facfu32.to_ne_bytes().to_vec(); + content.extend_from_slice(&[0u8; 64]); + fs::write(&binary, &content).expect("write"); + let found = discover_local_proxy_binary_in(&PresetId::Happ.spec(), &[], &[binary.clone()]); + assert_eq!(found, Some(binary)); + } + + #[test] + fn side_tunnel_ipc_budget_follows_the_command_timeout() { + let production = include_str!("lib.rs") + .split("mod tests {") + .next() + .expect("production source"); + assert!(production.contains("helper_ipc_reply_timeout")); + assert!(!production.contains("SIDE_TUNNEL_IPC_TIMEOUT")); + } +} diff --git a/crates/iran-split-platform-macos/src/system_proxy.rs b/crates/iran-split-platform-macos/src/system_proxy.rs new file mode 100644 index 0000000..3db6803 --- /dev/null +++ b/crates/iran-split-platform-macos/src/system_proxy.rs @@ -0,0 +1,422 @@ +//! macOS system proxy snapshot/restore via `networksetup`. +//! +//! Hiddify can publish an `HTTP`/`SOCKS` proxy on the macOS network services so +//! browsers bypass the TUN split routing. `BiFlow` snapshots that state, clears +//! it while connected, and restores it on disconnect — the same contract as +//! the GNOME/KDE backend on Linux, but driven by `networksetup` per network +//! service (Wi-Fi, Ethernet, …). + +use iran_split_core::CoreError; +use serde::{Deserialize, Serialize}; +use std::{ + path::{Path, PathBuf}, + process::Stdio, +}; +use tokio::process::Command; +use tracing::{info, warn}; + +/// Absolute path: a GUI `PATH` can omit `/usr/sbin`, and then every +/// `networksetup` call silently looks like "no proxy configured". +const NETWORKSETUP: &str = "/usr/sbin/networksetup"; + +/// One network service's proxy configuration, as reported by `networksetup`. +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, Default)] +pub struct ServiceProxy { + pub service: String, + pub web_enabled: bool, + pub secure_web_enabled: bool, + pub socks_enabled: bool, + pub http_host: String, + pub http_port: String, + pub https_host: String, + pub https_port: String, + pub socks_host: String, + pub socks_port: String, +} + +/// A snapshot of every network service's proxy state, used to restore later. +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, Default)] +pub struct Snapshot { + pub mode: String, + pub services: Vec, +} + +#[must_use] +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 + }) +} + +pub fn snapshot_path(user_data_dir: &Path) -> PathBuf { + user_data_dir.join("system-proxy-snapshot.json") +} + +/// Clears every network service's proxy when it points at Hiddify. +/// +/// # Errors +/// +/// Returns a platform error when `networksetup` cannot be read or written. +pub async fn clear_if_hiddify( + host: &str, + port: u16, + persist: &Path, +) -> Result, CoreError> { + let Some(snapshot) = read_current().await? else { + return Ok(None); + }; + if !points_at_hiddify(&snapshot, host, port) { + return Ok(None); + } + write_snapshot(persist, &snapshot)?; + apply_disabled(&snapshot).await?; + info!( + event = "system_proxy.cleared", + section = "system_proxy", + initiator = "macos_platform_backend", + cause = "hiddify_endpoint", + trace_route = "engine->macos_platform_backend->system_proxy", + "cleared a Hiddify system proxy without logging its endpoint" + ); + Ok(Some(snapshot)) +} + +/// Restores a previously cleared Hiddify system proxy. +/// +/// # Errors +/// +/// Returns a platform error when `networksetup` cannot reapply the snapshot. +pub async fn restore(persist: &Path) -> Result<(), CoreError> { + let Some(snapshot) = read_snapshot(persist)? else { + return Ok(()); + }; + apply_snapshot(&snapshot).await?; + let _ = std::fs::remove_file(persist); + info!( + event = "system_proxy.restored", + section = "system_proxy", + initiator = "macos_platform_backend", + cause = "resume", + trace_route = "engine->macos_platform_backend->system_proxy", + "restored the previous Hiddify system proxy" + ); + Ok(()) +} + +/// Reads the proxy state of every network service. +async fn read_current() -> Result, CoreError> { + if !Path::new(NETWORKSETUP).is_file() { + return Ok(None); + } + let services = list_services().await?; + let mut captured = Vec::with_capacity(services.len()); + let mut any_enabled = false; + for service in services { + let proxy = read_service(&service).await?; + if proxy.web_enabled || proxy.secure_web_enabled || proxy.socks_enabled { + any_enabled = true; + } + captured.push(proxy); + } + Ok(Some(Snapshot { + mode: if any_enabled { + "manual".into() + } else { + "none".into() + }, + services: captured, + })) +} + +async fn apply_disabled(snapshot: &Snapshot) -> Result<(), CoreError> { + // Always off. Replaying each service's saved enabled flag left Hiddify's + // HTTP/SOCKS proxy in place while "connected", so Safari/Chrome sent + // `localhost` through Hiddify and got 502 (ADR 0062). + for service in &snapshot.services { + set_proxy_state(&service.service, ProxyKind::Web, false).await?; + set_proxy_state(&service.service, ProxyKind::SecureWeb, false).await?; + set_proxy_state(&service.service, ProxyKind::Socks, false).await?; + } + Ok(()) +} + +async fn apply_snapshot(snapshot: &Snapshot) -> Result<(), CoreError> { + for service in &snapshot.services { + if service.web_enabled { + set_web_proxy(&service.service, &service.http_host, &service.http_port).await?; + } + set_proxy_state(&service.service, ProxyKind::Web, service.web_enabled).await?; + if service.secure_web_enabled { + set_secure_web_proxy(&service.service, &service.https_host, &service.https_port) + .await?; + } + set_proxy_state( + &service.service, + ProxyKind::SecureWeb, + service.secure_web_enabled, + ) + .await?; + if service.socks_enabled { + set_socks_proxy(&service.service, &service.socks_host, &service.socks_port).await?; + } + set_proxy_state(&service.service, ProxyKind::Socks, service.socks_enabled).await?; + } + Ok(()) +} + +#[derive(Copy, Clone)] +enum ProxyKind { + Web, + SecureWeb, + Socks, +} + +impl ProxyKind { + const fn state_flag(self) -> &'static str { + match self { + Self::Web => "webproxystate", + Self::SecureWeb => "securewebproxystate", + Self::Socks => "socksfirewallproxystate", + } + } +} + +async fn set_proxy_state(service: &str, kind: ProxyKind, enabled: bool) -> Result<(), CoreError> { + let flag = format!("-set{}", kind.state_flag()); + run_ok( + NETWORKSETUP, + &[&flag, service, if enabled { "on" } else { "off" }], + ) + .await +} + +async fn set_web_proxy(service: &str, host: &str, port: &str) -> Result<(), CoreError> { + run_ok(NETWORKSETUP, &["-setwebproxy", service, host, port]).await +} + +async fn set_secure_web_proxy(service: &str, host: &str, port: &str) -> Result<(), CoreError> { + run_ok(NETWORKSETUP, &["-setsecurewebproxy", service, host, port]).await +} + +async fn set_socks_proxy(service: &str, host: &str, port: &str) -> Result<(), CoreError> { + run_ok( + NETWORKSETUP, + &["-setsocksfirewallproxy", service, host, port], + ) + .await +} + +/// Lists network services, skipping the header line `networksetup` prints. +async fn list_services() -> Result, CoreError> { + let output = Command::new(NETWORKSETUP) + .arg("-listallnetworkservices") + .output() + .await + .map_err(|error| CoreError::Platform(error.to_string()))?; + if !output.status.success() { + return Err(CoreError::Platform( + "could not list macOS network services".into(), + )); + } + let text = String::from_utf8_lossy(&output.stdout); + let mut services = Vec::new(); + for line in text.lines() { + let trimmed = line.trim(); + if trimmed.is_empty() || trimmed.starts_with("An asterisk") { + continue; + } + services.push(trimmed.to_owned()); + } + Ok(services) +} + +async fn read_service(service: &str) -> Result { + let web = read_proxy(service, "-getwebproxy").await?; + let secure_web = read_proxy(service, "-getsecurewebproxy").await?; + let socks = read_proxy(service, "-getsocksfirewallproxy").await?; + Ok(ServiceProxy { + service: service.to_owned(), + web_enabled: web.0, + secure_web_enabled: secure_web.0, + socks_enabled: socks.0, + http_host: web.1, + http_port: web.2, + https_host: secure_web.1, + https_port: secure_web.2, + socks_host: socks.1, + socks_port: socks.2, + }) +} + +/// Parses `networksetup -get ` output: +/// ```text +/// Enabled: No +/// Server: 127.0.0.1 +/// Port: 12334 +/// Authenticated Proxy Enabled: 0 +/// ``` +async fn read_proxy(service: &str, kind: &str) -> Result<(bool, String, String), CoreError> { + let output = Command::new(NETWORKSETUP) + .args([kind, service]) + .output() + .await + .map_err(|error| CoreError::Platform(error.to_string()))?; + if !output.status.success() { + warn!( + event = "system_proxy.read_failed", + section = "system_proxy", + initiator = "macos_platform_backend", + cause = "networksetup_failed", + trace_route = "engine->macos_platform_backend->system_proxy", + service, + kind, + "networksetup could not read a proxy entry" + ); + return Ok((false, String::new(), String::new())); + } + let text = String::from_utf8_lossy(&output.stdout); + let mut enabled = false; + let mut host = String::new(); + let mut port = String::new(); + for line in text.lines() { + let trimmed = line.trim(); + if let Some(value) = trimmed.strip_prefix("Enabled:") { + enabled = matches!(value.trim(), "Yes" | "1" | "true"); + } else if let Some(value) = trimmed.strip_prefix("Server:") { + value.trim().clone_into(&mut host); + } else if let Some(value) = trimmed.strip_prefix("Port:") { + value.trim().clone_into(&mut port); + } + } + Ok((enabled, host, port)) +} + +async fn run_ok(program: &str, args: &[&str]) -> Result<(), CoreError> { + let status = Command::new(program) + .args(args) + .stdout(Stdio::null()) + .stderr(Stdio::null()) + .status() + .await + .map_err(|error| CoreError::Platform(error.to_string()))?; + if status.success() { + Ok(()) + } else { + Err(CoreError::Platform( + "could not update the macOS system proxy".into(), + )) + } +} + +fn write_snapshot(path: &Path, snapshot: &Snapshot) -> Result<(), CoreError> { + if let Some(parent) = path.parent() { + std::fs::create_dir_all(parent).map_err(|error| CoreError::Platform(error.to_string()))?; + } + std::fs::write( + path, + serde_json::to_vec(snapshot).map_err(|error| CoreError::Platform(error.to_string()))?, + ) + .map_err(|error| CoreError::Platform(error.to_string())) +} + +fn read_snapshot(path: &Path) -> Result, CoreError> { + if !path.is_file() { + return Ok(None); + } + let bytes = std::fs::read(path).map_err(|error| CoreError::Platform(error.to_string()))?; + serde_json::from_slice(&bytes) + .map(Some) + .map_err(|error| CoreError::Platform(error.to_string())) +} + +#[cfg(test)] +mod tests { + use super::*; + + fn service(name: &str, host: &str, port: &str) -> ServiceProxy { + ServiceProxy { + service: name.into(), + web_enabled: true, + secure_web_enabled: false, + socks_enabled: false, + http_host: host.into(), + http_port: port.into(), + https_host: String::new(), + https_port: String::new(), + socks_host: String::new(), + socks_port: String::new(), + } + } + + #[test] + fn matches_hiddify_web_proxy() { + let snapshot = Snapshot { + mode: "manual".into(), + services: vec![service("Wi-Fi", "127.0.0.1", "12334")], + }; + assert!(points_at_hiddify(&snapshot, "127.0.0.1", 12334)); + assert!(!points_at_hiddify(&snapshot, "10.0.0.1", 8080)); + } + + #[test] + fn disabled_mode_never_matches() { + let snapshot = Snapshot { + mode: "none".into(), + services: vec![service("Wi-Fi", "127.0.0.1", "12334")], + }; + assert!(!points_at_hiddify(&snapshot, "127.0.0.1", 12334)); + } + + #[test] + fn ignores_unrelated_corporate_proxy() { + let snapshot = Snapshot { + mode: "manual".into(), + services: vec![service("Wi-Fi", "proxy.corp.example", "8080")], + }; + assert!(!points_at_hiddify(&snapshot, "127.0.0.1", 12334)); + } + + #[test] + fn apply_disabled_turns_proxies_off_instead_of_replaying_snapshot() { + // `include_str` of this file would match the assertion itself if we + // scanned `mod tests`. Only the production half is the contract. + let production = include_str!("system_proxy.rs") + .split("mod tests") + .next() + .expect("production"); + let body = production + .split("async fn apply_disabled") + .nth(1) + .and_then(|rest| rest.split("async fn apply_snapshot").next()) + .expect("apply_disabled body"); + assert!( + body.contains("ProxyKind::Web, false") + && body.contains("ProxyKind::SecureWeb, false") + && body.contains("ProxyKind::Socks, false"), + "apply_disabled must force every proxy kind off" + ); + assert!( + !body.contains("service.web_enabled"), + "replaying snapshot enabled flags leaves the Hiddify proxy up" + ); + } + + #[test] + fn round_trips_snapshot_json() { + let snapshot = Snapshot { + mode: "manual".into(), + services: vec![service("Wi-Fi", "127.0.0.1", "12334")], + }; + let bytes = serde_json::to_vec(&snapshot).expect("serialize"); + let back: Snapshot = serde_json::from_slice(&bytes).expect("deserialize"); + assert_eq!(snapshot, back); + } +} diff --git a/crates/iran-split-platform-win/src/lib.rs b/crates/iran-split-platform-win/src/lib.rs index 525b196..4104f25 100644 --- a/crates/iran-split-platform-win/src/lib.rs +++ b/crates/iran-split-platform-win/src/lib.rs @@ -276,6 +276,9 @@ pub struct WindowsBackend { side_tunnel_connect_timeout: Mutex>, /// Last helper status, so connect progress does not open the pipe again. helper_cache: Mutex, + /// Last helper version reported by the helper, surfaced to the snapshot + /// so the desktop can detect a version mismatch and reinstall. + helper_version_cache: Mutex>, /// Config hash that already passed `mihomo -t` in this process. validated_config_sha256: Mutex>, } @@ -297,6 +300,7 @@ impl WindowsBackend { client_failures: Mutex::new(std::collections::HashMap::new()), side_tunnel_connect_timeout: Mutex::new(None), helper_cache: Mutex::new(ComponentStatus::default()), + helper_version_cache: Mutex::new(None), validated_config_sha256: Mutex::new(None), } } @@ -1468,8 +1472,13 @@ impl PlatformBackend for WindowsBackend { } let live_route = self.observe_live_route(&config, &handles).await; + let helper_version = match &helper_result { + Ok(status) => status.version.clone(), + Err(_) => None, + }; RuntimeHealth { helper, + helper_version, clients, mihomo, tun, @@ -1494,6 +1503,7 @@ impl PlatformBackend for WindowsBackend { Err(error) => Err(CoreError::Platform(error.to_string())), }; *self.helper_cache.lock().await = Self::helper_component(status.clone()); + *self.helper_version_cache.lock().await = status.version.clone(); status } @@ -2040,6 +2050,7 @@ impl PlatformBackend for WindowsBackend { }; RuntimeHealth { helper, + helper_version: self.helper_version_cache.lock().await.clone(), clients, mihomo, tun: ComponentStatus::new(ComponentPhase::Starting, Some("Waiting for TUN".into())), diff --git a/docs/adr/0062-no-hiddify-system-proxy-while-running.md b/docs/adr/0062-no-hiddify-system-proxy-while-running.md index ebb7736..54a9652 100644 --- a/docs/adr/0062-no-hiddify-system-proxy-while-running.md +++ b/docs/adr/0062-no-hiddify-system-proxy-while-running.md @@ -6,8 +6,9 @@ Accepted ## Context -Hiddify sets the desktop system proxy (GNOME/KDE on Linux, WinINET on Windows) -to its own mixed port when it starts or reconnects. Browsers honor that proxy +Hiddify sets the desktop system proxy (GNOME/KDE on Linux, WinINET on +Windows, `networksetup` on macOS) to its own mixed port when it starts or +reconnects. Browsers honor that proxy and send every request to Hiddify over loopback, which the TUN never sees, so Mihomo's split routing is bypassed entirely: DIRECT domains exit through the tunnel's foreign IP and Iranian hosts refuse or time out. @@ -36,6 +37,10 @@ every Pause → Resume cycle. - `PlatformBackend::clear_hiddify_system_proxy` now returns whether something was cleared so the engine can log with evidence. Non-Hiddify (corporate) proxies are untouched, as before. +- On macOS, `apply_disabled` must pass `off` for HTTP, HTTPS, and SOCKS. The + first port replayed each service's `*_enabled` flag from the snapshot, so + "clear" left Hiddify's proxy up. Safari/Chrome then sent `localhost` through + Hiddify and got HTTP 502 even though the TUN loopback path was fine. ## Consequences diff --git a/docs/adr/0108-windows-loopback-and-kubectl-bypass.md b/docs/adr/0108-windows-loopback-and-kubectl-bypass.md index bcd6414..e2dc06e 100644 --- a/docs/adr/0108-windows-loopback-and-kubectl-bypass.md +++ b/docs/adr/0108-windows-loopback-and-kubectl-bypass.md @@ -13,8 +13,15 @@ must remain usable without disconnecting BiFlow. ## Decision - Exclude IPv4 and IPv6 loopback destinations (`127.0.0.0/8` and `::1/128`) - from Windows TUN routes. These addresses stay local and do not enter Mihomo. -- Keep the route exclusion Windows-only. Linux routing is unchanged. + from Windows and macOS TUN routes. These addresses stay local and do not + enter Mihomo. +- Keep the route exclusion off Linux. Linux routing is unchanged (`lo` + already wins over the TUN default). macOS needs the same exclusion as + Windows because the helper redirects system DNS to Mihomo; a `localhost` + fake-ip then collides with `private.txt` and local dev servers + (`http://localhost:4200`) never reach the real listener. +- Put `localhost` and `+.localhost` in `fake-ip-filter` on every platform. + `+.local` does not cover `localhost`. - Application routes, including `kubectl.exe`, are configured by the user in List Management (ADR 0109) instead of being hard-coded to DIRECT. diff --git a/package.json b/package.json index 434bb34..97c995c 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "iran-split-desktop-workspace", - "version": "6.2.52", + "version": "6.2.55", "private": true, "packageManager": "pnpm@9.0.1", "engines": { diff --git a/resources/external-assets.toml b/resources/external-assets.toml index 892ccba..280d5c6 100644 --- a/resources/external-assets.toml +++ b/resources/external-assets.toml @@ -16,6 +16,15 @@ archive = "mihomo-windows-amd64-v1.19.29.zip" archive_sha256 = "1a8520cfe425441eba3eba8623b27b985020031243fe1ecaa1af2b92358a03f9" url = "https://github.com/MetaCubeX/mihomo/releases/download/v1.19.29/mihomo-windows-amd64-v1.19.29.zip" +[[assets]] +name = "mihomo-darwin-arm64" +version = "v1.19.29" +archive = "mihomo-darwin-arm64-v1.19.29.gz" +archive_sha256 = "4dc25df9e899f14161911302a8ee5fc9e202ed9c976fc405bf82c50ff27466ca" +installed_sha256 = "ec66e3e883bdc3fca06753784e324e08921e13239f8e945587cb1bfbf4c6b936" +url = "https://github.com/MetaCubeX/mihomo/releases/download/v1.19.29/mihomo-darwin-arm64-v1.19.29.gz" +local_source = "vendor/mihomo/darwin/mihomo" + [[assets]] name = "wintun-windows-x86_64" version = "0.14.1" diff --git a/scripts/build-plan.mjs b/scripts/build-plan.mjs index e1d36ba..8712e57 100644 --- a/scripts/build-plan.mjs +++ b/scripts/build-plan.mjs @@ -20,6 +20,11 @@ export function artifactLayout(fromRoot = root) { exe: "BiFlow.exe", installer: `BiFlow_${version}_x64-setup.exe`, }, + macos: { + dir: "artifacts/macos", + app: "BiFlow.app", + dmg: `BiFlow_${version}_aarch64.dmg`, + }, }; } diff --git a/scripts/check-bundled-assets.mjs b/scripts/check-bundled-assets.mjs index 1325461..8ea775f 100644 --- a/scripts/check-bundled-assets.mjs +++ b/scripts/check-bundled-assets.mjs @@ -18,6 +18,12 @@ const assets = [ sha256: "4316ff91fecec2fca9acb5612d7400ba228c069ffd325b1f17f46f1d4ef7e0cd", magic: Buffer.from("MZ"), }, + { + name: "macOS Mihomo", + path: "vendor/mihomo/darwin/mihomo", + sha256: "ec66e3e883bdc3fca06753784e324e08921e13239f8e945587cb1bfbf4c6b936", + magic: Buffer.from([0xcf, 0xfa, 0xed, 0xfe]), + }, { name: "Windows Wintun", path: "vendor/wintun/windows-x86_64/wintun.dll", diff --git a/scripts/stage-helper.sh b/scripts/stage-helper.sh index f6fb5bc..a20fcdb 100755 --- a/scripts/stage-helper.sh +++ b/scripts/stage-helper.sh @@ -48,7 +48,7 @@ if [[ "${1:-}" == "windows" ]]; then src="${TARGET_DIR}/release/iran-split-helper.exe" fi [[ -f "${src}" ]] || { echo "missing Windows helper: ${src}" >&2; exit 1; } - cp -- "${src}" "${STAGED}/iran-split-helper.exe" + cp -f "${src}" "${STAGED}/iran-split-helper.exe" echo "Staged ${STAGED}/iran-split-helper.exe" exit 0 fi @@ -56,6 +56,6 @@ fi helper_cargo_build src="${TARGET_DIR}/release/iran-split-helper" [[ -x "${src}" ]] || { echo "missing Linux helper: ${src}" >&2; exit 1; } -cp -- "${src}" "${STAGED}/iran-split-helper" -chmod 0755 -- "${STAGED}/iran-split-helper" +cp -f "${src}" "${STAGED}/iran-split-helper" +chmod 0755 "${STAGED}/iran-split-helper" echo "Staged ${STAGED}/iran-split-helper" diff --git a/src-tauri/Cargo.toml b/src-tauri/Cargo.toml index cf23ef6..a6eff0c 100644 --- a/src-tauri/Cargo.toml +++ b/src-tauri/Cargo.toml @@ -52,5 +52,8 @@ iran-split-platform-linux = { path = "../crates/iran-split-platform-linux" } [target.'cfg(target_os = "windows")'.dependencies] iran-split-platform-win = { path = "../crates/iran-split-platform-win" } +[target.'cfg(target_os = "macos")'.dependencies] +iran-split-platform-macos = { path = "../crates/iran-split-platform-macos" } + [lints] workspace = true diff --git a/src-tauri/icons/icon.icns b/src-tauri/icons/icon.icns new file mode 100644 index 0000000..63f6123 Binary files /dev/null and b/src-tauri/icons/icon.icns differ diff --git a/src-tauri/src/deps.rs b/src-tauri/src/deps.rs index 071e926..c7767f5 100644 --- a/src-tauri/src/deps.rs +++ b/src-tauri/src/deps.rs @@ -25,6 +25,10 @@ const MIHOMO_WINDOWS_ZIP_SHA256: &str = "1a8520cfe425441eba3eba8623b27b985020031243fe1ecaa1af2b92358a03f9"; const MIHOMO_WINDOWS_SHA256: &str = "4316ff91fecec2fca9acb5612d7400ba228c069ffd325b1f17f46f1d4ef7e0cd"; +const MIHOMO_MACOS_SHA256: &str = + "ec66e3e883bdc3fca06753784e324e08921e13239f8e945587cb1bfbf4c6b936"; +const MIHOMO_MACOS_ARCHIVE_SHA256: &str = + "4dc25df9e899f14161911302a8ee5fc9e202ed9c976fc405bf82c50ff27466ca"; const WINTUN_WINDOWS_SHA256: &str = "e5da8447dc2c320edc0fc52fa01885c103de8c118481f683643cacc3220dafce"; @@ -167,6 +171,13 @@ pub fn mihomo_candidates(data: &Path) -> Vec { candidates.push(home.join(".local").join("bin").join("mihomo")); candidates.push(home.join(".local").join("bin").join("clash-meta")); } + #[cfg(target_os = "macos")] + { + // Homebrew installs Mihomo under these prefixes on Apple Silicon and + // Intel Macs respectively. + candidates.push(PathBuf::from("/opt/homebrew/bin/mihomo")); + candidates.push(PathBuf::from("/usr/local/bin/mihomo")); + } candidates.extend(lookup_on_path(&["mihomo", "clash-meta"])); candidates } @@ -288,6 +299,17 @@ pub fn install_guide(id: DependencyId) -> InstallGuide { "Restart BiFlow and press Connect.".into(), ], }, + (DependencyId::Mihomo, "macos") => InstallGuide { + id: id.as_str(), + title: "Install Mihomo on macOS".into(), + download_url: mihomo_macos_url(), + steps: vec![ + "Download mihomo-darwin-arm64 gzip from the MetaCubeX GitHub release.".into(), + "Decompress it: gzip -dc mihomo-darwin-arm64-*.gz > ~/.local/share/biflow/bin/mihomo".into(), + "Make it executable: chmod +x ~/.local/share/biflow/bin/mihomo".into(), + "Restart BiFlow and press Connect.".into(), + ], + }, (DependencyId::Mihomo, _) => InstallGuide { id: id.as_str(), title: "Install Mihomo on Linux".into(), @@ -455,6 +477,17 @@ async fn install_mihomo(data: &Path, bundled_dependencies: &Path) -> Result Result<(), DepsError> { + verify_sha256(bytes, expected_sha256)?; + if !is_macho(bytes) { + return Err(DepsError::Integrity( + "Mihomo is not a macOS Mach-O binary".into(), + )); + } + write_executable(destination, bytes) +} + fn install_windows_mihomo_bytes( destination: &Path, bytes: &[u8], @@ -566,6 +613,12 @@ fn mihomo_windows_url() -> String { ) } +fn mihomo_macos_url() -> String { + format!( + "https://github.com/MetaCubeX/mihomo/releases/download/{MIHOMO_VERSION}/mihomo-darwin-arm64-{MIHOMO_VERSION}.gz" + ) +} + fn url_allowed(url: &str) -> bool { const PREFIXES: &[&str] = &[ "https://github.com/hiddify/hiddify-app/releases/", @@ -738,6 +791,11 @@ fn is_pe(bytes: &[u8]) -> bool { bytes.len() >= 2 && bytes[0] == b'M' && bytes[1] == b'Z' } +fn is_macho(bytes: &[u8]) -> bool { + // Mach-O 64-bit little-endian magic (MH_MAGIC_64): 0xfeedfacf + bytes.len() >= 4 && bytes[0] == 0xcf && bytes[1] == 0xfa && bytes[2] == 0xed && bytes[3] == 0xfe +} + fn is_zip(bytes: &[u8]) -> bool { bytes.len() >= 4 && bytes[0] == b'P' && bytes[1] == b'K' } @@ -772,7 +830,7 @@ fn open_url_allowed(url: &str) -> bool { // allowlist can never drift from the buttons that use it. iran_split_config::PresetId::all().iter().any(|preset| { let downloads = preset.spec().downloads; - url == downloads.linux || url == downloads.windows + url == downloads.linux || url == downloads.windows || url == downloads.macos }) } diff --git a/src-tauri/src/diagnostics.rs b/src-tauri/src/diagnostics.rs index b90bc67..bf83b66 100644 --- a/src-tauri/src/diagnostics.rs +++ b/src-tauri/src/diagnostics.rs @@ -677,6 +677,14 @@ fn reveal_command(path: &Path) -> (&'static str, Vec) { { ("explorer.exe", vec![windows_reveal_select(path)]) } + #[cfg(target_os = "macos")] + { + // `open -R` reveals the file in Finder instead of opening it. + ( + "open", + vec!["-R".into(), path.to_string_lossy().into_owned()], + ) + } } /// Explorer treats `/select,C:\dir/file` as a single unknown switch and opens @@ -799,6 +807,11 @@ mod tests { assert_eq!(program, "explorer.exe"); assert_eq!(arguments, vec![windows_reveal_select(path)]); } + #[cfg(target_os = "macos")] + { + assert_eq!(program, "open"); + assert_eq!(arguments, vec!["-R", "/tmp/biflow/debug.log"]); + } } #[test] diff --git a/src-tauri/src/environment/mod.rs b/src-tauri/src/environment/mod.rs index 75e629f..ac0a83c 100644 --- a/src-tauri/src/environment/mod.rs +++ b/src-tauri/src/environment/mod.rs @@ -802,6 +802,7 @@ fn no_proxy_setting(source: &str, value: &str) -> ProxySetting { } } +#[cfg_attr(not(any(target_os = "linux", target_os = "windows")), allow(dead_code))] fn adapter_kind(name: &str, description: &str) -> &'static str { let text = format!("{name} {description}").to_ascii_lowercase(); let rules: &[(&str, &'static str)] = &[ diff --git a/src-tauri/src/github_update.rs b/src-tauri/src/github_update.rs index d3364d9..5c7fe17 100644 --- a/src-tauri/src/github_update.rs +++ b/src-tauri/src/github_update.rs @@ -18,6 +18,7 @@ pub enum InstallKind { Deb, AppImage, Nsis, + Dmg, } #[derive(Debug, Clone, PartialEq, Eq)] @@ -102,9 +103,11 @@ pub fn user_agent(current_version: &str) -> String { } #[must_use] -pub fn install_kind_from(appimage: Option<&OsStr>, windows: bool) -> InstallKind { +pub fn install_kind_from(appimage: Option<&OsStr>, windows: bool, macos: bool) -> InstallKind { if windows { InstallKind::Nsis + } else if macos { + InstallKind::Dmg } else if appimage.is_some() { InstallKind::AppImage } else { @@ -117,6 +120,7 @@ pub fn detect_install_kind() -> InstallKind { install_kind_from( std::env::var_os("APPIMAGE").as_deref(), cfg!(target_os = "windows"), + cfg!(target_os = "macos"), ) } @@ -127,7 +131,16 @@ pub fn detect_install_kind() -> InstallKind { /// Returns an error when the release has no matching `.deb`, `AppImage`, or NSIS /// installer. pub fn pick_asset(release: &Release, kind: InstallKind) -> Result { - if !cfg!(target_arch = "x86_64") { + let arch = if cfg!(target_arch = "x86_64") { + "x64" + } else if cfg!(target_arch = "aarch64") { + "aarch64" + } else { + return Err("automatic updates are not available for this architecture".into()); + }; + // macOS ships both Apple Silicon and Intel builds; Linux/Windows update + // only the x86_64 release artifacts today. + if !matches!(kind, InstallKind::Dmg) && arch != "x64" { return Err("automatic updates are not available for this architecture".into()); } let version = &release.version; @@ -135,6 +148,7 @@ pub fn pick_asset(release: &Release, kind: InstallKind) -> Result InstallKind::Deb => format!("BiFlow_{version}_amd64.deb"), InstallKind::AppImage => format!("BiFlow_{version}_amd64.AppImage"), InstallKind::Nsis => format!("BiFlow_{version}_x64-setup.exe"), + InstallKind::Dmg => format!("BiFlow_{version}_{arch}.dmg"), }; release .assets @@ -152,6 +166,13 @@ pub const fn signed_target(kind: InstallKind) -> &'static str { InstallKind::Deb => "linux-deb-x86_64", InstallKind::AppImage => "linux-x86_64", InstallKind::Nsis => "windows-x86_64", + InstallKind::Dmg => { + if cfg!(target_arch = "aarch64") { + "macos-aarch64" + } else { + "macos-x86_64" + } + } } } @@ -323,6 +344,10 @@ pub async fn apply_package( install_nsis(package, current_exe, expected_version).await?; Ok(ApplyOutcome::HelperRestart) } + InstallKind::Dmg => { + install_dmg(package).await?; + Ok(ApplyOutcome::ManualRestart) + } } } @@ -397,6 +422,83 @@ async fn install_appimage(package: &Path) -> Result<(), String> { Ok(()) } +/// Installs a downloaded `.dmg` on macOS. Mounts the image, copies the bundled +/// `.app` into `/Applications` with administrator privileges, then unmounts. +/// The operator must restart `BiFlow` from `/Applications` afterwards. +#[cfg(target_os = "macos")] +async fn install_dmg(package: &Path) -> Result<(), String> { + let mountpoint = std::env::temp_dir().join("biflow-update").join("dmg-mount"); + tokio::fs::create_dir_all(&mountpoint) + .await + .map_err(|error| error.to_string())?; + let attach = tokio::process::Command::new("hdiutil") + .args(["attach", "-nobrowse", "-mountpoint"]) + .arg(&mountpoint) + .arg(package) + .output() + .await + .map_err(|error| error.to_string())?; + if !attach.status.success() { + return Err("could not mount the update image".into()); + } + let app_name = std::fs::read_dir(&mountpoint) + .map_err(|error| error.to_string())? + .flatten() + .find(|entry| entry.path().extension().is_some_and(|ext| ext == "app")) + .map(|entry| entry.file_name().to_string_lossy().into_owned()) + .ok_or_else(|| "the update image has no BiFlow.app bundle".to_owned())?; + let script = format!( + "set -e\nrm -rf '/Applications/{app}'\ncp -R '{mount}/{app}' '/Applications/{app}'\n", + app = app_name, + mount = mountpoint.display(), + ); + let script_path = std::env::temp_dir() + .join("biflow-update") + .join("apply-dmg.sh"); + tokio::fs::write(&script_path, &script) + .await + .map_err(|error| error.to_string())?; + #[cfg(unix)] + { + use std::os::unix::fs::PermissionsExt; + let mut permissions = tokio::fs::metadata(&script_path) + .await + .map_err(|error| error.to_string())? + .permissions(); + permissions.set_mode(0o700); + tokio::fs::set_permissions(&script_path, permissions) + .await + .map_err(|error| error.to_string())?; + } + let apple_script = format!( + "do shell script \"sh {script_path}\" with administrator privileges", + script_path = script_path.display(), + ); + let install = tokio::process::Command::new("osascript") + .args(["-e", &apple_script]) + .output() + .await + .map_err(|error| error.to_string())?; + let _ = tokio::process::Command::new("hdiutil") + .args(["detach", "-force"]) + .arg(&mountpoint) + .status() + .await; + let _ = tokio::fs::remove_dir_all(&mountpoint).await; + if install.status.success() { + Ok(()) + } else if install.status.code() == Some(-128) { + Err("package install was cancelled".into()) + } else { + Err("package install failed".into()) + } +} + +#[cfg(not(target_os = "macos"))] +async fn install_dmg(_package: &Path) -> Result<(), String> { + Err("macOS packages cannot be installed on this platform".into()) +} + async fn install_nsis( package: &Path, current_exe: &Path, @@ -535,8 +637,13 @@ mod tests { .expect("release"); assert_eq!(release.version, "3.6.0"); assert!(release.html_url.contains("releases/tag")); - let asset = pick_asset(&release, InstallKind::Deb).expect("deb"); - assert_eq!(asset.name, "BiFlow_3.6.0_amd64.deb"); + // The `.deb` artifact is only selected on x86_64 hosts; Apple Silicon + // Macs pick the `.dmg` instead. + #[cfg(target_arch = "x86_64")] + { + let asset = pick_asset(&release, InstallKind::Deb).expect("deb"); + assert_eq!(asset.name, "BiFlow_3.6.0_amd64.deb"); + } } #[test] @@ -562,6 +669,7 @@ mod tests { }; assert!(pick_asset(&release, InstallKind::Deb).is_err()); assert!(pick_asset(&release, InstallKind::Nsis).is_err()); + assert!(pick_asset(&release, InstallKind::Dmg).is_err()); let mut release = release; release.assets.push(Asset { name: "BiFlow_9.9.9_arm64.deb".into(), @@ -574,11 +682,31 @@ mod tests { url: "https://example.invalid/exact-setup.exe".into(), size: 1, }); + // NSIS is only selectable on x86_64; Apple Silicon falls back to dmg. + #[cfg(target_arch = "x86_64")] + { + assert_eq!( + pick_asset(&release, InstallKind::Nsis) + .expect("exact NSIS") + .url, + "https://example.invalid/exact-setup.exe" + ); + } + let arch_dmg = if cfg!(target_arch = "aarch64") { + "aarch64" + } else { + "x64" + }; + release.assets.push(Asset { + name: format!("BiFlow_9.9.9_{arch_dmg}.dmg"), + url: "https://example.invalid/exact.dmg".into(), + size: 1, + }); assert_eq!( - pick_asset(&release, InstallKind::Nsis) - .expect("exact NSIS") + pick_asset(&release, InstallKind::Dmg) + .expect("exact DMG") .url, - "https://example.invalid/exact-setup.exe" + "https://example.invalid/exact.dmg" ); } @@ -622,11 +750,17 @@ mod tests { #[test] fn install_kind_prefers_appimage_env_on_linux() { assert_eq!( - install_kind_from(Some(OsStr::new("/tmp/BiFlow.AppImage")), false), + install_kind_from(Some(OsStr::new("/tmp/BiFlow.AppImage")), false, false), InstallKind::AppImage ); - assert_eq!(install_kind_from(None, false), InstallKind::Deb); - assert_eq!(install_kind_from(None, true), InstallKind::Nsis); + assert_eq!(install_kind_from(None, false, false), InstallKind::Deb); + assert_eq!(install_kind_from(None, true, false), InstallKind::Nsis); + assert_eq!(install_kind_from(None, false, true), InstallKind::Dmg); + // macOS never reports AppImage even if the env var is set. + assert_eq!( + install_kind_from(Some(OsStr::new("/tmp/BiFlow.AppImage")), false, true), + InstallKind::Dmg + ); } #[test] diff --git a/src-tauri/src/helper_install.rs b/src-tauri/src/helper_install.rs index 9d3fbb2..e84c365 100644 --- a/src-tauri/src/helper_install.rs +++ b/src-tauri/src/helper_install.rs @@ -1,6 +1,8 @@ use super::services; use iran_split_core::PlatformBackend; use serde::Serialize; +#[cfg(unix)] +use std::os::unix::fs::PermissionsExt; use std::{ fs, path::{Path, PathBuf}, @@ -12,7 +14,7 @@ use tokio::process::Command; use tracing::warn; use tracing::{error, info}; -#[cfg(target_os = "linux")] +#[cfg(any(target_os = "linux", target_os = "macos"))] use sha2::{Digest, Sha256}; #[cfg(target_os = "linux")] use std::os::unix::fs::PermissionsExt; @@ -26,7 +28,7 @@ const PKEXEC: &str = "/usr/bin/pkexec"; #[cfg(target_os = "linux")] const ROOT_APP_REJECTION: &str = "BiFlow is running as root, so the helper cannot be installed. The helper authorizes one non-root user. Quit BiFlow, start it as your normal user without sudo, then install the helper again."; /// Keeps a runaway script error out of the dialog while preserving the reason. -#[cfg(any(target_os = "linux", target_os = "windows"))] +#[cfg(any(target_os = "linux", target_os = "windows", target_os = "macos"))] const MAX_DETAIL_CHARS: usize = 200; /// `ERROR_CANCELLED`: the operator refused the UAC prompt. #[cfg(any(target_os = "windows", test))] @@ -98,7 +100,21 @@ pub async fn install_helper(app: &AppHandle) -> Result Option<&'static str> { /// `install-helper.sh` and `PowerShell` both report why they stopped on their /// last stderr line. -#[cfg(any(target_os = "linux", target_os = "windows"))] +#[cfg(any(target_os = "linux", target_os = "windows", target_os = "macos"))] #[must_use] pub(crate) fn last_error_line(stderr: &[u8]) -> String { let text = String::from_utf8_lossy(stderr); @@ -558,6 +574,231 @@ fn unit_candidates(resource_root: &Path, exe_dir: &Path) -> Vec { ] } +/// Root-owned helper + Mihomo location on macOS. A launchd daemon runs as root +/// and reads its configuration from here. +#[cfg(target_os = "macos")] +const MACOS_HELPER_ROOT: &str = "/Library/Application Support/BiFlow"; +/// The launchd daemon plist lives in the system domain. +#[cfg(target_os = "macos")] +const MACOS_PLIST_PATH: &str = "/Library/LaunchDaemons/app.biflow.helper.plist"; +/// The daemon label launchd uses to track the helper. +#[cfg(target_os = "macos")] +const MACOS_HELPER_LABEL: &str = "app.biflow.helper"; + +#[cfg(target_os = "macos")] +fn macos_helper_candidates(resource_root: &Path, exe_dir: &Path) -> Vec { + vec![ + resource_root.join("helper/iran-split-helper"), + exe_dir.join("helper/iran-split-helper"), + exe_dir.join("iran-split-helper"), + resource_root.join("_up_/resources/helper/iran-split-helper"), + ] +} + +#[cfg(target_os = "macos")] +fn macos_mihomo_candidates(resource_root: &Path, exe_dir: &Path) -> Vec { + vec![ + resource_root.join("dependencies/mihomo"), + exe_dir.join("dependencies/mihomo"), + resource_root.join("_up_/resources/dependencies/mihomo"), + ] +} + +/// Installs the privileged helper on macOS via a launchd daemon. +/// +/// The desktop cannot write under `/Library` without elevation, so it stages +/// the helper binary, Mihomo, a launchd plist, and an install shell script +/// into the user's profile, then runs the script through `osascript` with +/// administrator privileges. The script copies the payload into +/// `/Library/Application Support/BiFlow`, writes `helper.toml` (root-owned), +/// installs the plist, and bootstraps the daemon with `launchctl`. +#[cfg(target_os = "macos")] +async fn install_macos( + resource_root: &Path, + exe_dir: &Path, + payload_dir: &Path, + staging_dir: &Path, + tun_name: &str, +) -> Result<(), String> { + let (uid, gid) = current_macos_ids()?; + let helper_src = first_existing_file(&macos_helper_candidates(resource_root, exe_dir)) + .ok_or_else(|| "packaged helper binary is missing".to_owned())?; + let mihomo_src = first_existing_file(&macos_mihomo_candidates(resource_root, exe_dir)) + .ok_or_else(|| "packaged Mihomo binary is missing".to_owned())?; + + if payload_dir.exists() { + fs::remove_dir_all(payload_dir).map_err(|error| error.to_string())?; + } + fs::create_dir_all(payload_dir).map_err(|error| error.to_string())?; + // 0700 keeps another unprivileged user from swapping the payload before + // the elevated install reads it. + fs::set_permissions(payload_dir, fs::Permissions::from_mode(0o700)) + .map_err(|error| error.to_string())?; + let helper_stage = copy_payload_file_macos(&helper_src, payload_dir, 0o755)?; + let mihomo_stage = copy_payload_file_macos(&mihomo_src, payload_dir, 0o755)?; + + // The helper validates `mihomo_sha256` against the installed Mihomo + // binary on startup and refuses to serve with an empty hash + // (`UnsafeConfig("mihomo_sha256 must be lowercase SHA-256")`). Compute + // the real digest of the staged Mihomo copy so the daemon can boot. + let mihomo_sha256 = sha256_file(&mihomo_stage)?; + let helper_toml = format!( + "authorized_uid = {uid}\nauthorized_gid = {gid}\nsocket_path = \ + \"{socket_path}\"\nstaging_dir = \ + \"{staging_dir}\"\nruntime_dir = \ + \"{runtime_dir}\"\nmihomo_binary = \ + \"{mihomo_binary}\"\nmihomo_sha256 = \ + \"{mihomo_sha256}\"\ntun_name = \"{tun_name}\"\n", + socket_path = MACOS_HELPER_ROOT.to_owned() + "/helper.sock", + staging_dir = staging_dir.display(), + runtime_dir = MACOS_HELPER_ROOT.to_owned() + "/runtime", + mihomo_binary = MACOS_HELPER_ROOT.to_owned() + "/mihomo", + ); + let helper_toml_stage = payload_dir.join("helper.toml"); + fs::write(&helper_toml_stage, helper_toml).map_err(|error| error.to_string())?; + + let plist = launchd_plist(); + let plist_stage = payload_dir.join("app.biflow.helper.plist"); + fs::write(&plist_stage, plist).map_err(|error| error.to_string())?; + + let install_script = payload_dir.join("install-helper.sh"); + let script = format!( + "set -e\n\ + mkdir -p '{MACOS_HELPER_ROOT}' '{MACOS_HELPER_ROOT}/runtime'\n\ + cp -f '{helper}' '{MACOS_HELPER_ROOT}/iran-split-helper'\n\ + cp -f '{mihomo}' '{MACOS_HELPER_ROOT}/mihomo'\n\ + cp -f '{toml}' '{MACOS_HELPER_ROOT}/helper.toml'\n\ + chmod 755 '{MACOS_HELPER_ROOT}/iran-split-helper' \ + '{MACOS_HELPER_ROOT}/mihomo'\n\ + chown root:wheel '{MACOS_HELPER_ROOT}/iran-split-helper' \ + '{MACOS_HELPER_ROOT}/mihomo' '{MACOS_HELPER_ROOT}/helper.toml'\n\ + cp -f '{plist}' '{MACOS_PLIST_PATH}'\n\ + chown root:wheel '{MACOS_PLIST_PATH}'\n\ + chmod 644 '{MACOS_PLIST_PATH}'\n\ + launchctl bootout system/{MACOS_HELPER_LABEL} 2>/dev/null || true\n\ + launchctl bootstrap system '{MACOS_PLIST_PATH}'\n\ + launchctl enable system/{MACOS_HELPER_LABEL}\n", + helper = helper_stage.display(), + mihomo = mihomo_stage.display(), + toml = helper_toml_stage.display(), + plist = plist_stage.display(), + ); + fs::write(&install_script, script).map_err(|error| error.to_string())?; + fs::set_permissions(&install_script, fs::Permissions::from_mode(0o755)) + .map_err(|error| error.to_string())?; + + // The payload path lives under `~/Library/Application Support/biflow/...`, + // which contains a space. `do shell script` runs through sh, so the + // path must be single-quoted or sh splits it at the space and tries to + // execute `/Users/.../Library/Application` (which does not exist). + let apple_script = macos_admin_applescript(&install_script); + let output = Command::new("osascript") + .args(["-e", &apple_script]) + .output() + .await + .map_err(|error| error.to_string())?; + // Discard the staged payload regardless of outcome. + let _ = fs::remove_dir_all(payload_dir); + if output.status.success() { + return Ok(()); + } + // osascript exits -128 when the operator dismissed the admin prompt. + if output.status.code() == Some(-128) { + return Err("helper installation was cancelled".into()); + } + let detail = last_error_line(&output.stderr); + error!( + event = "helper.install_failed", + section = "helper_install", + initiator = "tauri_command", + cause = "install_script_failed", + trace_route = "ui->tauri_command->install_helper->install_macos", + exit_code = output.status.code().unwrap_or(-1), + detail = %detail, + "privileged helper installation failed" + ); + if detail.is_empty() { + Err("privileged helper installation failed".into()) + } else { + Err(detail) + } +} + +/// Builds the `do shell script ... with administrator privileges` `AppleScript` +/// that elevates the staged install script. The payload path lives under +/// `~/Library/Application Support/biflow/...`, which contains a space, so the +/// path must be single-quoted or sh splits it at the space and tries to +/// execute `/Users/.../Library/Application` (which does not exist). +#[cfg(target_os = "macos")] +fn macos_admin_applescript(script_path: &Path) -> String { + format!( + "do shell script \"sh '{script_path}'\" with administrator privileges", + script_path = script_path.display(), + ) +} + +#[cfg(target_os = "macos")] +fn copy_payload_file_macos(source: &Path, directory: &Path, mode: u32) -> Result { + use std::os::unix::fs::PermissionsExt; + let name = source + .file_name() + .ok_or_else(|| format!("{} has no file name", source.display()))?; + let destination = directory.join(name); + fs::copy(source, &destination) + .map_err(|error| format!("cannot stage {}: {error}", source.display()))?; + fs::set_permissions(&destination, fs::Permissions::from_mode(mode)) + .map_err(|error| error.to_string())?; + Ok(destination) +} + +#[cfg(target_os = "macos")] +fn launchd_plist() -> String { + format!( + "\n\ + \n\ + \n\ + \n\ + \tLabel{MACOS_HELPER_LABEL}\n\ + \tProgramArguments\n\ + \t\n\ + \t\t{MACOS_HELPER_ROOT}/iran-split-helper\n\ + \t\t--config\n\ + \t\t{MACOS_HELPER_ROOT}/helper.toml\n\ + \t\n\ + \tRunAtLoad\n\ + \tKeepAlive\n\ + \tStandardOutPath{MACOS_HELPER_ROOT}/helper.log\n\ + \tStandardErrorPath{MACOS_HELPER_ROOT}/helper.log\n\ + \n\ + \n", + ) +} + +/// Returns the current user's UID and primary GID on macOS. The desktop runs +/// as the normal user, so these are the credentials the helper authorizes. +#[cfg(target_os = "macos")] +fn current_macos_ids() -> Result<(u32, u32), String> { + let uid = id_output("-u")?; + let gid = id_output("-g")?; + Ok((uid, gid)) +} + +#[cfg(target_os = "macos")] +fn id_output(flag: &str) -> Result { + let output = std::process::Command::new("id") + .arg(flag) + .output() + .map_err(|error| error.to_string())?; + if !output.status.success() { + return Err("could not read the current user id".into()); + } + String::from_utf8_lossy(&output.stdout) + .trim() + .parse::() + .map_err(|_| "current user id is not a number".to_owned()) +} + #[cfg(target_os = "windows")] fn windows_helper_candidates(resource_root: &Path, exe_dir: &Path) -> Vec { vec![ @@ -580,7 +821,7 @@ pub(crate) fn first_existing_file(candidates: &[PathBuf]) -> Option { candidates.iter().find(|path| path.is_file()).cloned() } -#[cfg(target_os = "linux")] +#[cfg(any(target_os = "linux", target_os = "macos"))] fn sha256_file(path: &Path) -> Result { let bytes = fs::read(path).map_err(|error| error.to_string())?; Ok(hex::encode(Sha256::digest(bytes))) @@ -871,4 +1112,51 @@ mod tests { Some(present) ); } + + #[cfg(target_os = "macos")] + #[test] + fn macos_admin_applescript_quotes_spaced_paths() { + // The payload path lives under `~/Library/Application Support/biflow/...` + // which contains a space. The AppleScript must single-quote it or sh + // splits the path at the space and the helper install fails with + // `sh: /Users/.../Library/Application: No such file or directory`. + let script = super::macos_admin_applescript(std::path::Path::new( + "/Users/omid/Library/Application Support/biflow/runtime/helper-install/install-helper.sh", + )); + assert!( + script.contains("sh '/Users/omid/Library/Application Support/biflow/runtime/helper-install/install-helper.sh'"), + "spaced payload path must be single-quoted inside `do shell script`: {script}" + ); + assert!(script.contains("with administrator privileges")); + } + + #[cfg(target_os = "macos")] + #[test] + fn macos_install_script_uses_modern_launchctl_service_targets() { + // Modern `launchctl` (macOS 10.10+) takes a service-target of the + // form `/` (e.g. `system/app.biflow.helper`), + // not ` `. The space form fails with + // `Usage: launchctl enable `. + let source = include_str!("helper_install.rs"); + let script_start = source + .find("let script = format!(\n \"set -e\\n\\") + .expect("install script template"); + let script_end = source[script_start..] + .find("fs::write(&install_script") + .expect("end of script template") + + script_start; + let script = &source[script_start..script_end]; + assert!( + script.contains("launchctl bootout system/{MACOS_HELPER_LABEL}"), + "bootout must use the system/ service-target form" + ); + assert!( + script.contains("launchctl enable system/{MACOS_HELPER_LABEL}"), + "enable must use the system/ service-target form" + ); + assert!( + !script.contains("launchctl enable system '{MACOS_HELPER_LABEL}'"), + "enable must not use the legacy `system ` space form" + ); + } } diff --git a/src-tauri/src/hiddify_reset.rs b/src-tauri/src/hiddify_reset.rs index c9ae18c..bdfb3fa 100644 --- a/src-tauri/src/hiddify_reset.rs +++ b/src-tauri/src/hiddify_reset.rs @@ -324,7 +324,67 @@ async fn stop_hiddify(executable: &Path) -> bool { } } -#[cfg(not(any(target_os = "linux", target_os = "windows")))] +#[cfg(target_os = "macos")] +async fn stop_hiddify(executable: &Path) -> bool { + // macOS has no `/proc`; `pgrep -f` matches the executable path against the + // full command line of every process, the same intent as the Linux + // `/proc//exe` symlink check. + let pids = macos_pids_for(executable).await; + if pids.is_empty() { + return false; + } + let mut command = Command::new("kill"); + command.arg("-TERM"); + for pid in &pids { + command.arg(pid.to_string()); + } + match command.status().await { + Ok(status) if status.success() => { + tokio::time::sleep(std::time::Duration::from_millis(700)).await; + true + } + Ok(status) => { + tracing::warn!( + event = "hiddify.stop_failed", + section = "hiddify_reset", + initiator = "tauri_command", + cause = "kill_exit_status", + trace_route = "ui->tauri_command->fresh_start->stop", + exit_code = status.code().unwrap_or(-1), + "could not terminate the running Hiddify" + ); + false + } + Err(error) => { + error!( + event = "hiddify.stop_failed", + section = "hiddify_reset", + initiator = "tauri_command", + cause = %error, + trace_route = "ui->tauri_command->fresh_start->stop", + "could not run kill" + ); + false + } + } +} + +#[cfg(target_os = "macos")] +async fn macos_pids_for(executable: &Path) -> Vec { + let needle = executable.to_string_lossy().into_owned(); + let Ok(output) = Command::new("pgrep").args(["-f", &needle]).output().await else { + return Vec::new(); + }; + if !output.status.success() { + return Vec::new(); + } + String::from_utf8_lossy(&output.stdout) + .lines() + .filter_map(|line| line.trim().parse::().ok()) + .collect() +} + +#[cfg(not(any(target_os = "linux", target_os = "windows", target_os = "macos")))] async fn stop_hiddify(_executable: &Path) -> bool { false } diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index 5222334..25f5aaf 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -56,6 +56,8 @@ use std::process::Command; #[cfg(target_os = "linux")] use iran_split_platform_linux::{LinuxBackend as NativeBackend, LinuxPaths}; +#[cfg(target_os = "macos")] +use iran_split_platform_macos::{MacosBackend as NativeBackend, MacosPaths}; #[cfg(target_os = "windows")] use iran_split_platform_win::{WindowsBackend as NativeBackend, WindowsPaths, HELPER_PIPE}; @@ -287,6 +289,77 @@ fn linux_mihomo_binary_with_override( override_path.map_or(default, PathBuf::from) } +/// Helper socket and system runtime root on macOS. The privileged helper runs +/// as a launchd daemon under `/Library/Application Support/BiFlow`; the socket +/// lives next to it so only root and the authorized group can reach it. +#[cfg(target_os = "macos")] +const MACOS_HELPER_SOCKET: &str = "/Library/Application Support/BiFlow/helper.sock"; +#[cfg(target_os = "macos")] +const MACOS_SYSTEM_RUNTIME: &str = "/Library/Application Support/BiFlow/runtime"; + +#[cfg(target_os = "macos")] +fn macos_helper_paths() -> (PathBuf, PathBuf) { + #[cfg(debug_assertions)] + { + macos_helper_paths_with_overrides( + std::env::var_os("BIFLOW_DEV_PROFILE"), + std::env::var_os("BIFLOW_DEV_HELPER_SOCKET"), + std::env::var_os("BIFLOW_DEV_SYSTEM_RUNTIME"), + ) + } + #[cfg(not(debug_assertions))] + { + ( + PathBuf::from(MACOS_HELPER_SOCKET), + PathBuf::from(MACOS_SYSTEM_RUNTIME), + ) + } +} + +#[cfg(all(target_os = "macos", debug_assertions))] +fn macos_helper_paths_with_overrides( + profile: Option, + socket: Option, + runtime: Option, +) -> (PathBuf, PathBuf) { + if profile.is_some_and(|value| !value.is_empty()) { + return ( + socket.map_or_else( + || PathBuf::from("/tmp/biflow-dev-missing-helper.sock"), + PathBuf::from, + ), + runtime.map_or_else( + || PathBuf::from("/tmp/biflow-dev-missing-runtime"), + PathBuf::from, + ), + ); + } + ( + socket.map_or_else(|| PathBuf::from(MACOS_HELPER_SOCKET), PathBuf::from), + runtime.map_or_else(|| PathBuf::from(MACOS_SYSTEM_RUNTIME), PathBuf::from), + ) +} + +#[cfg(target_os = "macos")] +fn macos_mihomo_binary(default: PathBuf) -> PathBuf { + #[cfg(debug_assertions)] + { + macos_mihomo_binary_with_override(default, std::env::var_os("BIFLOW_DEV_MIHOMO_BINARY")) + } + #[cfg(not(debug_assertions))] + { + default + } +} + +#[cfg(all(target_os = "macos", debug_assertions))] +fn macos_mihomo_binary_with_override( + default: PathBuf, + override_path: Option, +) -> PathBuf { + override_path.map_or(default, PathBuf::from) +} + const BUNDLE_IDENTIFIER: &str = "app.biflow.desktop"; #[cfg(target_os = "linux")] @@ -1063,6 +1136,16 @@ async fn disconnect_client(app: AppHandle, client_id: String) -> Result<(), Stri async fn prepare_stack_start(app: &AppHandle) -> Result<(), String> { let services = services(app)?; let helper_ready = connect_prep::helper_is_ready(services.engine.snapshot().helper.phase); + // A helper left over from a previous app version lacks the current + // platform fixes (macOS DNS takeover, utun naming). Reinstall it before + // Connect so the staged binary and helper.toml match this build. + let helper_version_matches = services + .engine + .snapshot() + .helper_version + .as_deref() + .is_some_and(|version| version == version::app_version()); + let helper_needs_reinstall = helper_ready && !helper_version_matches; let statuses = deps::dependency_status(&services.paths.data); let hiddify_required = services .config_store @@ -1107,6 +1190,21 @@ async fn prepare_stack_start(app: &AppHandle) -> Result<(), Strin } } } + if helper_needs_reinstall { + info!( + event = "connect.helper_reinstall_required", + section = "stack", + initiator = "prepare_stack_start", + cause = "helper_version_mismatch", + trace_route = "start_stack->prepare_stack_start->install_helper", + "reinstalling the privileged helper to match the app version" + ); + helper_install::install_helper(app).await?; + services.engine.refresh_health().await; + if !connect_prep::helper_is_ready(services.engine.snapshot().helper.phase) { + return Err("privileged helper is still unavailable after reinstallation".into()); + } + } services.engine.refresh_health().await; Ok(()) } @@ -2214,6 +2312,7 @@ fn spawn_environment_snapshot(app: &AppHandle, trigger: &'static github_update::InstallKind::Deb => "deb", github_update::InstallKind::AppImage => "appimage", github_update::InstallKind::Nsis => "nsis", + github_update::InstallKind::Dmg => "dmg", }, mihomo_path: deps::first_existing(&deps::mihomo_candidates(&data)), hiddify_executable: deps::first_existing(&deps::hiddify_candidates(&data)), @@ -2460,7 +2559,7 @@ async fn query_logs( maximum: u16, ) -> Result, String> { diagnostics::trace_action("diagnostics", "tauri_command", "query_logs", async move { - #[cfg(target_os = "linux")] + #[cfg(any(target_os = "linux", target_os = "macos"))] { services(&app)? .backend @@ -3437,6 +3536,37 @@ fn create_services(app: &AppHandle) -> Result { }, )) }; + #[cfg(target_os = "macos")] + let backend = { + let (socket_path, system_runtime_dir) = macos_helper_paths(); + let mihomo_binary = macos_mihomo_binary( + deps::first_existing(&deps::mihomo_candidates(&paths.data)) + .unwrap_or_else(|| paths.data.join("bin/mihomo")), + ); + info!( + event = "helper.paths_selected", + section = "startup", + initiator = "create_services", + cause = "platform_configuration", + trace_route = "application_process->create_services->macos_backend", + socket_path = %socket_path.display(), + runtime_path = %system_runtime_dir.display(), + controller_port = config.mihomo.controller_port, + mixed_port = config.mihomo.mixed_port, + "macOS helper paths selected" + ); + Arc::new(NativeBackend::new( + config, + MacosPaths { + socket_path, + user_data_dir: paths.data.clone(), + system_runtime_dir, + resources_dir: bundled_rules.clone(), + rules_cache_dir: rules_cache.clone(), + mihomo_binary, + }, + )) + }; let runtime = tauri::async_runtime::handle(); let engine = Engine::new(Arc::clone(&backend), runtime.inner()); let rules = RuleManager::load_with_legacy( @@ -4548,12 +4678,13 @@ mod tests { #[test] fn linux_deb_packages_install_via_apt_not_self_replace() { assert_eq!( - super::github_update::install_kind_from(None, false), + super::github_update::install_kind_from(None, false, false), super::github_update::InstallKind::Deb ); assert_eq!( super::github_update::install_kind_from( Some(std::ffi::OsStr::new("/tmp/BiFlow.AppImage")), + false, false ), super::github_update::InstallKind::AppImage diff --git a/src-tauri/tauri.conf.json b/src-tauri/tauri.conf.json index 2f8ce9f..0899d53 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.52", + "version": "6.2.55", "identifier": "app.biflow.desktop", "build": { "beforeDevCommand": "pnpm bundle:check && pnpm --dir apps/desktop dev", @@ -34,7 +34,8 @@ "icons/32x32.png", "icons/128x128.png", "icons/icon.ico", - "icons/icon.png" + "icons/icon.png", + "icons/icon.icns" ], "resources": { "../resources/rules/": "rules/", @@ -66,6 +67,11 @@ "displayLanguageSelector": false, "installerHooks": "../packaging/windows/installer-hooks.nsh" } + }, + "macOS": { + "minimumSystemVersion": "11.0", + "hardenedRuntime": true, + "signingIdentity": null } }, "plugins": { diff --git a/src-tauri/tauri.macos.conf.json b/src-tauri/tauri.macos.conf.json new file mode 100644 index 0000000..43ef429 --- /dev/null +++ b/src-tauri/tauri.macos.conf.json @@ -0,0 +1,13 @@ +{ + "bundle": { + "macOS": { + "minimumSystemVersion": "11.0", + "signingIdentity": null, + "hardenedRuntime": true + }, + "resources": { + "../vendor/mihomo/darwin/mihomo": "dependencies/mihomo", + "../packaging/staged/iran-split-helper": "helper/iran-split-helper" + } + } +} diff --git a/vendor/mihomo/darwin/mihomo b/vendor/mihomo/darwin/mihomo new file mode 100755 index 0000000..87ce23b Binary files /dev/null and b/vendor/mihomo/darwin/mihomo differ diff --git a/version b/version index e8b440f..a558a21 100644 --- a/version +++ b/version @@ -1 +1 @@ -6.2.52 +6.2.55