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#"CFBundleExecutableiran-split-desktopCFBundleIdentifierapp.biflow.desktopCFBundleShortVersionString{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