diff --git a/AGENTS.md b/AGENTS.md
index 746e2f2..88d1fdc 100644
--- a/AGENTS.md
+++ b/AGENTS.md
@@ -71,9 +71,73 @@ If a required command fails or emits a warning from project code, fix it in the
## Lessons
-- A failed macOS disk-image open must not resume a user-paused stack. Only
- package kinds that pause the stack for installation may restore it after
- failure; pass the install kind into the recovery decision (ADR 0129).
+- A native DMG fixture must select its filesystem and use a unique volume name;
+ do not depend on a runner's image-creation defaults. Preserve hdiutil exit
+ status and stderr in fixture assertions. A generic attach assertion hid the
+ upstream macOS failure while fork CI passed. Exercise the production attach
+ helper in that test; bound attach/detach and retain staging on an unconfirmed
+ detach rather than retrying arbitrary failures or deleting mounted paths
+ (ADR 0131, 6.2.84).
+
+- Reuse an existing `String` with `clone_from` when comparing a normalized
+ Linux proxy snapshot. Its native Clippy cfg rejects assignment from `clone`,
+ which the Windows workspace check cannot see (ADR 0132).
+
+- Clearing a user's already-enabled local proxy during Connect is temporary
+ ownership, not permission to discard their working network after Disconnect.
+ Capture before launching clients, retain that first baseline, and hand it back
+ only after verified TUN cleanup, only to a reachable loopback client, and only
+ when the settings have not changed outside BiFlow. Never restore into Running
+ or overwrite a corporate proxy/PAC choice. Keep a borrowed client alive and
+ cover the live-proxy and dead-proxy cases separately (ADR 0132).
+- An unused app must not clear a user proxy merely because Quit was requested.
+ Cleanup only state the app took over; a helper's presence is not ownership.
+- `BiFlow` in new Rust documentation needs Markdown backticks under strict
+ Clippy. An old test requiring a clear call during an early cancelled Connect
+ tested an implementation detail; assert preserved original proxy state instead.
+ Environment finding fixtures must set `biflow.stack_phase`, which the finding
+ policy actually consumes, rather than only the duplicate runtime snapshot.
+
+- Relaunch helpers that append JSONL must use the diagnostic writer's flat
+ event schema, including timestamp, level, and top-level event/action fields.
+ A nested `fields` object makes the launch result invisible to the normal log
+ reader; cover the actual JSON record shape in native tests (ADR 0131).
+
+- A parameter used only by a macOS cfg branch cannot be underscore-prefixed:
+ native Clippy rejects `used_underscore_binding`. Use its ordinary name and
+ consume the operation ID in the shared structured installer event so every
+ host compiles it without unused-variable warnings (ADR 0131).
+- `gh pr edit` can require unrelated `read:org` scopes through its GraphQL
+ metadata query. Update the PR through the repository REST endpoint with a
+ structured JSON input file instead of expanding credential permissions.
+
+- Fetch upstream main and compare the latest release before building a patch.
+ An older feature branch can carry a higher version while missing later fixes.
+ Base the patch on current main and preserve its accepted platform behavior.
+- NSIS staging must use `$COMMONPROGRAMDATA`; `$PROGRAMDATA` is not a defined
+ variable and silently corrupts the helper argument. Require the valid spelling
+ and reject the retired token in installer contracts, then compile the generated
+ installer with `/WX` before installing it (ADR 0130).
+- Refreshing main does not refresh a lockfile's yanked releases. Replace
+ `async-executor` 1.14.0 and `clipboard-win` 5.4.1 through `cargo update -p`
+ when the advisory check flags them; do not add an advisory ignore.
+- Repeated Quit requests must all prevent exit until owned network cleanup
+ completes. Drain client maintenance and pending Connect, reject new actions,
+ and retain cleanup errors for retry instead of exiting after a warning
+ (ADR 0130). Clear recovery's operation ID before releasing its lifecycle lock,
+ and cover the next user Pause so a successful repair cannot strand that lock.
+- A controller can report a stale enabled TUN after sleep. Use native timer gaps
+ and Windows unlock alongside persistent component failures, preserve idle
+ intent, and exclude the health/recovery work itself from timer-gap detection.
+
+- Failed installers must restore only a connection they actually paused. Track
+ that intent explicitly for every package kind; the paused phase alone does not
+ distinguish an installer pause from a user pause (ADR 0131). Update the
+ diagnostic fields with that decision too; retaining the removed `kind` local
+ in the recovery event breaks the desktop compilation.
+- Update-flow e2e changes must keep the Pause readiness assertion enabled;
+ only the repeated update action is disabled during restart. Broad replacement
+ of `toBeEnabled()` assertions reverses that setup and breaks the primary flow.
- Scope About update assertions to its region: the network footer also has
a `status` role, so a page-wide status locator matches two elements.
@@ -796,12 +860,10 @@ SHA-256")`); launchd `KeepAlive` then restarts it in a crash loop and the
`webpki-roots`, or `webpki-root-certs` fall inside a new RustSec range.
Update them with `cargo update -p`. `webpki-roots` has two majors, so
name `webpki-roots@1` (RUSTSEC-2026-0381, 0382, 0383, 0386).
-- macOS in-app update must not replace `/Applications/BiFlow.app` or quit.
- Closing the window only hides the menu-bar process, so an automatic swap
- and relaunch was easy to miss and left the operator on the old version
- when the helper never ran. Download the signed disk image, open it with
- `/usr/bin/open`, and leave the tunnel up. The new version starts only
- after the operator drags BiFlow onto Applications, quits the menu-bar
- process, and opens that Applications copy (ADR 0129). Linux `.deb` still
- waits for a manual restart. AppImage and NSIS still use their replace
- helpers.
+- Downloading/opening a DMG does not install an update. The requested macOS
+ automatic updater supersedes ADR 0129: await authorized replacement, verify
+ bundle identity and version before swapping, preserve rollback backups on any
+ rollback failure, and launch as the desktop user only after the old PID exits
+ (ADR 0131). Keep mounted staging out of automatic recursive cleanup before
+ starting attach, including cancellation. Linux `.deb` still waits for a manual
+ restart; AppImage and NSIS use their existing replace helpers.
diff --git a/Cargo.lock b/Cargo.lock
index c5b0f4c..31614c6 100644
--- a/Cargo.lock
+++ b/Cargo.lock
@@ -144,9 +144,9 @@ dependencies = [
[[package]]
name = "async-executor"
-version = "1.14.0"
+version = "1.14.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "c96bf972d85afc50bf5ab8fe2d54d1586b4e0b46c97c50a0c9e71e2f7bcd812a"
+checksum = "f426a31126c735126297bc5ea7a521263c9faff4f63ca415267ee625651c4f37"
dependencies = [
"async-task",
"concurrent-queue",
@@ -614,9 +614,9 @@ checksum = "c8d4a3bb8b1e0c1050499d1815f5ab16d04f0959b233085fb31653fbfc9d98f9"
[[package]]
name = "clipboard-win"
-version = "5.4.1"
+version = "5.4.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "bde03770d3df201d4fb868f2c9c59e66a3e4e2bd06692a0fe701e7103c7e84d4"
+checksum = "052af7878466feccda12ba7a6a67d019d712f296809559b47c12c8cc196d3a9a"
dependencies = [
"error-code",
]
@@ -2046,7 +2046,7 @@ dependencies = [
[[package]]
name = "iran-split-cli"
-version = "6.2.80"
+version = "6.2.84"
dependencies = [
"async-trait",
"chrono",
@@ -2064,7 +2064,7 @@ dependencies = [
[[package]]
name = "iran-split-clients"
-version = "6.2.80"
+version = "6.2.84"
dependencies = [
"async-trait",
"ipnet",
@@ -2080,7 +2080,7 @@ dependencies = [
[[package]]
name = "iran-split-config"
-version = "6.2.80"
+version = "6.2.84"
dependencies = [
"hex",
"rand 0.9.5",
@@ -2093,7 +2093,7 @@ dependencies = [
[[package]]
name = "iran-split-core"
-version = "6.2.80"
+version = "6.2.84"
dependencies = [
"async-trait",
"chrono",
@@ -2110,7 +2110,7 @@ dependencies = [
[[package]]
name = "iran-split-desktop"
-version = "6.2.80"
+version = "6.2.84"
dependencies = [
"chrono",
"dirs 6.0.0",
@@ -2152,7 +2152,7 @@ dependencies = [
[[package]]
name = "iran-split-helper"
-version = "6.2.80"
+version = "6.2.84"
dependencies = [
"clap",
"hex",
@@ -2177,7 +2177,7 @@ dependencies = [
[[package]]
name = "iran-split-helper-winacl"
-version = "6.2.80"
+version = "6.2.84"
dependencies = [
"tokio",
"windows",
@@ -2185,7 +2185,7 @@ dependencies = [
[[package]]
name = "iran-split-ipc"
-version = "6.2.80"
+version = "6.2.84"
dependencies = [
"bytes",
"chrono",
@@ -2198,7 +2198,7 @@ dependencies = [
[[package]]
name = "iran-split-mihomo"
-version = "6.2.80"
+version = "6.2.84"
dependencies = [
"chrono",
"futures-util",
@@ -2221,7 +2221,7 @@ dependencies = [
[[package]]
name = "iran-split-netstats"
-version = "6.2.80"
+version = "6.2.84"
dependencies = [
"libc",
"tempfile",
@@ -2230,7 +2230,7 @@ dependencies = [
[[package]]
name = "iran-split-platform-linux"
-version = "6.2.80"
+version = "6.2.84"
dependencies = [
"async-trait",
"chrono",
@@ -2254,7 +2254,7 @@ dependencies = [
[[package]]
name = "iran-split-platform-macos"
-version = "6.2.80"
+version = "6.2.84"
dependencies = [
"async-trait",
"chrono",
@@ -2278,7 +2278,7 @@ dependencies = [
[[package]]
name = "iran-split-platform-win"
-version = "6.2.80"
+version = "6.2.84"
dependencies = [
"async-trait",
"chrono",
@@ -2301,7 +2301,7 @@ dependencies = [
[[package]]
name = "iran-split-rules"
-version = "6.2.80"
+version = "6.2.84"
dependencies = [
"async-trait",
"chrono",
@@ -2323,7 +2323,7 @@ dependencies = [
[[package]]
name = "iran-split-taskbar"
-version = "6.2.80"
+version = "6.2.84"
dependencies = [
"iran-split-config",
"objc2",
diff --git a/Cargo.toml b/Cargo.toml
index 5461950..bce8499 100644
--- a/Cargo.toml
+++ b/Cargo.toml
@@ -19,7 +19,7 @@ members = [
]
[workspace.package]
-version = "6.2.80"
+version = "6.2.84"
edition = "2021"
license = "MIT OR Apache-2.0"
rust-version = "1.88"
diff --git a/apps/desktop/package.json b/apps/desktop/package.json
index e3514c5..335ca85 100644
--- a/apps/desktop/package.json
+++ b/apps/desktop/package.json
@@ -1,6 +1,6 @@
{
"name": "@iran-split/desktop",
- "version": "6.2.80",
+ "version": "6.2.84",
"private": true,
"type": "module",
"scripts": {
diff --git a/apps/desktop/src/api/mock.test.ts b/apps/desktop/src/api/mock.test.ts
index 0247c97..b6f8542 100644
--- a/apps/desktop/src/api/mock.test.ts
+++ b/apps/desktop/src/api/mock.test.ts
@@ -186,7 +186,7 @@ describe("mock transport", () => {
expect(phases.at(-1)).toBe("restarting");
});
- it("finishes a macOS update as downloaded without starting an installer", async () => {
+ it("installs and restarts a macOS update while retaining paused intent", async () => {
vi.spyOn(navigator, "platform", "get").mockReturnValue("MacIntel");
sessionStorage.setItem("biflow-mock-update-available", "1");
const before = await mockApi.getSnapshot();
@@ -196,9 +196,8 @@ describe("mock transport", () => {
});
await mockApi.installUpdate();
unsubscribe();
- expect(phases.at(-1)).toBe("downloaded");
- expect(phases).not.toContain("installing");
- expect(phases).not.toContain("restarting");
+ expect(phases.at(-1)).toBe("restarting");
+ expect(phases).toContain("installing");
expect(await mockApi.getSnapshot()).toEqual(before);
});
diff --git a/apps/desktop/src/api/mock.ts b/apps/desktop/src/api/mock.ts
index 96a26b4..e26b709 100644
--- a/apps/desktop/src/api/mock.ts
+++ b/apps/desktop/src/api/mock.ts
@@ -637,15 +637,6 @@ async function simulateInstallProgress(version: string) {
});
await new Promise((resolve) => setTimeout(resolve, 20));
}
- if (/mac/i.test(navigator.platform)) {
- emitUpdateProgress({
- phase: "downloaded",
- percent: 100,
- version,
- error: null,
- });
- return;
- }
emitUpdateProgress({
phase: "installing",
percent: 100,
diff --git a/apps/desktop/src/api/models.ts b/apps/desktop/src/api/models.ts
index 632ffaa..d380b8b 100644
--- a/apps/desktop/src/api/models.ts
+++ b/apps/desktop/src/api/models.ts
@@ -387,7 +387,6 @@ export type UpdatePhase =
| "installing"
| "restarting"
| "installed"
- | "downloaded"
| "failed";
export interface UpdateProgress {
diff --git a/apps/desktop/src/components/About.test.tsx b/apps/desktop/src/components/About.test.tsx
index 487489d..685a03d 100644
--- a/apps/desktop/src/components/About.test.tsx
+++ b/apps/desktop/src/components/About.test.tsx
@@ -81,23 +81,24 @@ describe("About", () => {
);
});
- it("tells the operator to drag a downloaded macOS disk image", () => {
+ it("shows automatic restart progress and disables another update", () => {
useAppStore.setState({
+ boot: { platform: "macos" } as BootstrapResult,
update: {
- phase: "downloaded",
+ phase: "restarting",
percent: 100,
version: "1.3.0",
error: null,
},
});
render( );
- expect(screen.getByRole("status")).toHaveTextContent(
- /drag BiFlow onto Applications/i,
- );
- expect(screen.getByRole("status")).toHaveTextContent(/menu bar/i);
+ expect(screen.getByRole("status")).toHaveTextContent(/restarting BiFlow/i);
+ expect(
+ screen.getByRole("button", { name: /check for updates/i }),
+ ).toBeDisabled();
});
- it("offers a download on macOS and an install elsewhere", async () => {
+ it("offers installation on macOS", async () => {
const installUpdate = vi.fn(async () => undefined);
useAppStore.setState({
boot: { platform: "macos" } as BootstrapResult,
@@ -111,7 +112,7 @@ describe("About", () => {
});
render( );
await userEvent.click(
- screen.getByRole("button", { name: /download 1\.3\.0/i }),
+ screen.getByRole("button", { name: /install update 1\.3\.0/i }),
);
expect(installUpdate).toHaveBeenCalledOnce();
});
diff --git a/apps/desktop/src/components/About.tsx b/apps/desktop/src/components/About.tsx
index 1abd81d..43474d3 100644
--- a/apps/desktop/src/components/About.tsx
+++ b/apps/desktop/src/components/About.tsx
@@ -9,7 +9,6 @@ const REPOSITORY_URL = "https://github.com/devlifeX/BiFlow";
export function About() {
const { t } = useTranslation();
const update = useAppStore((state) => state.update);
- const platform = useAppStore((state) => state.boot?.platform);
const checkForUpdate = useAppStore((state) => state.checkForUpdate);
const installUpdate = useAppStore((state) => state.installUpdate);
const retryUpdate = useAppStore((state) => state.retryUpdate);
@@ -121,9 +120,7 @@ export function About() {
disabled={busy}
onClick={() => void installUpdate()}
>
- {platform === "macos"
- ? t("updateDownload", { version: update.version ?? "" })
- : t("updateInstall", { version: update.version ?? "" })}
+ {t("updateInstall", { version: update.version ?? "" })}
) : null}
{showRetry ? (
@@ -174,8 +171,6 @@ function updateMessage(
return t("updateRestarting");
case "installed":
return t("updateInstalled");
- case "downloaded":
- return t("updateDownloaded", { version: update.version ?? "" });
case "failed":
return t("updateFailed");
default:
diff --git a/apps/desktop/src/i18n/config.ts b/apps/desktop/src/i18n/config.ts
index 0d2b3ce..6e89fe5 100644
--- a/apps/desktop/src/i18n/config.ts
+++ b/apps/desktop/src/i18n/config.ts
@@ -265,15 +265,12 @@ const resources = {
updateAvailable: "Version {{version}} is available.",
updateFailed: "Could not check for updates.",
updateInstall: "Install update {{version}}",
- updateDownload: "Download {{version}}",
updateRetry: "Retry update",
updateDownloading: "Downloading version {{version}}…",
updateInstalling: "Installing update…",
updateRestarting: "Restarting BiFlow…",
updateInstalled:
"BiFlow was updated. Quit and open the app again to use the new version.",
- updateDownloaded:
- "Version {{version}} is downloaded. The disk image is open. Drag BiFlow onto Applications, quit this BiFlow from the menu bar, then open the copy in Applications.",
updateDownloadProgress: "Download progress",
updateIdle: "Check GitHub Releases when you are ready to update.",
cloudRules: "Iran cloud rules",
@@ -688,15 +685,12 @@ const resources = {
updateAvailable: "نسخه {{version}} در دسترس است.",
updateFailed: "بررسی بهروزرسانی انجام نشد.",
updateInstall: "نصب بهروزرسانی {{version}}",
- updateDownload: "دانلود {{version}}",
updateRetry: "تلاش دوباره",
updateDownloading: "در حال دانلود نسخه {{version}}…",
updateInstalling: "در حال نصب بهروزرسانی…",
updateRestarting: "در حال راهاندازی مجدد BiFlow…",
updateInstalled:
"BiFlow بهروزرسانی شد. برنامه را ببندید و دوباره باز کنید تا نسخه جدید دیده شود.",
- updateDownloaded:
- "نسخه {{version}} دانلود شد. تصویر دیسک باز است. BiFlow را روی Applications بکشید، این برنامه را از نوار منو ببندید، سپس نسخه داخل Applications را باز کنید.",
updateDownloadProgress: "پیشرفت دانلود",
updateIdle:
"وقتی آماده بودید، بهروزرسانی GitHub Releases را بررسی کنید.",
diff --git a/crates/iran-split-core/src/lib.rs b/crates/iran-split-core/src/lib.rs
index 46181e1..f02d94e 100644
--- a/crates/iran-split-core/src/lib.rs
+++ b/crates/iran-split-core/src/lib.rs
@@ -16,6 +16,10 @@ use uuid::Uuid;
mod proxy_match;
pub use proxy_match::{proxy_endpoint_matches, proxy_server_matches};
+mod proxy_restore;
+pub use proxy_restore::{
+ proxy_endpoint_ready, proxy_host_is_loopback, proxy_restore_decision, ProxyRestoreDecision,
+};
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default)]
#[serde(rename_all = "snake_case")]
@@ -618,6 +622,10 @@ pub trait PlatformBackend: Send + Sync + 'static {
async fn stop_user_proxy(&self) -> Result<(), CoreError> {
Ok(())
}
+ /// Captures the user's proxy before any client is launched or reconfigured.
+ async fn capture_system_proxy(&self) -> Result<(), CoreError> {
+ Ok(())
+ }
/// Clears the desktop system proxy when it points at Hiddify.
///
/// Returns `true` when a Hiddify proxy was found and cleared. A system
@@ -721,6 +729,8 @@ pub struct Engine {
probe_degraded: AtomicBool,
/// Connect-time override for side-tunnel start (progressive 15/30/60s UX).
side_tunnel_connect_timeout: Mutex>,
+ shutting_down: AtomicBool,
+ maintenance: Mutex<()>,
}
/// Health ticks run every ~10s; three consecutive egress failures (~30s)
@@ -765,6 +775,8 @@ impl Engine {
primary_egress_failures: AtomicUsize::new(0),
probe_degraded: AtomicBool::new(false),
side_tunnel_connect_timeout: Mutex::new(None),
+ shutting_down: AtomicBool::new(false),
+ maintenance: Mutex::new(()),
});
runtime.spawn(Self::worker(Arc::clone(&engine), receiver));
engine
@@ -857,6 +869,10 @@ impl Engine {
/// the periodic health tick, so a recovered client rejoins routing without
/// restarting the stack. A failed apply is retried on the next tick.
pub async fn recover_clients(&self) {
+ let _maintenance = self.maintenance.lock().await;
+ if self.shutting_down.load(Ordering::SeqCst) {
+ return;
+ }
let snapshot = self.snapshot();
if !matches!(snapshot.phase, StackPhase::Running | StackPhase::Degraded) {
// Stop/start rebuilds every handle, so a leftover pending refresh
@@ -1047,6 +1063,10 @@ impl Engine {
/// Returns an error when the stack is not live, another operation is
/// reserved, or the platform retry fails.
pub async fn retry_side_tunnels(&self, timeout_seconds: u64) -> Result {
+ let _maintenance = self.maintenance.lock().await;
+ if self.shutting_down.load(Ordering::SeqCst) {
+ return Err(CoreError::OperationInProgress);
+ }
if !matches!(
self.snapshot().phase,
StackPhase::Running | StackPhase::Degraded
@@ -1100,6 +1120,10 @@ impl Engine {
client_id: ClientId,
timeout_seconds: u64,
) -> Result<(), CoreError> {
+ let _maintenance = self.maintenance.lock().await;
+ if self.shutting_down.load(Ordering::SeqCst) {
+ return Err(CoreError::OperationInProgress);
+ }
if !matches!(
self.snapshot().phase,
StackPhase::Running | StackPhase::Degraded
@@ -1144,6 +1168,10 @@ impl Engine {
/// holds the lock, and [`CoreError::Platform`] when the client is a local
/// proxy that stops only with the stack.
pub async fn disconnect_client(&self, client_id: ClientId) -> Result<(), CoreError> {
+ let _maintenance = self.maintenance.lock().await;
+ if self.shutting_down.load(Ordering::SeqCst) {
+ return Err(CoreError::OperationInProgress);
+ }
if self.snapshot().busy.is_some() {
return Err(CoreError::OperationInProgress);
}
@@ -1377,8 +1405,106 @@ impl Engine {
}
}
+ /// Cancels pending work and restores owned networking before process exit.
+ ///
+ /// # Errors
+ /// Returns the cleanup error, or a timeout if another action cannot drain.
+ pub async fn shutdown(&self) -> Result<(), CoreError> {
+ self.shutting_down.store(true, Ordering::SeqCst);
+ let result = tokio::time::timeout(self.timeouts.start + self.timeouts.stop, async {
+ // A reservation that passed the shutdown check must publish busy
+ // before we decide the worker has drained.
+ drop(self.pending.lock().await);
+ let _maintenance = self.maintenance.lock().await;
+ loop {
+ for operation in self.operations.lock().await.values() {
+ operation.cancel.cancel();
+ }
+ if self.snapshot().busy.is_none() && self.operations.lock().await.is_empty() {
+ break;
+ }
+ tokio::time::sleep(Duration::from_millis(50)).await;
+ }
+ // An unused install without a helper does not own the user's proxy.
+ let snapshot = self.snapshot();
+ if matches!(
+ snapshot.phase,
+ StackPhase::Stopped | StackPhase::Uninitialized
+ ) && snapshot.mihomo.phase != ComponentPhase::Running
+ && snapshot.tun.phase != ComponentPhase::Running
+ {
+ self.backend.stop_user_proxy().await?;
+ return self.backend.restore_hiddify_system_proxy().await;
+ }
+ self.run_stop(Uuid::new_v4()).await
+ })
+ .await
+ .unwrap_or(Err(CoreError::OperationTimeout));
+ if let Err(ref cause) = result {
+ self.update(|snapshot| {
+ snapshot.phase = StackPhase::Error;
+ snapshot.last_error = Some(cause.to_app_error(Uuid::new_v4()));
+ snapshot.operation_id = None;
+ });
+ self.shutting_down.store(false, Ordering::SeqCst);
+ }
+ result
+ }
+
+ /// Rebuilds owned routing after sleep or persistent local runtime failure.
+ /// Keeps the user's paused/stopped intent and uses the lifecycle lock.
+ ///
+ /// # Errors
+ /// Returns a lifecycle conflict, readiness failure, or cleanup error.
+ pub async fn recover_network(&self) -> Result<(), CoreError> {
+ let _maintenance = self.maintenance.lock().await;
+ if !matches!(
+ self.snapshot().phase,
+ StackPhase::Running | StackPhase::Degraded
+ ) {
+ return Ok(());
+ }
+ self.reserve_lifecycle(LifecycleBusy::Resuming).await?;
+ let trace_id = Uuid::new_v4();
+ info!(event = "network.recovery_started", section = "lifecycle", initiator = "health_loop",
+ cause = "resume_or_runtime_failure", trace_route = "health_loop->engine->pause->resume",
+ %trace_id, "rebuilding owned networking");
+ let work = async {
+ self.run_pause(trace_id).await?;
+ self.backend.forget_client_egress().await;
+ self.run_resume(trace_id, &CancellationToken::new()).await
+ };
+ let result = tokio::time::timeout(self.timeouts.start + self.timeouts.stop, work)
+ .await
+ .unwrap_or(Err(CoreError::OperationTimeout));
+ if let Err(ref cause) = result {
+ warn!(event = "network.recovery_failed", section = "lifecycle", initiator = "health_loop",
+ %cause, trace_route = "health_loop->engine->cleanup", %trace_id,
+ "network recovery failed; restoring ordinary networking");
+ let cleanup = tokio::time::timeout(self.timeouts.stop, self.run_pause(trace_id)).await;
+ if !matches!(cleanup, Ok(Ok(()))) {
+ self.update(|snapshot| snapshot.phase = StackPhase::Error);
+ error!(event = "network.recovery_cleanup_failed", section = "lifecycle",
+ initiator = "health_loop", cause = "cleanup_failed_or_timed_out",
+ trace_route = "health_loop->engine->cleanup", %trace_id,
+ "network cleanup needs operator attention");
+ }
+ self.update(|snapshot| snapshot.last_error = Some(cause.to_app_error(trace_id)));
+ } else {
+ info!(event = "network.recovery_completed", section = "lifecycle", initiator = "health_loop",
+ cause = "ready", trace_route = "health_loop->engine->readiness", %trace_id,
+ "owned networking recovered");
+ }
+ self.update(|snapshot| snapshot.operation_id = None);
+ self.release_lifecycle(LifecycleBusy::Resuming).await;
+ result
+ }
+
async fn reserve(&self, kind: OperationKind) -> Result<(), CoreError> {
let pending = self.pending.lock().await;
+ if self.shutting_down.load(Ordering::SeqCst) {
+ return Err(CoreError::OperationInProgress);
+ }
if let Some(operation_id) = pending.get(&kind) {
info!(
event = "operation.deduplicated",
@@ -1402,11 +1528,11 @@ impl Engine {
}
return Ok(());
}
- drop(pending);
self.update(|snapshot| {
snapshot.busy = Some(kind.busy());
snapshot.operation_stage = Some(kind.initial_stage());
});
+ drop(pending);
info!(
event = "operation.reserved",
section = "engine",
@@ -1616,8 +1742,8 @@ impl Engine {
let helper_ready = health.helper.phase == ComponentPhase::Running;
self.update(|snapshot| apply_health(snapshot, health));
if helper_ready {
- let process = self.backend.core_process().await.unwrap_or_default();
- let tun = self.backend.tun_status().await.unwrap_or_default();
+ let process = self.backend.core_process().await?;
+ let tun = self.backend.tun_status().await?;
if process.running || tun.active {
warn!(
event = "startup.owned_state_found",
@@ -1633,6 +1759,8 @@ impl Engine {
return Err(CoreError::TunCleanupFailed(report.warnings.join("; ")));
}
}
+ self.backend.verify_not_intercepting().await?;
+ self.backend.restore_hiddify_system_proxy().await?;
}
let health = self.backend.runtime_health().await;
self.set_stopped(health);
@@ -1745,6 +1873,7 @@ impl Engine {
);
});
+ self.backend.capture_system_proxy().await?;
self.start_enabled_clients(operation_id, cancel).await?;
self.announce(StackPhase::PreparingRuntime, operation_id)
@@ -1924,34 +2053,30 @@ impl Engine {
report.warnings.join("; ")
)));
}
+ self.backend.restore_hiddify_system_proxy().await?;
Ok(())
}
async fn run_stop(&self, operation_id: Uuid) -> Result<(), CoreError> {
- let was_active = matches!(
- self.snapshot().phase,
- StackPhase::Running | StackPhase::Degraded | StackPhase::Paused
- );
self.announce(StackPhase::Stopping, operation_id).await;
self.announce_stage(OperationStage::StoppingCore).await;
- if was_active {
- if let Err(cause) = self.backend.stop_core().await {
- warn!(
- event = "operation.stop_core_failed",
- section = "engine",
- initiator = "run_stop",
- cause = %cause,
- trace_id = %operation_id,
- trace_route = "engine_operation->platform_backend->stop_core",
- "stop continued after core stop failure"
- );
- }
+ if let Err(cause) = self.backend.stop_core().await {
+ warn!(
+ event = "operation.stop_core_failed",
+ section = "engine",
+ initiator = "run_stop",
+ cause = %cause,
+ trace_id = %operation_id,
+ trace_route = "engine_operation->platform_backend->stop_core",
+ "stop continued after core stop failure"
+ );
}
self.announce_stage(OperationStage::StoppingProxy).await;
// A proxy-tool failure must not skip TUN and DNS cleanup. Leaving the
// capture in place is what makes the network unusable until the next
// Connect (ADR 0120).
- if let Err(cause) = self.backend.stop_user_proxy().await {
+ let proxy_stop = self.backend.stop_user_proxy().await;
+ if let Err(ref cause) = proxy_stop {
warn!(
event = "operation.stop_proxy_failed",
section = "engine",
@@ -1962,7 +2087,8 @@ impl Engine {
"stop continued after the local proxy client could not be stopped"
);
}
- if let Err(cause) = self.backend.clear_hiddify_system_proxy().await {
+ let proxy_clear = self.backend.clear_hiddify_system_proxy().await;
+ if let Err(ref cause) = proxy_clear {
warn!(
event = "operation.stop_clear_proxy_failed",
section = "engine",
@@ -1984,6 +2110,9 @@ impl Engine {
)));
}
self.backend.forget_client_egress().await;
+ self.backend.restore_hiddify_system_proxy().await?;
+ proxy_stop?;
+ proxy_clear?;
let health = self.backend.runtime_health().await;
self.set_stopped(health);
Ok(())
@@ -2003,7 +2132,8 @@ impl Engine {
"pause continued after core stop failure"
);
}
- if let Err(cause) = self.backend.clear_hiddify_system_proxy().await {
+ let proxy_clear = self.backend.clear_hiddify_system_proxy().await;
+ if let Err(ref cause) = proxy_clear {
warn!(
event = "operation.pause_clear_proxy_failed",
section = "engine",
@@ -2025,6 +2155,8 @@ impl Engine {
)));
}
self.backend.verify_not_intercepting().await?;
+ self.backend.restore_hiddify_system_proxy().await?;
+ proxy_clear?;
let health = self.backend.runtime_health().await;
self.set_paused(health);
Ok(())
@@ -2082,6 +2214,7 @@ impl Engine {
report.warnings.join("; ")
)));
}
+ self.backend.restore_hiddify_system_proxy().await?;
Ok(())
}
@@ -2377,7 +2510,13 @@ mod tests {
proxy_stops: AtomicUsize,
proxy_clears: AtomicUsize,
proxy_restores: AtomicUsize,
+ proxy_captures: AtomicUsize,
+ proxy_active: AtomicBool,
+ proxy_baseline: AtomicBool,
+ proxy_lease: AtomicBool,
+ auto_client_sets_proxy: AtomicBool,
fail_proxy_clear: AtomicBool,
+ fail_proxy_stop: AtomicBool,
}
#[async_trait]
@@ -2459,6 +2598,9 @@ mod tests {
}
async fn ensure_hiddify(&self, cancel: CancellationToken) -> Result<(), CoreError> {
+ if self.auto_client_sets_proxy.load(Ordering::SeqCst) {
+ self.proxy_active.store(true, Ordering::SeqCst);
+ }
if self.hiddify_missing.load(Ordering::SeqCst) {
return Err(CoreError::HiddifyNotFound);
}
@@ -2533,11 +2675,24 @@ mod tests {
async fn stop_user_proxy(&self) -> Result<(), CoreError> {
self.proxy_stops.fetch_add(1, Ordering::SeqCst);
+ if self.fail_proxy_stop.load(Ordering::SeqCst) {
+ return Err(CoreError::Platform("proxy stop failed".into()));
+ }
+ Ok(())
+ }
+
+ async fn capture_system_proxy(&self) -> Result<(), CoreError> {
+ self.proxy_captures.fetch_add(1, Ordering::SeqCst);
+ if !self.proxy_lease.swap(true, Ordering::SeqCst) {
+ self.proxy_baseline
+ .store(self.proxy_active.load(Ordering::SeqCst), Ordering::SeqCst);
+ }
Ok(())
}
async fn clear_hiddify_system_proxy(&self) -> Result {
self.proxy_clears.fetch_add(1, Ordering::SeqCst);
+ self.proxy_active.store(false, Ordering::SeqCst);
if self.fail_proxy_clear.load(Ordering::SeqCst) {
return Err(CoreError::Platform("system proxy clear failed".into()));
}
@@ -2545,7 +2700,20 @@ mod tests {
}
async fn restore_hiddify_system_proxy(&self) -> Result<(), CoreError> {
+ assert!(
+ !self.tun.load(Ordering::SeqCst),
+ "proxy must never bypass an active TUN"
+ );
+ assert!(
+ !self.process.load(Ordering::SeqCst),
+ "core must stop before proxy handoff"
+ );
self.proxy_restores.fetch_add(1, Ordering::SeqCst);
+ if self.proxy_lease.load(Ordering::SeqCst) {
+ self.proxy_active
+ .store(self.proxy_baseline.load(Ordering::SeqCst), Ordering::SeqCst);
+ }
+ self.proxy_lease.store(false, Ordering::SeqCst);
Ok(())
}
@@ -2601,6 +2769,163 @@ mod tests {
}
}
+ #[tokio::test]
+ async fn disconnect_returns_the_original_proxy_after_detaching_capture() {
+ let backend = Arc::new(FakeBackend::default());
+ backend.proxy_active.store(true, Ordering::SeqCst);
+ let engine = Engine::new(Arc::clone(&backend), &tokio::runtime::Handle::current());
+ engine.start_stack(None).await.unwrap();
+ engine
+ .wait_for_phase(StackPhase::Running, Duration::from_secs(2))
+ .await
+ .unwrap();
+ assert_eq!(backend.proxy_captures.load(Ordering::SeqCst), 1);
+ assert!(!backend.proxy_active.load(Ordering::SeqCst));
+ engine.stop_stack().await.unwrap();
+ engine
+ .wait_for_phase(StackPhase::Stopped, Duration::from_secs(2))
+ .await
+ .unwrap();
+ assert!(!backend.tun.load(Ordering::SeqCst));
+ assert!(backend.proxy_active.load(Ordering::SeqCst));
+ assert!(!backend.proxy_lease.load(Ordering::SeqCst));
+ }
+
+ #[tokio::test]
+ async fn a_client_started_by_connect_does_not_replace_an_originally_disabled_proxy() {
+ let backend = Arc::new(FakeBackend::default());
+ backend.auto_client_sets_proxy.store(true, Ordering::SeqCst);
+ let engine = Engine::new(Arc::clone(&backend), &tokio::runtime::Handle::current());
+ engine.start_stack(None).await.unwrap();
+ engine
+ .wait_for_phase(StackPhase::Running, Duration::from_secs(2))
+ .await
+ .unwrap();
+ engine.stop_stack().await.unwrap();
+ engine
+ .wait_for_phase(StackPhase::Stopped, Duration::from_secs(2))
+ .await
+ .unwrap();
+ assert!(!backend.proxy_active.load(Ordering::SeqCst));
+ }
+
+ #[tokio::test]
+ async fn unused_application_quit_does_not_clear_the_users_proxy() {
+ let backend = Arc::new(FakeBackend::default());
+ backend.proxy_active.store(true, Ordering::SeqCst);
+ let engine = Engine::new(Arc::clone(&backend), &tokio::runtime::Handle::current());
+ engine.refresh_health().await;
+ engine.shutdown().await.unwrap();
+ assert_eq!(backend.proxy_clears.load(Ordering::SeqCst), 0);
+ assert!(backend.proxy_active.load(Ordering::SeqCst));
+ }
+
+ #[tokio::test]
+ async fn shutdown_cancels_connect_and_detaches_before_returning() {
+ let backend = Arc::new(FakeBackend::default());
+ backend.slow_hiddify.store(true, Ordering::SeqCst);
+ let engine = Engine::new(Arc::clone(&backend), &tokio::runtime::Handle::current());
+ engine.start_stack(None).await.expect("connect");
+ engine.shutdown().await.expect("shutdown");
+ assert!(!backend.process.load(Ordering::SeqCst));
+ assert!(!backend.tun.load(Ordering::SeqCst));
+ assert!(backend.cleanups.load(Ordering::SeqCst) > 0);
+ assert!(!backend.proxy_active.load(Ordering::SeqCst));
+ assert!(
+ engine.start_stack(None).await.is_err(),
+ "exit must reject a new Connect"
+ );
+ }
+
+ #[tokio::test]
+ async fn shutdown_cleans_error_state_even_when_optional_proxy_stop_fails() {
+ let backend = Arc::new(FakeBackend::default());
+ let engine = Engine::new(Arc::clone(&backend), &tokio::runtime::Handle::current());
+ backend.process.store(true, Ordering::SeqCst);
+ backend.tun.store(true, Ordering::SeqCst);
+ backend.fail_proxy_stop.store(true, Ordering::SeqCst);
+ engine.update(|snapshot| snapshot.phase = StackPhase::Error);
+ assert!(engine.shutdown().await.is_err());
+ assert!(!backend.process.load(Ordering::SeqCst));
+ assert!(!backend.tun.load(Ordering::SeqCst));
+ assert!(backend.proxy_clears.load(Ordering::SeqCst) > 0);
+ backend.fail_proxy_stop.store(false, Ordering::SeqCst);
+ engine.shutdown().await.expect("retry cleanup");
+ }
+
+ #[tokio::test]
+ async fn shutdown_drains_maintenance_and_prevents_client_relaunch() {
+ let backend = Arc::new(FakeBackend::default());
+ let engine = Engine::new(Arc::clone(&backend), &tokio::runtime::Handle::current());
+ engine.update(|snapshot| snapshot.phase = StackPhase::Running);
+ let maintenance = engine.maintenance.lock().await;
+ let exiting = Arc::clone(&engine);
+ let shutdown = tokio::spawn(async move { exiting.shutdown().await });
+ tokio::task::yield_now().await;
+ assert!(
+ !shutdown.is_finished(),
+ "must drain in-flight client maintenance"
+ );
+ drop(maintenance);
+ shutdown.await.expect("join").expect("cleanup");
+ engine.recover_clients().await;
+ assert_eq!(backend.recover_calls.load(Ordering::SeqCst), 0);
+ let client_id = ClientId::parse("11111111-1111-1111-1111-111111111111").expect("client id");
+ assert!(matches!(
+ engine.connect_client(client_id, 15).await,
+ Err(CoreError::OperationInProgress)
+ ));
+ assert!(matches!(
+ engine.retry_side_tunnels(15).await,
+ Err(CoreError::OperationInProgress)
+ ));
+ assert!(matches!(
+ engine.disconnect_client(client_id).await,
+ Err(CoreError::OperationInProgress)
+ ));
+ }
+
+ #[tokio::test]
+ async fn network_recovery_rebuilds_tun_and_preserves_paused_intent() {
+ let backend = Arc::new(FakeBackend::default());
+ let engine = Engine::new(Arc::clone(&backend), &tokio::runtime::Handle::current());
+ engine.start_stack(None).await.expect("start");
+ engine
+ .wait_for_phase(StackPhase::Running, Duration::from_secs(2))
+ .await
+ .expect("running");
+ backend.tun.store(false, Ordering::SeqCst);
+ engine.recover_network().await.expect("recover");
+ assert_eq!(engine.snapshot().phase, StackPhase::Running);
+ assert!(backend.tun.load(Ordering::SeqCst));
+ assert_eq!(backend.starts.load(Ordering::SeqCst), 2);
+ engine.pause_stack().await.expect("pause");
+ engine
+ .wait_for_phase(StackPhase::Paused, Duration::from_secs(2))
+ .await
+ .expect("paused");
+ engine.recover_network().await.expect("idle recovery");
+ assert_eq!(engine.snapshot().phase, StackPhase::Paused);
+ assert_eq!(backend.starts.load(Ordering::SeqCst), 2);
+ }
+
+ #[tokio::test]
+ async fn failed_network_recovery_leaves_normal_networking_detached() {
+ let backend = Arc::new(FakeBackend::default());
+ let engine = Engine::new(Arc::clone(&backend), &tokio::runtime::Handle::current());
+ engine.start_stack(None).await.expect("start");
+ engine
+ .wait_for_phase(StackPhase::Running, Duration::from_secs(2))
+ .await
+ .expect("running");
+ backend.fail_readiness.store(true, Ordering::SeqCst);
+ assert!(engine.recover_network().await.is_err());
+ assert_eq!(engine.snapshot().phase, StackPhase::Paused);
+ assert!(!backend.process.load(Ordering::SeqCst));
+ assert!(!backend.tun.load(Ordering::SeqCst));
+ assert!(engine.snapshot().busy.is_none());
+ }
+
#[tokio::test]
async fn egress_watchdog_degrades_after_a_streak_and_recovers() {
let backend = Arc::new(FakeBackend::default());
@@ -2705,7 +3030,7 @@ mod tests {
}
#[tokio::test]
- async fn stop_removes_the_tunnel_when_system_proxy_clear_fails() {
+ async fn stop_reports_proxy_failure_after_removing_the_tunnel() {
let backend = Arc::new(FakeBackend::default());
backend.fail_proxy_clear.store(true, Ordering::SeqCst);
let engine = Engine::new(Arc::clone(&backend), &tokio::runtime::Handle::current());
@@ -2719,7 +3044,8 @@ mod tests {
engine
.wait_for_phase(StackPhase::Stopped, Duration::from_secs(2))
.await
- .expect("stopped");
+ .expect_err("proxy cleanup failure must remain visible after TUN cleanup");
+ assert_eq!(engine.snapshot().phase, StackPhase::Error);
assert!(!backend.tun.load(Ordering::SeqCst));
assert!(backend.cleanups.load(Ordering::SeqCst) >= 1);
}
@@ -2745,9 +3071,9 @@ mod tests {
.await
.expect("running again");
assert!(backend.tun.load(Ordering::SeqCst));
- // resume must never restore a Hiddify system proxy: it would bypass
- // the TUN split routing and break DIRECT domains
- assert_eq!(backend.proxy_restores.load(Ordering::SeqCst), 0);
+ // Pause restores ordinary access, then Resume clears it for split routing.
+ assert_eq!(backend.proxy_restores.load(Ordering::SeqCst), 1);
+ assert!(!backend.proxy_active.load(Ordering::SeqCst));
// start, pause, and the resume's start steps each clear once
assert_eq!(backend.proxy_clears.load(Ordering::SeqCst), 3);
}
@@ -2788,7 +3114,7 @@ mod tests {
);
assert!(!backend.tun.load(Ordering::SeqCst));
assert_eq!(backend.proxy_stops.load(Ordering::SeqCst), 0);
- assert_eq!(backend.proxy_restores.load(Ordering::SeqCst), 0);
+ assert_eq!(backend.proxy_restores.load(Ordering::SeqCst), 2);
}
#[tokio::test]
diff --git a/crates/iran-split-core/src/proxy_match.rs b/crates/iran-split-core/src/proxy_match.rs
index 2e8c2ca..d4c0089 100644
--- a/crates/iran-split-core/src/proxy_match.rs
+++ b/crates/iran-split-core/src/proxy_match.rs
@@ -47,7 +47,7 @@ fn normalize_host(host: &str) -> String {
.to_owned()
}
-fn is_loopback(host: &str) -> bool {
+pub(crate) fn is_loopback(host: &str) -> bool {
["localhost", "127.0.0.1", "::1", "0:0:0:0:0:0:0:1"]
.iter()
.any(|candidate| host.eq_ignore_ascii_case(candidate))
diff --git a/crates/iran-split-core/src/proxy_restore.rs b/crates/iran-split-core/src/proxy_restore.rs
new file mode 100644
index 0000000..da34fd5
--- /dev/null
+++ b/crates/iran-split-core/src/proxy_restore.rs
@@ -0,0 +1,91 @@
+//! Restore only a managed, reachable proxy after releasing the TUN.
+
+use std::time::Duration;
+
+#[derive(Debug, Clone, Copy, PartialEq, Eq)]
+pub enum ProxyRestoreDecision {
+ Restore,
+ SettingsChanged,
+ NotManaged,
+ ClientUnavailable,
+}
+
+impl ProxyRestoreDecision {
+ #[must_use]
+ pub const fn cause(self) -> &'static str {
+ match self {
+ Self::Restore => "saved_client_reachable",
+ Self::SettingsChanged => "settings_changed_outside_biflow",
+ Self::NotManaged => "baseline_not_managed",
+ Self::ClientUnavailable => "saved_client_unavailable",
+ }
+ }
+}
+
+#[must_use]
+pub const fn proxy_restore_decision(
+ unchanged: bool,
+ managed: bool,
+ reachable: bool,
+) -> ProxyRestoreDecision {
+ if !managed {
+ ProxyRestoreDecision::NotManaged
+ } else if !unchanged {
+ ProxyRestoreDecision::SettingsChanged
+ } else if !reachable {
+ ProxyRestoreDecision::ClientUnavailable
+ } else {
+ ProxyRestoreDecision::Restore
+ }
+}
+
+/// Only local client endpoints belong to the system proxy handoff.
+#[must_use]
+pub fn proxy_host_is_loopback(host: &str) -> bool {
+ super::proxy_match::is_loopback(host.trim_matches(['[', ']']))
+}
+
+/// Test the actual socket; a process name or socket-enumeration entry does not
+/// prove that the saved local proxy can accept connections.
+pub async fn proxy_endpoint_ready(host: &str, port: u16) -> bool {
+ let host = host.trim_matches(['[', ']']);
+ if !proxy_host_is_loopback(host) {
+ return false;
+ }
+ tokio::time::timeout(
+ Duration::from_secs(1),
+ tokio::net::TcpStream::connect((host, port)),
+ )
+ .await
+ .is_ok_and(|result| result.is_ok())
+}
+
+#[cfg(test)]
+mod tests {
+ use super::*;
+
+ #[test]
+ fn only_an_unchanged_managed_live_proxy_is_restored() {
+ for unchanged in [false, true] {
+ for managed in [false, true] {
+ for reachable in [false, true] {
+ assert_eq!(
+ proxy_restore_decision(unchanged, managed, reachable)
+ == ProxyRestoreDecision::Restore,
+ unchanged && managed && reachable
+ );
+ }
+ }
+ }
+ }
+
+ #[tokio::test]
+ async fn actual_listener_is_required_and_remote_hosts_are_never_probed() {
+ let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
+ let port = listener.local_addr().unwrap().port();
+ assert!(proxy_endpoint_ready("127.0.0.1", port).await);
+ drop(listener);
+ assert!(!proxy_endpoint_ready("127.0.0.1", port).await);
+ assert!(!proxy_endpoint_ready("proxy.corp.example", port).await);
+ }
+}
diff --git a/crates/iran-split-platform-linux/src/lib.rs b/crates/iran-split-platform-linux/src/lib.rs
index 979354b..4936edd 100644
--- a/crates/iran-split-platform-linux/src/lib.rs
+++ b/crates/iran-split-platform-linux/src/lib.rs
@@ -1683,6 +1683,19 @@ impl PlatformBackend for LinuxBackend {
if !config.hiddify_stop_with_stack() {
return Ok(());
}
+ let persist = system_proxy::snapshot_path(&self.paths.user_data_dir);
+ let (host, port) = config.hiddify_endpoint();
+ if system_proxy::saved_proxy_uses(&persist, &host, port)? {
+ info!(
+ event = "client.borrowed_proxy_preserved",
+ section = "client_lifecycle",
+ initiator = "platform_backend",
+ cause = "preexisting_system_proxy",
+ trace_route = "disconnect->stop_user_proxy->saved_proxy",
+ "leaving the user's original proxy client running"
+ );
+ 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() {
@@ -1731,6 +1744,12 @@ impl PlatformBackend for LinuxBackend {
Ok(())
}
+ async fn capture_system_proxy(&self) -> Result<(), CoreError> {
+ let persist = system_proxy::snapshot_path(&self.paths.user_data_dir);
+ let config = self.config.read().await.clone();
+ system_proxy::capture(&persist, &config.local_proxy_endpoints()).await
+ }
+
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);
@@ -1748,7 +1767,8 @@ impl PlatformBackend for LinuxBackend {
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
+ let config = self.config.read().await.clone();
+ system_proxy::restore(&persist, &config.local_proxy_endpoints()).await
}
async fn core_process(&self) -> Result {
diff --git a/crates/iran-split-platform-linux/src/system_proxy.rs b/crates/iran-split-platform-linux/src/system_proxy.rs
index 25336e4..d830510 100644
--- a/crates/iran-split-platform-linux/src/system_proxy.rs
+++ b/crates/iran-split-platform-linux/src/system_proxy.rs
@@ -1,10 +1,12 @@
-use iran_split_core::CoreError;
+use iran_split_core::{
+ proxy_endpoint_ready, proxy_restore_decision, CoreError, ProxyRestoreDecision,
+};
use serde::{Deserialize, Serialize};
use std::path::{Path, PathBuf};
use tokio::process::Command;
use tracing::info;
-#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
+#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, Default)]
pub struct Snapshot {
pub mode: String,
pub http_host: String,
@@ -17,6 +19,9 @@ pub struct Snapshot {
#[must_use]
pub fn points_at_hiddify(snapshot: &Snapshot, host: &str, port: u16) -> bool {
+ if !iran_split_core::proxy_host_is_loopback(host) {
+ return false;
+ }
if matches!(snapshot.mode.as_str(), "none" | "0" | "") {
return false;
}
@@ -55,7 +60,7 @@ pub async fn clear_if_hiddify(
if !points_at_hiddify(&snapshot, host, port) {
return Ok(None);
}
- write_snapshot(persist, &snapshot)?;
+ remember_first_snapshot(persist, &snapshot)?;
apply_disabled().await?;
info!(
event = "system_proxy.cleared",
@@ -68,28 +73,88 @@ pub async fn clear_if_hiddify(
Ok(Some(snapshot))
}
-/// Restores a previously cleared Hiddify system proxy.
-///
-/// # Errors
-///
-/// Returns a platform error when a present desktop tool fails to write.
-pub async fn restore(persist: &Path) -> Result<(), CoreError> {
+/// Capture once before starting clients, including an originally disabled proxy.
+/// A disabled baseline prevents a newly launched client from becoming the
+/// apparent owner of the user's preexisting network settings.
+pub async fn capture(persist: &Path, endpoints: &[(String, u16)]) -> Result<(), CoreError> {
+ if read_snapshot(persist)?.is_none() {
+ if let Some(snapshot) = read_current().await? {
+ let baseline = if endpoints
+ .iter()
+ .any(|(host, port)| points_at_hiddify(&snapshot, host, *port))
+ {
+ snapshot
+ } else {
+ Snapshot::default()
+ };
+ write_snapshot(persist, &baseline)?;
+ info!(
+ event = "system_proxy.baseline_captured",
+ section = "system_proxy",
+ initiator = "platform_backend",
+ cause = "before_client_start",
+ trace_route = "connect->capture_system_proxy->snapshot",
+ "captured the original desktop proxy without logging its settings"
+ );
+ }
+ }
+ Ok(())
+}
+
+pub fn saved_proxy_uses(persist: &Path, host: &str, port: u16) -> Result {
+ Ok(read_snapshot(persist)?.is_some_and(|snapshot| points_at_hiddify(&snapshot, host, port)))
+}
+
+/// Restore after detaching the TUN, only if the saved local client is alive
+/// and the settings are still in the state `BiFlow` left them.
+pub async fn restore(persist: &Path, endpoints: &[(String, u16)]) -> Result<(), CoreError> {
let Some(snapshot) = read_snapshot(persist)? else {
return Ok(());
};
- apply_snapshot(&snapshot).await?;
- let _ = std::fs::remove_file(persist);
+ let mut managed = false;
+ let mut reachable = true;
+ for (host, port) in endpoints {
+ if points_at_hiddify(&snapshot, host, *port) {
+ managed = true;
+ reachable &= proxy_endpoint_ready(host, *port).await;
+ }
+ }
+ let current = read_current().await?;
+ let decision = proxy_restore_decision(
+ current
+ .as_ref()
+ .is_some_and(|current| unchanged_after_clear(&snapshot, current)),
+ managed,
+ reachable,
+ );
+ if decision == ProxyRestoreDecision::Restore {
+ apply_snapshot(&snapshot).await?;
+ if read_current().await?.as_ref() != Some(&snapshot) {
+ return Err(CoreError::Platform(
+ "restored system proxy could not be verified".into(),
+ ));
+ }
+ }
+ std::fs::remove_file(persist)
+ .map_err(|_| CoreError::Platform("could not retire the saved system proxy".into()))?;
info!(
- event = "system_proxy.restored",
+ event = "system_proxy.handoff_completed",
section = "system_proxy",
- initiator = "linux_platform_backend",
- cause = "resume",
- trace_route = "engine->linux_platform_backend->system_proxy",
- "restored the previous Hiddify system proxy"
+ initiator = "platform_backend",
+ cause = decision.cause(),
+ trace_route = "disconnect->restore_system_proxy->verify",
+ restored = decision == ProxyRestoreDecision::Restore,
+ "completed desktop proxy handoff without logging settings"
);
Ok(())
}
+fn unchanged_after_clear(original: &Snapshot, current: &Snapshot) -> bool {
+ let mut disabled = original.clone();
+ disabled.mode.clone_from(¤t.mode);
+ matches!(current.mode.as_str(), "none" | "0" | "") && current == &disabled
+}
+
async fn read_current() -> Result, CoreError> {
if which("gsettings") {
return Ok(Some(read_gnome().await?));
@@ -335,15 +400,32 @@ fn unquote(value: &str) -> String {
value.trim().trim_matches('\'').trim_matches('"').to_owned()
}
-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()))?;
+fn remember_first_snapshot(persist: &Path, snapshot: &Snapshot) -> Result<(), CoreError> {
+ if read_snapshot(persist)?.is_none() {
+ write_snapshot(persist, snapshot)?;
}
- 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()))
+ Ok(())
+}
+
+fn write_snapshot(path: &Path, snapshot: &Snapshot) -> Result<(), CoreError> {
+ use std::io::Write;
+ let parent = path
+ .parent()
+ .ok_or_else(|| CoreError::Platform("saved proxy has no parent directory".into()))?;
+ std::fs::create_dir_all(parent)
+ .map_err(|_| CoreError::Platform("could not prepare the saved proxy directory".into()))?;
+ let mut staged = tempfile::NamedTempFile::new_in(parent)
+ .map_err(|_| CoreError::Platform("could not stage the proxy baseline".into()))?;
+ let bytes = serde_json::to_vec(snapshot)
+ .map_err(|_| CoreError::Platform("could not encode the proxy baseline".into()))?;
+ staged
+ .write_all(&bytes)
+ .and_then(|()| staged.as_file().sync_all())
+ .map_err(|_| CoreError::Platform("could not write the proxy baseline".into()))?;
+ staged
+ .persist(path)
+ .map_err(|_| CoreError::Platform("could not publish the proxy baseline".into()))?;
+ Ok(())
}
fn read_snapshot(path: &Path) -> Result , CoreError> {
@@ -360,6 +442,50 @@ fn read_snapshot(path: &Path) -> Result , CoreError> {
mod tests {
use super::*;
+ #[test]
+ fn restore_preserves_new_proxy_and_auto_configuration_choices() {
+ let original = Snapshot {
+ mode: "manual".into(),
+ http_host: "127.0.0.1".into(),
+ http_port: "12334".into(),
+ https_port: "0".into(),
+ socks_port: "0".into(),
+ ..Snapshot::default()
+ };
+ let mut disabled = original.clone();
+ disabled.mode = "none".into();
+ assert!(unchanged_after_clear(&original, &disabled));
+ disabled.mode = "auto".into();
+ assert!(!unchanged_after_clear(&original, &disabled));
+ disabled.mode = "none".into();
+ disabled.http_host = "new.corp.example".into();
+ assert!(!unchanged_after_clear(&original, &disabled));
+ }
+
+ #[tokio::test]
+ async fn capture_before_start_cannot_be_overwritten_by_an_auto_started_client() {
+ let directory = tempfile::tempdir().unwrap();
+ let persist = snapshot_path(directory.path());
+ let original = Snapshot::default();
+ remember_first_snapshot(&persist, &original).unwrap();
+ let started = Snapshot {
+ mode: "manual".into(),
+ http_host: "127.0.0.1".into(),
+ http_port: "12334".into(),
+ https_port: "0".into(),
+ socks_port: "0".into(),
+ ..Snapshot::default()
+ };
+ remember_first_snapshot(&persist, &started).unwrap();
+ capture(&persist, &[("127.0.0.1".into(), 12334)])
+ .await
+ .unwrap();
+ assert_eq!(read_snapshot(&persist).unwrap(), Some(original));
+ assert!(!saved_proxy_uses(&persist, "127.0.0.1", 12334).unwrap());
+ write_snapshot(&persist, &started).unwrap();
+ assert!(saved_proxy_uses(&persist, "127.0.0.1", 12334).unwrap());
+ }
+
#[test]
fn matches_hiddify_http_or_socks() {
let snapshot = Snapshot {
diff --git a/crates/iran-split-platform-macos/src/lib.rs b/crates/iran-split-platform-macos/src/lib.rs
index 77582e0..046c9e9 100644
--- a/crates/iran-split-platform-macos/src/lib.rs
+++ b/crates/iran-split-platform-macos/src/lib.rs
@@ -1784,6 +1784,19 @@ impl PlatformBackend for MacosBackend {
if !config.hiddify_stop_with_stack() {
return Ok(());
}
+ let persist = system_proxy::snapshot_path(&self.paths.user_data_dir);
+ let (host, port) = config.hiddify_endpoint();
+ if system_proxy::saved_proxy_uses(&persist, &host, port)? {
+ info!(
+ event = "client.borrowed_proxy_preserved",
+ section = "client_lifecycle",
+ initiator = "platform_backend",
+ cause = "preexisting_system_proxy",
+ trace_route = "disconnect->stop_user_proxy->saved_proxy",
+ "leaving the user's original proxy client running"
+ );
+ 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() {
@@ -1832,6 +1845,12 @@ impl PlatformBackend for MacosBackend {
Ok(())
}
+ async fn capture_system_proxy(&self) -> Result<(), CoreError> {
+ let persist = system_proxy::snapshot_path(&self.paths.user_data_dir);
+ let config = self.config.read().await.clone();
+ system_proxy::capture(&persist, &config.local_proxy_endpoints()).await
+ }
+
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);
@@ -1849,7 +1868,8 @@ impl PlatformBackend for MacosBackend {
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
+ let config = self.config.read().await.clone();
+ system_proxy::restore(&persist, &config.local_proxy_endpoints()).await
}
async fn core_process(&self) -> Result {
diff --git a/crates/iran-split-platform-macos/src/system_proxy.rs b/crates/iran-split-platform-macos/src/system_proxy.rs
index 3c2a1e6..75f7e34 100644
--- a/crates/iran-split-platform-macos/src/system_proxy.rs
+++ b/crates/iran-split-platform-macos/src/system_proxy.rs
@@ -6,7 +6,9 @@
//! the GNOME/KDE backend on Linux, but driven by `networksetup` per network
//! service (Wi-Fi, Ethernet, …).
-use iran_split_core::CoreError;
+use iran_split_core::{
+ proxy_endpoint_ready, proxy_restore_decision, CoreError, ProxyRestoreDecision,
+};
use serde::{Deserialize, Serialize};
use std::{
path::{Path, PathBuf},
@@ -43,6 +45,9 @@ pub struct Snapshot {
#[must_use]
pub fn points_at_hiddify(snapshot: &Snapshot, host: &str, port: u16) -> bool {
+ if !iran_split_core::proxy_host_is_loopback(host) {
+ return false;
+ }
if matches!(snapshot.mode.as_str(), "none" | "") {
return false;
}
@@ -91,8 +96,8 @@ pub async fn clear_if_hiddify(
if !points_at_hiddify(&snapshot, host, port) {
return Ok(None);
}
- write_snapshot(persist, &snapshot)?;
- apply_disabled(&snapshot).await?;
+ remember_first_snapshot(persist, &snapshot)?;
+ apply_disabled(&snapshot, host, port).await?;
info!(
event = "system_proxy.cleared",
section = "system_proxy",
@@ -104,29 +109,134 @@ pub async fn clear_if_hiddify(
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> {
+/// Capture once before starting clients, including an originally disabled proxy.
+/// A disabled baseline prevents a newly launched client from becoming the
+/// apparent owner of the user's preexisting network settings.
+pub async fn capture(persist: &Path, endpoints: &[(String, u16)]) -> Result<(), CoreError> {
+ if read_snapshot(persist)?.is_none() {
+ if let Some(snapshot) = read_current().await? {
+ let baseline = if endpoints
+ .iter()
+ .any(|(host, port)| points_at_hiddify(&snapshot, host, *port))
+ {
+ snapshot
+ } else {
+ Snapshot::default()
+ };
+ write_snapshot(persist, &baseline)?;
+ info!(
+ event = "system_proxy.baseline_captured",
+ section = "system_proxy",
+ initiator = "platform_backend",
+ cause = "before_client_start",
+ trace_route = "connect->capture_system_proxy->snapshot",
+ "captured the original desktop proxy without logging its settings"
+ );
+ }
+ }
+ Ok(())
+}
+
+pub fn saved_proxy_uses(persist: &Path, host: &str, port: u16) -> Result {
+ Ok(read_snapshot(persist)?.is_some_and(|snapshot| points_at_hiddify(&snapshot, host, port)))
+}
+
+/// Restore after detaching the TUN, only if the saved local client is alive
+/// and the settings are still in the state `BiFlow` left them.
+pub async fn restore(persist: &Path, endpoints: &[(String, u16)]) -> Result<(), CoreError> {
let Some(snapshot) = read_snapshot(persist)? else {
return Ok(());
};
- apply_snapshot(&snapshot).await?;
- let _ = std::fs::remove_file(persist);
+ let mut managed = false;
+ let mut reachable = true;
+ for (host, port) in endpoints {
+ if points_at_hiddify(&snapshot, host, *port) {
+ managed = true;
+ reachable &= proxy_endpoint_ready(host, *port).await;
+ }
+ }
+ let current = read_current().await?;
+ let decision = proxy_restore_decision(
+ current
+ .as_ref()
+ .is_some_and(|current| unchanged_after_clear(&snapshot, current, endpoints)),
+ managed,
+ reachable,
+ );
+ if decision == ProxyRestoreDecision::Restore {
+ apply_snapshot(&snapshot).await?;
+ if read_current().await?.as_ref() != Some(&snapshot) {
+ return Err(CoreError::Platform(
+ "restored system proxy could not be verified".into(),
+ ));
+ }
+ }
+ std::fs::remove_file(persist)
+ .map_err(|_| CoreError::Platform("could not retire the saved system proxy".into()))?;
info!(
- event = "system_proxy.restored",
+ event = "system_proxy.handoff_completed",
section = "system_proxy",
- initiator = "macos_platform_backend",
- cause = "resume",
- trace_route = "engine->macos_platform_backend->system_proxy",
- "restored the previous Hiddify system proxy"
+ initiator = "platform_backend",
+ cause = decision.cause(),
+ trace_route = "disconnect->restore_system_proxy->verify",
+ restored = decision == ProxyRestoreDecision::Restore,
+ "completed desktop proxy handoff without logging settings"
);
Ok(())
}
-/// Reads the proxy state of every network service.
+fn cleared_snapshot(snapshot: &Snapshot, host: &str, port: u16) -> Snapshot {
+ let mut cleared = snapshot.clone();
+ for service in &mut cleared.services {
+ if iran_split_core::proxy_endpoint_matches(
+ &service.http_host,
+ &service.http_port,
+ host,
+ port,
+ ) {
+ service.web_enabled = false;
+ }
+ if iran_split_core::proxy_endpoint_matches(
+ &service.https_host,
+ &service.https_port,
+ host,
+ port,
+ ) {
+ service.secure_web_enabled = false;
+ }
+ if iran_split_core::proxy_endpoint_matches(
+ &service.socks_host,
+ &service.socks_port,
+ host,
+ port,
+ ) {
+ service.socks_enabled = false;
+ }
+ }
+ cleared.mode =
+ if cleared.services.iter().any(|service| {
+ service.web_enabled || service.secure_web_enabled || service.socks_enabled
+ }) {
+ "manual"
+ } else {
+ "none"
+ }
+ .into();
+ cleared
+}
+
+fn unchanged_after_clear(
+ original: &Snapshot,
+ current: &Snapshot,
+ endpoints: &[(String, u16)],
+) -> bool {
+ let mut disabled = original.clone();
+ for (host, port) in endpoints {
+ disabled = cleared_snapshot(&disabled, host, *port);
+ }
+ current == &disabled
+}
+
async fn read_current() -> Result, CoreError> {
if !Path::new(NETWORKSETUP).is_file() {
return Ok(None);
@@ -151,14 +261,18 @@ async fn read_current() -> Result , CoreError> {
}))
}
-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?;
+async fn apply_disabled(snapshot: &Snapshot, host: &str, port: u16) -> Result<(), CoreError> {
+ let cleared = cleared_snapshot(snapshot, host, port);
+ for (before, after) in snapshot.services.iter().zip(&cleared.services) {
+ if before.web_enabled != after.web_enabled {
+ set_proxy_state(&before.service, ProxyKind::Web, false).await?;
+ }
+ if before.secure_web_enabled != after.secure_web_enabled {
+ set_proxy_state(&before.service, ProxyKind::SecureWeb, false).await?;
+ }
+ if before.socks_enabled != after.socks_enabled {
+ set_proxy_state(&before.service, ProxyKind::Socks, false).await?;
+ }
}
Ok(())
}
@@ -331,15 +445,32 @@ async fn run_ok(program: &str, args: &[&str]) -> Result<(), CoreError> {
}
}
-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()))?;
+fn remember_first_snapshot(persist: &Path, snapshot: &Snapshot) -> Result<(), CoreError> {
+ if read_snapshot(persist)?.is_none() {
+ write_snapshot(persist, snapshot)?;
}
- 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()))
+ Ok(())
+}
+
+fn write_snapshot(path: &Path, snapshot: &Snapshot) -> Result<(), CoreError> {
+ use std::io::Write;
+ let parent = path
+ .parent()
+ .ok_or_else(|| CoreError::Platform("saved proxy has no parent directory".into()))?;
+ std::fs::create_dir_all(parent)
+ .map_err(|_| CoreError::Platform("could not prepare the saved proxy directory".into()))?;
+ let mut staged = tempfile::NamedTempFile::new_in(parent)
+ .map_err(|_| CoreError::Platform("could not stage the proxy baseline".into()))?;
+ let bytes = serde_json::to_vec(snapshot)
+ .map_err(|_| CoreError::Platform("could not encode the proxy baseline".into()))?;
+ staged
+ .write_all(&bytes)
+ .and_then(|()| staged.as_file().sync_all())
+ .map_err(|_| CoreError::Platform("could not write the proxy baseline".into()))?;
+ staged
+ .persist(path)
+ .map_err(|_| CoreError::Platform("could not publish the proxy baseline".into()))?;
+ Ok(())
}
fn read_snapshot(path: &Path) -> Result , CoreError> {
@@ -356,6 +487,26 @@ fn read_snapshot(path: &Path) -> Result , CoreError> {
mod tests {
use super::*;
+ #[tokio::test]
+ async fn capture_before_start_cannot_be_overwritten_by_an_auto_started_client() {
+ let directory = tempfile::tempdir().unwrap();
+ let persist = snapshot_path(directory.path());
+ let original = Snapshot::default();
+ remember_first_snapshot(&persist, &original).unwrap();
+ let started = Snapshot {
+ mode: "manual".into(),
+ services: vec![service("Wi-Fi", "127.0.0.1", "12334")],
+ };
+ remember_first_snapshot(&persist, &started).unwrap();
+ capture(&persist, &[("127.0.0.1".into(), 12334)])
+ .await
+ .unwrap();
+ assert_eq!(read_snapshot(&persist).unwrap(), Some(original));
+ assert!(!saved_proxy_uses(&persist, "127.0.0.1", 12334).unwrap());
+ write_snapshot(&persist, &started).unwrap();
+ assert!(saved_proxy_uses(&persist, "127.0.0.1", 12334).unwrap());
+ }
+
fn service(name: &str, host: &str, port: &str) -> ServiceProxy {
ServiceProxy {
service: name.into(),
@@ -405,28 +556,22 @@ mod tests {
}
#[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"
- );
+ fn clear_preserves_unrelated_services_and_restore_detects_user_edits() {
+ let original = Snapshot {
+ mode: "manual".into(),
+ services: vec![
+ service("Wi-Fi", "127.0.0.1", "12334"),
+ service("Corporate", "proxy.corp.example", "8080"),
+ ],
+ };
+ let cleared = cleared_snapshot(&original, "127.0.0.1", 12334);
+ assert!(!cleared.services[0].web_enabled);
+ assert!(cleared.services[1].web_enabled);
+ let endpoints = [("127.0.0.1".into(), 12334)];
+ assert!(unchanged_after_clear(&original, &cleared, &endpoints));
+ let mut edited = cleared;
+ edited.services[1].http_host = "new.corp.example".into();
+ assert!(!unchanged_after_clear(&original, &edited, &endpoints));
}
#[test]
diff --git a/crates/iran-split-platform-win/src/lib.rs b/crates/iran-split-platform-win/src/lib.rs
index 7f57356..fd42159 100644
--- a/crates/iran-split-platform-win/src/lib.rs
+++ b/crates/iran-split-platform-win/src/lib.rs
@@ -1831,6 +1831,19 @@ impl PlatformBackend for WindowsBackend {
if !config.hiddify_stop_with_stack() {
return Ok(());
}
+ let persist = system_proxy::snapshot_path(&self.paths.user_data_dir);
+ let (host, port) = config.hiddify_endpoint();
+ if system_proxy::saved_proxy_uses(&persist, &host, port)? {
+ info!(
+ event = "client.borrowed_proxy_preserved",
+ section = "client_lifecycle",
+ initiator = "platform_backend",
+ cause = "preexisting_system_proxy",
+ trace_route = "disconnect->stop_user_proxy->saved_proxy",
+ "leaving the user's original proxy client running"
+ );
+ 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() {
@@ -1879,6 +1892,12 @@ impl PlatformBackend for WindowsBackend {
Ok(())
}
+ async fn capture_system_proxy(&self) -> Result<(), CoreError> {
+ let persist = system_proxy::snapshot_path(&self.paths.user_data_dir);
+ let config = self.config.read().await.clone();
+ system_proxy::capture(&persist, &config.local_proxy_endpoints()).await
+ }
+
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);
@@ -1896,7 +1915,8 @@ impl PlatformBackend for WindowsBackend {
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
+ let config = self.config.read().await.clone();
+ system_proxy::restore(&persist, &config.local_proxy_endpoints()).await
}
async fn core_process(&self) -> Result {
diff --git a/crates/iran-split-platform-win/src/system_proxy.rs b/crates/iran-split-platform-win/src/system_proxy.rs
index 7cd5577..ecaa7a8 100644
--- a/crates/iran-split-platform-win/src/system_proxy.rs
+++ b/crates/iran-split-platform-win/src/system_proxy.rs
@@ -1,4 +1,6 @@
-use iran_split_core::CoreError;
+use iran_split_core::{
+ proxy_endpoint_ready, proxy_restore_decision, CoreError, ProxyRestoreDecision,
+};
use serde::{Deserialize, Serialize};
use std::path::{Path, PathBuf};
use tokio::process::Command;
@@ -7,14 +9,19 @@ use tracing::info;
/// `CREATE_NO_WINDOW`: `PowerShell` must not flash a console behind the UI.
const CREATE_NO_WINDOW: u32 = 0x0800_0000;
-#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
+#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, Default)]
pub struct Snapshot {
pub proxy_enable: u32,
pub proxy_server: String,
+ #[serde(default)]
+ pub auto_config_url: Option,
}
#[must_use]
pub fn points_at_hiddify(snapshot: &Snapshot, host: &str, port: u16) -> bool {
+ if !iran_split_core::proxy_host_is_loopback(host) {
+ return false;
+ }
if snapshot.proxy_enable == 0 {
return false;
}
@@ -41,7 +48,7 @@ pub async fn clear_if_hiddify(
if !points_at_hiddify(&snapshot, host, port) {
return Ok(None);
}
- write_snapshot(persist, &snapshot)?;
+ remember_first_snapshot(persist, &snapshot)?;
apply_disabled().await?;
info!(
event = "system_proxy.cleared",
@@ -54,41 +61,95 @@ pub async fn clear_if_hiddify(
Ok(Some(snapshot))
}
-/// Restores a previously cleared Hiddify system proxy.
-///
-/// # Errors
-///
-/// Returns a platform error when `PowerShell` cannot write the keys.
-pub async fn restore(persist: &Path) -> Result<(), CoreError> {
+/// Capture once before starting clients, including an originally disabled proxy.
+/// A disabled baseline prevents a newly launched client from becoming the
+/// apparent owner of the user's preexisting network settings.
+pub async fn capture(persist: &Path, endpoints: &[(String, u16)]) -> Result<(), CoreError> {
+ if read_snapshot(persist)?.is_none() {
+ if let Some(snapshot) = read_current().await? {
+ let baseline = if endpoints
+ .iter()
+ .any(|(host, port)| points_at_hiddify(&snapshot, host, *port))
+ {
+ snapshot
+ } else {
+ Snapshot::default()
+ };
+ write_snapshot(persist, &baseline)?;
+ info!(
+ event = "system_proxy.baseline_captured",
+ section = "system_proxy",
+ initiator = "platform_backend",
+ cause = "before_client_start",
+ trace_route = "connect->capture_system_proxy->snapshot",
+ "captured the original desktop proxy without logging its settings"
+ );
+ }
+ }
+ Ok(())
+}
+
+pub fn saved_proxy_uses(persist: &Path, host: &str, port: u16) -> Result {
+ Ok(read_snapshot(persist)?.is_some_and(|snapshot| points_at_hiddify(&snapshot, host, port)))
+}
+
+/// Restore after detaching the TUN, only if the saved local client is alive
+/// and the settings are still in the state `BiFlow` left them.
+pub async fn restore(persist: &Path, endpoints: &[(String, u16)]) -> Result<(), CoreError> {
let Some(snapshot) = read_snapshot(persist)? else {
return Ok(());
};
- apply_snapshot(&snapshot).await?;
- let _ = std::fs::remove_file(persist);
+ let mut managed = false;
+ let mut reachable = true;
+ for (host, port) in endpoints {
+ if points_at_hiddify(&snapshot, host, *port) {
+ managed = true;
+ reachable &= proxy_endpoint_ready(host, *port).await;
+ }
+ }
+ let current = read_current().await?;
+ let decision = proxy_restore_decision(
+ current
+ .as_ref()
+ .is_some_and(|current| unchanged_after_clear(&snapshot, current)),
+ managed,
+ reachable,
+ );
+ if decision == ProxyRestoreDecision::Restore {
+ apply_snapshot(&snapshot).await?;
+ if read_current().await?.as_ref() != Some(&snapshot) {
+ return Err(CoreError::Platform(
+ "restored system proxy could not be verified".into(),
+ ));
+ }
+ }
+ std::fs::remove_file(persist)
+ .map_err(|_| CoreError::Platform("could not retire the saved system proxy".into()))?;
info!(
- event = "system_proxy.restored",
+ event = "system_proxy.handoff_completed",
section = "system_proxy",
- initiator = "windows_platform_backend",
- cause = "resume",
- trace_route = "engine->windows_platform_backend->system_proxy",
- "restored the previous Hiddify system proxy"
+ initiator = "platform_backend",
+ cause = decision.cause(),
+ trace_route = "disconnect->restore_system_proxy->verify",
+ restored = decision == ProxyRestoreDecision::Restore,
+ "completed desktop proxy handoff without logging settings"
);
Ok(())
}
+fn unchanged_after_clear(original: &Snapshot, current: &Snapshot) -> bool {
+ current.proxy_enable == 0
+ && current.proxy_server.is_empty()
+ && current.auto_config_url == original.auto_config_url
+}
+
async fn read_current() -> Result, CoreError> {
let output = powershell(
- "$p = Get-ItemProperty -Path 'HKCU:\\Software\\Microsoft\\Windows\\CurrentVersion\\Internet Settings'; Write-Output (\"$($p.ProxyEnable)|$($p.ProxyServer)\")",
- )
- .await?;
- let Some((enable, server)) = output.split_once('|') else {
- return Ok(None);
- };
- let proxy_enable = enable.trim().parse().unwrap_or(0);
- Ok(Some(Snapshot {
- proxy_enable,
- proxy_server: server.trim().to_owned(),
- }))
+ "$ErrorActionPreference = 'Stop'; $p = Get-ItemProperty -Path 'HKCU:\\Software\\Microsoft\\Windows\\CurrentVersion\\Internet Settings'; @{proxy_enable=[int]$p.ProxyEnable;proxy_server=[string]$p.ProxyServer;auto_config_url=$p.AutoConfigURL} | ConvertTo-Json -Compress",
+ ).await?;
+ serde_json::from_str(&output)
+ .map(Some)
+ .map_err(|_| CoreError::Platform("could not decode the Windows system proxy".into()))
}
async fn apply_disabled() -> Result<(), CoreError> {
@@ -107,7 +168,7 @@ async fn apply_snapshot(snapshot: &Snapshot) -> Result<(), CoreError> {
fn write_script(enable: u32, server: &str) -> String {
format!(
- "Set-ItemProperty -Path 'HKCU:\\Software\\Microsoft\\Windows\\CurrentVersion\\Internet Settings' -Name ProxyEnable -Value {enable}; Set-ItemProperty -Path 'HKCU:\\Software\\Microsoft\\Windows\\CurrentVersion\\Internet Settings' -Name ProxyServer -Value '{server}'; Add-Type -TypeDefinition 'using System.Runtime.InteropServices; public class W {{ [DllImport(\"wininet.dll\")] public static extern bool InternetSetOption(System.IntPtr h, int o, System.IntPtr b, int l); }}'; [W]::InternetSetOption([IntPtr]::Zero, 39, [IntPtr]::Zero, 0) | Out-Null; [W]::InternetSetOption([IntPtr]::Zero, 37, [IntPtr]::Zero, 0) | Out-Null"
+ "$ErrorActionPreference = 'Stop'; Set-ItemProperty -Path 'HKCU:\\Software\\Microsoft\\Windows\\CurrentVersion\\Internet Settings' -Name ProxyEnable -Value {enable}; Set-ItemProperty -Path 'HKCU:\\Software\\Microsoft\\Windows\\CurrentVersion\\Internet Settings' -Name ProxyServer -Value '{server}'; Add-Type -TypeDefinition 'using System.Runtime.InteropServices; public class W {{ [DllImport(\"wininet.dll\")] public static extern bool InternetSetOption(System.IntPtr h, int o, System.IntPtr b, int l); }}'; [W]::InternetSetOption([IntPtr]::Zero, 39, [IntPtr]::Zero, 0) | Out-Null; [W]::InternetSetOption([IntPtr]::Zero, 37, [IntPtr]::Zero, 0) | Out-Null"
)
}
@@ -127,15 +188,32 @@ async fn powershell(script: &str) -> Result {
}
}
-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()))?;
+fn remember_first_snapshot(persist: &Path, snapshot: &Snapshot) -> Result<(), CoreError> {
+ if read_snapshot(persist)?.is_none() {
+ write_snapshot(persist, snapshot)?;
}
- 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()))
+ Ok(())
+}
+
+fn write_snapshot(path: &Path, snapshot: &Snapshot) -> Result<(), CoreError> {
+ use std::io::Write;
+ let parent = path
+ .parent()
+ .ok_or_else(|| CoreError::Platform("saved proxy has no parent directory".into()))?;
+ std::fs::create_dir_all(parent)
+ .map_err(|_| CoreError::Platform("could not prepare the saved proxy directory".into()))?;
+ let mut staged = tempfile::NamedTempFile::new_in(parent)
+ .map_err(|_| CoreError::Platform("could not stage the proxy baseline".into()))?;
+ let bytes = serde_json::to_vec(snapshot)
+ .map_err(|_| CoreError::Platform("could not encode the proxy baseline".into()))?;
+ staged
+ .write_all(&bytes)
+ .and_then(|()| staged.as_file().sync_all())
+ .map_err(|_| CoreError::Platform("could not write the proxy baseline".into()))?;
+ staged
+ .persist(path)
+ .map_err(|_| CoreError::Platform("could not publish the proxy baseline".into()))?;
+ Ok(())
}
fn read_snapshot(path: &Path) -> Result, CoreError> {
@@ -152,10 +230,48 @@ fn read_snapshot(path: &Path) -> Result , CoreError> {
mod tests {
use super::*;
+ #[test]
+ fn restore_preserves_new_proxy_and_pac_choices() {
+ let original = Snapshot {
+ proxy_enable: 1,
+ proxy_server: "127.0.0.1:12334".into(),
+ auto_config_url: None,
+ };
+ let mut disabled = Snapshot::default();
+ assert!(unchanged_after_clear(&original, &disabled));
+ disabled.auto_config_url = Some("https://proxy.corp.example/config.pac".into());
+ assert!(!unchanged_after_clear(&original, &disabled));
+ disabled = original.clone();
+ disabled.proxy_server = "proxy.corp.example:8080".into();
+ assert!(!unchanged_after_clear(&original, &disabled));
+ }
+
+ #[tokio::test]
+ async fn capture_before_start_cannot_be_overwritten_by_an_auto_started_client() {
+ let directory = tempfile::tempdir().unwrap();
+ let persist = snapshot_path(directory.path());
+ let original = Snapshot::default();
+ remember_first_snapshot(&persist, &original).unwrap();
+ let started = Snapshot {
+ proxy_enable: 1,
+ proxy_server: "127.0.0.1:12334".into(),
+ auto_config_url: None,
+ };
+ remember_first_snapshot(&persist, &started).unwrap();
+ capture(&persist, &[("127.0.0.1".into(), 12334)])
+ .await
+ .unwrap();
+ assert_eq!(read_snapshot(&persist).unwrap(), Some(original));
+ assert!(!saved_proxy_uses(&persist, "127.0.0.1", 12334).unwrap());
+ write_snapshot(&persist, &started).unwrap();
+ assert!(saved_proxy_uses(&persist, "127.0.0.1", 12334).unwrap());
+ }
+
#[test]
fn matches_enabled_hiddify_endpoint() {
let snapshot = Snapshot {
proxy_enable: 1,
+ auto_config_url: None,
proxy_server: "127.0.0.1:12334".into(),
};
assert!(points_at_hiddify(&snapshot, "127.0.0.1", 12334));
@@ -163,6 +279,7 @@ mod tests {
assert!(points_at_hiddify(
&Snapshot {
proxy_enable: 1,
+ auto_config_url: None,
proxy_server: "localhost:10808".into(),
},
"127.0.0.1",
@@ -175,6 +292,7 @@ mod tests {
assert!(!points_at_hiddify(
&Snapshot {
proxy_enable: 0,
+ auto_config_url: None,
proxy_server: "127.0.0.1:12334".into(),
},
"127.0.0.1",
@@ -183,6 +301,7 @@ mod tests {
assert!(!points_at_hiddify(
&Snapshot {
proxy_enable: 1,
+ auto_config_url: None,
proxy_server: "proxy.corp.example:8080".into(),
},
"127.0.0.1",
diff --git a/docs/adr/0128-macos-drag-install.md b/docs/adr/0128-macos-drag-install.md
index 93e005f..a2623f7 100644
--- a/docs/adr/0128-macos-drag-install.md
+++ b/docs/adr/0128-macos-drag-install.md
@@ -16,6 +16,5 @@ a failed AppleScript produced a disk image with nothing to drag onto.
volume name, icon position, Applications link, window size, and background as
the normal bundle. The alias is on the image either way.
-The in-app updater opens this same disk image and leaves the running app in
-place (ADR 0129). This decision is the disk image a person opens from the
-release or from About.
+The disk image still supports a manual first install from a release. In-app
+updates now install its verified bundle and restart automatically (ADR 0131).
diff --git a/docs/adr/0129-macos-update-downloads-only.md b/docs/adr/0129-macos-update-downloads-only.md
index 0659ac0..3961600 100644
--- a/docs/adr/0129-macos-update-downloads-only.md
+++ b/docs/adr/0129-macos-update-downloads-only.md
@@ -1,6 +1,6 @@
# ADR 0129: macOS update downloads a disk image and does not restart
-Status: Accepted (6.2.78)
+Status: Superseded by [ADR 0131](./0131-verified-macos-install-and-relaunch.md) (6.2.82)
Supersedes the install half of [ADR 0127](./0127-macos-update-relaunch.md).
diff --git a/docs/adr/0130-resume-recovery-and-serialized-quit.md b/docs/adr/0130-resume-recovery-and-serialized-quit.md
new file mode 100644
index 0000000..54ce8cd
--- /dev/null
+++ b/docs/adr/0130-resume-recovery-and-serialized-quit.md
@@ -0,0 +1,49 @@
+# ADR 0130: Recover after unlock and serialize network release on Quit
+
+Status: Accepted (6.2.81)
+
+## Context
+
+This change starts at upstream main `f35ece9` (6.2.80). Its existing macOS
+download-and-drag install flow (ADR 0129), DNS recovery, helper absence watchdog,
+and native speed displays remain in place. A higher version on an older feature
+branch does not establish that it contains current upstream fixes.
+
+The native health loop refreshes labels but does not rebuild a tunnel after
+sleep, Windows unlock, or persistent local runtime failure. The helper may also
+have released the capture while the desktop was suspended. Quit starts cleanup,
+but a second exit request can pass through while cleanup is pending. Cleanup
+failure is logged and the old quit path still exits.
+
+## Decision
+
+- Detect native timer gaps over 30 seconds, Windows lock-to-unlock transitions,
+ and two consecutive unhealthy Mihomo/TUN/DNS samples. The session query checks
+ only LogonUI in this desktop session, runs without a console, and has a
+ three-second timeout. Query failures are logged and leave the other checks active.
+- Serialize recovery with lifecycle actions and preserve paused/stopped intent.
+ A 60-second cooldown limits repeated repairs. Drain owned state, invalidate
+ cached client egress, rebuild, and check readiness. Failed recovery attempts
+ detach the capture and retain the error for an explicit retry.
+- Quit cancels and drains pending engine operations and client maintenance,
+ rejects new work, and awaits owned network cleanup. Every repeated Quit waits
+ until cleanup has completed. Failure keeps the app available for retry.
+ Installer exits retain the existing paused-stack behavior from ADR 0120.
+- Cleanup always stops the core, including from Error. A proxy failure must not
+ skip tunnel/DNS cleanup, and must still be reported after that cleanup rather
+ than producing a false successful disconnect.
+- Environment findings identify a running/degraded stack whose Mihomo, tunnel,
+ or DNS component is unhealthy. Recovery and exit paths emit structured events.
+- Windows installer staging uses NSIS's `COMMONPROGRAMDATA` variable. The
+ retired `PROGRAMDATA` token expands to an invalid path and emits a packaging
+ warning; positive and negative contracts pin the valid spelling. Compile the
+ generated installer separately with warnings treated as errors before installing.
+
+## Validation
+
+Engine tests cover Quit during Connect, maintenance drain, cleanup from Error
+despite a proxy failure, a lost TUN, paused intent, and failed recovery. Desktop
+tests cover repeated Quit, retry after failed cleanup, installer exit policy,
+unlock, timer gaps, cooldown, and environment findings. The connection e2e flow
+disconnects, reconnects, and disconnects again. Native lock/sleep behavior still
+requires a real host smoke test; mock browser tests cannot exercise OS adapters.
diff --git a/docs/adr/0131-verified-macos-install-and-relaunch.md b/docs/adr/0131-verified-macos-install-and-relaunch.md
new file mode 100644
index 0000000..4d87e85
--- /dev/null
+++ b/docs/adr/0131-verified-macos-install-and-relaunch.md
@@ -0,0 +1,67 @@
+# ADR 0131: Verify and replace the macOS bundle before scheduling a user relaunch
+
+Status: Accepted (6.2.82)
+
+Supersedes [ADR 0129](./0129-macos-update-downloads-only.md). The user requested
+an automatic macOS update: downloading and opening a disk image left the old
+menu-bar process running and did not install anything.
+
+## Decision
+
+Keep the existing exact-version, architecture, signed-manifest, and package
+signature checks. Mount the verified image read-only without opening Finder.
+Validate the exact `BiFlow.app`, executable, bundle identifier, and version;
+copy it with `ditto` into a unique staging directory beside the installed app,
+then validate that copy before touching the installed bundle.
+
+Await the administrator-authorized installation rather than launching a
+replacement script and assuming it succeeded. Move the previous bundle to a
+backup, rename the validated candidate into place, and verify it there. Restore
+the previous bundle if replacement fails. A failed rollback must retain the
+backup directory; no cleanup path may delete the only recoverable copy.
+Preserve custom installation locations. A disk-image or App Translocation
+launch installs into `/Applications/BiFlow.app` instead of trying to overwrite
+the read-only launch location.
+
+On macOS the loaded process can finish while its bundle has been renamed.
+Only after replacement completes do we schedule an unprivileged `nohup` shell
+to wait for that process to exit and open the exact installed bundle. The
+desktop reports `restarting` and uses the existing update exit path. A bounded
+wait and the launch result are appended as safe structured diagnostic events.
+Launching through the administrator shell would start the GUI as root and is
+forbidden. Shell argument quoting and AppleScript string encoding remain
+separate operations.
+
+Detach the image after success or failure. Disable automatic recursive staging
+cleanup before attach starts, so cancellation or failed detach cannot recurse
+into a mounted filesystem. Only a positively detached staging tree is removed.
+
+All package installers now record whether they paused an active connection.
+On failure, resume only that connection; preserve a preexisting user pause.
+About and the mock transport use the same install/restart progress on macOS as
+Windows and AppImage. Manual first installs still use the DMG drag layout;
+Debian package installation retains its existing manual restart behavior.
+
+## Validation
+
+Cross-platform tests cover destination selection, quoting, and pause intent.
+Native macOS tests create and mount a real temporary DMG, install its bundle,
+and verify detachment. They also execute the install script on temporary bundles, reject a
+version mismatch before replacement, inject a failed swap and failed rollback,
+and execute a relaunch watcher against a temporary process and fake launcher.
+The latter verifies safe JSON diagnostics for both launch success and failure.
+UI unit tests and the primary-flow e2e assert install/restarting rather than
+download-only completion. The real administrator prompt and signed release
+upgrade still require a physical Mac acceptance test; CI does not establish
+that those interactions occurred.
+
+Amendment (6.2.84): the upstream macOS job failed to attach its temporary
+fixture while fork CI passed, and the assertion discarded the command error.
+Create a uniquely named HFS+ fixture explicitly and include its exit status
+and stderr in test failures. Run attachment through the same helper as the
+installer. Bound attachment to 60 seconds and detachment to 20 seconds, killing
+the command on timeout while preserving the existing mounted-directory cleanup
+rule. Production diagnostics contain safe exit codes and operation classes;
+raw paths and command stderr remain out of debug.log. A failed command is not
+a successful mount, and failures are not hidden by whole-test retries. The
+fixture detaches before asserting failures, including command execution errors.
diff --git a/docs/adr/0132-return-user-proxy-on-disconnect.md b/docs/adr/0132-return-user-proxy-on-disconnect.md
new file mode 100644
index 0000000..1207967
--- /dev/null
+++ b/docs/adr/0132-return-user-proxy-on-disconnect.md
@@ -0,0 +1,53 @@
+# ADR 0132: Return the original live local proxy after releasing owned routing
+
+Status: Accepted (6.2.83)
+
+## Context
+
+A Windows report showed internet access and HTTPS failing after Disconnect,
+then working again after Connect. The saved baseline contained an enabled
+Hiddify proxy. Disconnect removed the owned TUN and disabled the desktop proxy
+but never returned that baseline. Network access that had depended on the
+user's existing proxy therefore stayed disabled. This establishes a handoff
+defect; it does not identify every earlier browser certificate error.
+
+ADR 0120 correctly requires removing a dead loopback proxy. Removing every
+previously working proxy indefinitely is a different behavior. Also, restoring
+on Resume would bypass the active TUN and break split routing (ADR 0062).
+
+## Decision
+
+- Capture the original managed proxy before starting any clients. Persist a
+ disabled marker when the original settings are not a managed local proxy,
+ so a client launched by BiFlow cannot become the apparent original owner.
+ Repeated clears retain the first baseline. Publish it atomically through a
+ private temporary file; keep the record if publication or restoration fails.
+- On Pause, Disconnect, rollback, and startup reconciliation, first verify that
+ the owned capture is gone. Then restore the baseline only if its configured
+ loopback endpoints actually accept TCP connections and settings still match
+ the state left by BiFlow. Do not replace intervening proxy or PAC choices.
+ Never restore a proxy while the TUN is running.
+- Leave a borrowed Hiddify client running when the baseline shows that the user
+ was already using it. The stop-with-stack option applies to clients owned by
+ BiFlow. Never re-enable a dead proxy or take ownership of a corporate server.
+- Retire the baseline after a verified restore or a deliberate safe skip.
+ Restoration failures retain it for retry. Quit without taking over the
+ network must not clear an unrelated working user proxy.
+- On macOS, clear only proxy kinds and network services pointing at the managed
+ endpoint; preserve other services' settings during the connected session.
+- Emit structured baseline and handoff events without raw settings. Diagnostics
+ reports `saved_user_proxy_handoff_pending` for an idle stack with a saved
+ baseline and disabled system proxies.
+
+## Validation
+
+Core tests cover returning a user proxy after detaching capture, restoring on
+Pause but clearing on Resume, cancellation/rollback, and unused Quit. Shared
+policy tests cover live/dead sockets, concurrent edits, and unmanaged endpoints.
+Each native backend tests baseline preservation and its settings comparison;
+macOS also tests preserving an unrelated service. The existing primary-flow
+e2e asserts connectivity after Pause and Disconnect.
+
+The user's current connection stays running during development. Build and CI
+validation do not replace a live disconnect acceptance test with the new
+installed version. No certificate-validation settings are weakened.
diff --git a/docs/adr/README.md b/docs/adr/README.md
index f667a23..c571d3c 100644
--- a/docs/adr/README.md
+++ b/docs/adr/README.md
@@ -132,4 +132,7 @@ Keep this index current. Add a new ADR for each non-obvious change, or update th
| [0126](./0126-macos-updater-platform.md) | Publish the Apple Silicon disk image as an updater platform | Accepted |
| [0127](./0127-macos-update-relaunch.md) | Relaunch the replaced macOS app after the old process exits | Superseded |
| [0128](./0128-macos-drag-install.md) | macOS disk image is a drag-to-Applications install | Accepted |
-| [0129](./0129-macos-update-downloads-only.md) | macOS update downloads a disk image and does not restart | Accepted |
+| [0129](./0129-macos-update-downloads-only.md) | macOS update downloads a disk image and does not restart | Superseded |
+| [0130](./0130-resume-recovery-and-serialized-quit.md) | Recover after unlock and serialize network release on Quit | Accepted |
+| [0131](./0131-verified-macos-install-and-relaunch.md) | Install and verify macOS updates before a user relaunch | Accepted |
+| [0132](./0132-return-user-proxy-on-disconnect.md) | Return the original live proxy after Disconnect | Accepted |
diff --git a/docs/screenshots/desktop.png b/docs/screenshots/desktop.png
index dfc1a2e..1662f36 100644
Binary files a/docs/screenshots/desktop.png and b/docs/screenshots/desktop.png differ
diff --git a/docs/screenshots/diagnostics.png b/docs/screenshots/diagnostics.png
index fb18cdb..7686277 100644
Binary files a/docs/screenshots/diagnostics.png and b/docs/screenshots/diagnostics.png differ
diff --git a/docs/screenshots/mobile.png b/docs/screenshots/mobile.png
index d97823b..97a0588 100644
Binary files a/docs/screenshots/mobile.png and b/docs/screenshots/mobile.png differ
diff --git a/docs/screenshots/settings-speed.png b/docs/screenshots/settings-speed.png
index 1a9e3b5..1fbc696 100644
Binary files a/docs/screenshots/settings-speed.png and b/docs/screenshots/settings-speed.png differ
diff --git a/e2e/primary-flows.spec.ts b/e2e/primary-flows.spec.ts
index d03c8b6..4a2bf6e 100644
--- a/e2e/primary-flows.spec.ts
+++ b/e2e/primary-flows.spec.ts
@@ -165,6 +165,7 @@ test.describe("primary BiFlow flows", () => {
page.getByRole("heading", { name: "Paused", exact: true }),
).toBeVisible();
await expect(page.getByRole("button", { name: "Resume" })).toBeVisible();
+ await expect(statusBar).toContainText("Internet connected");
await page.getByRole("button", { name: "Resume" }).click();
await expect(
page.getByRole("heading", { name: "Connected", exact: true }),
@@ -175,6 +176,15 @@ test.describe("primary BiFlow flows", () => {
page.getByRole("heading", { name: "Not connected" }),
).toBeVisible();
await expect(page.locator(".traffic-flow-route")).toHaveCount(0);
+ await expect(statusBar).toContainText("Internet connected");
+ await connectButton(page).click();
+ await expect(
+ page.getByRole("heading", { name: "Connected", exact: true }),
+ ).toBeVisible();
+ await disconnectButton(page).click();
+ await expect(
+ page.getByRole("heading", { name: "Not connected" }),
+ ).toBeVisible();
});
test("adds a pasted site from Home and reroutes it in place", async ({
@@ -845,7 +855,7 @@ test.describe("primary BiFlow flows", () => {
).toHaveCount(0);
});
- test("downloads the macOS update and preserves a paused connection", async ({
+ test("installs and restarts the macOS update while preserving paused intent", async ({
page,
}) => {
await page.addInitScript(() => {
@@ -864,15 +874,15 @@ test.describe("primary BiFlow flows", () => {
sessionStorage.setItem("biflow-mock-update-available", "1"),
);
await page.getByRole("button", { name: "Check for updates" }).click();
- await page.getByRole("button", { name: "Download 9.9.9" }).click();
+ await page.getByRole("button", { name: "Install update 9.9.9" }).click();
await expect(
page
.getByRole("region", { name: "About", exact: true })
.getByRole("status"),
- ).toContainText("Drag BiFlow onto Applications");
+ ).toContainText("Restarting BiFlow");
await expect(
page.getByRole("button", { name: "Check for updates" }),
- ).toBeEnabled();
+ ).toBeDisabled();
await goTo(page, "Home");
await expect(
page.getByRole("heading", { name: "Paused", exact: true }),
diff --git a/package.json b/package.json
index 233659c..e86bd13 100644
--- a/package.json
+++ b/package.json
@@ -1,6 +1,6 @@
{
"name": "iran-split-desktop-workspace",
- "version": "6.2.80",
+ "version": "6.2.84",
"private": true,
"packageManager": "pnpm@9.0.1",
"engines": {
diff --git a/packaging/windows/installer-hooks.nsh b/packaging/windows/installer-hooks.nsh
index 0828530..98db78d 100644
--- a/packaging/windows/installer-hooks.nsh
+++ b/packaging/windows/installer-hooks.nsh
@@ -1,5 +1,6 @@
!macro NSIS_HOOK_POSTINSTALL
- nsExec::ExecToLog '"$INSTDIR\helper\iran-split-helper.exe" --install --mihomo "$INSTDIR\dependencies\mihomo.exe" --staging-dir "$PROGRAMDATA\iran-split\staging" --tun-name clash-iran'
+ ; NSIS exposes the machine-wide data directory as COMMONPROGRAMDATA.
+ nsExec::ExecToLog '"$INSTDIR\helper\iran-split-helper.exe" --install --mihomo "$INSTDIR\dependencies\mihomo.exe" --staging-dir "$COMMONPROGRAMDATA\iran-split\staging" --tun-name clash-iran'
Pop $R0
StrCmp $R0 "0" helper_install_completed
DetailPrint "BiFlow Helper installation failed (exit: $R0)."
diff --git a/scripts/tauri-contract.test.mjs b/scripts/tauri-contract.test.mjs
index dc21cea..cc4597d 100644
--- a/scripts/tauri-contract.test.mjs
+++ b/scripts/tauri-contract.test.mjs
@@ -123,7 +123,8 @@ describe("Tauri frontend contract", () => {
join(root, "crates/iran-split-helper/src/windows.rs"),
"utf8",
);
- assert.match(hook, /\$PROGRAMDATA\\iran-split\\staging/);
+ assert.match(hook, /\$COMMONPROGRAMDATA\\iran-split\\staging/);
+ assert.doesNotMatch(hook, /\$PROGRAMDATA/);
assert.doesNotMatch(hook, /\$LOCALAPPDATA\\biflow\\runtime\\generations/);
assert.ok(install.includes(String.raw`C:\ProgramData\iran-split\staging`));
assert.match(desktop, /generation_staging_dir/);
@@ -427,14 +428,10 @@ describe("Tauri frontend contract", () => {
rust,
/ManualRestart =>[\s\S]*?emit_finished_update\([\s\S]*?"installed"/,
);
- assert.match(
- rust,
- /DiskImageOpened =>[\s\S]*?emit_finished_update\([\s\S]*?"downloaded"/,
- );
+ assert.match(rust, /HelperRestart =>[\s\S]*?schedule_update_exit/);
const updater = githubUpdate.split("mod tests")[0];
- assert.match(updater, /async fn open_downloaded_dmg/);
- assert.doesNotMatch(updater, /fn install_dmg/);
- assert.doesNotMatch(updater, /fn dmg_relaunch_script/);
+ assert.match(updater, /macos_update::install\([\s\S]*?operation_id/);
+ assert.doesNotMatch(updater, /open_downloaded_dmg/);
assert.doesNotMatch(rust, /fn spawn_background_update_checks\(/);
assert.doesNotMatch(rust, /fn fetch_signed_update\(/);
assert.doesNotMatch(rust, /open_linux_deb_release/);
diff --git a/src-tauri/src/environment/apps.rs b/src-tauri/src/environment/apps.rs
index 6bcfbec..b585296 100644
--- a/src-tauri/src/environment/apps.rs
+++ b/src-tauri/src/environment/apps.rs
@@ -36,8 +36,8 @@ pub struct AppsInfo {
pub mihomo: Option,
pub openvpn: Option,
pub side_tunnels: Vec,
- /// `system-proxy-snapshot.json`: Pause cleared a Hiddify OS proxy and
- /// will restore it on Resume.
+ /// A pending proxy baseline, captured before client startup and retired
+ /// after handing ordinary networking back on Pause/Disconnect.
pub system_proxy_saved_by_pause: bool,
}
@@ -637,6 +637,10 @@ pub(super) fn app_findings(
&& phase_is(report, &[StackPhase::Paused, StackPhase::Stopped])
{
findings.push("pause_cleared_hiddify_system_proxy".into());
+ if !report.system_proxy.is_empty() && report.system_proxy.iter().all(|proxy| !proxy.enabled)
+ {
+ findings.push("saved_user_proxy_handoff_pending".into());
+ }
}
}
@@ -807,6 +811,31 @@ fn side_tunnel_findings(report: &EnvironmentReport, findings: &mut Vec)
#[cfg(test)]
mod tests {
use super::*;
+ #[test]
+ fn disconnected_stack_exposes_a_proxy_baseline_not_handed_back() {
+ let mut report = EnvironmentReport::default();
+ report.apps.system_proxy_saved_by_pause = true;
+ report.biflow.stack_phase = "stopped".into();
+ report.system_proxy.push(super::super::ProxySetting {
+ source: "test".into(),
+ enabled: false,
+ scheme: None,
+ host: None,
+ port: None,
+ detail: None,
+ });
+ let mut findings = Vec::new();
+ app_findings(&report, None, &mut findings);
+ assert!(findings
+ .iter()
+ .any(|finding| finding == "saved_user_proxy_handoff_pending"));
+ report.apps.system_proxy_saved_by_pause = false;
+ findings.clear();
+ app_findings(&report, None, &mut findings);
+ assert!(!findings
+ .iter()
+ .any(|finding| finding == "saved_user_proxy_handoff_pending"));
+ }
#[test]
fn redact_addresses_keeps_timestamps_and_classifies_ips() {
diff --git a/src-tauri/src/environment/mod.rs b/src-tauri/src/environment/mod.rs
index 8d4727d..845bfb0 100644
--- a/src-tauri/src/environment/mod.rs
+++ b/src-tauri/src/environment/mod.rs
@@ -1314,6 +1314,18 @@ fn running_findings(
config: Option<&AppConfig>,
findings: &mut Vec,
) {
+ if let Some(stack) = &report.runtime.stack {
+ for component in ["mihomo", "tun", "dns"] {
+ if stack
+ .get(component)
+ .and_then(|status| status.get("phase"))
+ .and_then(serde_json::Value::as_str)
+ .is_some_and(|phase| phase != "running")
+ {
+ findings.push(format!("active_stack_runtime_unhealthy:{component}"));
+ }
+ }
+ }
let is_tun = |name: &str| is_biflow_tun(config, name);
{
let client_ports: BTreeSet = config
@@ -1670,6 +1682,23 @@ async fn collect_platform(_report: &mut EnvironmentReport, _tun_name: &str) -> P
#[cfg(test)]
mod tests {
+ #[test]
+ fn active_stack_reports_missing_tun_and_dns_after_resume() {
+ let mut report = EnvironmentReport::default();
+ report.biflow.stack_phase = "running".into();
+ report.runtime.stack = Some(serde_json::json!({
+ "mihomo": {"phase": "running"}, "tun": {"phase": "stopped"},
+ "dns": {"phase": "error"}
+ }));
+ assert_eq!(
+ findings(&report, None),
+ vec![
+ "active_stack_runtime_unhealthy:tun",
+ "active_stack_runtime_unhealthy:dns"
+ ]
+ );
+ }
+
#[test]
fn physical_adapter_failures_are_findings_only_when_enabled() {
let mut report = super::EnvironmentReport {
diff --git a/src-tauri/src/github_update.rs b/src-tauri/src/github_update.rs
index 67703de..b6c56c9 100644
--- a/src-tauri/src/github_update.rs
+++ b/src-tauri/src/github_update.rs
@@ -2,9 +2,9 @@
//!
//! Check uses the public Releases API. Install downloads the platform package
//! and applies it with `pkexec apt-get` (`.deb`), a replace helper (`AppImage`),
-//! or an elevated NSIS installer (Windows). On macOS the verified disk image
-//! is opened for a drag install and the running app is left in place. Do not
-//! log asset URLs.
+//! or an elevated NSIS installer (Windows). macOS validates and replaces the
+//! installed bundle, then relaunches after the old process exits. Do not log
+//! asset URLs.
use serde::Deserialize;
use std::{
@@ -52,14 +52,8 @@ pub struct UpdateInfo {
pub enum ApplyOutcome {
/// Linux `.deb`: package is installed; the running process is still old.
ManualRestart,
- /// A helper script will replace this process after exit.
+ /// A helper relaunches after exit; package replacement may already be complete.
HelperRestart,
- /// The signed macOS disk image is open. Nothing was replaced or restarted.
- ///
- /// Only macOS constructs this variant. Leaving it in the Linux and Windows
- /// builds makes Clippy reject it as dead code.
- #[cfg(target_os = "macos")]
- DiskImageOpened,
}
#[must_use]
@@ -347,7 +341,18 @@ pub async fn apply_package(
package: &Path,
current_exe: &Path,
expected_version: &str,
+ operation_id: uuid::Uuid,
) -> Result {
+ info!(
+ event = "update.package_apply_started",
+ section = "updates",
+ initiator = "install_update",
+ cause = "verified_package",
+ trace_route = "update->apply_package->platform_installer",
+ trace_id = %operation_id,
+ install_kind = ?kind,
+ "applying the verified update package"
+ );
match kind {
InstallKind::Deb => {
install_deb(package).await?;
@@ -364,8 +369,9 @@ pub async fn apply_package(
InstallKind::Dmg => {
#[cfg(target_os = "macos")]
{
- open_downloaded_dmg(package).await?;
- Ok(ApplyOutcome::DiskImageOpened)
+ crate::macos_update::install(package, current_exe, expected_version, operation_id)
+ .await?;
+ Ok(ApplyOutcome::HelperRestart)
}
#[cfg(not(target_os = "macos"))]
{
@@ -447,33 +453,6 @@ async fn install_appimage(package: &Path) -> Result<(), String> {
Ok(())
}
-/// Opens the verified disk image in Finder.
-///
-/// The user drags `BiFlow` onto Applications. This process stays running, so
-/// the menu-bar copy keeps the old version until they quit it and open the
-/// Applications bundle.
-#[cfg(target_os = "macos")]
-async fn open_downloaded_dmg(package: &Path) -> Result<(), String> {
- info!(
- event = "update.dmg_opened",
- section = "updater",
- initiator = "tauri_command",
- cause = "verified_package",
- trace_route = "ui->apply_app_update->open_downloaded_dmg",
- "opening the verified disk image for drag install"
- );
- let status = tokio::process::Command::new("/usr/bin/open")
- .arg(package)
- .status()
- .await
- .map_err(|error| error.to_string())?;
- if status.success() {
- Ok(())
- } else {
- Err("could not open the update disk image".into())
- }
-}
-
async fn install_nsis(
package: &Path,
current_exe: &Path,
@@ -744,19 +723,6 @@ mod tests {
);
}
- #[test]
- fn macos_update_opens_the_disk_image_without_replacing_the_app() {
- let source = include_str!("github_update.rs");
- let production = source.split("mod tests").next().expect("production half");
- assert!(production.contains("async fn open_downloaded_dmg"));
- assert!(production.contains("ApplyOutcome::DiskImageOpened"));
- assert!(production.contains("/usr/bin/open"));
- assert!(!production.contains("fn install_dmg"));
- assert!(!production.contains("fn dmg_relaunch_script"));
- assert!(!production.contains("with administrator privileges"));
- assert!(!production.contains("/Applications/BiFlow.app"));
- }
-
#[test]
fn nsis_update_waits_for_elevation_and_never_masks_installer_failure() {
let script = nsis_update_script(
diff --git a/src-tauri/src/helper_install.rs b/src-tauri/src/helper_install.rs
index bd30398..7eec55a 100644
--- a/src-tauri/src/helper_install.rs
+++ b/src-tauri/src/helper_install.rs
@@ -1104,13 +1104,13 @@ mod tests {
#[test]
fn nsis_hook_stages_into_programdata_iran_split() {
let hook = include_str!("../../packaging/windows/installer-hooks.nsh");
- assert!(hook.contains(r"$PROGRAMDATA\iran-split\staging"));
+ assert!(hook.contains(r"$COMMONPROGRAMDATA\iran-split\staging"));
assert_eq!(
super::WINDOWS_HELPER_STAGING,
r"C:\ProgramData\iran-split\staging"
);
assert!(!hook.contains(r"$LOCALAPPDATA\biflow\runtime\generations"));
- assert!(!hook.contains("$COMMONPROGRAMDATA"));
+ assert!(!hook.contains("$PROGRAMDATA"));
let source = include_str!("helper_install.rs");
assert!(source.contains("PathBuf::from(WINDOWS_HELPER_STAGING)"));
}
diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs
index 6099886..821d640 100644
--- a/src-tauri/src/lib.rs
+++ b/src-tauri/src/lib.rs
@@ -6,6 +6,9 @@ mod environment;
mod github_update;
mod helper_install;
mod hiddify_reset;
+mod lifecycle;
+#[cfg(any(target_os = "macos", test))]
+mod macos_update;
mod network;
mod platform_paths;
mod profile;
@@ -26,8 +29,8 @@ use iran_split_config::{
ValidationIssue,
};
use iran_split_core::{
- ComponentPhase, CoreError, Engine, LifecycleBusy, OperationAccepted, PlatformBackend,
- StackPhase, StackSnapshot,
+ ComponentPhase, Engine, LifecycleBusy, OperationAccepted, PlatformBackend, StackPhase,
+ StackSnapshot,
};
use iran_split_mihomo::{ActiveConnection, ControllerClient};
use iran_split_rules::{
@@ -531,12 +534,12 @@ fn emit_update_progress(app: &AppHandle, mut progress: UpdateProg
}
}
-async fn pause_stack_for_update(services: &AppServices) -> Result<(), String> {
+async fn pause_stack_for_update(services: &AppServices) -> Result {
if matches!(
services.engine.snapshot().phase,
StackPhase::Running | StackPhase::Degraded
) {
- services
+ let accepted = services
.engine
.pause_stack()
.await
@@ -546,8 +549,9 @@ async fn pause_stack_for_update(services: &AppServices) -> Result<(), String> {
.wait_for_phase(StackPhase::Paused, Duration::from_secs(25))
.await
.map_err(|error| error.to_string())?;
+ return Ok(!accepted.already_complete);
}
- Ok(())
+ Ok(false)
}
fn services(app: &AppHandle) -> Result<&AppServices, String> {
@@ -2807,15 +2811,12 @@ fn verify_direct_rules_after_upgrade(data: &Path) -> Result<(), String> {
.map_err(|cause| format!("could not clear route-pin upgrade guard: {cause}"))
}
-fn failed_update_requires_resume(kind: github_update::InstallKind, phase: StackPhase) -> bool {
- kind != github_update::InstallKind::Dmg && phase == StackPhase::Paused
+fn failed_update_requires_resume(paused_for_install: bool, phase: StackPhase) -> bool {
+ paused_for_install && phase == StackPhase::Paused
}
-async fn restore_stack_after_failed_install(
- services: &AppServices,
- kind: github_update::InstallKind,
-) {
- if failed_update_requires_resume(kind, services.engine.snapshot().phase) {
+async fn restore_stack_after_failed_install(services: &AppServices, paused_for_install: bool) {
+ if failed_update_requires_resume(paused_for_install, services.engine.snapshot().phase) {
if let Err(cause) = services.engine.resume_stack().await {
warn!(
event = "update.stack_restore_failed",
@@ -2833,7 +2834,7 @@ async fn restore_stack_after_failed_install(
initiator = "install_update",
cause = "no_installer_pause_to_restore",
trace_route = "install_update->restore_stack_after_failed_install",
- install_kind = ?kind,
+ paused_for_install,
"failed update preserves the existing stack state"
);
}
@@ -3035,46 +3036,50 @@ async fn apply_downloaded_package(
operation_id: Uuid,
info: &github_update::UpdateInfo,
package: &Path,
+ paused_for_install: bool,
) -> Result {
let target_version = info.latest_version.clone();
let kind = github_update::detect_install_kind();
- if kind != github_update::InstallKind::Dmg {
- emit_update_progress(
- app,
- UpdateProgress {
- phase: "installing".into(),
- percent: Some(100),
- version: Some(target_version.clone()),
- error: None,
- operation_id: Some(operation_id),
- app_available: None,
- rules_available: None,
- thirdparty_available: None,
- },
- );
+ emit_update_progress(
+ app,
+ UpdateProgress {
+ phase: "installing".into(),
+ percent: Some(100),
+ version: Some(target_version.clone()),
+ error: None,
+ operation_id: Some(operation_id),
+ app_available: None,
+ rules_available: None,
+ thirdparty_available: None,
+ },
+ );
+ let applied = async {
+ let current_exe =
+ std::env::current_exe().map_err(|_| "could not locate the installed application")?;
+ github_update::apply_package(kind, package, ¤t_exe, &target_version, operation_id)
+ .await
}
- let current_exe = std::env::current_exe().map_err(|error| error.to_string())?;
- let outcome =
- match github_update::apply_package(kind, package, ¤t_exe, &target_version).await {
- Ok(outcome) => outcome,
- Err(cause) => {
- restore_stack_after_failed_install(services(app)?, kind).await;
- emit_update_progress(
- app,
- UpdateProgress {
- phase: "failed".into(),
- percent: None,
- version: Some(target_version),
- error: Some(cause.clone()),
- operation_id: Some(operation_id),
- app_available: None,
- rules_available: None,
- thirdparty_available: None,
- },
- );
- return Err(cause);
- }
- };
+ .await;
+ let outcome = match applied {
+ Ok(outcome) => outcome,
+ Err(cause) => {
+ restore_stack_after_failed_install(services(app)?, paused_for_install).await;
+ emit_update_progress(
+ app,
+ UpdateProgress {
+ phase: "failed".into(),
+ percent: None,
+ version: Some(target_version),
+ error: Some(cause.clone()),
+ operation_id: Some(operation_id),
+ app_available: None,
+ rules_available: None,
+ thirdparty_available: None,
+ },
+ );
+ return Err(cause);
+ }
+ };
record_package_apply_outcome(operation_id, &target_version, outcome);
match outcome {
github_update::ApplyOutcome::ManualRestart => Ok(emit_finished_update(
@@ -3083,13 +3088,6 @@ async fn apply_downloaded_package(
target_version,
"installed",
)),
- #[cfg(target_os = "macos")]
- github_update::ApplyOutcome::DiskImageOpened => Ok(emit_finished_update(
- app,
- operation_id,
- target_version,
- "downloaded",
- )),
github_update::ApplyOutcome::HelperRestart => {
emit_update_progress(
app,
@@ -3125,26 +3123,15 @@ fn record_package_apply_outcome(
update_version = %target_version,
"application package installation completed; restart is required"
),
- #[cfg(target_os = "macos")]
- github_update::ApplyOutcome::DiskImageOpened => info!(
- event = "update.dmg_ready",
- section = "updates",
- initiator = "install_update",
- cause = "disk_image_opened",
- trace_route = "tauri_command->github_update->open_downloaded_dmg",
- trace_id = %operation_id,
- update_version = %target_version,
- "verified disk image is open; the running app was not replaced"
- ),
github_update::ApplyOutcome::HelperRestart => info!(
event = "update.restart_helper_started",
section = "updates",
initiator = "install_update",
- cause = "package_install_pending",
+ cause = "relaunch_handoff",
trace_route = "tauri_command->github_update->apply_helper",
trace_id = %operation_id,
update_version = %target_version,
- "application update helper was launched; package installation is pending"
+ "application update helper was launched; restart is scheduled"
),
}
}
@@ -3181,13 +3168,9 @@ async fn perform_complete_update_install(
});
}
let package = download_github_package(app, operation_id, &update).await?;
- // A disk image is opened for the user to drag. Pausing the tunnel or
- // writing an upgrade guard would treat a download as an in-place install.
- if github_update::detect_install_kind() != github_update::InstallKind::Dmg {
- record_direct_rules_upgrade_guard(&services(app)?.paths.data)?;
- pause_stack_for_update(services(app)?).await?;
- }
- apply_downloaded_package(app, operation_id, &update, &package).await
+ record_direct_rules_upgrade_guard(&services(app)?.paths.data)?;
+ let paused_for_install = pause_stack_for_update(services(app)?).await?;
+ apply_downloaded_package(app, operation_id, &update, &package, paused_for_install).await
}
#[tauri::command]
@@ -3872,8 +3855,12 @@ fn spawn_traffic_loop(handle: AppHandle) {
/// Refreshes health and rejoins late local proxies every 10 seconds.
fn spawn_health_loop(health_engine: Arc>) {
tauri::async_runtime::spawn(async move {
+ let mut recovery = lifecycle::RecoveryMonitor::default();
+ #[cfg(windows)]
+ let mut was_locked = false;
loop {
tokio::time::sleep(Duration::from_secs(10)).await;
+ let tick_time = std::time::SystemTime::now();
if matches!(
health_engine.snapshot().phase,
StackPhase::Stopped
@@ -3883,10 +3870,50 @@ fn spawn_health_loop(health_engine: Arc>) {
| StackPhase::Error
) {
health_engine.refresh_health().await;
+ #[cfg(not(windows))]
+ let unlocked = false;
+ #[cfg(windows)]
+ let unlocked = match lifecycle::session_locked().await {
+ Ok(locked) => {
+ let changed = was_locked && !locked;
+ if was_locked != locked {
+ info!(
+ event = "session.lock_state_changed",
+ section = "lifecycle",
+ initiator = "health_loop",
+ cause = "session_transition",
+ locked,
+ trace_route = "health_loop->session_query->recovery_monitor",
+ "Windows session lock state changed"
+ );
+ }
+ was_locked = locked;
+ changed
+ }
+ Err(cause) => {
+ warn!(
+ event = "session.state_query_failed",
+ section = "lifecycle",
+ initiator = "health_loop",
+ cause,
+ trace_route = "health_loop->session_query",
+ "session state unavailable; timer and health recovery remain active"
+ );
+ false
+ }
+ };
+ if recovery.observe(tick_time, &health_engine.snapshot(), unlocked) {
+ if let Err(cause) = health_engine.recover_network().await {
+ warn!(event = "network.recovery_request_failed", section = "lifecycle",
+ initiator = "health_loop", %cause, trace_route = "health_loop->engine",
+ "automatic network recovery did not complete");
+ }
+ }
// ADR 0076: a local proxy (e.g. Happ) the operator connected
// after Connect rejoins live routing without a full reconnect.
health_engine.recover_clients().await;
}
+ recovery.finish_tick(std::time::SystemTime::now());
}
});
}
@@ -4216,30 +4243,41 @@ pub fn run() {
}
static UPDATE_EXIT_KEEPS_NETWORK: AtomicBool = AtomicBool::new(false);
-static NETWORK_RELEASE_STARTED: AtomicBool = AtomicBool::new(false);
-
-/// User quit gives the machine its previous network back. An in-app update
-/// already paused the tunnel and must keep that paused stack (ADR 0120).
-#[must_use]
-fn exit_releases_owned_network(update_exit: bool) -> bool {
- !update_exit
-}
+static NETWORK_EXIT: lifecycle::ExitCoordinator = lifecycle::ExitCoordinator::new();
fn handle_run_event(handle: &AppHandle, event: &tauri::RunEvent) {
match event {
tauri::RunEvent::ExitRequested { api, code, .. } => {
diagnostics::exit_requested(*code);
- if !exit_releases_owned_network(UPDATE_EXIT_KEEPS_NETWORK.load(Ordering::SeqCst)) {
- return;
- }
- if NETWORK_RELEASE_STARTED.swap(true, Ordering::SeqCst) {
- return;
+ match NETWORK_EXIT.decision(UPDATE_EXIT_KEEPS_NETWORK.load(Ordering::SeqCst)) {
+ lifecycle::ExitDecision::Allow => return,
+ lifecycle::ExitDecision::Wait => {
+ api.prevent_exit();
+ return;
+ }
+ lifecycle::ExitDecision::Cleanup => api.prevent_exit(),
}
- api.prevent_exit();
let app = handle.clone();
+ let exit_code = code.unwrap_or(0);
tauri::async_runtime::spawn(async move {
- release_owned_network(&app).await;
- app.exit(0);
+ match release_owned_network(&app).await {
+ Ok(()) => {
+ NETWORK_EXIT.completed();
+ app.exit(exit_code);
+ }
+ Err(cause) => {
+ warn!(
+ event = "session.network_release_failed",
+ section = "lifecycle",
+ initiator = "application_exit",
+ cause,
+ trace_route = "application_exit->engine->shutdown",
+ "application remains open so network cleanup can be retried"
+ );
+ NETWORK_EXIT.failed();
+ show_main(&app);
+ }
+ }
});
}
tauri::RunEvent::Exit => {
@@ -4251,61 +4289,22 @@ fn handle_run_event(handle: &AppHandle, event: &tauri::RunEvent) {
}
}
-async fn release_owned_network(app: &AppHandle) {
- let Ok(services) = services(app) else {
- return;
- };
- info!(
- event = "session.network_release_started",
- section = "lifecycle",
- initiator = "application_exit",
- cause = "quit",
- trace_route = "application_exit->stop_stack",
- "releasing the tunnel, system DNS, and system proxy before exit"
- );
- let engine = &services.engine;
- if let Some(operation_id) = engine.snapshot().operation_id {
- engine.cancel_operation(operation_id).await;
- }
- let deadline = std::time::Instant::now() + Duration::from_secs(50);
- loop {
- match engine.stop_stack().await {
- Ok(accepted) => {
- if !accepted.already_complete {
- let remaining = deadline.saturating_duration_since(std::time::Instant::now());
- if let Err(cause) = engine
- .wait_for_phase(StackPhase::Stopped, remaining.max(Duration::from_secs(1)))
- .await
- {
- warn!(
- event = "session.network_release_failed",
- section = "lifecycle",
- initiator = "application_exit",
- cause = %cause,
- trace_route = "application_exit->stop_stack",
- "quit continued after the network release did not finish"
- );
- }
- }
- break;
- }
- Err(CoreError::OperationInProgress) if std::time::Instant::now() < deadline => {
- tokio::time::sleep(Duration::from_millis(200)).await;
- }
- Err(cause) => {
- warn!(
- event = "session.network_release_failed",
- section = "lifecycle",
- initiator = "application_exit",
- cause = %cause,
- trace_route = "application_exit->stop_stack",
- "quit continued without a completed disconnect"
- );
- break;
- }
- }
- }
+async fn release_owned_network(app: &AppHandle) -> Result<(), String> {
+ let result = diagnostics::trace_action(
+ "lifecycle",
+ "application_exit",
+ "release_owned_network",
+ async {
+ services(app)?
+ .engine
+ .shutdown()
+ .await
+ .map_err(|cause| cause.to_string())
+ },
+ )
+ .await;
diagnostics::flush();
+ result
}
#[cfg(test)]
@@ -4317,60 +4316,11 @@ mod tests {
};
#[test]
- fn quit_releases_the_network_and_an_update_exit_does_not() {
- assert!(exit_releases_owned_network(false));
- assert!(!exit_releases_owned_network(true));
- }
-
- #[test]
- fn failed_disk_image_open_preserves_a_user_paused_stack() {
- use github_update::InstallKind;
-
- assert!(
- !failed_update_requires_resume(InstallKind::Dmg, StackPhase::Paused),
- "opening a DMG never pauses the stack, so failure must not resume it"
- );
- for kind in [InstallKind::Deb, InstallKind::AppImage, InstallKind::Nsis] {
- assert!(failed_update_requires_resume(kind, StackPhase::Paused));
- assert!(!failed_update_requires_resume(kind, StackPhase::Running));
- assert!(!failed_update_requires_resume(kind, StackPhase::Stopped));
- }
- }
-
- #[test]
- fn macos_disk_image_update_stays_running() {
- let source = include_str!("lib.rs");
- let production = source.split("mod tests").next().expect("production half");
- let start = production
- .find("async fn perform_complete_update_install")
- .expect("install");
- let end = production[start..]
- .find("async fn install_update")
- .expect("command");
- let body = &production[start..start + end];
- let skip = body
- .find("!= github_update::InstallKind::Dmg")
- .expect("dmg skip");
- let pause = body.find("pause_stack_for_update").expect("pause");
- assert!(
- skip < pause,
- "a disk image download must not pause the tunnel"
- );
- let apply = production
- .find("async fn apply_downloaded_package")
- .expect("apply");
- let record = production[apply..]
- .find("fn record_package_apply_outcome")
- .expect("record");
- let apply_body = &production[apply..apply + record];
- let opened = apply_body.find("DiskImageOpened").expect("opened");
- let helper = apply_body.find("HelperRestart").expect("helper");
- assert!(opened < helper);
- assert!(apply_body.contains("\"downloaded\""));
- assert!(
- !apply_body[opened..helper].contains("schedule_update_exit"),
- "opening a disk image must not quit BiFlow"
- );
+ fn failed_install_restores_only_a_connection_paused_by_the_installer() {
+ assert!(failed_update_requires_resume(true, StackPhase::Paused));
+ assert!(!failed_update_requires_resume(false, StackPhase::Paused));
+ assert!(!failed_update_requires_resume(true, StackPhase::Running));
+ assert!(!failed_update_requires_resume(true, StackPhase::Stopped));
}
/// Regression (ADR 0115): the application's data roots and the
diff --git a/src-tauri/src/lifecycle.rs b/src-tauri/src/lifecycle.rs
new file mode 100644
index 0000000..b818a95
--- /dev/null
+++ b/src-tauri/src/lifecycle.rs
@@ -0,0 +1,205 @@
+//! Native timer and session recovery, independent of webview throttling.
+use iran_split_core::{ComponentPhase, StackPhase, StackSnapshot};
+use std::{
+ sync::atomic::{AtomicBool, Ordering},
+ time::{Duration, SystemTime},
+};
+
+#[derive(Debug, Clone, Copy, PartialEq, Eq)]
+pub enum ExitDecision {
+ Allow,
+ Wait,
+ Cleanup,
+}
+
+#[derive(Default)]
+pub struct ExitCoordinator {
+ pending: AtomicBool,
+ ready: AtomicBool,
+}
+
+impl ExitCoordinator {
+ pub const fn new() -> Self {
+ Self {
+ pending: AtomicBool::new(false),
+ ready: AtomicBool::new(false),
+ }
+ }
+
+ pub fn decision(&self, update_exit: bool) -> ExitDecision {
+ if update_exit || self.ready.load(Ordering::SeqCst) {
+ ExitDecision::Allow
+ } else if self.pending.swap(true, Ordering::SeqCst) {
+ ExitDecision::Wait
+ } else {
+ ExitDecision::Cleanup
+ }
+ }
+
+ pub fn completed(&self) {
+ self.ready.store(true, Ordering::SeqCst);
+ }
+
+ pub fn failed(&self) {
+ self.pending.store(false, Ordering::SeqCst);
+ }
+}
+
+#[derive(Default)]
+pub struct RecoveryMonitor {
+ last_tick: Option,
+ failed_ticks: u8,
+ last_attempt: Option,
+ wake_pending: bool,
+}
+
+impl RecoveryMonitor {
+ pub fn observe(&mut self, now: SystemTime, snapshot: &StackSnapshot, unlocked: bool) -> bool {
+ let resumed = self.last_tick.replace(now).is_some_and(|last| {
+ now.duration_since(last)
+ .is_ok_and(|gap| gap > Duration::from_secs(30))
+ });
+ if !matches!(snapshot.phase, StackPhase::Running | StackPhase::Degraded) {
+ self.failed_ticks = 0;
+ self.wake_pending = false;
+ return false;
+ }
+ self.wake_pending |= resumed || unlocked;
+ if snapshot.busy.is_some() {
+ return false;
+ }
+ let broken = [&snapshot.mihomo, &snapshot.tun, &snapshot.dns]
+ .iter()
+ .any(|status| status.phase != ComponentPhase::Running);
+ self.failed_ticks = if broken {
+ self.failed_ticks.saturating_add(1)
+ } else {
+ 0
+ };
+ let cooldown = self.last_attempt.is_some_and(|last| {
+ now.duration_since(last)
+ .is_ok_and(|gap| gap < Duration::from_secs(60))
+ });
+ if (self.wake_pending || self.failed_ticks >= 2) && !cooldown {
+ self.last_attempt = Some(now);
+ self.failed_ticks = 0;
+ self.wake_pending = false;
+ return true;
+ }
+ false
+ }
+
+ /// Exclude health-query/recovery duration from the next timer-gap check.
+ pub fn finish_tick(&mut self, now: SystemTime) {
+ self.last_tick = Some(now);
+ }
+}
+
+/// Query only the current session's secure-desktop process. No process list
+/// or account information crosses this boundary.
+#[cfg(windows)]
+pub async fn session_locked() -> Result {
+ let mut command = tokio::process::Command::new("powershell.exe");
+ command.creation_flags(0x0800_0000).kill_on_drop(true).args([
+ "-NoProfile", "-NonInteractive", "-Command",
+ "$s=(Get-Process -Id $PID).SessionId; if (Get-Process -Name LogonUI -ErrorAction SilentlyContinue | Where-Object SessionId -eq $s) { 'locked' } else { 'unlocked' }",
+ ]);
+ let output = tokio::time::timeout(Duration::from_secs(3), command.output())
+ .await
+ .map_err(|_| "session_query_timeout")?
+ .map_err(|_| "session_query_spawn_failed")?;
+ if !output.status.success() {
+ return Err("session_query_failed");
+ }
+ parse_session_state(&String::from_utf8_lossy(&output.stdout))
+}
+
+#[cfg(any(windows, test))]
+fn parse_session_state(output: &str) -> Result {
+ match output.trim() {
+ "locked" => Ok(true),
+ "unlocked" => Ok(false),
+ _ => Err("session_query_invalid"),
+ }
+}
+
+#[cfg(test)]
+mod tests {
+ use super::*;
+ use iran_split_core::ComponentStatus;
+
+ #[test]
+ fn repeated_quit_waits_for_cleanup_and_failure_allows_a_retry() {
+ let exit = ExitCoordinator::new();
+ assert_eq!(exit.decision(false), ExitDecision::Cleanup);
+ assert_eq!(exit.decision(false), ExitDecision::Wait);
+ exit.failed();
+ assert_eq!(exit.decision(false), ExitDecision::Cleanup);
+ exit.completed();
+ assert_eq!(exit.decision(false), ExitDecision::Allow);
+ }
+
+ #[test]
+ fn update_exit_preserves_the_already_paused_stack() {
+ let exit = ExitCoordinator::new();
+ assert_eq!(exit.decision(true), ExitDecision::Allow);
+ assert_eq!(exit.decision(false), ExitDecision::Cleanup);
+ }
+
+ fn running() -> StackSnapshot {
+ StackSnapshot {
+ phase: StackPhase::Running,
+ mihomo: ComponentStatus::new(ComponentPhase::Running, None),
+ tun: ComponentStatus::new(ComponentPhase::Running, None),
+ dns: ComponentStatus::new(ComponentPhase::Running, None),
+ ..StackSnapshot::default()
+ }
+ }
+
+ #[test]
+ fn sleep_rebuilds_even_when_controller_reports_a_stale_healthy_tun() {
+ let mut monitor = RecoveryMonitor::default();
+ let now = SystemTime::UNIX_EPOCH;
+ assert!(!monitor.observe(now, &running(), false));
+ assert!(monitor.observe(now + Duration::from_secs(90), &running(), false));
+ assert!(!monitor.observe(now + Duration::from_secs(130), &running(), false));
+ }
+
+ #[test]
+ fn persistent_failure_recovers_but_transient_failure_and_idle_intent_do_not() {
+ let mut monitor = RecoveryMonitor::default();
+ let now = SystemTime::UNIX_EPOCH;
+ let mut snapshot = running();
+ snapshot.tun.phase = ComponentPhase::Stopped;
+ assert!(!monitor.observe(now, &snapshot, false));
+ assert!(monitor.observe(now + Duration::from_secs(10), &snapshot, false));
+ snapshot.phase = StackPhase::Paused;
+ assert!(!monitor.observe(now + Duration::from_secs(100), &snapshot, true));
+ snapshot.phase = StackPhase::Stopped;
+ assert!(!monitor.observe(now + Duration::from_secs(200), &snapshot, true));
+ }
+
+ #[test]
+ fn unlock_triggers_recovery_without_a_sleep_gap() {
+ let mut monitor = RecoveryMonitor::default();
+ let now = SystemTime::UNIX_EPOCH;
+ assert!(!monitor.observe(now, &running(), false));
+ assert!(monitor.observe(now + Duration::from_secs(10), &running(), true));
+ assert_eq!(parse_session_state("locked\r\n"), Ok(true));
+ assert_eq!(parse_session_state("unlocked\r\n"), Ok(false));
+ assert!(parse_session_state("").is_err());
+ }
+
+ #[test]
+ fn unlock_waits_for_rule_apply_and_slow_recovery_does_not_look_like_sleep() {
+ let mut monitor = RecoveryMonitor::default();
+ let now = SystemTime::UNIX_EPOCH;
+ let mut snapshot = running();
+ snapshot.busy = Some(iran_split_core::LifecycleBusy::ApplyingRules);
+ assert!(!monitor.observe(now, &snapshot, true));
+ snapshot.busy = None;
+ assert!(monitor.observe(now + Duration::from_secs(10), &snapshot, false));
+ monitor.finish_tick(now + Duration::from_secs(120));
+ assert!(!monitor.observe(now + Duration::from_secs(130), &snapshot, false));
+ }
+}
diff --git a/src-tauri/src/macos_update.rs b/src-tauri/src/macos_update.rs
new file mode 100644
index 0000000..c47ce6c
--- /dev/null
+++ b/src-tauri/src/macos_update.rs
@@ -0,0 +1,506 @@
+//! Install a verified DMG synchronously; hand only the relaunch to a user process.
+
+use crate::helper_install::macos_shell_quote;
+use std::path::{Path, PathBuf};
+use std::time::Duration;
+#[cfg(target_os = "macos")]
+use tracing::{info, warn};
+
+fn destination(current_exe: &Path) -> Result {
+ let bundle = current_exe
+ .ancestors()
+ .find(|path| path.extension().is_some_and(|extension| extension == "app"))
+ .ok_or("the running executable is not inside an application bundle")?;
+ if bundle.starts_with("/Volumes")
+ || bundle
+ .components()
+ .any(|part| part.as_os_str() == "AppTranslocation")
+ {
+ return Ok(PathBuf::from("/Applications/BiFlow.app"));
+ }
+ if !bundle.starts_with("/") {
+ return Err("the installed application location must be absolute".into());
+ }
+ Ok(bundle.to_path_buf())
+}
+
+fn install_script(source: &Path, target: &Path, version: &str) -> String {
+ format!(
+ r#"#!/bin/sh
+set -eu
+source={source}
+target={target}
+version={version}
+validate() {{
+ [ -d "$1" ] && [ ! -L "$1" ] &&
+ [ -x "$1/Contents/MacOS/iran-split-desktop" ] &&
+ [ "$(/usr/libexec/PlistBuddy -c 'Print :CFBundleExecutable' "$1/Contents/Info.plist")" = 'iran-split-desktop' ] &&
+ [ "$(/usr/libexec/PlistBuddy -c 'Print :CFBundleIdentifier' "$1/Contents/Info.plist")" = 'app.biflow.desktop' ] &&
+ [ "$(/usr/libexec/PlistBuddy -c 'Print :CFBundleShortVersionString' "$1/Contents/Info.plist")" = "$version" ]
+}}
+validate "$source" || exit 65
+[ ! -L "$target" ] || exit 66
+parent=$(/usr/bin/dirname "$target")
+[ -d "$parent" ] || exit 66
+work=$(/usr/bin/mktemp -d "$parent/.biflow-update.XXXXXX")
+backup="$work/previous.app"
+committed=0
+cleanup() {{
+ result=$?
+ trap - EXIT HUP INT TERM
+ if [ "$committed" = 1 ]; then
+ /bin/rm -rf "$work" || exit 74
+ elif [ -e "$backup" ]; then
+ # Never delete the only recoverable copy if either rollback rename fails.
+ if [ -e "$target" ]; then
+ /bin/mv "$target" "$work/failed.app" || exit 74
+ fi
+ /bin/mv "$backup" "$target" || exit 74
+ /bin/rm -rf "$work" || exit 74
+ else
+ /bin/rm -rf "$work" || exit 74
+ fi
+ exit "$result"
+}}
+trap cleanup EXIT
+trap 'exit 74' HUP INT TERM
+/usr/bin/ditto "$source" "$work/BiFlow.app"
+validate "$work/BiFlow.app" || exit 65
+if [ -e "$target" ]; then /bin/mv "$target" "$backup"; fi
+/bin/mv "$work/BiFlow.app" "$target"
+validate "$target" || exit 65
+committed=1
+"#,
+ source = macos_shell_quote(&source.to_string_lossy()),
+ target = macos_shell_quote(&target.to_string_lossy()),
+ version = macos_shell_quote(version),
+ )
+}
+
+fn relaunch_script(pid: u32, target: &Path, log: &Path, trace_id: uuid::Uuid) -> String {
+ format!(
+ r#"#!/bin/sh
+target={target}
+log={log}
+audit() {{
+ level=ERROR
+ if [ "$1" = launch_requested ]; then level=INFO; fi
+ printf '{{"timestamp":"%s","level":"%s","event":"update.relaunch_result","section":"updates","initiator":"user_relaunch_helper","cause":"%s","trace_route":"update->wait_old_pid->open","trace_id":"{trace_id}"}}\n' "$(/bin/date -u +'%Y-%m-%dT%H:%M:%SZ')" "$level" "$1" >> "$log"
+}}
+count=0
+while /bin/kill -0 {pid} 2>/dev/null; do
+ count=$((count + 1))
+ if [ "$count" -ge 120 ]; then audit old_process_still_running; exit 1; fi
+ /bin/sleep 1
+done
+if /usr/bin/open -n "$target"; then audit launch_requested; else audit launch_failed; exit 1; fi
+"#,
+ target = macos_shell_quote(&target.to_string_lossy()),
+ log = macos_shell_quote(&log.to_string_lossy()),
+ )
+}
+
+#[cfg(target_os = "macos")]
+pub async fn install(
+ package: &Path,
+ current_exe: &Path,
+ version: &str,
+ trace_id: uuid::Uuid,
+) -> Result<(), String> {
+ let target = destination(current_exe)?;
+ let log = PathBuf::from(crate::diagnostics::status()?.path);
+ let directory = tempfile::Builder::new()
+ .prefix("biflow-update-")
+ .tempdir()
+ .map_err(|_| "could not create a private update directory")?;
+ let mount = directory.path().join("image");
+ std::fs::create_dir(&mount).map_err(|_| "could not prepare the disk image mount")?;
+ // A dropped install future must not make TempDir recurse into a mounted
+ // image. Opt out before starting attach, including cancellation paths.
+ let directory = directory.keep();
+ info!(
+ trace_id = %trace_id,
+ event = "update.dmg_mount_started",
+ section = "updates",
+ initiator = "install_update",
+ cause = "verified_package",
+ trace_route = "update->hdiutil->install_bundle",
+ "mounting verified update image"
+ );
+ let attached = attach_image(package, &mount).await;
+ // Even an attach failure can leave a mounted device. Never recursively
+ // remove the mount root unless detach has positively succeeded.
+ let result = match attached {
+ Ok(output) if output.status.success() => {
+ replace_bundle(&directory, &mount.join("BiFlow.app"), &target, version).await
+ }
+ failure => {
+ warn!(trace_id = %trace_id, event = "update.dmg_mount_failed",
+ section = "updates", initiator = "install_update",
+ cause = if failure.is_err() { "attach_execution_failed" } else { "attach_rejected" },
+ exit_code = failure.ok().and_then(|output| output.status.code()),
+ trace_route = "update->hdiutil->attach",
+ "could not attach the verified disk image");
+ Err("could not mount the verified update disk image".into())
+ }
+ };
+ cleanup_mount(&directory, &mount, trace_id).await;
+ result?;
+ schedule_relaunch(&target, &log, trace_id)
+}
+
+#[cfg(target_os = "macos")]
+async fn attach_image(package: &Path, mount: &Path) -> std::io::Result {
+ let mut command = tokio::process::Command::new("/usr/bin/hdiutil");
+ command
+ .args([
+ "attach",
+ "-readonly",
+ "-nobrowse",
+ "-noautoopen",
+ "-mountpoint",
+ ])
+ .arg(mount)
+ .arg(package)
+ .kill_on_drop(true);
+ disk_command(command.output(), Duration::from_secs(60)).await
+}
+
+async fn disk_command(
+ command: impl std::future::Future>,
+ budget: Duration,
+) -> std::io::Result {
+ tokio::time::timeout(budget, command).await.map_err(|_| {
+ std::io::Error::new(std::io::ErrorKind::TimedOut, "disk image command timed out")
+ })?
+}
+
+#[cfg(target_os = "macos")]
+async fn cleanup_mount(directory: &Path, mount: &Path, trace_id: uuid::Uuid) {
+ let mut command = tokio::process::Command::new("/usr/bin/hdiutil");
+ command.arg("detach").arg(mount).kill_on_drop(true);
+ let detached = disk_command(command.output(), Duration::from_secs(20)).await;
+ if detached.is_ok_and(|output| output.status.success()) {
+ if std::fs::remove_dir_all(directory).is_err() {
+ warn!(trace_id = %trace_id, event = "update.staging_cleanup_failed",
+ section = "updates", initiator = "install_update",
+ cause = "unmounted_staging_retained", trace_route = "update->detach->cleanup",
+ "could not remove unmounted update staging");
+ }
+ } else {
+ warn!(trace_id = %trace_id, event = "update.dmg_detach_failed",
+ section = "updates", initiator = "install_update",
+ cause = "mount_retained", trace_route = "update->hdiutil->detach",
+ "update mount retained because detachment was not confirmed");
+ }
+}
+
+#[cfg(target_os = "macos")]
+fn schedule_relaunch(target: &Path, log: &Path, trace_id: uuid::Uuid) -> Result<(), String> {
+ // This stays in the desktop user's session, never in the elevated shell.
+ let mut launcher = tokio::process::Command::new("/usr/bin/nohup");
+ launcher.args(["/bin/sh", "-c"]).arg(relaunch_script(
+ std::process::id(),
+ target,
+ log,
+ trace_id,
+ ));
+ launcher
+ .stdin(std::process::Stdio::null())
+ .stdout(std::process::Stdio::null())
+ .stderr(std::process::Stdio::null());
+ launcher.spawn().map_err(|_| {
+ "the new application is installed, but relaunch could not start; quit and reopen BiFlow"
+ })?;
+ info!(
+ trace_id = %trace_id,
+ event = "update.bundle_installed",
+ section = "updates",
+ initiator = "install_update",
+ cause = "bundle_verified_relaunch_scheduled",
+ trace_route = "update->replace_bundle->user_relaunch",
+ "application replacement verified; relaunch waits for the old process to exit"
+ );
+ Ok(())
+}
+
+#[cfg(target_os = "macos")]
+async fn replace_bundle(
+ directory: &Path,
+ source: &Path,
+ target: &Path,
+ version: &str,
+) -> Result<(), String> {
+ let script = directory.join("install.sh");
+ std::fs::write(&script, install_script(source, target, version))
+ .map_err(|_| "could not stage the application installer")?;
+ let output = tokio::process::Command::new("/usr/bin/osascript")
+ .arg("-e")
+ .arg(crate::helper_install::macos_admin_applescript(&script))
+ .output()
+ .await
+ .map_err(|_| "could not start application installation authorization")?;
+ if output.status.success() {
+ Ok(())
+ } else if String::from_utf8_lossy(&output.stderr).contains("(-128)") {
+ Err("application installation was cancelled".into())
+ } else {
+ Err("application replacement failed; the previous copy was preserved for recovery".into())
+ }
+}
+
+#[cfg(test)]
+mod tests {
+ use super::*;
+
+ #[tokio::test]
+ async fn disk_command_preserves_failures_and_bounds_a_stalled_operation() {
+ let result = disk_command(
+ async { Ok::<_, std::io::Error>(42) },
+ Duration::from_secs(1),
+ )
+ .await;
+ assert_eq!(result.unwrap(), 42);
+ let refused = disk_command(
+ async { Err::<(), _>(std::io::Error::from(std::io::ErrorKind::PermissionDenied)) },
+ Duration::from_secs(1),
+ )
+ .await;
+ assert_eq!(
+ refused.unwrap_err().kind(),
+ std::io::ErrorKind::PermissionDenied
+ );
+ let stalled = disk_command(
+ std::future::pending::>(),
+ Duration::ZERO,
+ )
+ .await;
+ assert_eq!(stalled.unwrap_err().kind(), std::io::ErrorKind::TimedOut);
+ }
+
+ #[test]
+ fn installed_location_is_preserved_but_disk_images_are_not_destinations() {
+ for (executable, expected) in [
+ ("/Applications/BiFlow.app/Contents/MacOS/iran-split-desktop", "/Applications/BiFlow.app"),
+ ("/Users/example/My Apps/BiFlow.app/Contents/MacOS/iran-split-desktop", "/Users/example/My Apps/BiFlow.app"),
+ ("/Volumes/BiFlow/BiFlow.app/Contents/MacOS/iran-split-desktop", "/Applications/BiFlow.app"),
+ ("/private/var/folders/example/AppTranslocation/token/d/BiFlow.app/Contents/MacOS/iran-split-desktop", "/Applications/BiFlow.app"),
+ ] {
+ assert_eq!(destination(Path::new(executable)).unwrap(), Path::new(expected));
+ }
+ assert!(destination(Path::new("/tmp/iran-split-desktop")).is_err());
+ }
+
+ #[test]
+ fn scripts_quote_user_paths_and_wait_for_the_old_process() {
+ let script = install_script(
+ Path::new("/tmp/BiFlow's image/BiFlow.app"),
+ Path::new("/Applications/BiFlow.app"),
+ "6.2.82",
+ );
+ assert!(script.contains("BiFlow'\\''s image"));
+ let relaunch = relaunch_script(
+ 42,
+ Path::new("/Users/example/My Apps/BiFlow.app"),
+ Path::new("/tmp/debug.log"),
+ uuid::Uuid::nil(),
+ );
+ assert!(
+ relaunch.find("/bin/kill -0 42").unwrap() < relaunch.find("/usr/bin/open -n").unwrap()
+ );
+ assert!(relaunch.contains("launch_failed"));
+ assert!(!relaunch.contains("administrator privileges"));
+ }
+
+ #[cfg(target_os = "macos")]
+ fn bundle(root: &Path, name: &str, version: &str) -> PathBuf {
+ use std::os::unix::fs::PermissionsExt;
+ let app = root.join(name);
+ std::fs::create_dir_all(app.join("Contents/MacOS")).unwrap();
+ let executable = app.join("Contents/MacOS/iran-split-desktop");
+ std::fs::write(&executable, "#!/bin/sh\nexit 0\n").unwrap();
+ std::fs::set_permissions(executable, std::fs::Permissions::from_mode(0o755)).unwrap();
+ std::fs::write(app.join("Contents/Info.plist"), format!(r#"CFBundleExecutable iran-split-desktop CFBundleIdentifier app.biflow.desktop CFBundleShortVersionString {version} "#)).unwrap();
+ app
+ }
+
+ #[cfg(target_os = "macos")]
+ #[test]
+ fn native_install_validates_before_replacing_and_rolls_back_failed_swaps() {
+ let directory = tempfile::tempdir().unwrap();
+ let source = bundle(directory.path(), "BiFlow's source.app", "6.2.82");
+ let target = bundle(directory.path(), "Installed BiFlow.app", "6.2.81");
+ std::fs::write(target.join("old-marker"), "old").unwrap();
+ let run = |script: &str| {
+ std::process::Command::new("/bin/sh")
+ .arg("-c")
+ .arg(script)
+ .output()
+ .unwrap()
+ };
+ let script = install_script(&source, &target, "0.0.0");
+ assert!(!run(&script).status.success());
+ assert!(target.join("old-marker").exists());
+ let script = install_script(&source, &target, "6.2.82");
+ let failed = script.replace("/bin/mv \"$work/BiFlow.app\" \"$target\"", "false");
+ assert!(!run(&failed).status.success());
+ assert!(
+ target.join("old-marker").exists(),
+ "the previous app must be restored"
+ );
+ assert!(run(&script).status.success());
+ assert!(!target.join("old-marker").exists());
+ assert!(source.exists());
+ }
+
+ #[cfg(target_os = "macos")]
+ #[test]
+ fn native_failed_rollback_retains_the_only_previous_bundle() {
+ let directory = tempfile::tempdir().unwrap();
+ let source = bundle(directory.path(), "Source.app", "6.2.82");
+ let target = bundle(directory.path(), "Target.app", "6.2.81");
+ std::fs::write(target.join("old-marker"), "old").unwrap();
+ let script = install_script(&source, &target, "6.2.82")
+ .replace("/bin/mv \"$work/BiFlow.app\" \"$target\"", "false")
+ .replace("/bin/mv \"$backup\" \"$target\"", "false");
+ let output = std::process::Command::new("/bin/sh")
+ .arg("-c")
+ .arg(script)
+ .output()
+ .unwrap();
+ assert!(!output.status.success());
+ let retained = std::fs::read_dir(directory.path())
+ .unwrap()
+ .filter_map(Result::ok)
+ .find(|entry| {
+ entry
+ .file_name()
+ .to_string_lossy()
+ .starts_with(".biflow-update.")
+ })
+ .unwrap();
+ assert!(retained.path().join("previous.app/old-marker").exists());
+ }
+
+ #[cfg(target_os = "macos")]
+ #[tokio::test]
+ async fn native_disk_image_installs_exact_bundle_and_detaches() {
+ let fixtures = tempfile::tempdir().unwrap();
+ let payload = fixtures.path().join("payload");
+ std::fs::create_dir(&payload).unwrap();
+ bundle(&payload, "BiFlow.app", "6.2.82");
+ let image = fixtures.path().join("update.dmg");
+ let volume_name = format!("BiFlow-test-{}", uuid::Uuid::new_v4());
+ let created = std::process::Command::new("/usr/bin/hdiutil")
+ .args([
+ "create",
+ "-fs",
+ "HFS+",
+ "-volname",
+ &volume_name,
+ "-format",
+ "UDZO",
+ "-srcfolder",
+ ])
+ .arg(&payload)
+ .arg(&image)
+ .output()
+ .unwrap();
+ assert!(
+ created.status.success(),
+ "fixture disk image creation failed: status={:?}, stderr={}",
+ created.status.code(),
+ String::from_utf8_lossy(&created.stderr)
+ );
+ let staging = tempfile::tempdir().unwrap().keep();
+ let mount = staging.join("image");
+ std::fs::create_dir(&mount).unwrap();
+ let attached = attach_image(&image, &mount).await;
+ let target = fixtures.path().join("Installed BiFlow.app");
+ let installed = if attached
+ .as_ref()
+ .is_ok_and(|output| output.status.success())
+ {
+ std::process::Command::new("/bin/sh")
+ .arg("-c")
+ .arg(install_script(&mount.join("BiFlow.app"), &target, "6.2.82"))
+ .output()
+ .map(|output| output.status.success())
+ } else {
+ Ok(false)
+ };
+ cleanup_mount(&staging, &mount, uuid::Uuid::nil()).await;
+ let attached = attached.expect("fixture disk image attach could not complete");
+ assert!(
+ attached.status.success(),
+ "fixture disk image attachment failed: status={:?}, stderr={}",
+ attached.status.code(),
+ String::from_utf8_lossy(&attached.stderr)
+ );
+ assert!(
+ installed.expect("fixture installer could not complete"),
+ "the mounted application's replacement must succeed"
+ );
+ assert!(
+ !staging.exists(),
+ "successful detachment must release staging"
+ );
+ assert!(target.join("Contents/MacOS/iran-split-desktop").exists());
+ }
+
+ #[cfg(target_os = "macos")]
+ #[test]
+ fn native_relaunch_waits_and_records_open_failures_without_user_paths() {
+ let directory = tempfile::tempdir().unwrap();
+ let log = directory.path().join("debug.log");
+ let marker = directory.path().join("launched");
+ let mut old = std::process::Command::new("/bin/sleep")
+ .arg("0.3")
+ .spawn()
+ .unwrap();
+ let script = relaunch_script(
+ old.id(),
+ Path::new("/Applications/BiFlow.app"),
+ &log,
+ uuid::Uuid::nil(),
+ );
+ let open = format!(
+ "/usr/bin/touch {}",
+ macos_shell_quote(&marker.to_string_lossy())
+ );
+ let mut watcher = std::process::Command::new("/bin/sh")
+ .arg("-c")
+ .arg(script.replace("/usr/bin/open -n \"$target\"", &open))
+ .spawn()
+ .unwrap();
+ assert!(!marker.exists());
+ assert!(old.wait().unwrap().success());
+ assert!(watcher.wait().unwrap().success());
+ assert!(marker.exists());
+ let script = relaunch_script(
+ 99_999_999,
+ Path::new("/private/secret/My App.app"),
+ &log,
+ uuid::Uuid::nil(),
+ )
+ .replace("/usr/bin/open -n \"$target\"", "false");
+ assert!(!std::process::Command::new("/bin/sh")
+ .arg("-c")
+ .arg(script)
+ .status()
+ .unwrap()
+ .success());
+ let events = std::fs::read_to_string(log).unwrap();
+ let causes: Vec<_> = events
+ .lines()
+ .map(|line| {
+ let event: serde_json::Value = serde_json::from_str(line).unwrap();
+ assert!(event["timestamp"].as_str().unwrap().ends_with('Z'));
+ assert_eq!(event["event"], "update.relaunch_result");
+ event["cause"].as_str().unwrap().to_owned()
+ })
+ .collect();
+ assert_eq!(causes, ["launch_requested", "launch_failed"]);
+ assert!(!events.contains("secret"));
+ }
+}
diff --git a/src-tauri/tauri.conf.json b/src-tauri/tauri.conf.json
index a2a754a..66d1f95 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.80",
+ "version": "6.2.84",
"identifier": "app.biflow.desktop",
"build": {
"beforeDevCommand": "pnpm bundle:check && pnpm --dir apps/desktop dev",
diff --git a/version b/version
index 6d2db09..3ea7ad3 100644
--- a/version
+++ b/version
@@ -1 +1 @@
-6.2.80
+6.2.84